@wabery/sdk
v0.12.1
Published
Official TypeScript server-side SDK for the Wabery messaging infrastructure API.
Maintainers
Readme
Wabery TypeScript SDK
Official server-side TypeScript SDK for the Wabery REST API.
Wabery covers the full messaging workflow for WhatsApp, Instagram, and Messenger: channels and routing, contacts and registration intents, conversations and message history, outbound text/templates/media/interactive messages, reactions, locations, stickers, inbound media files with signed URLs, WhatsApp Flows, hosted functions, submissions, dispatches, config-as-code, signed webhooks, and Meta Business Agent API types.
Read the full Wabery API documentation at www.wabery.com/docs.
npm install @wabery/sdkimport { Wabery } from "@wabery/sdk";
const wabery = new Wabery();
const identity = await wabery.identity.retrieve();
console.log(identity.project.id);
const message = await wabery.messages.send({
channelId: "channel_...",
conversationId: "conversation_...",
text: "Thanks for your message",
});
const status = await wabery.messages.get(message.id);
console.log(status.delivery_status);The SDK reads WABERY_API_KEY, WABERY_BASE_URL, and WABERY_API_VERSION
from the environment. It ships a dual build, so both ESM (import) and CommonJS
(require("@wabery/sdk")) work.
First sandbox reply
For a new project, create or select the project in the Wabery dashboard, then run
wabery login and paste the one-time key shown in the browser back into the
terminal. The CLI stores the key locally; for your app process, export it as
WABERY_API_KEY.
Use the dashboard's WhatsApp button or manually text the project join code
(wab-ABC123 style) to the shared Wabery sandbox number. That join-code message
only registers your phone. From the CLI, wabery projects get "project_id"
returns join_code and enrollment_channel.phone_number. Send one normal
WhatsApp message next, then find the ids:
wabery projects get "project_id"
wabery channels list
wabery conversations list
wabery conversations messages "conversation_..."Use the returned channel_id and conversation_id with the SDK:
await wabery.messages.send({
channelId: "channel_...",
conversationId: "conversation_...",
text: "Thanks for your message",
});Use conversationId when replying in an existing conversation. Sending directly
to to: "+14155550100" is supported only on dedicated WhatsApp channels after
you have recorded an active opt-in for that contact. Phone numbers are expected in
E.164 format (a leading + and country
code); Wabery normalizes and dedupes them per organization server-side.
Outbound content can be text, templates, media, interactive buttons/lists, reactions, locations, or stickers. Provide exactly one content field:
await wabery.messages.send({
channelId: "channel_...",
conversationId: "conversation_...",
reaction: { messageId: "wamid....", emoji: "👍" },
});
await wabery.messages.send({
channelId: "channel_...",
conversationId: "conversation_...",
location: {
latitude: 37.7749,
longitude: -122.4194,
name: "San Francisco office",
},
});
await wabery.messages.send({
channelId: "channel_...",
conversationId: "conversation_...",
sticker: { link: "https://example.com/sticker.webp" },
});Registration intents
Create single-use WhatsApp onboarding links from a browser-safe publishable key.
When the key starts with wab_pub_, the SDK sends it as
x-wabery-publishable-key instead of a Bearer token:
const wabery = new Wabery({ apiKey: "wab_pub_..." });
const intent = await wabery.registrationIntents.create({
customerReference: "user_42",
metadata: { plan: "starter" },
});
// Send the customer to intent.wa_link, then poll status:
const status = await wabery.registrationIntents.get(intent.id);Server-side code may also create and poll intents with a wab_live_ key that
has the registrations:create scope.
Webhooks
Verify and parse a delivery into a typed, discriminated event with
constructEvent (Stripe-style). Pass the raw, unparsed request body and the
full x-wabery-signature header (formatted sha256=<hex>):
import { Wabery, type WaberyEvent } from "@wabery/sdk";
const wabery = new Wabery();
// rawBody must be the exact bytes received — do not JSON.parse then re-stringify.
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;
}constructEvent throws WaberySignatureVerificationError on a bad signature.
If you only need a boolean, wabery.webhooks.verifySignature(rawBody, header, secret)
verifies without parsing.
Inbound message.received events include messages, assets, media, and
location. Cached WhatsApp media uses short-lived Wabery signed URLs; download it
promptly. expires_at is the retention deadline: 24 hours on free plans and 7
days on paid plans. assets is a flattened list of inbound files, media is the
latest file, and messages[n].media is the file attached to that specific
message. Supported inbound file payloads include WhatsApp images, voice notes,
audio, video, documents, and stickers.
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 saveInboundFile({
id: asset.id,
messageId: asset.message_id,
fileName: asset.file_name,
contentType: asset.mime_type,
bytes: await response.arrayBuffer(),
retainedUntil: asset.expires_at,
});
}
}Deliveries are at-least-once. Deduplicate on a stable id (message_id for
message.received, flow_token for flow.completed):
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. For multiple instances, implement the
DedupeStore interface over a shared store (Redis SET … NX PX, a unique DB
column) — add(key) returns true when the key was already seen.
The flow.completed submission is a Record<string, unknown>; coerce values with
asNumber / asString / asBoolean / asDate / asDateRange / asStringArray, or
coerceSubmission(submission, { budget: "number", … }) for the whole record at once.
Contacts
Enroll a contact (records opt-in, returns a stable contact_id), optionally with a
preferredLanguage used to resolve locale-specific flows, and update it later:
const contact = await wabery.contacts.enroll({
phone: "+14155550100",
preferredLanguage: "es", // BCP-47; resolves locale flows on sendByConfigKey
referenceId: "user_42", // your id, echoed back on every event
optIn: { source: "checkout" },
});
// Set the language later, or mark enrollment state read by gated automations.
// `metadata` is shallow-merged; `preferredLanguage: null` clears it.
await wabery.contacts.update(contact.id, {
preferredLanguage: "id",
metadata: { linked: true },
});Channels & routing
A channel's routing_mode decides what happens to inbound on it — EXTERNAL
forwards to your project webhook, FLOWS runs native automations, NONE records
only. For a dedicated number this overrides the project's routing_mode, so
a channel left in FLOWS under an EXTERNAL project silently never reaches your
webhook. New channels inherit the project routing at connect time.
list() / get() return a routing_warning when a channel would drop inbound,
and update() lets you fix it (or set it in CI):
const { data } = await wabery.channels.list();
for (const channel of data) {
if (channel.routing_warning) console.warn(channel.id, channel.routing_warning);
}
// Forward this dedicated number's inbound to your webhook.
await wabery.channels.update("channel_...", { routingMode: "EXTERNAL" });
// Or set the project-wide default that new channels inherit.
await wabery.projects.update("project_...", { routingMode: "EXTERNAL" });WhatsApp channels and projects also expose publish_readiness. The sandbox can
publish Flows but cannot submit templates; dedicated WhatsApp channels must have
a Meta-verified business account before Flows or templates are submitted.
Template submission and proactive template sends also require a Meta payment
method; if NO_PAYMENT_METHOD is present, add one in Meta Business Settings
before sending templates. If readiness blockers such as BUSINESS_NOT_VERIFIED
or NO_PAYMENT_METHOD are present, publish/template calls return 412 with
the same publish_readiness object and do not queue work.
WhatsApp Flows
Author a flow's draft with the typed builders, apply it, then publish:
import { defineFlow, screen, textField, numberField, choiceField } from "@wabery/sdk";
const draft = defineFlow([
screen("intake", "Tell us about your project", [
textField("name", { label: "Your name", required: true }),
numberField("budget", { label: "Budget (USD)" }),
choiceField("timeline", {
label: "Timeline",
options: [
{ id: "asap", title: "ASAP" },
{ id: "this_month", title: "This month" },
],
}),
]),
]);
await wabery.config.apply({
flows: [{ key: "lead_intake", name: "Lead intake", draft }],
automations: [],
});
// config.apply works in flow keys; publish/send work in flow_ids. Bridge them:
const flow = await wabery.flows.findByConfigKey("lead_intake");
await wabery.flows.publish(flow.id); // queues the publish job
// Meta reviews asynchronously; poll until it is sendable:
await wabery.flows.waitUntilPublishable(flow.id, { timeoutMs: 120_000 });
// Or force a one-off live status refresh:
await wabery.flows.status(flow.id);
const { flow_token } = await wabery.flows.send(flow.id, {
channelId: "channel_...",
contactId: "contact_...", // exactly one recipient: contactId | conversationId | to
});
// Inspect, debug, and clean up:
const full = await wabery.flows.get(flow.id); // includes the compiled flow_json
const { data: events } = await wabery.flows.events(flow.id); // publish/submission/reconcile log
await wabery.flows.delete(flow.id); // draft → delete on Meta, published → deprecatePrefer hand-written Meta Flow JSON? Set flow_json (typed as MetaFlowJson)
instead of draft, and check it locally before publishing with validateFlowJson(json)
— a structural-only check (missing version, no terminal screen, dangling/unreachable
screens). A clean result does not guarantee Meta will accept it (e.g. flow-name
uniqueness is only enforced at publish). The completed submission arrives on your
flow.completed webhook — there is no onSubmit URL — with valid and, when
invalid, validation_errors. Correlate it via the flow_token returned from
flows.send, or via contact_reference echoed on every event.
Localization
WhatsApp Flows have no native locale switch. For fixed copy, translate a draft into
one draft per language with localizeFlow, publish each as a flow that shares one
config_key with a distinct locale (omit locale for the group default), then
send by config key — Wabery resolves the variant for the contact's language:
import { localizeFlow } from "@wabery/sdk";
const byLocale = localizeFlow(draft, {
en: {}, // group default (no locale)
es: { "Your name": "Tu nombre", Submit: "Enviar" },
});
await wabery.config.apply({
flows: [
{ key: "lead_intake", name: "Lead intake", draft: byLocale.en },
{ key: "lead_intake", name: "Lead intake (es)", locale: "es", draft: byLocale.es },
],
automations: [],
});
// publish each variant, then let Wabery pick by the contact's preferredLanguage
// (explicit locale -> contact language -> "" default), falling back gracefully:
const { flow_token, locale } = await wabery.flows.sendByConfigKey("lead_intake", {
channelId: "channel_...",
contactId: "contact_...", // uses this contact's preferredLanguage
locale: "es", // optional override
});For per-user copy, drive translations through the data-exchange endpoint instead.
Proactive flows (outside the 24h window)
An interactive flows.send only works inside the 24-hour customer-service window.
To reach a contact proactively, send an approved flow-type message template (a
template with a FLOW button) and pass the flow token via a button action:
await wabery.messages.send({
channelId: "channel_...",
to: "+14155550100",
template: {
name: "daily_reminder",
language: "en",
components: [
{
type: "button",
sub_type: "flow",
index: "0",
parameters: [{ type: "action", action: { flow_token: "..." } }],
},
],
},
});Dynamic flows (data-exchange)
For DATA_EXCHANGE flows, Wabery POSTs to your data_exchange_url signed with the
same x-wabery-signature scheme and secret as webhooks. Verify and build
responses with the typed helpers:
import { flowOptions, dataExchangeScreen, dataExchangeError } from "@wabery/sdk";
const req = wabery.webhooks.verifyDataExchange(rawBody, signatureHeader, secret);
if (req.action === "ping") return { data: { status: "active" } };
const projects = await projectsFor(req.contact_reference, req.data?.workspace);
return dataExchangeScreen("SELECT", {
...flowOptions("project", projects), // -> { project_options: [{ id, title }] }
});
// To surface a validation problem instead of advancing:
// return dataExchangeError("SELECT", "No active projects in this workspace.");Replace, not merge: the data you return becomes the target screen's data model
wholesale — resend all <field>_options and prefills the screen needs on every
response that lands on it.
Hosted functions
Hosted functions are Wabery-hosted TypeScript handlers for message-triggered logic, Business Agent tools, and external MCP/tool invocation. Create metadata, deploy source, test, then expose as a tool if needed:
const fn = await wabery.functions.create({
slug: "quote-customer",
name: "Quote customer",
triggerType: "ANY_MESSAGE",
});
await wabery.functions.deploy(fn.id, {
source: `
export default async function handler(event, ctx) {
ctx.log("received", event.type);
return { ok: true };
}
`,
});
await wabery.functions.test(fn.id, { text: "I need a quote" });
await wabery.functions.update(fn.id, {
exposeAsMcpTool: true,
inputSchema: { type: "object" },
});
await wabery.functions.invoke(fn.id, {
arguments: { orderId: "ord_123" },
});
const logs = await wabery.functions.logs(fn.id);Use API scopes functions:read, functions:write, and functions:invoke for
least-privilege keys.
Local testing
Exercise your webhook and data-exchange handlers without deploying. signPayload
produces the x-wabery-signature header so you can POST fixtures to your local server:
import { signPayload } from "@wabery/sdk";
const body = JSON.stringify(myFakeEvent);
await fetch("http://localhost:3000/webhooks", {
method: "POST",
headers: { "x-wabery-signature": signPayload(body, secret) },
body,
});Or use the CLI: wabery webhooks send-test --url <url> --secret <s> and
wabery flows test --url <url> --secret <s> --action data_exchange.
