Skip to content
This repository was archived by the owner on May 7, 2026. It is now read-only.

OpenLoopHealth/openloop-webhook-receiver

Repository files navigation

@openloop/webhook-receiver

Verify OpenLoop webhook signatures in Node.js applications. Tiny, zero-runtime-dependency, timing-attack-safe.

npm install @openloop/webhook-receiver
# or: pnpm add @openloop/webhook-receiver / yarn add @openloop/webhook-receiver

Usage

import {
  verifyWebhookSignature,
  WebhookSignatureError,
} from "@openloop/webhook-receiver";

// In an Express handler. The framework adapter MUST give you the
// raw POST body bytes (e.g. `express.raw({ type: "application/json" })`)
// — do NOT pass the parsed `req.body` object; whitespace differences
// after JSON.stringify will fail verification.
app.post("/webhooks/openloop", (req, res) => {
  try {
    const event = verifyWebhookSignature({
      payload: req.body.toString("utf8"),
      signature: req.header("Webhook-Signature") ?? "",
      secret: process.env.OPENLOOP_WEBHOOK_SECRET ?? "",
    });

    switch (event.type) {
      case "patient.created":
        await onPatientCreated(event.data);
        break;
      case "appointment.confirmed":
        await onAppointmentConfirmed(event.data);
        break;
      default:
        // Unknown event type — accept to acknowledge receipt;
        // log for follow-up.
        break;
    }

    res.status(204).end();
  } catch (err) {
    if (err instanceof WebhookSignatureError) {
      // 400 for client-fixable issues (malformed header, version
      // mismatch); 401 for unverifiable signatures.
      const status =
        err.code === "signature-mismatch" ||
        err.code === "timestamp-out-of-tolerance"
          ? 401
          : 400;
      res.status(status).json({ code: err.code, message: err.message });
      return;
    }
    throw err;
  }
});

Signature scheme

OpenLoop signs each webhook with HMAC-SHA256 over a Stripe-style canonical payload:

Webhook-Signature: t=<unix-seconds>,v1=<lowercase-hex>

Where v1 is computed as:

hmac_sha256(secret, `${t}.${rawBody}`).hex()

The ${t}.${rawBody} concatenation prevents timestamp swap attacks; the t= prefix makes the timestamp part of the signed payload, not just metadata.

The signed-payload bytes are byte-identical to the bytes in the HTTP body. If you re-serialize through JSON.parse + JSON.stringify, key ordering or whitespace differences will break verification.

API

verifyWebhookSignature(options): WebhookEvent

Throws WebhookSignatureError on any verification failure. Returns the parsed WebhookEvent envelope on success.

interface VerifyWebhookSignatureOptions {
  /** Raw POST body, byte-identical to what was signed. */
  payload: string;
  /** Value of the `Webhook-Signature` header. */
  signature: string;
  /** Partner's signing secret from endpoint registration. */
  secret: string;
  /** Maximum acceptable abs(now - t) in seconds. Default 300. */
  toleranceSeconds?: number;
  /** Injected clock for tests. Default `Math.floor(Date.now() / 1000)`. */
  now?: () => number;
}

interface WebhookEvent {
  id: string;
  type: string;
  customerId: string;
  occurredAt: string; // ISO 8601 UTC
  publishedAt: string; // ISO 8601 UTC
  data: unknown; // event-specific; parse with zod or your own schema
}

WebhookSignatureError

class WebhookSignatureError extends Error {
  readonly code: WebhookSignatureErrorCode;
}

type WebhookSignatureErrorCode =
  | "malformed-header"
  | "no-supported-version"
  | "timestamp-out-of-tolerance"
  | "signature-mismatch"
  | "invalid-payload-json";
Code Meaning Suggested HTTP response
malformed-header Header didn't parse as t=<digits>,v1=<hex> 400
no-supported-version Header had only future versions (e.g., v2=) 400
timestamp-out-of-tolerance Drift between signed t and your now exceeds tolerance 401
signature-mismatch HMAC didn't match — wrong secret or tampered body 401
invalid-payload-json Signature valid but body wasn't a JSON envelope 400

