Webhooks

Your events, your route.

Cosend events on your endpoint, a forwarded copy alongside the inbox, or Meta's raw payloads delivered straight to you with nothing stored here. One setting per connection, free on every plan.

Three modes, and the trade each one makes

Most vendors pick one of these and call it a philosophy. It is a two-field setting, so we ship all three and let you decide per connection.

The three webhook delivery modes: what your endpoint receives and what Cosend stores.
Mode Meta override What you receive What Cosend stores
Default None Cosend events on your endpoint, plus the inbox, the history import, AI and automations. Everything
Forwarding None All of the above, and a normalised copy relayed to your endpoint with retries, a delivery log and replay. Everything
Direct delivery Phone-number level Meta’s raw payloads, unmodified, straight from Meta to your endpoint. Metadata only. No message bodies, ever

Forwarding is the interesting one. You get webhooks on your own server and you keep the inbox, because we relay from our side rather than routing Meta away from us. A router that never sees message content cannot offer that; it is not a roadmap item for them, it is a rebuild.

Direct delivery has consequences, and you see them before you switch, not after. It disables the inbox, the history import, AI and every content-based automation trigger on that connection. Switching returns a list of exactly which objects would break rather than a warning, and an automation that references a direct connection fails validation when you save it — not at 3am inside a run.

On a direct connection payloads are never written to disk, not even briefly in a raw table that is pruned later. A table that is pruned is still a table that held the content.

Override precedence

Meta accepts an alternate callback URL at two levels, and resolves them in a fixed order. Cosend configures whichever level your mode needs and reads the setting back afterwards rather than assuming it took:

  1. Business phone number override — the narrowest, and what direct delivery uses.
  2. WABA-level override — every number on the account.
  3. The app-level callback — ours, which is the default.

Two things about overrides are worth knowing before you plan around them. Template and account-level webhooks do not support overrides at all — those always arrive on the app-level callback, which means on a direct connection they still come to us and we relay them. And Meta verifies your endpoint with a challenge request before it will accept the configuration, so the URL has to be live at the moment you set it.

Event catalogue

One envelope for every event, of every type. data.object is the full resource in the same shape a GET returns, so there is one parser rather than one per event, and previous_attributes appears on updates listing only what changed.

Two of these are ours alone. message.echo fires when the owner answers from their phone — no cloud-API-only vendor can emit it, because it exists only on coexistence numbers, and it is what lets a workflow stop its own follow-up. And conversation.window_expiring turns the most common WhatsApp integration bug, sending into a closed 24-hour window, into an event you can build on.

Messages

Messages events and when each fires.
Event Fires when
message.received An inbound message from a contact
message.sent An outbound message accepted by Meta
message.status Delivery status changed — sent, delivered, read or failed
message.echo The owner replied from the WhatsApp Business app. Coexistence-only Coexistence only

Conversations

Conversations events and when each fires.
Event Fires when
conversation.created The first message in a new thread
conversation.updated State, assignee, control or labels changed
conversation.window_expiring The 24-hour service window closes in one hour
conversation.lock_changed An agent took, released or handed over a thread
conversation.lock_requested Someone asked the holder to hand over

Contacts

Contacts events and when each fires.
Event Fires when
contact.created A new contact appears
contact.updated Any field changed
contact.consent_changed An opt-in or an opt-out was recorded

The number itself

The number itself events and when each fires.
Event Fires when
connection.connected A number finished onboarding
connection.disconnected The coexistence link ended
connection.needs_reauth The connection needs reconnecting
connection.quality_changed Meta changed the quality rating
connection.limit_changed The messaging limit tier changed
onboarding.progress The history import advanced. Also completed and failed
template.status_changed A template was approved, rejected, paused or disabled

Automation and usage

Automation and usage events and when each fires.
Event Fires when
automation.run_started A run began. Also run_finished, run_failed and auto_disabled
approval.requested A step is waiting on a human. Also decided
notification.sent The notify rail. Also delivered, failed and expired
usage.threshold_reached 80% or 100% of a meter
ping Sent by the test button, and never by anything else

Signing — two schemes, and which one you get

Which header arrives depends on who sent the delivery, so both are here and each is labelled. Verify before you trust the body, and compare in constant time in both cases — a plain string comparison leaks timing.

Cosend events — default and forwarding modes

v1 is an HMAC-SHA256 of "{t}.{raw body}", hex. Parse t, reject anything more than five minutes old, recompute over the raw body. During a secret rotation both secrets sign and both v1= values are present.

Cosend-Signature
Cosend-Signature: t=1755687852,v1=5257a869e7bcfd6d6…

This is Stripe's scheme, deliberately. Every developer has already implemented it, every language has a snippet for it, and the timestamped payload resists replay. Inventing a signing scheme here would be a novelty with no upside.

Meta's payloads — direct delivery

Meta signs with X-Hub-Signature-256, prefixed sha256=. Capture the raw bytes before any JSON middleware touches them: re-serialising loses Meta's exact escaping and the HMAC will never match.

node · verify a direct delivery
// the raw request body, not the parsed object
const expected = crypto
  .createHmac("sha256", APP_SECRET)
  .update(rawBody)
  .digest("hex");

const got = header.slice("sha256=".length);
return got.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));

The one that catches people. On a direct connection the payload is still signed with Cosend's app secret, even though the delivery goes nowhere near us and even if you set your own verify token — because it is still our app's subscription at Meta. The secret is on your connection's settings page.

Delivery and retries

At-least-once, unordered, idempotent by event id. Every webhook system is this one; the ones that pretend otherwise create bugs in their customers' code.

Cosend webhook delivery properties: timeouts, retry schedule and auto-disable.
Property Value
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 — but a message status never regresses to an earlier state
Duplicates Possible. The event id is stable, so consumers must be idempotent
Auto-disable 100 consecutive failures over 24 hours disables the endpoint, emails the owner and writes an audit event
Source addresses We publish no stable IP range, because ours are not static. Allowlist by hostname, or verify the signature

Meta's own retry schedule is a different thing, and its documentation contradicts itself. The WhatsApp webhooks page says delivery is retried with decreasing frequency for up to seven days; the general Graph webhooks page says a few times over the following 36 hours. On direct delivery that is the schedule your endpoint is on, so plan durability against the shorter figure and duplicate suppression against the longer one. Meta also batches updates into one request when it can, and says batching is not guaranteed.