Meridian Documentation

Everything your platform emits, delivered.

Guides and reference for sending, transforming, and observing events with Meridian — retries, signing, and replay included.

docs.meridian.dev/webhooks/verifying-signatures
v2.4.1
Webhooks Verifying signatures

Verifying webhook signatures

Every delivery is signed with an HMAC-SHA256 signature, so your endpoint can prove a payload came from Meridian and was not modified in transit.

6 min read Applies to API v2.0+

Verify the signature

Recompute the digest from the raw request body and compare it against v1 with a constant-time check.

verify.ts
const { t, v1 } = parseHeader(header)

const expected = createHmac("sha256", secret)
  .update(`${t}.` + rawBody)
  .digest("hex")

if (timingSafeEqual(v1, expected)) accept(event)
Webhooks / Verifying signatures

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.

6 min read Applies to API v2.0+

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:

  1. Concatenate the timestamp, a . separator, and the raw request body.
  2. Compute an HMAC-SHA256 digest of that string using your destination’s signing secret as the key.
  3. Send the timestamp (t) and hex digest (v1) with the delivery, alongside the event type and delivery ID.
Delivery request headers
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.

FieldTypeDescription
tinteger Unix timestamp (seconds) at which the delivery was signed.
v1string 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 Deprecatedstring 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.

Stuck on something?

Answers usually already exist — and when they don’t, a human replies fast.