Verifying webhook signatures
Every webhook Meridian delivers is signed with an HMAC-SHA256 signature, so your endpoint can prove a payload came from Meridian and was not modified in transit.
Where to find your secret. Each destination has its own signing secret under Destinations → Settings → Signing. It is shown once at creation — store it in your secret manager, never in source control.
How signing works#
Before dispatching a delivery, Meridian computes a signature from the exact bytes of the request body and a Unix timestamp, then sends both in the Meridian-Signature header:
- Concatenate the timestamp, a . separator, and the raw request body.
- Compute an HMAC-SHA256 digest of that string using your destination’s signing secret as the key.
- Send the timestamp (t) and hex digest (v1) with the delivery, alongside the event type and delivery ID.
POST /webhooks/meridian HTTP/1.1
Host: api.lumen-billing.com
Content-Type: application/json
Meridian-Event: invoice.payment_succeeded
Meridian-Delivery: dlv_7Kx2mPqR4tNvA9
Meridian-Signature: t=1782431920,v1=9f6b1c2ad84e03f7715c2a9be4d6f0a8c3e5b7d9012f4a6c8e0b2d4f6a8c1e3b
Verify the signature#
Recompute the digest from the raw request body and compare it to v1 with a constant-time comparison. Reject deliveries whose timestamp is more than 300 seconds old to block replay attacks.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(rawBody, header, secret) {
const { t, v1 } = parseHeader(header);
// Reject stale timestamps to prevent replay attacks
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
import hmac, hashlib, time
def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
t, v1 = parse_header(header)
# Reject stale timestamps to prevent replay attacks
if abs(time.time() - int(t)) > 300:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
func VerifySignature(rawBody []byte, header, secret string) bool {
t, v1 := parseHeader(header)
// Reject stale timestamps to prevent replay attacks
if time.Now().Unix()-t > 300 || t-time.Now().Unix() > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%d.", t)))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(v1), []byte(expected))
}
Verify against the raw body. Parsing and re-serializing JSON changes key order and whitespace, which produces a different digest. Read the request body as bytes before any middleware touches it.
Signature header format#
The Meridian-Signature header is a comma-separated list of key=value pairs. Your parser should tolerate unknown keys — new schemes may be added in minor versions.
| Field | Type | Description |
|---|---|---|
| t | integer | Unix timestamp (seconds) at which the delivery was signed. |
| v1 | string | Hex-encoded HMAC-SHA256 of {t}.{raw_body}, keyed with your current signing secret. |
| v1 (repeated) | string | During a secret rotation window, a second v1 is included, computed with the previous secret. Accept the delivery if any value matches. |
| v0 Deprecated | string | Legacy SHA-1 scheme, removed in v2.0. Ignore unless your destination was created before March 2024. |
Troubleshooting#
The signature never matches
Almost always a raw-body issue. Frameworks like Express and Rails parse JSON before your handler runs; use express.raw() or request.body.read to capture the original bytes. Also confirm you are using the secret for this destination — each one is distinct.
Verification fails only in production
Check for a proxy or gateway that rewrites the body (gzip re-encoding, header casing is fine, body mutation is not). Cloud load balancers with request inspection enabled are the usual culprit — exempt your webhook path.
Deliveries rejected with timestamp outside tolerance
Your server clock has drifted. Sync with NTP, or if you process deliveries from a queue, verify the signature at ingestion, before enqueueing — not when the worker picks the job up.