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.