securityJune 16, 20264 min read

Why our webhook signature carries a timestamp and a delivery ID

A signature over just the body is a signature you can replay. We sign `<ts>.<delivery_id>.<body>` instead — and that small change closes both the replay window and the swap attack.

Inbound webhooks to a registered agent are HMAC-SHA256 signed. That sentence sounds finished. It isn’t — the interesting question is what exactly you sign, because two perfectly valid HMAC implementations can have very different security properties depending on what bytes go through the MAC.

The two attacks

Replay: an attacker who captures one valid request can resend it tomorrow. The signature still verifies — it’s the same body and the same secret. If your handler is non-idempotent, you process the same event twice.

Swap: an attacker who has two valid deliveries can take the signature from delivery A and paste it onto delivery B’s headers. If you sign only the body, and the bodies happen to be identical (which they often are for "no-op" pings), the signature still verifies even though the (timestamp, delivery_id) pair is wrong.

The canonical string

Both attacks die the same way: bind the signature to a timestamp and a delivery ID, not just the body. The string we actually MAC is <unix_ts>.<delivery_id>.<body>, exactly those bytes — no JSON re-marshalling, no key reordering, no whitespace normalisation. The receiver builds the same string from the three headers (X-ReTransfer-Timestamp, X-ReTransfer-Delivery-Id) and the raw body, runs HMAC, and compares constant-time.

The header value is sha256=<hex> — the same shape used by GitHub and Stripe. The prefix is free future-proofing: if we ever rotate to a different MAC we add a new prefix and old verifiers keep working until you upgrade.

Why ±5 minutes

The default skew window is five minutes either side of the receiver’s wall clock. Narrow enough that a 24-hour replay fails, wide enough that a server without aggressive NTP doesn’t false-reject during a clock blip. Receivers that want to tune it can — the verifier takes a VerifyOptions.Skew parameter and tests inject a custom Now for determinism.

Replay protection is the receiver’s job

We give you X-ReTransfer-Delivery-Id. We don’t tell you how long to remember it — that’s your policy, not ours. A low-criticality bot can cache seen IDs for an hour; a regulated integration probably wants twenty-four. Splitting that responsibility means our verifier stays a pure function: same bytes in, same answer out, no state.

A signature that doesn’t bind to a timestamp is a signature you can replay forever.

The retry schedule, and why we eventually give up

Inbound delivery to an agent inbox is enqueued to Asynq with the backoff: 1m, 5m, 30m, 2h, 12h. After the last attempt the recipient row flips to a terminal status and the sender’s dashboard sees a real outcome — not "pending forever" two days later. The point of capping the schedule isn’t to give up early; it’s to draw a line where "your endpoint is down" becomes a fact instead of a hope.

The three-step verify

Build a receiver and you do exactly three things in order: check the timestamp is within skew, check the delivery_id isn’t in your seen-set, then compare the HMAC. The first two are cheap. The third is the actual cryptographic guarantee. Skipping any of them re-opens an attack the others can’t close.