Connect by JBRH Open Connect

Provider webhook signatures

A webhook signature is a keyed hash the sender computes over the exact bytes it sends, so the receiver can prove the request came from someone holding the shared secret and was not altered on the way. It proves origin and integrity. It does not prove freshness, uniqueness or permission — replay protection is a separate mechanism, and Connect stores a nonce under a unique index to get it.

Status
Reference What this means
Audience
developer
Last verified
Product version
6.3.2

What a signature proves, and what people assume it proves#

ClaimProved?What actually provides it
The sender holds the shared secretYesThe keyed hash itself
The bytes were not modified in transitYesThe hash covers the raw body
This request is recentNoA signed timestamp plus a tolerance window
This request has not been seen beforeNoA stored nonce or event id with a uniqueness constraint
The sender may act on this resourceNoYour own authorisation, after resolving the tenant

The last row is the one that produces real incidents. A signature authenticates a *sender*, not a *request's right to touch a record*. A correctly signed event naming somebody else's number is still an event that must be refused, which is why the resolution of who owns the endpoint belongs after the signature check and before anything is written.

Verifying one, in the order that matters#

  1. Read the raw body bytes before anything parses them.

    Result You are hashing what the sender hashed. Parsing JSON and re-serialising it changes whitespace and key order, and the signature will not match — or worse, will match for one provider and not another, so the bug looks intermittent.

  2. Reconstruct the signed string exactly as the provider documents it.

    Result Usually a concatenation of some subset of: the timestamp, the HTTP method, the full URL including query, and the body. Getting the URL wrong is common behind a proxy that rewrites the scheme.

  3. Compute the keyed hash and compare in constant time.

    Result An ordinary string comparison returns as soon as two bytes differ, which leaks how much of a guess was right. Every language's crypto library has a fixed-time comparison; use it.

  4. Check the signed timestamp against a tolerance window.

    Result A signature with no freshness bound is valid until the secret rotates. A few minutes of tolerance is normal; unbounded is not.

  5. Answer a bad signature with a 4xx and no detail.

    Result A 4xx tells the sender not to retry. A 5xx invites the provider to resend a request that will fail identically, forever.

Replay is a separate problem with a separate fix#

Providers retry. Networks duplicate. An attacker who captures one valid signed request can send it again unchanged, and it will verify perfectly every time, because nothing about it has been altered. Telephony makes this concrete: a duplicated *call finished* event that is processed twice closes a call twice, bills it twice, or overwrites a human decision with a stale machine one.

The fix is storage, not cryptography. Take the unique identifier the provider puts on the event — a nonce, a message id, a delivery id — and make the database refuse the second one. A uniqueness constraint is the only version of this that survives two application processes racing; checking for existence and then inserting does not.

A timestamp window narrows the replay opportunity to the width of the window, which is useful and is not sufficient on its own. Both together is the standard position: freshness from the timestamp, uniqueness from the stored identifier. See idempotency for the general form of the same idea.

Does Connect use webhook signatures?#

Used, on every provider webhook that carries one. The verification lives in the carrier adapter layer, which is deliberately the only place in the codebase where a provider's own signature scheme, event shape and instruction dialect appear at all. Everything above it is written against normalised events, which is what makes a provider replaceable.

Replay protection is exactly the mechanism described above: call_events stores every normalised provider event once, with the signature nonce under a partial unique index. The database refuses the duplicate; no application code has to be careful.

Not every door uses a signature, and the ones that do not say what they use instead. The realtime voice worker's own endpoint is authenticated by a shared token over loopback and answers 503 when that token is unset — it is never open by omission. That is a deliberate difference: the worker is first-party software on the same host, not an outside provider.

Verification bugs that pass code review#

  • Verifying the parsed body. The code reads, it just does not hash what was sent. Symptom: works for one provider, fails for another.
  • A framework that consumed the stream first. By the time your handler runs, the raw bytes are gone and something reconstructs them approximately.
  • Comparing with ==. Correct results, timing side channel.
  • No timestamp check. Every captured request stays valid until the secret changes.
  • Retrying a 4xx. A rejected signature answered with a 5xx becomes an infinite loop between two systems that both think they are being careful.
  • One URL, two meanings. An endpoint that serves both a status callback and a hang-up callback must branch on the event, not on having been called — that mistake once hung up on a live caller during a routine *ringing* notification.

Questions#

Is HTTPS not enough on its own?

Transport security protects the connection between two endpoints; it says nothing about who originated the payload once proxies, load balancers and queues are in the path. The signature is what survives all of them, and it is what lets a receiver refuse a request that reached the right URL from the wrong sender.

What should a receiver return for a duplicate event?

Success. The duplicate was recognised and discarded, which is exactly what the sender needs to know; an error would make it retry. The uniqueness constraint does the discarding, so a second delivery costs one refused insert.

Where does Connect verify these?

In the carrier adapter, the single layer that knows any provider's own format. Core telephony asks whether a provider supports a capability rather than branching on its name, and nothing above the adapter sees a raw provider payload at all.