@kapso/whatsapp-cloud-api
v0.1.1
Published
TypeScript client for WhatsApp Business Cloud API with typed responses and Zod-validated builders.
Maintainers
Readme
whatsapp-cloud-api-js
TypeScript client for the WhatsApp Cloud API.
Install
npm install @kapso/whatsapp-cloud-apiQuick start
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: "<PHONE_NUMBER_ID>",
to: "+15551234567",
body: "Hello from Kapso",
});Choose your setup
- Meta setup (~ 1 hour)
Create a Meta WhatsApp app, generate a system token, and link a WhatsApp Business phone number in Meta Business Manager.
- 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.
API surface
Core
client.messages— send text/media/interactive/templates and mark messages as readclient.templates— list/create/delete templates on your WABAclient.media— upload media, fetch metadata, delete mediaclient.phoneNumbers— request/verify code, register/deregister, settings, business profileclient.flows— author, validate, deploy, and preview WhatsApp FlowsverifySignature— 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 rawcomponentsand normalize/camelize inputs
Kapso proxy extras
Requires baseUrl and kapsoApiKey.
client.conversations— list/get/update conversations across your projectclient.messages.query/listByConversation— pull stored message historyclient.contacts— list/get/update contacts, withcustomerIdfilterclient.calls— initiate calls plus historic call logs (list/get) and permission helpersKapso Extensions— opt-in to extra fields viafields=kapso(...)
Using the Kapso Proxy
To use Kapso’s proxy, set the client base URL and API key:
const client = new WhatsAppClient({
baseUrl: "https://api.kapso.ai/meta/whatsapp",
kapsoApiKey: process.env.KAPSO_API_KEY!,
});Why Kapso?
- 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
phoneNumberIdquery on the proxy. - Responses mirror Meta’s Cloud API message schema.
- Kapso-only enrichments live under the
kapsokey; use thefieldsparameter (for examplefields: "kapso(flow_response,flow_token)") to opt into specific fields orfields: "kapso()"to omit them entirely. - You can also pass a bearer
accessTokeninstead ofkapsoApiKeyif you’ve stored a token with Kapso.
Sending messages
Below are concise examples for common message types. Assume client is created as shown above.
Text
await client.messages.sendText({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
body: "Hello!",
});Image
By media ID:
await client.messages.sendImage({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
image: { id: "<MEDIA_ID>", caption: "Check this out" },
});By link:
await client.messages.sendImage({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
image: { link: "https://example.com/photo.jpg", caption: "Photo" },
});Document
await client.messages.sendDocument({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
document: { link: "https://example.com/invoice.pdf", filename: "invoice.pdf", caption: "Invoice" },
});Video
await client.messages.sendVideo({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
video: { link: "https://example.com/clip.mp4", caption: "Clip" },
});Sticker
await client.messages.sendSticker({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
sticker: { id: "<MEDIA_ID>" },
});Location
await client.messages.sendLocation({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
location: { latitude: -33.45, longitude: -70.66, name: "Santiago", address: "CL" },
});Contacts
await client.messages.sendContacts({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
contacts: [
{ name: { formattedName: "John Doe" }, phones: [{ phone: "+15551234567", type: "WORK" }] },
],
});Reaction
await client.messages.sendReaction({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
reaction: { messageId: "wamid......", emoji: "😀" },
});Mark read & typing indicator
await client.messages.markRead({
phoneNumberId: "<PHONE_NUMBER_ID>",
messageId: "wamid......",
typingIndicator: { type: "text" },
});Interactive buttons
await client.messages.sendInteractiveButtons({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
header: { type: "image", image: { id: "<MEDIA_ID>" } },
bodyText: "Pick an option",
footerText: "Footer",
buttons: [ { id: "accept", title: "Accept" }, { id: "decline", title: "Decline" } ],
});Interactive CTA URL
await client.messages.sendInteractiveCtaUrl({
phoneNumberId: "<PHONE_NUMBER_ID>",
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" }
});Catalog Message
await client.messages.sendInteractiveCatalogMessage({
phoneNumberId: "<PHONE_NUMBER_ID>",
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.
Deploy a Flow
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
});Send a Flow message
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" }
}
});
flowCtais required by Meta.flowMessageVersiondefaults to"3"when omitted.
For a full walkthrough (authoring guidance, deployment scripts, Express/Edge examples, and manual testing tips) see docs/flows.md.
Templates
Build with components
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).
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.
Typed builder
Prefer typed guardrails? Use buildTemplateSendPayload. It outputs the same Meta structure but gives compile‑time guidance. Example with body parameters and a Flow button:
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.
Template creation
The creation builder validates components and examples like Meta’s review.
Minimal examples:
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.
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.pagingexposescursors.before/cursors.afterplusnext/previousURLs when present.- Supply
fields: buildKapsoFields()(or the string"kapso(default)") to include all Kapso extensions, or pass your own subset such asfields: "kapso(flow_response,flow_token)". Usefields: "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:
buildKapsoFieldsis exported from the SDK, so you canimport { buildKapsoFields } from "@kapso/whatsapp-cloud-api";and drop it straight into your queries.
Templates
Create
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: "<WABA_ID>",
name: templateDefinition.name,
language: templateDefinition.language,
category: templateDefinition.category,
parameterFormat: templateDefinition.parameterFormat,
components: templateDefinition.components,
});Send a template
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: "<PHONE_NUMBER_ID>",
to: "+15551234567",
template: templatePayload,
});Media
const imageBlob = new Blob([/* binary data */], { type: "image/png" });
await client.media.upload({ phoneNumberId: "<PHONE_NUMBER_ID>", type: "image", file: imageBlob, fileName: "photo.png", });
const metadata = await client.media.get({ mediaId: "<MEDIA_ID>", phoneNumberId: "<PHONE_NUMBER_ID>", }); // Kapso requires phoneNumberId
await client.media.delete({ mediaId: "<MEDIA_ID>", phoneNumberId: "<PHONE_NUMBER_ID>", });Receiving media
Common cases:
- 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).
import { buildKapsoMessageFields } from "@kapso/whatsapp-cloud-api";
const fields = buildKapsoMessageFields("media_url");
const page = await client.messages.listByConversation({
phoneNumberId: "<PHONE_NUMBER_ID>",
conversationId: "<CONVERSATION_ID>",
fields,
});
const msg = page.data.find(m => m.type === "image");
const src = msg?.kapso?.mediaUrl ?? msg?.image?.link; // use direct URL when present- 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 viamedia.get()then fetches the bytes.- Return types: default
ArrayBuffer,as: "blob"→Blob,as: "response"→Response. - Direct Meta:
phoneNumberIdis not required. - Kapso proxy: pass
phoneNumberId.
Examples:
// 1) From a message record you loaded (e.g., via client.messages.query):
const { data } = await client.messages.query({ phoneNumberId: "<PHONE_NUMBER_ID>", 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: "<PHONE_NUMBER_ID>", });
// bytes is an ArrayBuffer; do what you need with it
}Phone numbers
await client.phoneNumbers.requestCode({ phoneNumberId: "<PHONE_NUMBER_ID>", codeMethod: "SMS", language: "en_US", });
await client.phoneNumbers.verifyCode({ phoneNumberId: "<PHONE_NUMBER_ID>", code: "123456", });
await client.phoneNumbers.register({ phoneNumberId: "<PHONE_NUMBER_ID>", pin: "000111", });
await client.phoneNumbers.settings.update({ phoneNumberId: "<PHONE_NUMBER_ID>", fallbackLanguage: "en_US", });
await client.phoneNumbers.businessProfile.update({ phoneNumberId: "<PHONE_NUMBER_ID>", about: "My Shop", websites: ["https://example.com"], });Webhooks
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 camelCasednormalizeWebhook() 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.<fieldName> (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.
// 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:
const response = await client.request<MyType>("GET", "<path>", { 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":{...}}License
MIT
