Webhooks & events
Receive every message, status, and Flow submission as a signed event.
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.
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 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_* 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.
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_...",
"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, contact_id is stable per number within
your organization, and contact_reference echoes the externalId you set in
the contact's metadata — reply by calling POST /api/v1/messages with the
channel_id and conversation_id from this event.
message.received carries both contact_reference and customer_reference with
the same value. contact_reference is the canonical name used across all
events (flow.completed, data-exchange); customer_reference is a
backward-compatible alias. Prefer contact_reference in new code.
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
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));
}Always verify against the raw request body before parsing JSON, and use a
constant-time comparison. Respond 2xx quickly; Wabery retries non-2xx
responses with exponential backoff.
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
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
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,
});