TypeScript client for WhatsApp Business Cloud API with typed responses and Zod-validated builders.
npm install @kapso/whatsapp-cloud-apiwhatsapp-cloud-api-js

TypeScript client for the WhatsApp Cloud API.
``bash`
npm install @kapso/whatsapp-cloud-api
`ts
import { WhatsAppClient } from "@kapso/whatsapp-cloud-api";
const client = new WhatsAppClient({
accessToken: process.env.WHATSAPP_TOKEN!,
// or route via Kapso proxy:
// baseUrl: "https://api.kapso.ai/meta/whatsapp",
// kapsoApiKey: process.env.KAPSO_API_KEY,
});
await client.messages.sendText({
phoneNumberId: "
to: "+15551234567",
body: "Hello from Kapso",
});
`
1. Meta setup (~ 1 hour)
Create a Meta WhatsApp app, generate a system token, and link a WhatsApp Business phone number in Meta Business Manager.
2. Kapso proxy (~ 2 minutes)
Have Kapso provision and connect a WhatsApp number for you, then use your Kapso API key and base URL to begin sending immediately.
Query conversations, messages, contacts, and more.
- client.messages — send text/media/interactive/templates and mark messages as read
- client.templates — list/create/delete templates on your WABA
- client.media — upload media, fetch metadata, delete media
- client.phoneNumbers — request/verify code, register/deregister, settings, business profile
- client.flows — author, validate, deploy, and preview WhatsApp Flows
- verifySignature — verify webhook signatures (app secret)
- receiveFlowEvent, respondToFlow, downloadFlowMedia — decrypt and respond to Flow callbacksTemplateDefinition
- — strict template creation buildersbuildTemplateSendPayload
- — build send-time template payloadsbuildTemplatePayload
- — accept Meta-style raw components and normalize/camelize inputs
and kapsoApiKey.client.conversations — list/get/update conversations across your project
- client.messages.query / listByConversation — pull stored message history
- client.contacts — list/get/update contacts, with customerId filter
- client.calls — initiate calls plus historic call logs (list/get) and permission helpers
- Kapso Extensions — opt-in to extra fields via fields=kapso(...)Using the Kapso Proxy
To use Kapso’s proxy, set the client base URL and API key:
`ts
const client = new WhatsAppClient({
baseUrl: "https://api.kapso.ai/meta/whatsapp",
kapsoApiKey: process.env.KAPSO_API_KEY!,
});
`$3
- Get a WhatsApp API for your number in ~2 minutes.
- Built‑in inbox for your team.
- Query conversations, messages and contacts.
- Automatic backup to Supabase.
- Webhooks for critical events: message received, message sent, conversation inactive, and more.
- Get a US phone number for WhatsApp (works globally).
- Multi‑tenant by design — onboard thousands of customers safely.
- And more.
Notes:
- Media GET/DELETE requires
phoneNumberId query on the proxy.
- Responses mirror Meta’s Cloud API message schema.
- Kapso-only enrichments live under the kapso key; use the fields parameter (for example fields: "kapso(flow_response,flow_token)") to opt into specific fields or fields: "kapso()" to omit them entirely.
- You can also pass a bearer accessToken instead of kapsoApiKey if you’ve stored a token with Kapso.Sending messages
Below are concise examples for common message types. Assume
client is created as shown above.$3
`ts
await client.messages.sendText({
phoneNumberId: "",
to: "+15551234567",
body: "Hello!",
});
`$3
By media ID:
`ts
await client.messages.sendImage({
phoneNumberId: "",
to: "+15551234567",
image: { id: "", caption: "Check this out" },
});
`By link:
`ts
await client.messages.sendImage({
phoneNumberId: "",
to: "+15551234567",
image: { link: "https://example.com/photo.jpg", caption: "Photo" },
});
`$3
`ts
await client.messages.sendDocument({
phoneNumberId: "",
to: "+15551234567",
document: { link: "https://example.com/invoice.pdf", filename: "invoice.pdf", caption: "Invoice" },
});
`$3
`ts
await client.messages.sendVideo({
phoneNumberId: "",
to: "+15551234567",
video: { link: "https://example.com/clip.mp4", caption: "Clip" },
});
`$3
`ts
await client.messages.sendSticker({
phoneNumberId: "",
to: "+15551234567",
sticker: { id: "" },
});
`$3
`ts
await client.messages.sendLocation({
phoneNumberId: "",
to: "+15551234567",
location: { latitude: -33.45, longitude: -70.66, name: "Santiago", address: "CL" },
});
`$3
`ts
await client.messages.sendContacts({
phoneNumberId: "",
to: "+15551234567",
contacts: [
{ name: { formattedName: "John Doe" }, phones: [{ phone: "+15551234567", type: "WORK" }] },
],
});
`$3
`ts
await client.messages.sendReaction({
phoneNumberId: "",
to: "+15551234567",
reaction: { messageId: "wamid......", emoji: "😀" },
});
`$3
`ts
await client.messages.markRead({
phoneNumberId: "",
messageId: "wamid......",
typingIndicator: { type: "text" },
});
`$3
`ts
await client.messages.sendInteractiveButtons({
phoneNumberId: "",
to: "+15551234567",
header: { type: "image", image: { id: "" } },
bodyText: "Pick an option",
footerText: "Footer",
buttons: [ { id: "accept", title: "Accept" }, { id: "decline", title: "Decline" } ],
});
`$3
`ts
await client.messages.sendInteractiveCtaUrl({
phoneNumberId: "",
to: "+15551234567",
header: { type: "image", image: { link: "https://example.com/banner.png" } },
bodyText: "Tap the button to see dates.",
parameters: { displayText: "See Dates", url: "https://example.com?utm=wa" }
});
`$3
`ts
await client.messages.sendInteractiveCatalogMessage({
phoneNumberId: "",
to: "+15551234567",
bodyText: "Browse our catalog on WhatsApp",
parameters: { thumbnailProductRetailerId: "SKU-123" }
});
`Flows
Use
client.flows.deploy() for idempotent deployments, or create/updateAsset/publish/preview for granular control. Server utilities (receiveFlowEvent, respondToFlow, downloadFlowMedia) handle Data Endpoint callbacks.$3
`ts
import { WhatsAppClient } from "@kapso/whatsapp-cloud-api";const flowJson = {
version: "7.2",
screens: [
{
id: "CSAT",
terminal: true,
layout: {
type: "SingleColumnLayout",
children: [
{ type: "RadioButtonsGroup", name: "rating", label: "Rate us", dataSource: [
{ id: "up", title: "👍" },
{ id: "down", title: "👎" }
] },
{ type: "Footer", label: "Submit", onClickAction: { name: "complete", payload: { rating: "${form.rating}" } } }
]
}
}
]
};
const client = new WhatsAppClient({ accessToken: process.env.WHATSAPP_TOKEN! });
await client.flows.deploy(flowJson, {
wabaId: process.env.WABA_ID!,
name: "csat-flow",
publish: true,
preview: true
});
`$3
`ts
import { WhatsAppClient, type FlowInteractiveInput } from "@kapso/whatsapp-cloud-api";const client = new WhatsAppClient({ accessToken: process.env.WHATSAPP_TOKEN! });
await client.messages.sendInteractiveFlow({
phoneNumberId: "1234567890",
to: "+15551234567",
bodyText: "Check out our new experience",
parameters: {
flowId: "1234567890",
flowCta: "Open",
flowToken: "token123",
flowAction: "navigate",
flowActionPayload: { screen: "WELCOME" }
}
});
`>
flowCta is required by Meta. flowMessageVersion defaults to "3" when omitted.For a full walkthrough (authoring guidance, deployment scripts, Express/Edge examples, and manual testing tips) see docs/flows.md.
Templates
$3
Use
buildTemplatePayload as the primary way to build templates. It accepts Meta‑style components, normalizes casing, and enforces shape (e.g., language.policy = 'deterministic' when using an object).`ts
import { buildTemplatePayload } from '@kapso/whatsapp-cloud-api';const template = buildTemplatePayload({
name: 'order_confirmation',
language: 'en_US', // or { code: 'en_US', policy: 'deterministic' }
components: [
{ type: 'body', parameters: [{ type: 'text', text: 'Jessica', parameter_name: 'customer_name' }] },
],
});
`When you pass raw Meta-style components, keep the snake_case field (
parameter_name) Meta expects.$3
Prefer typed guardrails? Use
buildTemplateSendPayload. It outputs the same Meta structure but gives compile‑time guidance. Example with body parameters and a Flow button:`ts
import { buildTemplateSendPayload } from '@kapso/whatsapp-cloud-api';const template = buildTemplateSendPayload({
name: 'order_confirmation',
language: 'en_US',
body: [
{ type: 'text', text: 'Jessica', parameterName: 'customerName' },
{ type: 'text', text: 'SKBUP2-4CPIG9', parameterName: 'orderId' },
],
buttons: [
{
type: 'button',
subType: 'flow',
index: 0,
parameters: [{ type: 'action', action: { flow_token: 'FT_123', flow_action_data: { step: 'one' } } }],
},
],
});
`The typed builder accepts camelCase
parameterName and the client automatically snake-cases it when sending.$3
The creation builder validates components and examples like Meta’s review.
Minimal examples:
`ts
import { buildTemplateDefinition } from '@kapso/whatsapp-cloud-api';// Authentication (copy code)
const authenticationTemplate = buildTemplateDefinition({
name: 'authentication_code',
language: 'en_US',
category: 'AUTHENTICATION',
messageSendTtlSeconds: 60,
components: [
{ type: 'BODY', addSecurityRecommendation: true },
{ type: 'FOOTER', codeExpirationMinutes: 10 },
{ type: 'BUTTONS', buttons: [{ type: 'OTP', otpType: 'COPY_CODE' }] },
],
});
// Named parameters (parameter_format = NAMED)
const namedOrderTemplate = buildTemplateDefinition({
name: 'order_confirmation_named',
language: 'en_US',
category: 'UTILITY',
parameterFormat: 'NAMED',
components: [
{
type: 'BODY',
text: 'Thank you, {{customer_name}}! Your order {{order_number}} ships {{ship_date}}.',
example: {
bodyTextNamedParams: [
{ paramName: 'customer_name', example: 'Pablo' },
{ paramName: 'order_number', example: '860198-230332' },
{ paramName: 'ship_date', example: '2025-11-15' },
],
},
},
],
});
// Limited-time offer
const limitedTimeOfferTemplate = buildTemplateDefinition({
name: 'limited_offer', language: 'en_US', category: 'MARKETING',
components: [
{ type: 'BODY', text: 'Hello {{1}}', example: { bodyText: [['Pablo']] } },
{ type: 'LIMITED_TIME_OFFER', limitedTimeOffer: { text: 'Expiring!', hasExpiration: true } },
],
});
// Catalog / MPM / SPM
const catalogTemplate = buildTemplateDefinition({
name: 'catalog_push', language: 'en_US', category: 'MARKETING',
components: [ { type: 'BODY', text: 'Browse our catalog' }, { type: 'BUTTONS', buttons: [{ type: 'CATALOG', text: 'View catalog' }] } ],
});
`parameterFormat matches the API’s parameter_format field. When set to "NAMED", use named placeholders (for example {{customer_name}}) and provide examples via bodyTextNamedParams / headerTextNamedParams so WhatsApp can validate your template.Query history & contacts
When you point the client to Kapso’s proxy (
baseUrl: "https://api.kapso.ai/meta/whatsapp" plus kapsoApiKey), you can query stored data in addition to sending messages.`ts
const client = new WhatsAppClient({
baseUrl: "https://api.kapso.ai/meta/whatsapp",
kapsoApiKey: process.env.KAPSO_API_KEY!,
});// Conversations
const conversations = await client.conversations.list({
phoneNumberId: "647015955153740",
status: "active",
limit: 50,
});
const conversation = await client.conversations.get({ conversationId: conversations.data[0].id, });
await client.conversations.updateStatus({ conversationId: conversation.id, status: "ended", });
// Message history
const history = await client.messages.query({
phoneNumberId: "647015955153740",
direction: "inbound",
since: "2025-01-01T00:00:00Z",
limit: 50,
after: conversations.paging.cursors.after,
});
// Contacts
const contacts = await client.contacts.list({ phoneNumberId: "647015955153740", customerId: "123", });
await client.contacts.update({
phoneNumberId: "647015955153740",
waId: contacts.data[0].waId,
metadata: { tags: ["vip"], source: "import" },
});
// Call logs
const calls = await client.calls.list({ phoneNumberId: "647015955153740", direction: "INBOUND", limit: 20, });
const call = await client.calls.get({ phoneNumberId: "647015955153740", callId: calls.data[0].id, });
`All history endpoints return Meta-compatible records with Graph paging:
-
page.data (camelCased) mirrors Meta’s message/contact/conversation/call schema.
- page.paging exposes cursors.before / cursors.after plus next / previous URLs when present.
- Supply fields: buildKapsoFields() (or the string "kapso(default)") to include all Kapso extensions, or pass your own subset such as fields: "kapso(flow_response,flow_token)". Use fields: "kapso()" to omit Kapso extras entirely.
- When you store messages via Kapso, request kapso(content) to hydrate the normalized message with the original payload fragment (for example, catalog interactive content).
- Tip: buildKapsoFields is exported from the SDK, so you can import { buildKapsoFields } from "@kapso/whatsapp-cloud-api"; and drop it straight into your queries.Templates
$3
`ts
import { TemplateDefinition } from "@kapso/whatsapp-cloud-api";const templateDefinition = TemplateDefinition.buildTemplateDefinition({
name: "seasonal_promo",
language: "en_US",
category: "MARKETING",
parameterFormat: "NAMED",
components: [
{
type: "HEADER",
format: "TEXT",
text: "Our {{sale_name}} is on!",
example: { headerTextNamedParams: [{ paramName: "sale_name", example: "Summer Sale" }] }
},
{
type: "BODY",
text: "Shop now through {{end_date}} using code {{discount_code}}",
example: {
bodyTextNamedParams: [
{ paramName: "end_date", example: "Aug 31" },
{ paramName: "discount_code", example: "SALE25" }
]
}
},
{ type: "FOOTER", text: "Tap a button below" },
{
type: "BUTTONS",
buttons: [
{ type: "QUICK_REPLY", text: "Unsubscribe" },
{
type: "URL",
text: "Shop",
url: "https://store.example/promo?code={{discount_code}}",
example: ["SALE25"]
}
]
}
],
});
await client.templates.create({
businessAccountId: "",
name: templateDefinition.name,
language: templateDefinition.language,
category: templateDefinition.category,
parameterFormat: templateDefinition.parameterFormat,
components: templateDefinition.components,
});
`$3
`ts
import { buildTemplateSendPayload } from "@kapso/whatsapp-cloud-api";const templatePayload = buildTemplateSendPayload({
name: "seasonal_promo",
language: "en_US",
header: { type: "image", image: { link: "https://cdn.example/banner.jpg" } },
body: [ { type: "text", text: "Aug 31" }, { type: "text", text: "SALE25" } ],
buttons: [ { type: "button", subType: "quick_reply", index: 0, parameters: [{ type: "payload", payload: "STOP" }] } ],
});
await client.messages.sendTemplate({
phoneNumberId: "",
to: "+15551234567",
template: templatePayload,
});
`Media
`ts
const imageBlob = new Blob([/ binary data /], { type: "image/png" });
await client.media.upload({ phoneNumberId: "", type: "image", file: imageBlob, fileName: "photo.png", });
const metadata = await client.media.get({ mediaId: "", phoneNumberId: "", }); // Kapso requires phoneNumberId
await client.media.delete({ mediaId: "", phoneNumberId: "", });
`$3
Common cases:
1) URL‑first with Kapso
Kapso stores inbound media and now also mirrors outbound media shortly after send. Ask for
kapso(media_url) when listing messages and render the URL directly (SSR‑friendly).`ts
import { buildKapsoMessageFields } from "@kapso/whatsapp-cloud-api";const fields = buildKapsoMessageFields("media_url");
const page = await client.messages.listByConversation({
phoneNumberId: "",
conversationId: "",
fields,
});
const msg = page.data.find(m => m.type === "image");
const src = msg?.kapso?.mediaUrl ?? msg?.image?.link; // use direct URL when present
`2) Bytes fallback (universal)
If you need the raw bytes or the URL has not been mirrored yet, use
download(). The SDK automatically skips auth headers for public WhatsApp CDNs and uses them for Kapso hosts.Key points:
-
client.media.download({ mediaId, ... }) resolves the short‑lived URL via media.get() then fetches the bytes.
- Return types: default ArrayBuffer, as: "blob" → Blob, as: "response" → Response.
- Direct Meta: phoneNumberId is not required.
- Kapso proxy: pass phoneNumberId.Examples:
`ts
// 1) From a message record you loaded (e.g., via client.messages.query):
const { data } = await client.messages.query({ phoneNumberId: "", limit: 1, });
const msg = data[0];if (msg.type === "image" && msg.image?.id) {
const mediaId = msg.image.id;
const bytes = await client.media.download({ mediaId, phoneNumberId: "", });
// bytes is an ArrayBuffer; do what you need with it
}
`Phone numbers
`ts
await client.phoneNumbers.requestCode({ phoneNumberId: "", codeMethod: "SMS", language: "en_US", });
await client.phoneNumbers.verifyCode({ phoneNumberId: "", code: "123456", });
await client.phoneNumbers.register({ phoneNumberId: "", pin: "000111", });
await client.phoneNumbers.settings.update({ phoneNumberId: "", fallbackLanguage: "en_US", });
await client.phoneNumbers.businessProfile.update({ phoneNumberId: "", about: "My Shop", websites: ["https://example.com"], });
`Webhooks
`ts
import express from "express";
import { normalizeWebhook, verifySignature } from "@kapso/whatsapp-cloud-api/server";app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifySignature({
appSecret: process.env.META_APP_SECRET!,
rawBody: req.body,
signatureHeader: req.headers["x-hub-signature-256"] as string,
});
if (!ok) return res.status(401).end();
const payload = JSON.parse(req.body.toString("utf8"));
const events = normalizeWebhook(payload);
events.messages.forEach((message) => {
// message matches the same shape returned by client.messages.query()
});
events.statuses.forEach((status) => {
// handle delivery receipts
});
events.calls.forEach((call) => {
// handle calling events
});
res.sendStatus(200);
});
// events.contacts contains the contact array from the webhook, already camelCased
`normalizeWebhook() unwraps the raw Graph payload, returning { messages, statuses, calls, contacts } with camelCased fields so webhook events and history queries share the same Meta-compatible structure. Each normalized message also gets kapso.direction ("inbound"/"outbound") and SMB echoes are tagged with kapso.source = "smb_message_echo" so you can tell when the business initiated a message. All other webhook field payloads are exposed under events.raw. (camelCased), so you can react to updates like accountAlerts, templateCategoryUpdate, etc., without additional parsing.Raw fetch helper
Use
client.fetch(url, init?) to make a request to any absolute URL with the client’s auth headers applied. Most users do not need this for media anymore because media.download() handles header policy automatically.`ts
// Sends Authorization (Meta) or X-API-Key (Kapso) automatically
const response = await client.fetch("https://files.example/resource", { headers: { Accept: "image/*" }, });
`Typed responses
- All helpers return typed payloads (e.g.,
SendMessageResponse, MediaUploadResponse, etc.).
- You can also call the low-level client with typing:`ts
const response = await client.request("GET", "", { responseType: "json", });
`Error handling
When a response is not OK, the client throws an
Error whose message includes the HTTP status and response text, e.g.:`
Meta API request failed with status 400: {"error":{...}}
``MIT