Webhooks
Wabery delivers everything that happens on your channels to your endpoint as typed, signed events — inbound messages, delivery statuses, and Flow submissions. No polling, with automatic retries on failure.
The dashboard’s Webhooks page is the single home for both configuration and delivery activity. Endpoint setup is collapsed by default; select Manage to edit the URL, send a test event, copy or rotate the signing secret, and review verification requirements. Recent deliveries remains visible underneath, with event and status filters, automatic refresh, response codes, retry counts, and expandable payloads.
Register an endpoint
Section titled “Register an endpoint”Add your HTTPS endpoint in the dashboard under Webhooks, or fully from code so your setup stays scriptable:
# Set the webhook URL and a bring-your-own signing secret on a projectwabery projects update "proj_id" \ --webhook-url "https://yourapp.com/webhooks/wabery" \ --webhook-secret "$WABERY_WEBHOOK_SECRET"
# Read it back any time (also includes webhook_url)wabery projects get "proj_id" # → includes webhook_secretwabery env --project-id "proj_id" # → ready-to-paste WABERY_* blockEach project has one signing secret used to verify deliveries (and outbound
data-exchange calls). It’s revealed by GET /projects/{id},
settable on create/update (bring-your-own), and rotatable with
POST /projects/{id}/rotate-webhook-secret. The list endpoint never returns it.
Routing mode: external webhook vs in-app automations
Section titled “Routing mode: external webhook vs in-app automations”A project’s routing_mode decides what happens to each inbound message:
routing_mode |
Inbound handling |
|---|---|
EXTERNAL |
Every inbound message is forwarded to your webhook as message.received. You own the reply logic and call the REST API to respond. This is the default for API-first projects. |
FLOWS |
Inbound messages are handled by Wabery’s built-in automations (KEYWORD / WELCOME / ANY_MESSAGE) and auto-replies. |
Channel routing overrides the project
Section titled “Channel routing overrides the project”Each dedicated channel also carries its own routing_mode, and for that
channel it takes precedence over the project’s. A dedicated number sitting in
FLOWS does not forward inbound to your webhook even if the project is
EXTERNAL with a webhook configured — inbound is handled by automations (and
silently dropped if none match). Newly linked channels inherit the project’s
routing mode at connect time, so a number added under an EXTERNAL project
forwards from the start.
To diagnose or fix an existing channel, channels.list() and
channels.get(id) return a routing_warning whenever a channel’s routing would
silently drop inbound (e.g. FLOWS under an EXTERNAL, webhook-backed project).
The inbound worker also logs the same warning per message. Flip the channel to
EXTERNAL programmatically:
await wabery.channels.update("channel_...", { routingMode: "EXTERNAL" });wabery channels update channel_... --routing-mode EXTERNALcurl -X PATCH https://api.wabery.com/v1/channels/channel_... \ -H "Authorization: Bearer $WABERY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "routing_mode": "EXTERNAL" }'Event shape
Section titled “Event shape”Every delivery is { event, payload, sentAt }. An inbound
message.received looks like this:
{ "event": "message.received", "payload": { "agent_id": "agent_...", "organization_id": "org_...", "channel_id": "channel_...", "conversation_id": "conv_...", "contact_id": "contact_...", "from": "+15551234567", "text": "do you ship internationally?", "message_id": "msg_...", "type": "text", "ai_handoff": { "status": "AI_ACTIVE", "reason": null, "handoff_at": null, "is_on_hold": false }, "messages": [ { "id": "msg_...", "type": "text", "text": "do you ship internationally?", "received_at": "2026-06-20T14:21:10Z", "media": null, "location": null, "interactive": null, "replied_to": null } ], "assets": [], "media": null, "location": null, "contact_reference": "your-own-user-id", "customer_reference": "your-own-user-id", "metadata": { "plan": "pro" } }, "sentAt": "2026-06-20T14:21:10Z"}from is the sender’s E.164 number when Meta discloses it. A WhatsApp user who
contacts you by username can have from: null; in that case user_id is the
stable WhatsApp Business Scoped User ID (BSUID). BSUIDs are scoped to the Meta
business portfolio that owns the number, so do not compare them across
portfolios. On Instagram and Messenger, user_id remains the Instagram-scoped
or Page-scoped sender ID. parent_user_id and username are included when Meta
supplies them.
contact_id is Wabery’s stable contact identifier, and contact_reference
echoes the externalId you set in the contact’s metadata. Reply by calling
POST /v1/messages with the channel_id and conversation_id from this event.
Wabery follows Meta identity migrations delivered through user_id_update and
emits contact.identity.updated when a stored WhatsApp ID changes. Delivery
status events can also contain recipient_user_id; treat it as the identity and
recipient_id as an optional phone address.
Contact-info responses arrive as messages[n].type: "contacts" with the full
cards in messages[n].contacts. WhatsApp account/system notices arrive as
type: "system" with messages[n].system.
Unsupported WhatsApp messages
Section titled “Unsupported WhatsApp messages”When Meta Cloud API cannot forward the original content it still delivers a
messages webhook. Wabery keeps that official envelope and forwards it on
message.received as type: "unsupported". Do not treat text as customer
copy — it is null.
This matches the unsupported messages webhook reference:
{ "id": "msg_...", "type": "unsupported", "text": null, "unsupported": { "type": "unknown", "raw_type": "unknown", "errors": [ { "code": 131060, "title": "This message is unavailable.", "message": "This message is unavailable.", "error_data": { "details": "This message is currently unavailable." } } ] }}Official Cloud API error codes:
131051— Cloud API does not support the message type (Message type unknown).131060— The message is currently unavailable. Meta documents this for first messages to a WhatsApp Business app phone number, especially click-to-WhatsApp ads. The same envelope also appears when Meta withholds view-once or disappearing content.
unsupported.type is Meta’s official messages[].unsupported.type (for
example edit, poll_update, unknown). Ask the customer to resend as a
regular text or photo — Cloud API cannot recover the original body.
Deleted and edited WhatsApp messages
Section titled “Deleted and edited WhatsApp messages”Official Cloud API revoke
and edit
webhooks are coexistence-only (WhatsApp Business app). Wabery applies them to
the original stored message and also forwards the official envelope on
message.received.
Delete (type: "revoke")
Section titled “Delete (type: "revoke")”{ "id": "msg_...", "type": "revoke", "text": null, "revoke": { "original_message_id": "wamid...." }}original_message_id is the WhatsApp id (wamid) of the message the customer
deleted. Wabery clears that row’s content in the inbox. Native automations do
not reply.
Edit (type: "edit")
Section titled “Edit (type: "edit")”{ "id": "msg_...", "type": "edit", "text": "Updated body", "edit": { "original_message_id": "wamid....", "message": { "type": "text", "text": { "body": "Updated body" } } }}Wabery updates the original row with the new body/media. Native automations do not treat the edit as a new customer turn.
Meta currently delivers many customer edits as type: "unsupported" with
unsupported.type: "edit" and error 131051 instead of type: "edit". Those
arrive on the unsupported path above — Cloud API did not include the new body,
so Wabery cannot apply the edit.
Business-app coexistence echoes (the business deletes/edits from the WhatsApp
Business app) update the stored message in the inbox. They do not emit
message.received.
When handoff is on for a conversation, Wabery does not deliver
new message.received events to your external webhook and rejects automated
POST /v1/messages replies with 409 ai_handoff_active. Treat that response as
a hard stop until a teammate turns handoff off in the inbox. The ai_handoff
object is included on delivered inbound events so your service can log or branch
on the current state.
Quoted replies
Section titled “Quoted replies”When a customer replies to a specific message rather than sending a fresh one,
that message carries replied_to:
{ "id": "msg_...", "type": "text", "text": "yes, that one", "replied_to": { "message_id": "wamid....", "from": "+15551234567" }}message_id is the quoted message’s WhatsApp id. Pass it straight back as
reply_to on POST /v1/messages
to answer in the same thread. replied_to is null when nothing is quoted.
Inbound media files
Section titled “Inbound media files”When a contact sends an image, voice note, audio file, video, document, or
sticker, the message.received webhook includes file metadata and a Wabery-hosted
download URL:
messages[n].mediais the media attached to that specific inbound message.assetsis a flattened list of all media files in the delivery.mediais the latest media file in the delivery, for convenience.locationcarries latitude/longitude for location messages. Locations do not have a file URL.
The url is a short-lived signed URL, valid for about 1 hour by default and
capped by the asset’s remaining retention. The expires_at field is the asset
retention deadline, not the signed URL deadline: 24 hours on the free plan and 7
days on paid plans. Store files on your side promptly. If you read the same
message later through GET /conversations/{id}/messages, Wabery re-signs a
fresh URL as long as the retained object still exists.
Wabery starts caching inbound WhatsApp, Instagram, and Messenger media as soon as
the webhook is ingested and retries before delivery. If caching still fails, or
the retained object has expired, the webhook is still delivered with status
set to unavailable or expired and url: null.
Example image payload:
{ "event": "message.received", "payload": { "agent_id": "agent_...", "organization_id": "org_...", "channel_id": "channel_...", "conversation_id": "conv_...", "contact_id": "contact_...", "from": "+15551234567", "text": null, "message_id": "msg_...", "type": "image", "ai_handoff": { "status": "AI_ACTIVE", "reason": null, "handoff_at": null, "is_on_hold": false }, "messages": [ { "id": "msg_...", "type": "image", "text": null, "received_at": "2026-06-20T14:21:10Z", "media": { "id": "asset_...", "message_id": "msg_...", "type": "image", "url": "https://storage.wabery.com/assets/inbound/asset_...?token=...", "expires_at": "2026-06-27T14:21:10Z", "mime_type": "image/jpeg", "file_name": "msg_...-image.jpg", "file_size": 248913, "provider": "whatsapp", "provider_media_id": "wamid...", "status": "available" }, "location": null } ], "assets": [ { "id": "asset_...", "message_id": "msg_...", "type": "image", "url": "https://storage.wabery.com/assets/inbound/asset_...?token=...", "expires_at": "2026-06-27T14:21:10Z", "mime_type": "image/jpeg", "file_name": "msg_...-image.jpg", "file_size": 248913, "provider": "whatsapp", "provider_media_id": "wamid...", "status": "available" } ], "media": { "id": "asset_...", "message_id": "msg_...", "type": "image", "url": "https://storage.wabery.com/assets/inbound/asset_...?token=...", "expires_at": "2026-06-27T14:21:10Z", "mime_type": "image/jpeg", "file_name": "msg_...-image.jpg", "file_size": 248913, "provider": "whatsapp", "provider_media_id": "wamid...", "status": "available" }, "location": null, "contact_reference": "your-own-user-id", "customer_reference": "your-own-user-id", "metadata": { "plan": "pro" } }, "sentAt": "2026-06-20T14:21:10Z"}Handle files by checking status and downloading only when url is present:
if (event.event === "message.received") { for (const asset of event.payload.assets ?? []) { if (asset.status !== "available" || !asset.url) continue;
const response = await fetch(asset.url); if (!response.ok) throw new Error(`Failed to download ${asset.id}`);
await storeFile({ waberyAssetId: asset.id, messageId: asset.message_id, contentType: asset.mime_type, fileName: asset.file_name, body: await response.arrayBuffer(), retainedUntil: asset.expires_at }); }}The signed URL already contains its authorization in the token query
parameter. Do not attach the Wabery API key or an Authorization header when
downloading it. provider is whatsapp, instagram, or messenger;
provider_media_id may be null when Meta supplies only a temporary source
URL. See Media messages for complete Instagram and
Messenger payloads, filename behavior, and provider limits.
Common event types:
| Type | When |
|---|---|
message.received |
A contact sent you a message. |
message.status |
A message you sent was delivered / read / failed. |
flow.completed |
A contact completed a WhatsApp Flow. See its payload in the Flows guide. |
flow.status |
Meta reported a Flow status change such as PUBLISHED, BLOCKED, or DEPRECATED. |
template.status |
Meta reported a WhatsApp template review/status change such as APPROVED or REJECTED. |
participant.joined |
A sandbox participant joined a project. |
Verify the signature
Section titled “Verify the signature”Every delivery includes an x-wabery-signature header formatted as
sha256=<hex>. Verify it over the raw request body with your endpoint’s signing
secret and reject anything that doesn’t match:
import { Wabery } from "@wabery/sdk";
const wabery = new Wabery();
const valid = wabery.webhooks.verifySignature( rawBody, request.headers["x-wabery-signature"], process.env.WABERY_WEBHOOK_SECRET,);import crypto from "node:crypto";
function verify(rawBody: string, signature: string, secret: string) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));}$rawBody = file_get_contents("php://input");$signature = $_SERVER["HTTP_X_WABERY_SIGNATURE"] ?? "";$secret = getenv("WABERY_WEBHOOK_SECRET");
if ($rawBody === false || $secret === false || $secret === "") { throw new RuntimeException("Webhook secret or raw body is unavailable");}
$expected = "sha256=" . hash_hmac("sha256", $rawBody, $secret);$valid = hash_equals($expected, $signature);import hashlibimport hmacimport os
expected = "sha256=" + hmac.new( os.environ["WABERY_WEBHOOK_SECRET"].encode("utf-8"), raw_body, hashlib.sha256,).hexdigest()
valid = hmac.compare_digest(expected, signature)Parse into a typed event
Section titled “Parse into a typed event”constructEvent verifies the signature and returns a typed, discriminated
event in one step — switch on event.event and the payload narrows automatically.
It throws WaberySignatureVerificationError on a bad signature, so a caught error
always means “reject”.
import { Wabery, type WaberyEvent } from "@wabery/sdk";
const wabery = new Wabery();
// rawBody must be the exact bytes received (not re-stringified JSON).const event: WaberyEvent = wabery.webhooks.constructEvent( rawBody, request.headers["x-wabery-signature"], process.env.WABERY_WEBHOOK_SECRET,);
switch (event.event) { case "message.received": await reply(event.payload.from, event.payload.text); break; case "flow.completed": await save(event.payload.submission); break; case "message.status": case "participant.joined": break;}Idempotency
Section titled “Idempotency”Deliveries are at-least-once. Deduplicate on a stable id — message_id for
message.received, flow_token for flow.completed (one flow.completed per
flow_token, even if Meta retries):
import { createDedupeStore } from "@wabery/sdk";
const seen = createDedupeStore({ ttlMs: 24 * 60 * 60 * 1000 });if (seen.add(event.payload.message_id)) return; // already processedcreateDedupeStore is single-instance (state lives in one process). For multiple
instances, implement the DedupeStore interface over a shared store — add(key)
returns true when the key was already seen:
import type { DedupeStore } from "@wabery/sdk";
const store: DedupeStore = { async add(key) { // SET key NX with a TTL; null reply means it already existed → duplicate. const ok = await redis.set(`wh:${key}`, "1", "PX", 86_400_000, "NX"); return ok === null; },};
if (await store.add(event.payload.message_id)) return; // already processedTest locally
Section titled “Test locally”Exercise your handler without deploying. The CLI signs a fake payload with your secret and POSTs it to your local server:
wabery webhooks send-test --url http://localhost:3000/webhooks \ --secret "$WABERY_WEBHOOK_SECRET" --event message.receivedIn code, signPayload produces the same x-wabery-signature header for fixtures and
tests:
import { signPayload } from "@wabery/sdk";
const body = JSON.stringify(myFakeEvent);const res = await fetch("http://localhost:3000/webhooks", { method: "POST", headers: { "x-wabery-signature": signPayload(body, secret) }, body,});