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-receiverimport {
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;
}
});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.
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
}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 |
- Constant-time compare: this library uses
crypto.timingSafeEqualand 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: 0for 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.idas your idempotency key to dedupe redeliveries.
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
}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().
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)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
}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- Use the raw request body (bytes/string) for
payload, not a re-serialized object. - Treat
signature-mismatchandtimestamp-out-of-toleranceas 401; treatmalformed-header/no-supported-version/invalid-payload-jsonas 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.
- Register a webhook endpoint:
POST /v1/webhook-endpoints(Partner API). - Replay missed events:
POST /v1/webhook-deliveries/{id}/replayor backfill a window viaPOST /v1/webhook-endpoints/{id}/replay.
See the OpenLoop developer docs for the full receive-flow guide.
MIT. See LICENSE.