Why you must verify
Your webhook URL is a public HTTPS endpoint. Anyone who learns it can send it a well-formed JSON body that looks exactly like a CaseXchange event. TheX-CaseXchange-Signature header is the only thing that proves a delivery came from CaseXchange and arrived unaltered, so verify it on every request — including webhook.test deliveries — before you read a single field of the body.
The signing secret is per subscription. It is returned exactly once, by the call that generates it: the response to POST /webhooks, and thereafter the response to each POST /webhooks/{id}/rotate-secret — which returns a new secret, never the old one. It can never be read back, so store it the moment you receive it. See Managing subscriptions for the create flow.
The signature header
Every delivery carries one signature header:
Only one
v1 value is ever sent per delivery — there is no dual signing. The signed message is the timestamp, a literal ., and the body, in that order, with nothing else in between.
The same header format is used on every delivery, including the synthetic webhook.test body and the retries of a failed delivery. A retried delivery is signed afresh on every attempt, so t and v1 change between attempts while X-CaseXchange-Delivery stays the same.
Verification steps
1
Read the raw body bytes
Capture the request body exactly as it arrived, before any JSON middleware touches it. In Express use
express.raw({ type: 'application/json' }) on the webhook route; in Django read request.body. If your framework has already turned the body into an object, you no longer have the bytes that were signed.2
Parse t and v1 from the header
Split
X-CaseXchange-Signature on ,, then each part on the first =. Look the header up case-insensitively — HTTP header names are not case-sensitive, and most frameworks normalise them to lower case.3
Reject a stale timestamp
Reject the delivery if
t is not a finite number or is more than about 300 seconds away from your current clock. This blunts replay of a captured delivery.4
Compute the expected signature
Compute
HMAC-SHA256 over the string "<t>.<rawBody>" using the secret as the key, and hex-encode the result.5
Compare in constant time
Compare your value with
v1 using a constant-time function — crypto.timingSafeEqual in Node, hmac.compare_digest in Python. Check the lengths first where the function requires it. Never compare with ==.6
Only then parse the JSON
Once the signature matches, parse the body, deduplicate on
X-CaseXchange-Delivery, store it, and return 2xx. Do the real work asynchronously.Reference implementations
Node.js
This is the reference verifier.rawBody must be the exact bytes CaseXchange sent — a Buffer or the undecoded body string — and the function must run before any JSON.parse.
Express wiring
Mountexpress.raw on the webhook route so req.body is a Buffer, not a parsed object. Keep express.json() for the rest of your app, but make sure it does not run first on this path.
Python
The same rules in Python.raw_body must be bytes: request.body in Django, request.get_data() in Flask.
verify before json.loads. Once it returns True, parse the body, deduplicate on the X-CaseXchange-Delivery header, persist, and respond 2xx.
Computing a signature by hand
To check your implementation against a known input, computev1 from the shell. Use printf rather than echo so no trailing newline is added to the signed message.
v1 value for that timestamp and body. Any byte difference in BODY — a space, a reordered key, a different number format — produces a different digest, which is why you must sign the raw bytes and never a re-serialised copy.
Common mistakes
Rotating the secret
POST /webhooks/{id}/rotate-secret generates a new secret for the subscription and returns it. It requires a standard or full key.
200 response is the subscription object with its new secret — the only time this secret is ever returned:
What rotation does
Because there is no overlap on the CaseXchange side, the overlap has to live in your verifier.Recommended procedure
1
Deploy a verifier that accepts both secrets
Change your endpoint so it tries the current secret first and, on failure, a second “incoming” secret slot. Deploy this before you rotate, while the second slot is still empty — the point is that the handler can hold two secrets by the time the new one exists.
2
Rotate and store the new secret
Call
POST /webhooks/{id}/rotate-secret, then write the returned secret into the second slot in your secrets manager. Deliveries signed with the new secret now verify on the second attempt; any in-flight retries also carry the new signature.3
Confirm deliveries verify with the new secret
Watch
GET /webhooks/{id}/deliveries — or trigger POST /webhooks/{id}/test — and confirm new deliveries land as DELIVERED. Check your own logs that they matched on the new secret, not the old one.4
Drop the old secret
Once you are satisfied, remove the old value and promote the new one to the primary slot. A few minutes is enough; the old secret never signs another delivery after the rotation.
If you forget to update your verifier
Your endpoint keeps computing signatures with the old secret, so every delivery fails verification. Provided your handler returns a non-2xx status on a bad signature, this is recoverable:
- Each failed delivery is retried after 30 s, 2 min, 10 min and 30 min — about 43 minutes end to end.
- Every retry is re-signed with the current secret. As soon as your verifier is updated, the next attempt succeeds.
- After the fifth failed attempt a delivery is
EXHAUSTEDand is never retried. The event is lost as a push; reconcile it fromGET /status-updatesor the case lists. - After 10 consecutive exhausted deliveries the subscription is deactivated (
isActive: false). Update your secret, then re-enable it withPATCH /webhooks/{id}and{ "isActive": true }.
Echo captures and the 24-hour read-back window
Echo-mode captures report asignatureValid flag. For 24 hours after a rotation, that read-back check also accepts the previous secret, so a capture whose delivery was signed just before you rotated still reads as signatureValid: true rather than looking like a broken signature.
The practical consequence: after a rotation, a capture that reads as valid does not prove your endpoint would have accepted the delivery. To check your own verifier, take the capture’s rawBody and signature header and run them through your code with the secret your endpoint currently holds. See Testing webhooks for how to enable echo mode and read captures.
Handling the secret
- Store it in a secrets manager, the same way you store the API key. Never commit it to source control or bake it into an image.
- Never log it. Scrub it from error reports, request dumps and crash traces the same way you scrub the API key.
- Keep one secret per subscription. If you run several subscriptions, key your secret store by subscription
id. - Rotate on any suspicion of exposure — a leaked log, a departed contractor, a compromised host — using the procedure above. Rotation needs a
standardorfullkey. - If the secret is lost, rotate. There is no endpoint that reads it back.
Next steps
Anatomy of a delivery
Headers, the envelope, and how to deduplicate and order what you receive.
Reliability
Retry schedule, delivery statuses, and how a subscription gets deactivated.
Testing webhooks
Test deliveries and echo mode for checking your verifier against real signed bytes.
Managing subscriptions
Create, update and delete subscriptions; where the secret comes from.