Security notes

  • Constant-time compare: this library uses crypto.timingSafeEqual and length-checks before invoking it (the buffer-length-mismatch throw in Node's API is a timing leak). Do not roll your own string compare on the signature value.
  • Replay protection: the default 5-minute tolerance window mirrors industry conventions (Stripe, Slack, GitHub). Set toleranceSeconds: 0 for strict-equality checks (typically only useful in test fixtures); set higher only if you have rigorously tested clock skew between OpenLoop's signing infrastructure and your receiver.
  • Secret handling: store partner secrets in your secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault, Doppler, etc.). Never commit to source. Rotate on personnel changes.
  • Idempotency: OpenLoop guarantees at-least-once delivery. Use event.id as your idempotency key to dedupe redeliveries.

Typed payloads

event.data is unknown by design. Once Wave 27 (the auto-generated @openloop/sdk package) ships, partners can re-import verifyWebhookSignature from @openloop/sdk for a typed-payload overload using the canonical event union. Until then, parse event.data with your own zod schema:

import { z } from "zod";

const PatientCreatedSchema = z.object({
  patientId: z.string().uuid(),
  customerId: z.string(),
  // ...
});

if (event.type === "patient.created") {
  const data = PatientCreatedSchema.parse(event.data);
  // data is fully typed
}

Manual verification (other languages)

For non-TypeScript receivers, the verification logic is small enough to re-implement. Critical: use a constant-time compare for the signature, never == or equals().

Python

import hmac
import hashlib
import time

def verify(payload: bytes, signature: str, secret: str, tolerance: int = 300) -> dict:
    parts = dict(p.split("=", 1) for p in signature.split(","))
    t = int(parts["t"])
    v1 = parts["v1"]
    if abs(time.time() - t) > tolerance:
        raise ValueError("timestamp-out-of-tolerance")
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{t}.{payload.decode('utf-8')}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, v1):  # constant-time
        raise ValueError("signature-mismatch")
    return json.loads(payload)

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "math"
    "strings"
    "time"
)

func verify(payload []byte, signature, secret string, tolerance time.Duration) error {
    var t int64
    var v1 string
    for _, p := range strings.Split(signature, ",") {
        if strings.HasPrefix(p, "t=") { fmt.Sscanf(p, "t=%d", &t) }
        if strings.HasPrefix(p, "v1=") { v1 = strings.TrimPrefix(p, "v1=") }
    }
    if math.Abs(float64(time.Now().Unix()-t)) > tolerance.Seconds() {
        return errors.New("timestamp-out-of-tolerance")
    }
    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%d.%s", t, payload)
    expected := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(expected), []byte(v1)) {  // constant-time
        return errors.New("signature-mismatch")
    }
    return nil
}

Ruby

require "openssl"
require "json"

def verify(payload, signature, secret, tolerance: 300)
  parts = signature.split(",").map { |p| p.split("=", 2) }.to_h
  t = parts["t"].to_i
  v1 = parts["v1"]
  raise "timestamp-out-of-tolerance" if (Time.now.to_i - t).abs > tolerance
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{payload}")
  raise "signature-mismatch" unless OpenSSL.fixed_length_secure_compare(expected, v1)  # constant-time
  JSON.parse(payload)
end

Receiver implementation checklist

  • Use the raw request body (bytes/string) for payload, not a re-serialized object.
  • Treat signature-mismatch and timestamp-out-of-tolerance as 401; treat malformed-header/no-supported-version/invalid-payload-json as 400.
  • Idempotency: dedupe on event.id — at-least-once delivery means the same event may arrive multiple times.
  • Respond fast: 2xx within 30 seconds (else OpenLoop retries with exponential backoff). Defer slow work to a background queue.
  • 5xx if downstream is unhealthy: signals OpenLoop to retry. 4xx for permanent rejection (e.g. validation errors against your business rules) — OpenLoop will not retry.
  • Store secrets in a secrets manager and rotate on personnel changes.

Subscribing & replay

  • Register a webhook endpoint: POST /v1/webhook-endpoints (Partner API).
  • Replay missed events: POST /v1/webhook-deliveries/{id}/replay or backfill a window via POST /v1/webhook-endpoints/{id}/replay.

See the OpenLoop developer docs for the full receive-flow guide.

License

MIT. See LICENSE.

About

Verify OpenLoop webhook signatures (HMAC-SHA256, Stripe-style)

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors