Webhooks
Registering an endpoint, the event catalogue, verifying the Cosend-Signature header, and what our delivery guarantees actually are — at-least-once, unordered, idempotent by event id.
Cosend posts an event to your endpoint whenever something happens on your WhatsApp number. One envelope shape for every event, Stripe’s signing scheme, and delivery guarantees stated plainly rather than implied.
The webhooks page covers routing and overrides — sending Meta’s raw payloads to your existing handler, which is a different thing from the events below. This page is Cosend’s own event stream.
Registering an endpoint
POST /v1/webhook-endpoints
{ "url": "https://example.com/hooks/cosend", "events": ["message.received"], "connection_id": "conn_01K3F8…" }
connection_id is optional: omit it and the endpoint receives events for every connection in the
organization.
The response carries the signing secret, and it is the only response that ever does. Store it
when you create the endpoint. GET /v1/webhook-endpoints never returns it, and neither does
anything else.
| Method | Path | What it does |
|---|---|---|
GET |
/v1/webhook-endpoints |
List your endpoints |
POST |
/v1/webhook-endpoints |
Create one. Returns the signing secret once |
PATCH |
/v1/webhook-endpoints/{id} |
Change the URL or the event list |
DELETE |
/v1/webhook-endpoints/{id} |
Remove it |
POST |
/v1/webhook-endpoints/{id}/test |
Send a ping synchronously and return the result |
GET |
/v1/webhook-endpoints/{id}/deliveries |
Attempt history, with the status code we got back |
POST |
/v1/webhook-deliveries/{id}/replay |
Re-deliver one attempt |
POST |
/v1/webhook-endpoints/{id}/rotate-secret |
New secret. Both are valid for 24 hours |
Your URL is re-resolved on every delivery
Not only at registration. A hostname that resolved to a public address when you registered it can point somewhere else an hour later, so we check on each send rather than trusting the answer we got once. If you move your endpoint behind a new host, nothing on your side needs to change.
The envelope
Every event, every type, the same shape:
{
"id": "evt_01K3F8QY7ZC4M2N9V6XJ0RTBHE",
"object": "event",
"type": "message.received",
"api_version": "v1",
"created_at": "2026-08-20T11:04:12Z",
"organization_id": "org_01K3F8…",
"connection_id": "conn_01K3F8…",
"data": { "object": {} }
}
data.object is the full resource in the same shape GET returns it, so you write one parser
and use it for both. previous_attributes appears only on *.updated events and lists just the
fields that changed.
Verifying the signature
Cosend-Signature: t=1755687852,v1=5257a869e7bcfd6d6...
v1 is HMAC-SHA256(secret, "{t}.{raw_body}"), hex-encoded.
To verify: parse t, reject anything more than five minutes old, recompute the HMAC over the
raw request body, and compare in constant time.
Compute over the raw bytes, before any JSON parsing. Parsing and re-serialising changes whitespace and key order, and the HMAC is over the bytes we sent. This is the single most common way a webhook integration fails verification while looking correct.
During a secret rotation both secrets sign and both v1= values are present in the header, so
you can deploy the new secret before or after rotating without dropping deliveries.
This is Stripe’s scheme deliberately — every language has a snippet for it, and the timestamped payload construction resists replay. A novel signing scheme here would be a novelty with no upside.
Delivery guarantees
| Timeout | 10 seconds |
| Success | Any 2xx |
| Retries | 8, exponential with jitter: 5 s, 30 s, 2 m, 10 m, 1 h, 4 h, 12 h, 24 h |
| Ordering | Not guaranteed. Order by created_at. On message.status, a later state never regresses |
| Duplicates | Possible. id is stable — make your handler idempotent on it |
| Auto-disable | 100 consecutive failures over 24 hours disables the endpoint, emails the owner, and writes an audit event |
At-least-once, unordered, idempotent by id. Every webhook system in the world works this way;
the ones that imply otherwise create bugs in their customers’ code. Return 2xx as soon as you have
stored the event, and do your work afterwards — a handler that finishes its processing before
replying is a handler that will time out and be retried.
There is no stable IP range to allowlist
Our egress addresses are not static. Allowlist by hostname, or — better — use the signature above as the check. It is the stronger control anyway: an IP allowlist proves where a request came from, and the signature proves we sent it.
Event catalogue
Thirty-three events, from the published contract.
| Event | Fires when |
|---|---|
message.received |
Inbound message from a contact |
message.sent |
Outbound message accepted by Meta |
message.status |
Delivery status changed — sent, delivered, read or failed |
message.echo |
The owner sent a message from the WhatsApp Business app — coexistence only |
conversation.created |
First message in a new thread |
conversation.updated |
State, assignee, control or labels changed |
conversation.window_expiring |
The 24-hour customer service window closes in one hour |
conversation.lock_changed |
A lock was acquired, released, expired, went idle or transferred |
conversation.lock_requested |
Someone asked the current holder to hand a conversation over |
contact.created |
A contact was created |
contact.updated |
A contact changed |
contact.consent_changed |
An opt-in or opt-out was recorded |
connection.connected |
A WhatsApp number finished connecting |
connection.disconnected |
A number was disconnected |
connection.needs_reauth |
A number needs its access re-authorised |
connection.quality_changed |
Meta changed the quality rating |
connection.limit_changed |
Meta changed the messaging limit tier |
onboarding.progress |
History import progressed |
onboarding.completed |
History import finished |
onboarding.failed |
History import failed |
template.status_changed |
A template was approved, rejected, paused or disabled |
notification.sent |
The notify rail sent one |
notification.delivered |
It was delivered |
notification.failed |
It failed |
notification.expired |
It expired before it could be sent |
automation.run_started |
An automation run began |
automation.run_finished |
A run finished |
automation.run_failed |
A run failed |
automation.auto_disabled |
An automation was disabled after repeated failures |
approval.requested |
A run is waiting on a human decision |
approval.decided |
That decision was made |
usage.threshold_reached |
A usage meter crossed a warning threshold |
ping |
Test only — what /test sends |
Two of these are ours alone. message.echo exists because coexistence is the product: a
cloud-API-only provider cannot see a message its customer sent from their own phone.
conversation.window_expiring turns the most common WhatsApp integration bug — sending into a
closed window — into an event you can act on an hour ahead.
Subscribe to what you need
events on the endpoint is a filter, and a narrow one is worth setting. message.status alone is
four deliveries per outbound message; an endpoint subscribed to everything on a busy number receives
a great deal it will discard.