Skip to main content
Back to blog

Webhook Signature Verification for CPaaS: HMAC, Replay Windows, and Tenant-Owned Rotation on Devotel Orbit

The security half of a webhook integration: the three replayable failure modes (no signature, weak secret, no timestamp check), the verification recipe as pseudocode plus a worked curl against a live Orbit delivery, the tenant-owned levers (secret rotation, IP-allowlisting posture, per-endpoint retry backoff, the retry log), and the edge-vs-consumer decision.

Orbit Editorial Team

Two posts in this series cover webhook reliability — Designing Reliable CPaaS Webhooks walks the receiver-side patterns (delivery logs, retries, the tester loop) and Webhook Retry Logic: Framework vs Delivery-Guaranteed compares the two retry architectures. Neither covers the half your security review asks about first: how do you prove the POST that just hit your endpoint came from Devotel Orbit, carries an untampered payload, and is not a replay of a delivery from three days ago? This is that half — the failure modes, the verification recipe mapped to the headers Orbit actually emits, the tenant-owned levers behind it, and the one decision (edge vs consumer) you make once per integration.

1. The three replayable failure modes

Webhook verification fails in production in exactly three ways. Each is a distinct threat model with a distinct attacker, not three phrasings of the same bug.

No signature check. The receiver reads the JSON body and acts on it. Anyone who can reach the URL — a port scanner, a leaked log line, a former employee — can POST a forged message.delivered receipt and flip your CRM's delivery state, or POST a forged contact.opted_out and silently suppress a customer. The defense is the HMAC check itself: reject any request that cannot prove it came from your endpoint's signing secret.

Weak or leaked secret. The signature check exists but the secret is password123, committed to a repo, or reused across 40 endpoints. HMAC-SHA256 of an attacker-chosen body is trivial once the key is known — the attacker verifies correctly and your endpoint acks forged events. The defense is twofold: a high-entropy secret per endpoint (Orbit generates these for you), and rotation when the secret's blast radius changes — a contractor leaves, a log aggregator starts storing headers, a repo goes public.

No timestamp check. The receiver verifies the HMAC but ignores the t= timestamp inside the signed string. A validly-signed delivery captured in transit — by a compromised middlebox, a verbose log shipper, an errant APM trace — can be replayed months later, and it still verifies, because the signature is over bytes the attacker never changed. The defense is the replay window: reject deliveries whose timestamp is older than a few minutes, so a captured request has a short useful life.

These three compose. Skipping the timestamp check alone leaves you open to replays even with a strong secret and a correct compare. Skipping the secret rotation lever leaves you stuck when the secret does leak. The verification recipe below closes all three.

2. The verification recipe

Every delivery Orbit dispatches carries the signature in up to three headers. The canonical one is X-Orbit-Signature, in the form t=<unix_timestamp>,v1=<hmac_hex>. During a key-rotation grace window Orbit also sends X-Orbit-Signature-Next, the same payload signed with your previous secret, so your receiver can keep accepting old-key deliveries while you roll the new secret out. A legacy X-Devotel-Signature combines both for back-compat. All three sign the same string: `<t>.<raw_body>` — the timestamp, a literal dot, then the exact request bytes, no re-serialization.

The recipe in pseudocode:

function verifyOrbitWebhook(rawBody, signatureHeader, currentSecret, previousSecret):
    parts = parse(signatureHeader)                    # "t=…,v1=…" → dict
    assert parts.t is within ±300 seconds of now()     # replay window
    signed = parts.t + "." + rawBody                   # byte-exact, no JSON re-parse
    expected = hmac_sha256(key=currentSecret, msg=signed).hex()
    if constant_time_equal(expected, parts.v1):
        return ACCEPT
    if previousSecret is not None and header X-Orbit-Signature-Next is present:
        prev = hmac_sha256(key=previousSecret, msg=signed).hex()
        if constant_time_equal(prev, parts.v1_of_next):
            return ACCEPT                              # mid-rotation, old key still valid
    return REJECT

Three details the pseudocode captures that prose often drops. The raw body is read before any JSON middleware — a parse-then-stringify round trip changes whitespace and key order, and the HMAC no longer matches. The timestamp window (±300 seconds is the standard) is checked before you trust anything else in the request, so a replayed request expires on the first gate. The compare is constant-time (crypto.timingSafeEqual in Node, hmac.compare_digest in Python), not ===, because a naive string compare leaks how many leading characters matched and lets an attacker recover the signature byte-by-byte.

Worked example: curl against a live Orbit delivery

The Webhook Tester under Developer → Webhook tester emits a real, signed delivery you can reproduce from the shell. Suppose the tester has just sent your endpoint a message.delivered event, with t=1759651200 and body {"id":"evt_01JRD7…","type":"message.delivered","data":{…}}. The request that arrived at your endpoint carried:

X-Orbit-Signature: t=1759651200,v1=4f9c2e6b8a1d3f5e7c9b1a3d5f7e9c1b3a5d7f9e1c3b5a7d9f1e3c5b7a9d1f3e

