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_* 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.

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:

TypeWhen
message.receivedA contact sent you a message.
message.statusA message you sent was delivered / read / failed.
flow.completedA contact completed a WhatsApp Flow. See its payload in the Flows guide.
flow.statusMeta reported a Flow status change such as PUBLISHED, BLOCKED, or DEPRECATED.
template.statusMeta reported a WhatsApp template review/status change such as APPROVED or REJECTED.
participant.joinedA 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 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

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.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,
});
Webhooks & events | Wabery Docs | Wabery