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.
| 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:
- Business phone number override — the narrowest, and what direct delivery uses.
- WABA-level override — every number on the account.
- 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
| 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
| 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
| 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
| 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
| 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: 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.
// 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.
| 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.