To verify it manually with your endpoint's signing secret in $ORBIT_WEBHOOK_SECRET:

# 1. Check the timestamp is fresh (±300 seconds).
now=$(date +%s)
t=1759651200
[ $((now - t)) -lt 300 ] && [ $((t - now)) -lt 300 ] || { echo "stale"; exit 1; }

# 2. Recompute the HMAC over "<t>.<raw_body>".
signed="${t}.{\"id\":\"evt_01JRD7…\",\"type\":\"message.delivered\",\"data\":{…}}"
expected=$(printf '%s' "$signed" \
  | openssl dgst -sha256 -hmac "$ORBIT_WEBHOOK_SECRET" -hex \
  | awk '{print $2}')

# 3. Constant-time compare — in a real receiver, not a shell script.
[ "$expected" = "4f9c2e…" ] && echo "valid" || echo "reject"

Step 3 is a placeholder — a shell [ = ] is not constant-time, which is why production receivers use a library. The Orbit SDKs do all three steps in one call: Orbit.webhooks.constructEvent(rawBody, signatureHeader, secret) in Node, verify_webhook(payload=…, signature=…, secret=…) in Python, and the equivalent in every shipped SDK language. The recipe above is what those calls implement; reach for it directly only when you cannot take the SDK dependency.

Mapping to the shipped surface

The events you subscribe to are grouped by prefix in the create/edit dropdown — the families are message, call, contact, campaign, agent, flow, and account-activity groups, plus the normalized-inbound variants. Every event in every family is signed the same way, with the same headers and the same <t>.<raw_body> recipe, so one verifier protects the whole subscription. The webhook-signature glossary term and the Webhook security reference pin the header names, the signed-string shape, and the rotation-grace semantics this recipe depends on.

3. Tenant-owned levers

The platform signs every delivery, but the security posture is yours. Four levers, each under your control, in the order they show up in a review.

Rotation cadence. Rotate the signing secret every time its blast radius changes — not on a fixed calendar you will forget. The concrete triggers: a developer with access leaves, a log pipeline starts capturing headers, a repo goes public, or an auditor flags the secret as stale. Orbit's dual-signature grace window (X-Orbit-Signature + X-Orbit-Signature-Next) means you can roll the new secret in across your replicas while the old one still verifies, with zero dropped deliveries — the rotation happens in your deploy, not in a maintenance window. The shape is documented in Webhook security.

IP-allowlisting posture. Where your network permits it, allowlisting Orbit's egress IPs is a defense-in-depth layer under the HMAC check, never a substitute. Signature verification proves the sender holds the secret; allowlisting proves the TCP connection came from an expected range. Use both if your compliance framework asks for the second layer — the signature check alone is sufficient for the threat model in section 1.

Per-endpoint retry backoff. Orbit retries a failed delivery nine times on a published exponential schedule starting at 30 seconds with jitter. Your receiver should not layer its own exponential backoff inside a single attempt — the platform's schedule is already running above yours, and exponential-inside-exponential just burns the 30-second response window. Return 2xx fast, queue the real work, and rely on the platform retry for endpoint-level recovery. The retry schedule is the receiver-side contract in Designing Reliable CPaaS Webhooks.

The operator-visible retry log. Under Developer → Request logs you see every delivery attempt per endpoint — the attempt number in the schedule, the status code your endpoint returned, and the captured request/response headers. During a rotation this is the ground truth for "did the old-key deliveries stop arriving yet?" During a replay-attack investigation it is the ledger of which evt_… ids actually came from Orbit. Treat it as a first-class monitoring surface, not a debug tool.

None of these is platform-mandated. Orbit signs and retries; you decide how often to rotate, whether to allowlist, and how your receiver spends its 30 seconds.

4. Decide once: edge or consumer

One architectural decision per integration: verify at the edge, or in the consumer?

Verify at the edge (reverse proxy, API gateway, or a thin middleware in front of your queue) when you have many consumers behind one endpoint. The forged-traffic and replay load is rejected before it reaches any of them, and the consumer code behind the edge trusts the envelope. This is the right default for a multi-service deployment: one verifier, one place to update the secret, one audit point.

Verify in the consumer when the endpoint is a single service or a small stack, or when the consumer needs the raw-body guarantee that an edge re-serializer would destroy. The Orbit SDK's constructEvent is built for this shape — a try/catch around one call inside your request handler, catching the typed OrbitWebhookSignatureError to return 401, and nothing else changes.

The wrong answer is verifying twice with different secrets, or verifying in the consumer and letting the edge tamper with the body (a spine that rewrites JSON breaks the HMAC before the consumer can check it). Pick one layer, give it the raw bytes, and let everything behind it trust the verified envelope.

---

The reliability half — retries, idempotency, the dead-letter drain — is in Designing Reliable CPaaS Webhooks and the retry-architecture comparison in Webhook Retry Logic. This post is the security half: verify the HMAC, check the timestamp, rotate the secret, and keep the levers in your hands.

Webhook Signature Verification for CPaaS: HMAC, Replay Windows, and Tenant-Owned Rotation on Devotel Orbit — Orbit by Devotel