# 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
- **Audience:** developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/technology/webhook-signature/

## What a signature proves, and what people assume it proves

| Claim | Proved? | What actually provides it |
|---|---|---|
| The sender holds the shared secret | Yes | The keyed hash itself |
| The bytes were not modified in transit | Yes | The hash covers the raw body |
| This request is recent | No | A signed timestamp plus a tolerance window |
| This request has not been seen before | No | A stored nonce or event id with a uniqueness constraint |
| The sender may act on this resource | No | Your 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.

> **Careful** Never let the request choose its own algorithm. A header naming the hash function is a suggestion from an unauthenticated party; pin the algorithm in your own configuration and reject anything else.

## 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](/docs/technology/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.

> **Note** A signature check is not workspace entry. One production defect here was a handler that verified the signature and *then* read workspace-scoped settings from outside any workspace, saw them empty, and answered a live caller with the goodbye message. Every handler now runs inside the owning workspace, not just the signature check.

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.

## Related

- [Verifying an inbound webhook](https://connectbyjbrh.com/docs/security/webhook-verification/)
- [Webhooks](https://connectbyjbrh.com/docs/technology/webhook/)
- [Idempotency](https://connectbyjbrh.com/docs/technology/idempotency/)
- [Verifying a Connect webhook signature](https://connectbyjbrh.com/developers/webhook-signatures/)
- [HTTPS and TLS](https://connectbyjbrh.com/docs/technology/https/)
- [Idempotency for retried telephony webhooks](https://connectbyjbrh.com/research/idempotent-telephony-webhooks/)
- [Call routing](https://connectbyjbrh.com/docs/technology/call-routing/)

## What this page is based on

- `docs-source/sources/PHONE.md` §1 — the adapter boundary and call_events
- `docs-source/sources/PHONE.md` §2 — production defects in webhook handling
- `docs-source/sources/PHONE.md` §3 — the voice worker's authenticated door
