Webhooks
A webhook is an HTTP request a provider makes to a URL you gave it, when something happens on their side. It replaces polling and it inverts the reliability problem: you no longer control when the call arrives, how often it repeats, or in what order. A receiver that verifies the sender, answers immediately and tolerates duplicates is correct; one that does any of those loosely will lose events or process them twice.
Four rules, and the failure each one prevents#
| Rule | Prevents |
|---|---|
| Verify the signature over the raw request bytes | Accepting a forged event. Your URL is public; anyone can POST to it |
| Answer 2xx quickly, then do the work | Provider timeouts that trigger redelivery of an event you are already processing |
| Key on the provider's event identifier | The same delivery being applied twice when a retry overtakes your acknowledgement |
| Never derive identity from the body alone | A payload that names a workspace, mailbox or account it has no right to name |
The second rule fights the third. Answering fast means acknowledging before you have finished, which means a crash after the acknowledgement loses the event, and a slow acknowledgement gets you a duplicate. There is no arrangement without one of those risks — so choose duplicates, and make them harmless. Store the raw event first, acknowledge, then process from storage.
Safe to receive twice#
Delivery is at-least-once, always, whatever a provider's marketing says. The mechanism that makes that survivable is a unique constraint, not a check. Reading "have I seen this?" and then writing is two operations with a gap between them, and two concurrent deliveries will both read *no*. A uniqueness constraint on the provider's event identifier is decided by the database in one operation, and the loser catching a conflict is the correct outcome rather than an error to report.
Read the raw bytes and verify the signature before parsing anything.
Result A forged or mangled body is rejected without ever becoming a record.
Insert the event keyed on the provider's identifier.
Result A duplicate collides and is discarded. The first arrival wins, and which one it was does not matter.
Acknowledge with 2xx.
Result The provider stops retrying. Nothing downstream has run yet, and nothing needs to have.
Process from the stored record.
Result A failure here is your problem to retry on your own schedule, rather than a redelivery storm you cannot pause.
Order, and why you cannot have it#
Events arrive in the order the network delivers them, which is not the order they happened. A message-status update can land before the message it describes; a call-ended event can beat a call-answered one. Any receiver that assumes sequence will eventually build a record of something that never happened.
The fix is to make state derived rather than accumulated: hold the provider's own timestamps, apply the latest one wins, and let a late arrival be ignored rather than replayed. State that can be recomputed from what you have stored survives out-of-order delivery; state you incremented as events arrived does not.
Does Connect use webhooks?#
Used, inbound, on every real-time channel. WhatsApp messages arrive by webhook (whatsapp_inbound), inbound SMS arrives by webhook (sms_inbound), and inbound calls reach Connect through the carrier's callbacks (inbound_call). Each one lands in the provider-shaped tenant_* tables first and is bridged into the canonical threads, messages and contacts the engine reads — which is precisely why adding a provider changes nothing downstream.
That two-stage shape is also what makes duplicate delivery cheap here. The provider-shaped row is the record of *what arrived*; the canonical row is the record of *what it means*. A second copy of an arrival does not become a second conversation.
Questions#
Is a shared secret in the URL good enough instead of a signature?
No. A URL travels through logs, proxies and browser history, and it authenticates nothing about the body — an attacker who learns the URL can send any payload they like to it. A signature covers the content as well as the sender.
Should a webhook receiver do the work inline if it is quick?
Only if 'quick' is guaranteed, which it is not once a model call, a provider round trip or a slow query is involved. The cost of storing first and processing after is one insert; the cost of getting it wrong is redelivery while you are already busy.
What if I miss events entirely — a receiver down for an hour?
Providers retry for a while and then stop, so a long outage means gaps. That is the argument for a reconciliation path that can re-read state from the provider rather than relying only on pushes, and for treating webhooks as the fast path rather than the only one.