Skip to content
Start free

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.

Add your HTTPS endpoint in the dashboard under Webhooks, or fully from code so your setup stays scriptable:

Terminal window
# Set the webhook URL and a bring-your-own signing secret on a project
wabery 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_secret
wabery env --project-id "proj_id" # → ready-to-paste WABERY_* block

Each 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.

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" });

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.

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.

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.

{
"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.

{
"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.

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.

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].media is the media attached to that specific inbound message.
  • assets is a flattened list of all media files in the delivery.
  • media is the latest media file in the delivery, for convenience.
  • location carries 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.

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,
);

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;
}

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 processed

createDedupeStore 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 processed

Exercise your handler without deploying. The CLI signs a fake payload with your secret and POSTs it to your local server:

Terminal window
wabery webhooks send-test --url http://localhost:3000/webhooks \
--secret "$WABERY_WEBHOOK_SECRET" --event message.received

In 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,
});