Verifying Signatures
Every webhook delivery is signed with your webhook's signing secret. Verifying that signature proves the request came from HealthEx and that nobody altered it in transit. Your endpoint is a publicly reachable URL, so treat an unverified request as untrusted input and verify before you act on anything in it.
HealthEx implements the Standard Webhooks specification. That means you can usually verify with a maintained library in a couple of lines rather than writing the check yourself — and if you'd rather not add a dependency, the algorithm is short enough to implement directly.
Using a Standard Webhooks Library
A library handles the timestamp tolerance, the multi-signature header, and constant-time comparison for you. This is the recommended path. The Standard Webhooks repository lists the currently available libraries; the three below are the ones our examples use.
- Node.js
- Python
- Go
import express from "express"
import { Webhook } from "standardwebhooks"
const app = express()
const wh = new Webhook(process.env.HEALTHEX_WEBHOOK_SECRET)
// express.raw() keeps the body as a Buffer so the bytes are unchanged.
app.post("/webhooks/healthex", express.raw({ type: "application/json" }), (req, res) => {
let event
try {
event = wh.verify(req.body, {
"webhook-id": req.header("webhook-id"),
"webhook-timestamp": req.header("webhook-timestamp"),
"webhook-signature": req.header("webhook-signature"),
})
} catch (error) {
return res.sendStatus(400)
}
// Acknowledge first, process afterwards.
res.sendStatus(204)
void handleEvent(req.header("webhook-id"), event)
})
verify() throws if the signature doesn't match or the timestamp is outside its five-minute tolerance, and returns the parsed body if it does.
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
wh = Webhook(os.environ["HEALTHEX_WEBHOOK_SECRET"])
@app.post("/webhooks/healthex")
def healthex_webhook():
try:
# request.get_data() returns the unmodified body bytes.
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "", 400
enqueue_for_processing(request.headers["webhook-id"], event)
return "", 204
package main
import (
"io"
"net/http"
"os"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)
func handler(w http.ResponseWriter, r *http.Request) {
wh, err := standardwebhooks.NewWebhook(os.Getenv("HEALTHEX_WEBHOOK_SECRET"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if err := wh.Verify(body, r.Header); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
go handleEvent(r.Header.Get("webhook-id"), body)
}
How the Signature Is Built
Given a delivery with headers webhook-id, webhook-timestamp, and webhook-signature, and the raw request body:
- Strip the leading
whsec_from your signing secret and base64-decode the remainder. This yields 32 raw bytes, which are the HMAC key. - Build the signed string by joining three parts with literal periods:
{webhook-id}.{webhook-timestamp}.{raw body}. - Compute
HMAC-SHA256of that string using the key from step 1. - Base64-encode the result.
- Split the
webhook-signatureheader on spaces. Each part looks likev1,<base64>. Compare your computed value against each part's signature, using a constant-time comparison. If any matches, the request is authentic. - Separately, reject the delivery if
webhook-timestampdiffers from your current clock by more than five minutes.
The HMAC key is the base64-decoded bytes of the secret, not the whsec_… string. HMAC-ing the ASCII string produces a signature that will never match, and is a common reason for failure. If your signatures are consistently wrong and everything else looks right, check this first.
Sign over the exact bytes you received. Parsing the JSON and re-serializing it changes whitespace and key order, which changes the signature. Most frameworks buffer and discard the raw body by default, so you generally have to opt in — the examples below show how for each stack.
Verifying Without a Library
If you'd rather not take a dependency, the whole check is about twenty lines. Each example below handles the space-separated multi-signature header and the timestamp tolerance.
- Node.js
- Python
- Go
const crypto = require("crypto")
const TOLERANCE_SECONDS = 300
function verifyWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"]
const timestamp = headers["webhook-timestamp"]
const signatureHeader = headers["webhook-signature"]
if (!id || !timestamp || !signatureHeader) {
throw new Error("Missing webhook headers")
}
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
throw new Error("Timestamp outside tolerance window")
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`, "utf8").digest()
for (const candidate of signatureHeader.split(" ")) {
const [version, value] = candidate.split(",")
if (version !== "v1" || !value) {
continue
}
const received = Buffer.from(value, "base64")
if (received.length === expected.length && crypto.timingSafeEqual(received, expected)) {
return true
}
}
throw new Error("No matching signature")
}
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signature_header = headers["webhook-signature"]
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
raise ValueError("Timestamp outside tolerance window")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
for candidate in signature_header.split(" "):
version, _, value = candidate.partition(",")
if version != "v1" or not value:
continue
if hmac.compare_digest(base64.b64decode(value), expected):
return True
raise ValueError("No matching signature")
Note that raw_body stays as bytes and is concatenated rather than decoded, so no character-encoding step can alter it.
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func Verify(rawBody []byte, id, timestamp, signatureHeader, secret string) error {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return fmt.Errorf("bad webhook-timestamp: %w", err)
}
if delta := time.Now().Unix() - ts; delta > toleranceSeconds || delta < -toleranceSeconds {
return errors.New("timestamp outside tolerance window")
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return fmt.Errorf("bad signing secret: %w", err)
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%s.", id, timestamp)
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, candidate := range strings.Split(signatureHeader, " ") {
version, value, found := strings.Cut(candidate, ",")
if !found || version != "v1" {
continue
}
received, err := base64.StdEncoding.DecodeString(value)
if err != nil {
continue
}
if hmac.Equal(received, expected) {
return nil
}
}
return errors.New("no matching signature")
}
A Test Vector
Use this to check an implementation without waiting on a live delivery. Given the signing secret:
whsec_9Y1kTPZ0uQ3rWvB7xLmN4gHsJdF6cAe2iOyUqRtKlXo=
and these headers:
webhook-id: evt_01K2YXQ8N3M4P5Q6R7S8T9V0W1
webhook-timestamp: 1755613927
and this exact request body, as a single line with no trailing newline:
{"type":"consent.withdrawn","timestamp":"2026-08-19T14:32:07.421Z","apiVersion":1,"deliveryAttempt":1,"organizationId":"56696fdb-2e0d","projectId":"694d61c2-3f1b-4dc8","data":{"patientId":"17d4513f-73e8-4b2a-9c1d-5e6f7a8b9c0d","consentType":"PATIENT_DIRECTED_DATA_EXCHANGE","withdrawnAt":"2026-08-19T14:32:05.891Z","fullRecordWithoutRestrictedData":true,"consentDataResourceScopes":null,"consentDataSensitivityScopes":null,"withdrawalSource":"PATIENT","effectiveImmediately":true}}
the correct webhook-signature value is:
v1,G60wTDnmPxHNTUtESI0wOL8m5xFTvCvPyWXBUuIIBoU=
The timestamp in this vector is fixed, so a correct implementation will reject it on the five-minute tolerance check. Compare the computed signature directly, or stub your clock to 1755613927, when testing against it.
Rejecting Stale Deliveries
If you're verifying with a Standard Webhooks library, this check is built in — verify() rejects a stale delivery for you, and there's nothing further to implement. This section matters if you're verifying without a library.
The signature proves authenticity but not freshness. Without a timestamp check, anyone who captured a valid request could replay it later verbatim. Reject any delivery whose webhook-timestamp is more than five minutes from your current time — the tolerance the Standard Webhooks libraries use by default.
Compare against webhook-timestamp, not the body's timestamp. The header is regenerated for each delivery attempt, so a legitimate retry sent 24 hours after the event still carries a fresh header value and passes the check. The body's timestamp stays pinned to when the event happened and would fail it.
Allow for clock skew in both directions, as the examples above do — a receiver whose clock runs slightly fast will otherwise see legitimate deliveries as future-dated.
Handling Multiple Signatures
If you're verifying with a Standard Webhooks library, this is handled for you — verify() checks every signature in the header and succeeds if any one matches. This section matters if you're verifying without a library.
The webhook-signature header can contain more than one signature, space-separated:
webhook-signature: v1,ONAy3tM+6tqaE3CpyJZM4a/6VPyzGosBBU6aZmxcCwc= v1,iFzfocX3H4IQ41eJe5YzP2xrAWLMxYjFo/+PJaIaWDY=
This happens during a secret rotation grace period, when we sign each delivery with both the new secret and the previous one so you can roll the secret out without dropping events. The new secret's signature comes first, but don't rely on the order.
Accept the delivery if any signature matches your secret. That is what makes a zero-downtime rotation possible: whichever of the two secrets you currently have deployed, one of the values in the header will verify against it.
A verifier that splits on the comma and stops, or that treats the whole header as one signature, appears to work perfectly until the first rotation — then fails for the duration of the grace period, which is when you are least able to debug it. Always iterate over every space-separated value. The examples on this page do.
Next Steps
- Setting Up a Webhook: Send yourself a test event to verify against
- Idempotency and Ordering: What to do after the signature checks out
- Troubleshooting: Diagnosing signature failures against real deliveries