Send and receive a live message
The end-to-end credentialed path — send a template, expose a webhook, receive a reply, reply back.
live-validated · reviewed 2026-07-05
The quickstart runs offline against a mock transport. This walkthrough swaps in real credentials and live Meta traffic. The send path here has carried live traffic; the webhook receive path is the same code the mock exercises, wired to a public HTTPS endpoint.
Prerequisites
You need a Meta WhatsApp Business app, a WhatsApp Business Account (WABA), a
phone number id, and a permanent access token with the whatsapp_business_messaging
permission. Set up those assets in the
Meta WhatsApp Business Platform console;
WATS does not provision them. This site never asks for or handles tokens —
export them as env vars.
export WATS_ACCESS_TOKEN=EAA...
export WATS_PHONE_NUMBER_ID=103... # from your test number
export WATS_WABA_ID=...
export WATS_APP_SECRET=... # App Secret from your Meta app
export WATS_VERIFY_TOKEN=... # any string you choose; Meta echoes it on verifyInstall
bun add @wats/core @wats/graph @wats/httpSend a message
createWhatsApp wires the Graph client (default fetch transport) and typed
router behind one facade. accessToken is the only required option; the
token flows into the Authorization header and is never logged.
import { createWhatsApp } from "@wats/core";
const wa = createWhatsApp({
accessToken: process.env.WATS_ACCESS_TOKEN!,
phoneNumberId: process.env.WATS_PHONE_NUMBER_ID!,
});
// A template must exist and be approved in your WABA before you can send it.
// Use a plain text send instead if you just want to see traffic.
const sent = await wa.sendText({ to: "<your-test-number-e164>", text: "hello from wats" });
console.log(sent.messages?.[0]?.id); // wamid.HR...Template sends validate parameter counts locally before they hit Graph:
await wa.sendTemplate({
to: "<your-test-number-e164>",
name: "hello_world", // an approved template in your WABA
languageCode: "en_US",
});Receive a webhook
A send accepted with HTTP 200 only proves Meta took the request. Delivered and
read state arrives as inbound webhooks. Meta requires a public HTTPS callback
URL; use a tunnel for local development — the
live webhook guide covers the tunnel, the verify
token, and the wats serve live path.
The adapter verifies X-Hub-Signature-256 (HMAC over the raw body), normalizes
the envelope, and dispatches typed updates through the facade:
import { createWhatsApp } from "@wats/core";
import { createWebhookAdapter, createFetchWebhookHandler } from "@wats/http";
const wa = createWhatsApp({
accessToken: process.env.WATS_ACCESS_TOKEN!,
phoneNumberId: process.env.WATS_PHONE_NUMBER_ID!,
});
// Reply to inbound text with sendText.
wa.onMessage(async (ctx) => {
const message = ctx.update.message;
if (message.type !== "text") return;
await wa.sendText({ to: message.from, text: `you said: ${message.text.body}` });
});
const adapter = createWebhookAdapter({
verifyToken: process.env.WATS_VERIFY_TOKEN!,
appSecret: process.env.WATS_APP_SECRET!,
whatsapp: wa,
});
export default { fetch: createFetchWebhookHandler(adapter) };Run it behind a tunnel, point Meta's webhook at https://<your-tunnel>/webhook,
complete the verify challenge, and send yourself a message. Status webhooks
(sent/delivered/read/failed) flow through wa.onStatus — see
handle status callbacks.