Signing
HMAC-SHA256 over the raw body, with a worked example you can reproduce before writing code.
Every delivery carries a signature and a timestamp in one header:
X-LDI-Signature: t=1758637862,v1=54f311486063ca1dcb3a21d2831a698ab45b59b58732e4d32df9f9aacb0989f6t— Unix seconds at send timev1— lowercase hex HMAC-SHA256
Send exactly one v1= value. The format this borrows from permits
several; we do not.
How to compute it
The signed string is the timestamp, a literal ., then the request
body:
signed_payload = t + "." + raw_request_body
signature = hex(hmac_sha256(shared_secret, signed_payload))Sign the exact bytes you put on the wire
This is where HMAC integrations actually break, and the failure is opaque from both ends.
If the payload is serialized once for signing and again for sending, any difference in key order or whitespace between the two changes the signature. Every delivery then fails verification, and neither side can see why from the logs.
Serialize once, sign that string, send that string.
This is the same scheme Stripe uses. Deliberately so — it is widely implemented, there are reference implementations in every language, and it is probably already familiar to whoever is reading this.
Why the timestamp is in the signature
A bare signature is replayable forever by anyone who captures one
delivery. Including t in the signed payload, and checking it on
receipt, bounds that window.
We reject any request where |now − t| exceeds five minutes.
That has one consequence for retries: t is stamped when an attempt is
sent, not when the event occurred, so every attempt is signed afresh.
See Delivery.
Worked example
Reproduce this before writing the sender. If these inputs produce this signature, the implementation is correct.
Example key only
The secret below is an illustration, not a real credential. Yours is exchanged out of band.
Secret
whsec_3f8a1c94d2e64b07a5c18e2f9b03d7a6Timestamp
1758637862Body — exactly 154 bytes, no trailing newline
{"eventId":"9f2c1a84-3d7e-4b19-a0f5-1c2e6b8d4a30","eventType":"load.created","occurredAt":"2026-09-23T14:31:02Z","apiVersion":1,"data":{"loadId":2129858}}Signed payload — t, a dot, then that body
1758637862.{"eventId":"9f2c1a84-3d7e-4b19-a0f5-1c2e6b8d4a30","eventType":"load.created","occurredAt":"2026-09-23T14:31:02Z","apiVersion":1,"data":{"loadId":2129858}}Expected signature
54f311486063ca1dcb3a21d2831a698ab45b59b58732e4d32df9f9aacb0989f6Resulting header
X-LDI-Signature: t=1758637862,v1=54f311486063ca1dcb3a21d2831a698ab45b59b58732e4d32df9f9aacb0989f6Check it from a shell
printf '1758637862.{"eventId":"9f2c1a84-3d7e-4b19-a0f5-1c2e6b8d4a30","eventType":"load.created","occurredAt":"2026-09-23T14:31:02Z","apiVersion":1,"data":{"loadId":2129858}}' \
| openssl dgst -sha256 -hmac 'whsec_3f8a1c94d2e64b07a5c18e2f9b03d7a6'Secrets and rotation
Secrets are 32+ random bytes, one per environment, exchanged out of band. They never appear in these docs.
We accept two valid secrets during a rotation window — a delivery matching either is accepted. So rotation is:
- we add the new secret
- you switch to it whenever suits you
- we drop the old one
No coordinated cutover, no maintenance window, no dropped events. The rotation is ours to absorb rather than yours to schedule.