
Integrate Whatsapp
- 3.1k installs
- 144 repo stars
- Updated August 2, 2026
- gokapso/agent-skills
integrate-whatsapp is an agent skill that connects WhatsApp to a product via Kapso setup links, webhooks, Cloud API messaging, templates, media, and WhatsApp Flows management.
About
integrate-whatsapp is an agent skill for end-to-end WhatsApp integration through Kapso, covering customer onboarding with setup links, connection detection, webhook event routing, and Cloud API messaging for text, templates, media, and interactive messages. The preferred path uses the Kapso CLI with kapso login, kapso setup, and kapso whatsapp numbers resolve before operating on phone_number_id values. Fallback flows document direct Platform API calls with X-API-Key auth, customer and setup_link creation, and embedded signup completion. Webhook guidance distinguishes project-level connection lifecycle events from phone-number scoped whatsapp.message and whatsapp.conversation traffic, with signature verification and payload version v2 recommended. Messaging examples include the @kapso/whatsapp-cloud-api SDK, template create and send scripts, and CLI commands for listing conversations and templates. WhatsApp Flows management covers create, update, publish, data endpoints, and encryption. Developers reach for it when wiring WhatsApp Business onboarding, inbound webhook handlers, outbound transactional or template messaging, and operational tooling around Kapso phone numbers.
- Documents Kapso CLI onboarding with kapso setup plus fallback Platform API customer and setup_link flows.
- Separates project webhooks from phone-number webhooks for whatsapp.message and whatsapp.conversation events.
- Covers SDK and script paths for text, template, media, and interactive WhatsApp Cloud API messaging.
- Includes WhatsApp Flows lifecycle guidance for create, update, publish, data endpoints, and encryption.
- Recommends signature verification, payload v2, and phone_number_id resolution before send operations.
Integrate Whatsapp by the numbers
- 3,095 all-time installs (skills.sh)
- +75 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #255 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
integrate-whatsapp capabilities & compatibility
- Capabilities
- customer onboarding via setup links · webhook scope configuration · text and template messaging · media and interactive sends · whatsapp flows management
- Use cases
- api development · orchestration
- Pricing
- Bring your own API key
What integrate-whatsapp says it does
Connect WhatsApp to your product with Kapso: onboard customers with setup links, detect connections, receive events via webhooks
npx skills add https://github.com/gokapso/agent-skills --skill integrate-whatsappAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.1k |
|---|---|
| repo stars | ★ 144 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 2, 2026 |
| Repository | gokapso/agent-skills ↗ |
How do I onboard WhatsApp numbers, receive message events, and send templates or media through Kapso without mixing project and phone-number webhook scopes?
Integrate WhatsApp end-to-end with Kapso: onboard customers, detect connections, receive webhook events, and send messages, templates, media, and Flows.
Who is it for?
Developers integrating WhatsApp Business messaging, webhooks, and Flows into an app using Kapso CLI or Platform API credentials.
Skip if: Skip when the task is generic chat UI design without Kapso or Meta WhatsApp Cloud API integration.
When should I use this skill?
User asks to connect WhatsApp, create Kapso setup links, configure webhooks, send templates, or manage WhatsApp Flows end-to-end.
What you get
Working Kapso onboarding, scoped webhooks, resolved phone_number_id values, and send or template flows aligned with Kapso CLI and SDK examples.
- WhatsApp Flow JSON definition
- routing_model configuration
- Booking screen layout JSON
By the numbers
- WhatsApp Flow JSON version 7.3
- data_api_version 3.0
- Defines SELECT_DATE and SELECT_SLOT booking screens
Files
Integrate WhatsApp
Setup
Preferred path:
- Kapso CLI installed and authenticated (
kapso login) - Use
kapso statusto confirm project access before onboarding or messaging
Fallback path: Env vars:
KAPSO_API_BASE_URL(host only, no/platform/v1)KAPSO_API_KEYMETA_GRAPH_VERSION(optional, defaultv24.0)
Auth header (direct API calls):
X-API-Key: <api_key>Install deps (once):
npm iConnect WhatsApp (setup links)
Preferred onboarding path (CLI):
1. Start onboarding: kapso setup 2. If setup is blocked, resolve context with:
kapso projects listkapso projects use <project-id>kapso customers listkapso customers new --name "<customer-name>" --external-id <external-id>kapso setup --customer <customer-id>
3. Complete the hosted onboarding URL 4. Confirm connected numbers: kapso whatsapp numbers list --output json 5. Resolve the exact number you want to operate: kapso whatsapp numbers resolve --phone-number "<display-number>" --output json
Fallback onboarding flow (direct API):
1. Create customer: POST /platform/v1/customers 2. Generate setup link: POST /platform/v1/customers/:id/setup_links 3. Customer completes embedded signup 4. Use phone_number_id to send messages and configure webhooks
Detect connection:
- Project webhook
whatsapp.phone_number.created(recommended) - Success redirect URL query params (use for frontend UX)
Recommended Kapso setup-link defaults:
{
"setup_link": {
"allowed_connection_types": ["dedicated"],
"provision_phone_number": true,
"phone_number_country_isos": ["US"]
}
}Notes:
kapso setupandkapso whatsapp numbers newuse dedicated plus provisioning by default.- Keep
phone_number_country_isos,phone_number_area_code,language, and redirect URLs as optional overrides.
- Platform API base:
/platform/v1 - Meta proxy base:
/meta/whatsapp/v24.0(messaging, templates, media) - Use
phone_number_idas the primary WhatsApp identifier
Receive events (webhooks)
Use webhooks to receive:
- Project events (connection lifecycle, workflow events)
- Phone-number events (messages, conversations, delivery status)
Scope rules:
- Project webhooks: only project-level events (connection lifecycle, workflow events)
- Phone-number webhooks: only WhatsApp message + conversation events for that
phone_number_id - WhatsApp message/conversation events (
whatsapp.message.*,whatsapp.conversation.*) are phone-number only
Create a webhook:
- Project-level:
node scripts/create.js --scope project --url <https://...> --events <csv> - Phone-number:
node scripts/create.js --phone-number-id <id> --url <https://...> --events <csv>
Common flags for create/update:
--url <https://...>- webhook destination--events <csv|json-array>- event types (Kapso webhooks)--kind <kapso|meta>- Kapso (event-based) vs raw Meta forwarding--payload-version <v1|v2>- payload format (v2recommended)--buffer-enabled <true|false>- enable buffering forwhatsapp.message.received--buffer-window-seconds <n>- 1-60 seconds--max-buffer-size <n>- 1-100--active <true|false>- enable/disable
Test delivery:
node scripts/test.js --webhook-id <id>Always verify signatures. See:
references/webhooks-overview.mdreferences/webhooks-reference.md
Send and read messages
Discover IDs first
Two Meta IDs are needed for different operations:
| ID | Used for | How to discover |
|---|---|---|
business_account_id (WABA) | Template CRUD | kapso whatsapp numbers resolve --phone-number "<display-number>" --output json or node scripts/list-platform-phone-numbers.mjs |
phone_number_id | Sending messages, media upload | kapso whatsapp numbers resolve --phone-number "<display-number>" --output json or node scripts/list-platform-phone-numbers.mjs |
Operate with the CLI first
Common commands:
kapso whatsapp numbers list --output json
kapso whatsapp numbers resolve --phone-number "<display-number>" --output json
kapso whatsapp messages send --phone-number-id <PHONE_NUMBER_ID> --to <wa-id> --text "Hello from Kapso"
kapso whatsapp messages list --phone-number-id <PHONE_NUMBER_ID> --limit 50 --output json
kapso whatsapp messages get <MESSAGE_ID> --phone-number-id <PHONE_NUMBER_ID> --output json
kapso whatsapp conversations list --phone-number-id <PHONE_NUMBER_ID> --output json
kapso whatsapp templates list --phone-number-id <PHONE_NUMBER_ID> --output json
kapso whatsapp templates get <TEMPLATE_ID> --phone-number-id <PHONE_NUMBER_ID> --output jsonSDK setup
Install:
npm install @kapso/whatsapp-cloud-apiCreate client:
import { WhatsAppClient } from "@kapso/whatsapp-cloud-api";
const client = new WhatsAppClient({
baseUrl: "https://api.kapso.ai/meta/whatsapp",
kapsoApiKey: process.env.KAPSO_API_KEY!
});Send a text message
Via SDK:
await client.messages.sendText({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
body: "Hello from Kapso"
});Send a template message
1. Discover IDs: node scripts/list-platform-phone-numbers.mjs 2. Draft template payload from assets/template-utility-order-status-update.json 3. Create: node scripts/create-template.mjs --business-account-id <WABA_ID> --file <payload.json> 4. Check status: node scripts/template-status.mjs --business-account-id <WABA_ID> --name <name> 5. Send: node scripts/send-template.mjs --phone-number-id <ID> --file <send-payload.json>
Send an interactive message
Interactive messages require an active 24-hour session window. For outbound notifications outside the window, use templates.
1. Discover phone_number_id 2. Pick payload from assets/send-interactive-*.json 3. Send: node scripts/send-interactive.mjs --phone-number-id <ID> --file <payload.json>
Read inbox data
Preferred path:
- CLI:
kapso whatsapp messages ...,kapso whatsapp conversations ...,kapso whatsapp templates ...
Fallback path:
- Proxy:
GET /{phone_number_id}/messages,GET /{phone_number_id}/conversations - SDK:
client.messages.query(),client.messages.get(),client.conversations.list(),client.conversations.get(),client.templates.get()
Embed the inbox
Use Platform API inbox embeds when the user wants to place Kapso's inbox inside their own app.
Create:
POST /platform/v1/inbox_embeds- Envelope:
inbox_embed - Public scopes:
project,customer,phone_number scope_idis blank forproject, a customer UUID forcustomer, and WhatsAppphone_number_idforphone_number- Create returns
tokenandembed_urlonce. Storeembed_url; list/get/update omit secrets.
Example:
{
"inbox_embed": {
"name": "Support inbox",
"scope_type": "phone_number",
"scope_id": "1234567890",
"allowed_origins": ["https://app.example.com"],
"default_mode": "system"
}
}Manage:
GET /platform/v1/inbox_embedsGET /platform/v1/inbox_embeds/:idPATCH /platform/v1/inbox_embeds/:idDELETE /platform/v1/inbox_embeds/:id(revokes)
Template rules
Creation:
- Use
parameter_format: "NAMED"with{{param_name}}(preferred over positional) - Include examples when using variables in HEADER/BODY
- Use
language(notlanguage_code) - Don't interleave QUICK_REPLY with URL/PHONE_NUMBER buttons
- URL button variables must be at the end of the URL and use positional
{{1}}
Send-time:
- For NAMED templates, include
parameter_namein header/body params - URL buttons need a
buttoncomponent withsub_type: "url"andindex - Media headers use either
idorlink(never both)
WhatsApp Flows
Use Flows to build native WhatsApp forms. Read references/whatsapp-flows-spec.md before editing Flow JSON.
Create and publish a flow
1. Create flow: node scripts/create-flow.js --phone-number-id <id> --name <name> 2. Update JSON: node scripts/update-flow-json.js --flow-id <id> --json-file <path> 3. Publish: node scripts/publish-flow.js --flow-id <id> 4. Test: node scripts/send-test-flow.js --phone-number-id <id> --flow-id <id> --to <phone>
Attach a data endpoint (dynamic flows)
1. Set up encryption: node scripts/setup-encryption.js --flow-id <id> 2. Create endpoint: node scripts/set-data-endpoint.js --flow-id <id> --code-file <path> 3. Deploy: node scripts/deploy-data-endpoint.js --flow-id <id> 4. Register: node scripts/register-data-endpoint.js --flow-id <id>
Flow JSON rules
Static flows (no data endpoint):
- Use
version: "7.3" routing_modelanddata_api_versionare optional- See
assets/sample-flow.json
Dynamic flows (with data endpoint):
- Use
version: "7.3"withdata_api_version: "3.0" routing_modelis required (defines valid screen transitions)- See
assets/dynamic-flow.json
Data endpoint rules
Handler signature:
async function handler(request, env) {
const body = await request.json();
// body.data_exchange.action: INIT | data_exchange | BACK
// body.data_exchange.screen: current screen id
// body.data_exchange.data: user inputs
return Response.json({
version: "3.0",
screen: "NEXT_SCREEN_ID",
data: { }
});
}- Do not use
exportormodule.exports - Completion uses
screen: "SUCCESS"withextension_message_response.params - Do not include
endpoint_uriordata_channel_uri(Kapso injects these)
Troubleshooting
- Preview shows
"flow_token is missing": flow is dynamic without a data endpoint. Attach one and refresh. - Encryption setup errors: enable encryption in Settings for the phone number/WABA.
- OAuthException 139000 (Integrity): WABA must be verified in Meta security center.
Scripts
Webhooks
| Script | Purpose |
|---|---|
list.js | List webhooks |
get.js | Get webhook details |
create.js | Create a webhook |
update.js | Update a webhook |
delete.js | Delete a webhook |
test.js | Send a test event |
Messaging and templates
| Script | Purpose | Required ID |
|---|---|---|
list-platform-phone-numbers.mjs | Discover business_account_id + phone_number_id | — |
list-connected-numbers.mjs | List WABA phone numbers | business_account_id |
list-templates.mjs | List templates (with filters) | business_account_id |
template-status.mjs | Check single template status | business_account_id |
create-template.mjs | Create a template | business_account_id |
update-template.mjs | Update existing template | business_account_id |
send-template.mjs | Send template message | phone_number_id |
send-interactive.mjs | Send interactive message | phone_number_id |
upload-media.mjs | Upload media for send-time headers | phone_number_id |
Flows
| Script | Purpose |
|---|---|
list-flows.js | List all flows |
create-flow.js | Create a new flow |
get-flow.js | Get flow details |
read-flow-json.js | Read flow JSON |
update-flow-json.js | Update flow JSON (creates new version) |
publish-flow.js | Publish a flow |
get-data-endpoint.js | Get data endpoint config |
set-data-endpoint.js | Create/update data endpoint code |
deploy-data-endpoint.js | Deploy data endpoint |
register-data-endpoint.js | Register data endpoint with Meta |
get-encryption-status.js | Check encryption status |
setup-encryption.js | Set up flow encryption |
send-test-flow.js | Send a test flow message |
delete-flow.js | Delete a flow |
list-flow-responses.js | List stored flow responses |
list-function-logs.js | List function logs |
list-function-invocations.js | List function invocations |
OpenAPI
| Script | Purpose |
|---|---|
openapi-explore.mjs | Explore OpenAPI (search/op/schema/where) |
Examples:
node scripts/openapi-explore.mjs --spec whatsapp search "template"
node scripts/openapi-explore.mjs --spec whatsapp op sendMessage
node scripts/openapi-explore.mjs --spec whatsapp schema TemplateMessage
node scripts/openapi-explore.mjs --spec platform ops --tag "WhatsApp Flows"
node scripts/openapi-explore.mjs --spec platform op setupWhatsappFlowEncryption
node scripts/openapi-explore.mjs --spec platform search "setup link"Assets
| File | Description |
|---|---|
template-utility-order-status-update.json | UTILITY template with named params + URL button |
send-template-order-status-update.json | Send-time payload for order_status_update |
template-utility-named.json | UTILITY template showing button ordering rules |
template-marketing-media-header.json | MARKETING template with IMAGE header |
template-authentication-otp.json | AUTHENTICATION OTP template (COPY_CODE) |
send-interactive-buttons.json | Interactive button message |
send-interactive-list.json | Interactive list message |
send-interactive-cta-url.json | Interactive CTA URL message |
send-interactive-location-request.json | Location request message |
send-interactive-catalog-message.json | Catalog message |
sample-flow.json | Static flow example (no endpoint) |
dynamic-flow.json | Dynamic flow example (with endpoint) |
webhooks-example.json | Webhook create/update payload example |
References
- references/getting-started.md - Platform onboarding
- references/platform-api-reference.md - Full endpoint reference
- references/setup-links.md - Setup link configuration
- references/detecting-whatsapp-connection.md - Connection detection methods
- references/webhooks-overview.md - Webhook types, signature verification, retries
- references/webhooks-event-types.md - Available events
- references/webhooks-reference.md - Webhook API and payload notes
- references/templates-reference.md - Template creation rules, components cheat sheet, send-time components
- references/whatsapp-api-reference.md - Meta proxy payloads for messages and conversations
- references/whatsapp-cloud-api-js.md - SDK usage for sending and reading messages
- references/whatsapp-flows-spec.md - Flow JSON spec
Related skills
automate-whatsapp- Workflows, agents, and automationsobserve-whatsapp- Debugging, logs, health checks
<!-- FILEMAP:BEGIN -->
[integrate-whatsapp file map]|root: .
|.:{package.json,SKILL.md}
|assets:{dynamic-flow.json,sample-flow.json,send-interactive-buttons.json,send-interactive-catalog-message.json,send-interactive-cta-url.json,send-interactive-list.json,send-interactive-location-request.json,send-template-order-status-update.json,template-authentication-otp.json,template-marketing-media-header.json,template-utility-named.json,template-utility-order-status-update.json,webhooks-example.json}
|references:{detecting-whatsapp-connection.md,getting-started.md,platform-api-reference.md,setup-links.md,templates-reference.md,webhooks-event-types.md,webhooks-overview.md,webhooks-reference.md,whatsapp-api-reference.md,whatsapp-cloud-api-js.md,whatsapp-flows-spec.md}
|scripts:{create-flow.js,create-function.js,create-template.mjs,create.js,delete-flow.js,delete.js,deploy-data-endpoint.js,deploy-function.js,get-data-endpoint.js,get-encryption-status.js,get-flow.js,get-function.js,get.js,list-connected-numbers.mjs,list-flow-responses.js,list-flows.js,list-function-invocations.js,list-function-logs.js,list-platform-phone-numbers.mjs,list-templates.mjs,list.js,openapi-explore.mjs,publish-flow.js,read-flow-json.js,register-data-endpoint.js,send-interactive.mjs,send-template.mjs,send-test-flow.js,set-data-endpoint.js,setup-encryption.js,submit-template.mjs,template-status.mjs,test.js,update-flow-json.js,update-function.js,update-template.mjs,update.js,upload-media.mjs,upload-template-header-handle.mjs}
|scripts/lib:{args.mjs,cli.js,env.js,env.mjs,http.js,output.js,output.mjs,request.mjs,run.js,whatsapp-flow.js}
|scripts/lib/webhooks:{args.js,kapso-api.js,webhook.js}<!-- FILEMAP:END -->
{
"version": "7.3",
"data_api_version": "3.0",
"routing_model": {
"SELECT_DATE": ["SELECT_SLOT", "SUCCESS"],
"SELECT_SLOT": ["SUCCESS"]
},
"screens": [
{
"id": "SELECT_DATE",
"title": "Book appointment",
"data": {
"error_message": {
"type": "string",
"__example__": ""
}
},
"layout": {
"type": "SingleColumnLayout",
"children": [
{
"type": "TextBody",
"text": "Select a date for your appointment."
},
{
"type": "DatePicker",
"name": "date",
"label": "Date",
"on-select-action": {
"name": "data_exchange",
"payload": {
"date": "${form.date}"
}
}
},
{
"type": "Footer",
"label": "Continue",
"on-click-action": {
"name": "data_exchange",
"payload": {
"date": "${form.date}"
}
}
}
]
}
},
{
"id": "SELECT_SLOT",
"title": "Select time",
"data": {
"selected_date": {
"type": "string",
"__example__": "2025-01-15"
},
"available_slots": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" }
}
},
"__example__": [
{ "id": "09:00", "title": "9:00 AM" },
{ "id": "10:00", "title": "10:00 AM" }
]
},
"error_message": {
"type": "string",
"__example__": ""
}
},
"terminal": true,
"layout": {
"type": "SingleColumnLayout",
"children": [
{
"type": "TextBody",
"text": "Available times for ${data.selected_date}:"
},
{
"type": "RadioButtonsGroup",
"name": "slot",
"label": "Time slot",
"data-source": "${data.available_slots}",
"required": true
},
{
"type": "Footer",
"label": "Book",
"on-click-action": {
"name": "data_exchange",
"payload": {
"date": "${data.selected_date}",
"slot": "${form.slot}"
}
}
}
]
}
}
]
}
{
"version": "7.3",
"screens": [
{
"id": "WELCOME",
"terminal": true,
"title": "Quick signup",
"layout": {
"type": "SingleColumnLayout",
"children": [
{
"type": "TextBody",
"text": "Tell us your name to get started."
},
{
"type": "TextInput",
"name": "full_name",
"label": "Full name",
"required": true
},
{
"type": "Footer",
"label": "Submit",
"on-click-action": {
"name": "complete",
"payload": {}
}
}
]
}
}
]
}
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Choose an option:" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "accept", "title": "Accept" } },
{ "type": "reply", "reply": { "id": "decline", "title": "Decline" } }
]
}
}
}
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "catalog_message",
"body": { "text": "Browse our catalog on WhatsApp." },
"action": {
"name": "catalog_message",
"parameters": { "thumbnail_product_retailer_id": "SKU_THUMBNAIL" }
}
}
}
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "cta_url",
"body": { "text": "Track your order:" },
"action": {
"name": "cta_url",
"parameters": {
"display_text": "Track order",
"url": "https://example.com/orders/ORDER-123"
}
}
}
}
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "list",
"body": { "text": "Choose your preferred delivery option:" },
"action": {
"button": "View options",
"sections": [
{
"title": "Delivery",
"rows": [
{ "id": "standard", "title": "Standard", "description": "3-5 business days" },
{ "id": "express", "title": "Express", "description": "1-2 business days" }
]
}
]
}
}
}
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "location_request_message",
"body": { "text": "Please share your location." },
"action": { "name": "send_location" }
}
}
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "template",
"template": {
"name": "order_status_update",
"language": { "code": "en_US" },
"components": [
{
"type": "header",
"parameters": [
{ "type": "text", "parameter_name": "order_id", "text": "ORDER-123" }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "parameter_name": "customer_name", "text": "Alex" },
{ "type": "text", "parameter_name": "status", "text": "shipped" },
{ "type": "text", "parameter_name": "details", "text": "Expected delivery: Jan 25" }
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "ORDER-123" }]
}
]
}
}
{
"name": "login_otp",
"language": "en_US",
"category": "AUTHENTICATION",
"components": [
{
"type": "BODY",
"add_security_recommendation": true,
"code_expiration_minutes": 10
},
{
"type": "BUTTONS",
"buttons": [
{ "type": "OTP", "otp_type": "COPY_CODE", "text": "Copy code" }
]
}
]
}
{
"name": "product_launch_image",
"language": "en_US",
"category": "MARKETING",
"parameter_format": "NAMED",
"components": [
{
"type": "HEADER",
"format": "IMAGE",
"example": {
"header_handle": ["HEADER_HANDLE_PLACEHOLDER"]
}
},
{
"type": "BODY",
"text": "Hi {{first_name}}, meet the new release.",
"example": {
"body_text_named_params": [
{ "param_name": "first_name", "example": "Jordan" }
]
}
}
]
}
{
"name": "order_ready_named",
"language": "en_US",
"category": "UTILITY",
"parameter_format": "NAMED",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Order {{order_id}} is ready",
"example": {
"header_text_named_params": [
{ "param_name": "order_id", "example": "ORDER-123" }
]
}
},
{
"type": "BODY",
"text": "Hi {{customer_name}}, your total is {{amount}}.",
"example": {
"body_text_named_params": [
{ "param_name": "customer_name", "example": "Alex" },
{ "param_name": "amount", "example": "$42.00" }
]
}
},
{
"type": "FOOTER",
"text": "Reply STOP to opt out"
},
{
"type": "BUTTONS",
"buttons": [
{ "type": "QUICK_REPLY", "text": "Need help" },
{
"type": "URL",
"text": "Track order",
"url": "https://example.com/track?id={{1}}",
"example": ["https://example.com/track?id=ORDER-123"]
}
]
}
]
}
{
"name": "order_status_update",
"language": "en_US",
"category": "UTILITY",
"parameter_format": "NAMED",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Order {{order_id}} update",
"example": {
"header_text_named_params": [
{ "param_name": "order_id", "example": "ORDER-123" }
]
}
},
{
"type": "BODY",
"text": "Hi {{customer_name}}, your order is {{status}}. {{details}}",
"example": {
"body_text_named_params": [
{ "param_name": "customer_name", "example": "Alex" },
{ "param_name": "status", "example": "shipped" },
{ "param_name": "details", "example": "Expected delivery: Jan 25" }
]
}
},
{
"type": "FOOTER",
"text": "Reply STOP to opt out"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Track order",
"url": "https://example.com/orders/{{1}}",
"example": ["https://example.com/orders/ORDER-123"]
}
]
}
]
}
{
"url": "https://example.com/webhooks/kapso",
"events": ["whatsapp.message.received", "whatsapp.message.failed"],
"payload_version": "v2",
"active": true,
"headers": {
"X-Custom-Header": "my-value"
},
"buffer_enabled": true,
"buffer_window_seconds": 5,
"max_buffer_size": 10
}
{
"private": true,
"dependencies": {
"yaml": "^2.6.0"
},
"scripts": {
"openapi": "node scripts/openapi-explore.mjs"
}
}
You have two ways to detect when customers connect their WhatsApp account through setup links.
1. Project webhooks
Configure a project webhook to receive the whatsapp.phone_number.created event. This is the recommended approach for server-to-server notifications.
Setup
1. Open the sidebar and click Integrations → Webhooks 2. Go to the Platform webhooks tab 3. Click Add Webhook 4. Enter your HTTPS endpoint URL 5. Copy the auto-generated secret key 6. Subscribe to whatsapp.phone_number.created event
Webhook payload
{
"phone_number_id": "123456789012345",
"project": {
"id": "990e8400-e29b-41d4-a716-446655440004"
},
"customer": {
"id": "880e8400-e29b-41d4-a716-446655440003"
}
}Handle the webhook
app.post('/webhooks/project', async (req, res) => {
const { event, data } = req.body;
if (event === 'whatsapp.phone_number.created') {
const { phone_number_id, customer } = data;
// Update your database
await db.customers.update(customer.id, {
phone_number_id,
whatsapp_connected: true,
connected_at: new Date()
});
// Trigger welcome flow
await sendWelcomeMessage(phone_number_id, customer.id);
}
res.status(200).send('OK');
});See webhooks documentation for signature verification and best practices.
2. Success redirect URL
When customers complete WhatsApp setup, they're redirected to your success_redirect_url with query parameters.
Setup
When creating a setup link, provide redirect URLs:
const KAPSO_API_BASE_URL = 'https://api.kapso.ai';
const setupLink = await fetch(`${KAPSO_API_BASE_URL}/platform/v1/customers/customer-123/setup_links`, {
method: 'POST',
headers: {
'X-API-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
setup_link: {
success_redirect_url: 'https://your-app.com/whatsapp/success',
failure_redirect_url: 'https://your-app.com/whatsapp/failed'
}
})
});Query parameters
After successful setup, customer is redirected to:
https://your-app.com/whatsapp/success?setup_link_id=...&status=completed&phone_number_id=123456789012345&business_account_id=...&provisioned_phone_number_id=...&display_phone_number=%2B15551234567Parameters:
setup_link_id- UUID of the setup linkstatus- Alwayscompletedfor successphone_number_id- WhatsApp phone number ID (primary identifier)business_account_id- Meta WABA ID (if available)provisioned_phone_number_id- Kapso phone number ID (if provisioning was used)display_phone_number- E.164 formatted phone number (URL encoded)
Handle the redirect
app.get('/whatsapp/success', async (req, res) => {
const {
setup_link_id,
status,
phone_number_id,
business_account_id,
provisioned_phone_number_id,
display_phone_number
} = req.query;
// Update your database
await db.customers.update({
phone_number_id,
business_account_id,
display_phone_number: decodeURIComponent(display_phone_number),
whatsapp_connected: true,
connected_at: new Date()
});
// Show success page to customer
res.render('whatsapp-connected', {
phoneNumber: decodeURIComponent(display_phone_number)
});
});<Note> These parameters are convenience identifiers to avoid extra API fetches. Use phone_number_id as the primary identifier. </Note>
Failure redirect
If setup fails, customer is redirected to your failure_redirect_url:
https://your-app.com/whatsapp/failed?setup_link_id=...&error_code=facebook_auth_failedError codes:
facebook_auth_failed- Facebook login cancelledphone_verification_failed- Phone verification failedwaba_limit_reached- Too many WhatsApp accountstoken_exchange_failed- OAuth failedlink_expired- Link expired (30 days)already_used- Link already used
app.get('/whatsapp/failed', (req, res) => {
const { setup_link_id, error_code } = req.query;
// Log failure for monitoring
await logSetupFailure(setup_link_id, error_code);
// Show user-friendly error message
res.render('whatsapp-setup-failed', {
errorMessage: getErrorMessage(error_code)
});
});Choosing the right method
Use project webhooks when:
- You need server-to-server notification
- Customer doesn't need immediate visual feedback
- You're building automated onboarding flows
- You need to process the connection before showing UI
Use success redirect when:
- Customer needs immediate confirmation in your app
- You want to show a custom success page
- You're building a wizard-style onboarding flow
- You need to collect additional information after connection
Use both:
- Webhook for backend processing (database updates, welcome messages)
- Redirect for frontend experience (success page, next steps)
Kapso Platform lets your customers connect their own WhatsApp Business accounts without sharing credentials. Each customer uses their own number while you handle the automation.
Quick example
// 1. Create customer
const customer = await createCustomer({ name: 'Acme Corp' });
// 2. Setup link
const setupLink = await generateSetupLink(customer.id);
console.log(`Setup link: ${setupLink.url}`);
// 3. Send message
await sendWhatsAppMessage({
customer_id: customer.id,
phone_number: '+1234567890',
content: 'Order confirmed!'
});Use cases
Perfect when you need your customers to use their own WhatsApp:
- CRM platforms - Let each client connect their WhatsApp for customer communication
- Appointment booking - Clinics and salons send reminders from their own number
- E-commerce tools - Stores send order updates using their WhatsApp Business
- Marketing platforms - Agencies manage multiple client WhatsApp accounts
- Support software - Each company provides support through their WhatsApp
Step 1: Create a customer
curl -X POST https://api.kapso.ai/platform/v1/customers \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer": {
"name": "Acme Corporation",
"external_customer_id": "CUS-12345"
}
}'Response:
{
"data": {
"id": "customer-abc123",
"name": "Acme Corporation",
"external_customer_id": "CUS-12345"
}
}Step 2: Generate setup link
curl -X POST https://api.kapso.ai/platform/v1/customers/customer-abc123/setup_links \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"setup_link":{}}'Response:
{
"data": {
"id": "link-xyz789",
"url": "https://app.kapso.ai/whatsapp/setup/aBcD123...",
"expires_at": "2024-03-15T10:00:00Z"
}
}Share the url with your customer. They'll click it, log in with Facebook, and connect their WhatsApp in ~5 minutes.
Step 3: Send messages
After setup completes, use the customer's phone_number_id to send messages:
curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/110987654321/messages \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "text",
"text": {
"body": "Your order has been shipped!"
}
}'JavaScript example
const KAPSO_API_KEY = 'YOUR_API_KEY';
const KAPSO_API_BASE_URL = 'https://api.kapso.ai';
const PLATFORM_API_URL = `${KAPSO_API_BASE_URL}/platform/v1`;
const WHATSAPP_API_URL = `${KAPSO_API_BASE_URL}/meta/whatsapp`;
async function onboardCustomer(customerData) {
// 1. Create customer
const customer = await fetch(`${PLATFORM_API_URL}/customers`, {
method: 'POST',
headers: {
'X-API-Key': KAPSO_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ customer: customerData })
}).then(r => r.json());
// 2. Generate setup link
const setupLink = await fetch(
`${PLATFORM_API_URL}/customers/${customer.data.id}/setup_links`,
{
method: 'POST',
headers: {
'X-API-Key': KAPSO_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ setup_link: {} })
}
).then(r => r.json());
return setupLink.data.url;
}
async function sendMessage(phoneNumberId, recipientPhone, message) {
return fetch(`${WHATSAPP_API_URL}/v24.0/${phoneNumberId}/messages`, {
method: 'POST',
headers: {
'X-API-Key': KAPSO_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to: recipientPhone,
type: 'text',
text: { body: message }
})
});
}What customers see
1. Click your setup link 2. Log in with Facebook 3. Connect their WhatsApp Business 4. Verify phone number 5. Done - you can now send messages
The entire process takes ~5 minutes.
Next steps
- Setup links - Customize redirect URLs, branding, connection types
- Connection detection - Know when customers connect
- Webhooks - Handle real-time message events
Kapso Platform API Overview
Authentication
All Platform API requests require:
X-API-Key: <api_key>Base host: https://api.kapso.ai Platform API base path: /platform/v1
Meta proxy (WhatsApp Cloud API)
Base URL: https://api.kapso.ai/meta/whatsapp/v24.0
Use Meta proxy for WhatsApp Cloud API calls (messages, templates, media, flows). Auth still uses X-API-Key.
Multi-tenant WhatsApp (Customers)
Use Customers when your end-users connect their own WhatsApp numbers.
Flow: 1. Create customer 2. Create setup link 3. Customer completes embedded signup 4. Use their phone_number_id for sending
Endpoints:
POST /customersGET /customersGET /customers/:idPOST /customers/:customer_id/setup_linksPOST /customers/:customer_id/whatsapp/phone_numbersGET /whatsapp/phone_numbers?customer_id=<uuid>(filter phone numbers by customer)
If you are only sending from your own WhatsApp number, skip Customers.
Core Platform API endpoints
Webhooks:
- Project-level:
GET/POST /whatsapp/webhooks - Config-level:
GET/POST /whatsapp/phone_numbers/:id/webhooks - Test delivery:
POST /whatsapp/webhooks/:id/test
Messages and conversations:
GET /whatsapp/messagesGET /whatsapp/messages/:id(WAMID)GET /whatsapp/conversationsGET /whatsapp/conversations/:id
Inbox embeds:
GET /inbox_embedsPOST /inbox_embedsGET /inbox_embeds/:idPATCH /inbox_embeds/:idDELETE /inbox_embeds/:id(revokes)
Inbox embed request shape:
- Envelope:
inbox_embed scope_type:project,customer, orphone_numberscope_id: blank forproject, customer UUID forcustomer, WhatsAppphone_number_idforphone_number- Create returns
tokenandembed_urlonce. Storeembed_url; list/get/update omit secrets.
Message list query params (use GET /whatsapp/messages):
phone_number_id,conversation_id,phone_numberdirection(inbound|outbound),status(pending|sent|delivered|read|failed)message_type(text|image|audio|video|document),has_media(true|false)limit(max 100),after,beforefor cursor pagination
Example: GET /whatsapp/messages?conversation_id=<uuid>&phone_number_id=<id>&direction=inbound&limit=50
Conversation list query params (use GET /whatsapp/conversations):
phone_number_id,phone_numberstatus(active|ended)limit(max 100),after,beforefor cursor pagination
Example: GET /whatsapp/conversations?phone_number_id=<id>&status=active&limit=50
Workflows:
GET /workflowsPOST /workflowsGET /workflows/:idGET /workflows/:id/definitionPATCH /workflows/:idGET /workflows/:id/variablesGET /workflows/:workflow_id/executionsPOST /workflows/:workflow_id/executionsGET /workflow_executions/:idPATCH /workflow_executions/:idPOST /workflow_executions/:id/resumeGET /workflow_executions/:id/eventsGET /workflows/:workflow_id/triggersPOST /workflows/:workflow_id/triggersPATCH /triggers/:idDELETE /triggers/:id
Workflow execution lists use cursor pagination:
limit(max 100),after,before- Responses include
paging.cursors.before,paging.cursors.after,paging.next, andpaging.previous
Functions:
GET /functionsPOST /functionsGET /functions/:idPATCH /functions/:idPOST /functions/:id/deployPOST /functions/:id/invokeGET /functions/:id/invocations
Logs:
GET /api_logsGET /webhook_deliveries
Use cursor pagination on log and delivery lists:
limit(max 100),after,before- Responses include
paging.cursors.before,paging.cursors.after,paging.next, andpaging.previous
Provider models:
GET /provider_models
Guidance
- For template creation/sending, use the Meta proxy endpoints (see
integrate-whatsappskill). - For WhatsApp Flows, use Platform API flow endpoints (see
integrate-whatsappskill). - For workflow graph edits, use workflow definition endpoints (see
automate-whatsappskill).
Setup links let customers connect their WhatsApp Business accounts to your platform. Send a link, customer clicks, logs in with Facebook, and you're connected.
Quick start
Create a basic setup link:
curl -X POST https://api.kapso.ai/platform/v1/customers/{customer_id}/setup_links \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"setup_link": {
"success_redirect_url": "https://your-app.com/whatsapp/success",
"failure_redirect_url": "https://your-app.com/whatsapp/failed"
}
}'Response includes a url you send to your customer. Links expire after 30 days.
See Connection detection for handling successful connections.
Connection types
Customers can connect their WhatsApp in two ways:
Coexistence - Keep using WhatsApp Business app alongside API
- 5 messages/second
- App stays active
- Good for small businesses
Dedicated - API-only access for automation
- Up to 1000 messages/second
- No app access
- Built for scale
Redirect URLs
Configure where customers land after completing or failing setup:
{
"setup_link": {
"success_redirect_url": "https://your-app.com/whatsapp/success",
"failure_redirect_url": "https://your-app.com/whatsapp/failed"
}
}Both URLs receive query parameters with setup details. See Connection detection for handling redirects and available parameters.
Language
Set the setup page language instead of auto-detecting from browser:
{
"setup_link": {
"language": "es"
}
}Supported languages:
en- Englishes- Spanishpt- Portuguesehi- Hindiid- Indonesianar- Arabic
When omitted, language is auto-detected from the customer's browser.
Recommended Kapso default
For Kapso-managed onboarding, prefer a dedicated connection plus phone provisioning:
{
"setup_link": {
"allowed_connection_types": ["dedicated"],
"provision_phone_number": true,
"phone_number_country_isos": ["US"]
}
}kapso setup and kapso whatsapp numbers new follow this default path and let you override country, area code, language, and redirect URLs when needed.
Connection type control
Show both options (default)
{
"setup_link": {
"allowed_connection_types": ["coexistence", "dedicated"]
}
}Coexistence only
For customers using WhatsApp Business app:
{
"setup_link": {
"allowed_connection_types": ["coexistence"]
}
}Dedicated only
For API-only automation:
{
"setup_link": {
"allowed_connection_types": ["dedicated"]
}
}When you provide one option, it auto-selects.
Theme customization
Match your brand colors:
{
"setup_link": {
"theme_config": {
"primary_color": "#3b82f6",
"background_color": "#ffffff",
"text_color": "#1f2937",
"muted_text_color": "#64748b",
"card_color": "#f9fafb",
"border_color": "#e5e7eb"
}
}
}All colors use hex format (#RRGGBB).
Phone number provisioning
Automatically provision a phone number for customers:
{
"setup_link": {
"provision_phone_number": true,
"phone_number_country_isos": ["US"]
}
}Country support
- Default:
["US"]- US phone numbers only - Non-US countries require custom Twilio credentials (contact sales)
{
"setup_link": {
"provision_phone_number": true,
"phone_number_country_isos": ["US", "CL"]
}
}Reconnect existing numbers
For customers whose WhatsApp connection broke (token revoked, password change, Meta de-auth), generate a setup link scoped to one of their existing numbers:
{
"setup_link": {
"reconnect_phone_number": "+14155551234"
}
}Constraints:
- The number must match an existing production WhatsApp config on the same customer.
provision_phone_numberis forced tofalse.allowed_connection_typesis locked to["dedicated"]or["coexistence"]to match the existing config.- During Meta's embedded signup, the customer must reconnect the same WABA + number — selecting a different one fails the setup.
The hosted onboarding page uses this to refresh credentials in place rather than create a new config.
Full example
const KAPSO_API_BASE_URL = 'https://api.kapso.ai';
const setupLink = await fetch(
`${KAPSO_API_BASE_URL}/platform/v1/customers/${customerId}/setup_links`,
{
method: 'POST',
headers: {
'X-API-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
setup_link: {
language: 'es',
success_redirect_url: 'https://app.example.com/onboarding/complete',
failure_redirect_url: 'https://app.example.com/onboarding/error',
allowed_connection_types: ['dedicated'],
provision_phone_number: true,
phone_number_country_isos: ['US'],
theme_config: {
primary_color: '#10b981',
background_color: '#ffffff',
text_color: '#111827'
}
}
})
}
);
// Send link to customer
await sendEmail(customer.email, {
subject: 'Connect your WhatsApp',
body: `Click here to connect: ${setupLink.data.url}`
});Link management
List all links
curl https://api.kapso.ai/platform/v1/customers/{customer_id}/setup_links \
-H "X-API-Key: YOUR_API_KEY"Automatic revocation
Creating a new link revokes the previous one. Only one active link per customer.
Expiration
Links expire after 30 days. Check the expires_at field.
WhatsApp Templates via Meta Proxy
Environment
Required env vars:
KAPSO_API_BASE_URL(host only, no/platform/v1, e.g.https://api.kapso.ai)KAPSO_API_KEYMETA_GRAPH_VERSION(optional, default:v24.0)KAPSO_META_BASE_URL(optional, defaults to${KAPSO_API_BASE_URL}/meta/whatsapp)
Discover IDs (recommended)
Template CRUD requires business_account_id (WABA ID). Sending messages and uploading media require phone_number_id (Meta phone number id).
Use the Platform API to discover both:
- Script:
node scripts/list-platform-phone-numbers.mjs - Raw:
GET /platform/v1/whatsapp/phone_numbers(header:X-API-Key: $KAPSO_API_KEY)
Meta proxy endpoints used
- List WABA phone numbers:
GET /{business_account_id}/phone_numbers- List templates:
GET /{business_account_id}/message_templates- Create template:
POST /{business_account_id}/message_templates- Update template:
POST /{business_account_id}/message_templates?hsm_id=<template_id>- Delete template (not scripted):
DELETE /{business_account_id}/message_templates?name=<template_name>- Send template message:
POST /{phone_number_id}/messages- Upload media for send-time headers:
POST /{phone_number_id}/media
Template concepts
Categories:
- MARKETING: promotional content.
- UTILITY: transactional updates.
- AUTHENTICATION: OTP/verification (special rules below).
AUTHENTICATION templates:
- Require Meta business verification.
- Body text is fixed by Meta (not customizable).
- Must include an OTP button (COPY_CODE or ONE_TAP).
- Send-time still requires the OTP value in body param {{1}} and URL button param.
- If user wants custom OTP text, use UTILITY instead.
Status flow:
- Kapso does not maintain a separate draft state; create/update calls go to Meta immediately.
- Use
statusfrom Meta (APPROVED,PENDING,REJECTED, etc) via list/status scripts.
Parameter types:
- POSITIONAL:
{{1}},{{2}}(sequential). - NAMED:
{{customer_name}}(lowercase + underscores). Prefer NAMED.
Component types:
- HEADER (optional)
- BODY (required)
- FOOTER (optional)
- BUTTONS (optional)
Parameter format (creation time)
Set parameter_format:
POSITIONAL(default):{{1}},{{2}}with no gaps.NAMED(recommended):{{order_id}}.
Example requirements (creation time)
If any variables appear in HEADER or BODY, you must include examples:
- POSITIONAL:
example.header_textand 2Dexample.body_text. - NAMED:
example.header_text_named_paramsandexample.body_text_named_params.
Components cheat sheet (creation time)
Header (TEXT, named)
{
"type": "HEADER",
"format": "TEXT",
"text": "Sale starts {{sale_date}}",
"example": {
"header_text_named_params": [
{ "param_name": "sale_date", "example": "December 1" }
]
}
}Header (TEXT, positional)
{
"type": "HEADER",
"format": "TEXT",
"text": "Sale starts {{1}}",
"example": {
"header_text": ["December 1"]
}
}Header (IMAGE/VIDEO/DOCUMENT)
{
"type": "HEADER",
"format": "IMAGE",
"example": {
"header_handle": ["<header_handle>"]
}
}Body (named)
{
"type": "BODY",
"text": "Hi {{customer_name}}, order {{order_id}} is ready.",
"example": {
"body_text_named_params": [
{ "param_name": "customer_name", "example": "Alex" },
{ "param_name": "order_id", "example": "ORDER-123" }
]
}
}Body (positional)
{
"type": "BODY",
"text": "Order {{1}} is ready for {{2}}.",
"example": {
"body_text": [["ORDER-123", "Alex"]]
}
}Footer (no variables)
{
"type": "FOOTER",
"text": "Reply STOP to opt out"
}Buttons
{
"type": "BUTTONS",
"buttons": [
{ "type": "QUICK_REPLY", "text": "Need help" },
{ "type": "URL", "text": "Track", "url": "https://example.com/track?id={{1}}", "example": ["https://example.com/track?id=ORDER-123"] }
]
}Button ordering rules:
- Do not interleave QUICK_REPLY with URL/PHONE_NUMBER.
- Valid: QUICK_REPLY, QUICK_REPLY, URL, PHONE_NUMBER
- Invalid: QUICK_REPLY, URL, QUICK_REPLY
- Dynamic URL variables must be at the end of the URL.
URL button variables use positional placeholders in the URL (for example {{1}}). At send-time, include a button component with sub_type: "url" and the correct index.
Example (send-time URL button param):
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "ORDER-123" }]
}AUTHENTICATION components
{
"type": "BODY",
"add_security_recommendation": true,
"code_expiration_minutes": 10
}{
"type": "BUTTONS",
"buttons": [
{ "type": "OTP", "otp_type": "COPY_CODE", "text": "Copy code" }
]
}Send-time components
Named parameters:
{
"type": "body",
"parameters": [
{ "type": "text", "parameter_name": "order_id", "text": "ORDER-123" }
]
}Positional parameters:
{
"type": "body",
"parameters": [
{ "type": "text", "text": "ORDER-123" }
]
}AUTHENTICATION send-time:
[
{
"type": "body",
"parameters": [{ "type": "text", "text": "123456" }]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "123456" }]
}
]Media header send-time (use id or link, not both):
{
"type": "header",
"parameters": [
{ "type": "image", "image": { "id": "4490709327384033" } }
]
}Header handle limitation
The Meta proxy does not expose resumable upload endpoints for header_handle. Use Platform media ingest (/platform/v1/whatsapp/media with delivery: meta_resumable_asset) if a header_handle is required.
{
"type": "header",
"parameters": [
{ "type": "image", "image": { "link": "https://example.com/header.jpg" } }
]
}Rules:
- Use either
idorlink(never both). - Always include the header component when the template has a media header.
All webhook payloads use v2 format with phone_number_id at the top level.
Payload structure
Webhook payloads separate message data from conversation data:
- message.kapso - Message-scoped only: direction, status, processing_status, statuses (raw status history), origin, has_media, content (text representation), transcript (for audio), media helpers (media_data, media_url, message_type_data)
- conversation - Top-level identifiers (id, phone_number, phone_number_id). Optional conversation.kapso contains summary metrics (counts, last-message metadata, timestamps)
- phone_number_id - Included at top level for routing
Project webhook events
Use project webhooks for connection lifecycle and workflow events only.
whatsapp.phone_number.created
Fires when a customer successfully connects their WhatsApp through a setup link.
See Connection detection for implementation guide.
Payload:
{
"phone_number_id": "123456789012345",
"project": {
"id": "990e8400-e29b-41d4-a716-446655440004"
},
"customer": {
"id": "880e8400-e29b-41d4-a716-446655440003"
}
}workflow.execution.handoff
Fires when a workflow execution is handed off to a human agent.
Payload:
{
"event": "workflow.execution.handoff",
"occurred_at": "2025-12-08T12:00:00Z",
"project_id": "990e8400-e29b-41d4-a716-446655440004",
"workflow_id": "880e8400-e29b-41d4-a716-446655440001",
"workflow_execution_id": "770e8400-e29b-41d4-a716-446655440002",
"status": "handoff",
"tracking_id": "track-abc123",
"channel": "whatsapp",
"whatsapp_conversation_id": "conv_789",
"handoff": {
"reason": "User requested human assistance",
"source": "agent_tool"
}
}| Field | Description |
|---|---|
handoff.reason | Optional reason provided during handoff |
handoff.source | agent_tool (from agent step) or action_step (from workflow action) |
workflow.execution.failed
Fires when a workflow execution fails due to an error.
Payload:
{
"event": "workflow.execution.failed",
"occurred_at": "2025-12-08T12:00:00Z",
"project_id": "990e8400-e29b-41d4-a716-446655440004",
"workflow_id": "880e8400-e29b-41d4-a716-446655440001",
"workflow_execution_id": "770e8400-e29b-41d4-a716-446655440002",
"status": "failed",
"tracking_id": "track-abc123",
"channel": "whatsapp",
"whatsapp_conversation_id": "conv_789",
"error": {
"message": "Workflow execution timed out"
}
}WhatsApp webhook events
Use phone-number webhooks for whatsapp.message.* and whatsapp.conversation.* events only.
<CardGroup cols={2}> <Card title="Message received" icon="message"> whatsapp.message.received
Fired when a new WhatsApp message is received from a customer. Supports message buffering for batch delivery. </Card> <Card title="Message sent" icon="paper-plane"> whatsapp.message.sent
Fired when a message is successfully sent to WhatsApp </Card> <Card title="Message delivered" icon="check"> whatsapp.message.delivered
Fired when a message is successfully delivered to the recipient's device </Card> <Card title="Message read" icon="eye"> whatsapp.message.read
Fired when the recipient reads your message </Card> <Card title="Message failed" icon="triangle-exclamation"> whatsapp.message.failed
Fired when a message fails to deliver </Card> <Card title="Conversation created" icon="comments"> whatsapp.conversation.created
Fired when a new WhatsApp conversation is initiated </Card> <Card title="Conversation ended" icon="clock"> whatsapp.conversation.ended
Fired when a WhatsApp conversation ends (agent action, manual closure, or 24-hour inactivity) </Card> <Card title="Conversation inactive" icon="timer"> whatsapp.conversation.inactive
Fired when no messages (inbound/outbound) for configured minutes (1-1440, default 60) </Card> </CardGroup>
Payload structures
whatsapp.message.received
{
"message": {
"id": "wamid.123",
"timestamp": "1730092800",
"type": "text",
"text": { "body": "Hello" },
"kapso": {
"direction": "inbound",
"status": "received",
"processing_status": "pending",
"origin": "cloud_api",
"has_media": false,
"content": "Hello"
}
},
"conversation": {
"id": "conv_123",
"phone_number": "+15551234567",
"status": "active",
"last_active_at": "2025-10-28T14:25:01Z",
"created_at": "2025-10-28T13:40:00Z",
"updated_at": "2025-10-28T14:25:01Z",
"metadata": {},
"phone_number_id": "123456789012345",
"kapso": {
"contact_name": "John Doe",
"messages_count": 1,
"last_message_id": "wamid.123",
"last_message_type": "text",
"last_message_timestamp": "2025-10-28T14:25:01Z",
"last_message_text": "Hello",
"last_inbound_at": "2025-10-28T14:25:01Z",
"last_outbound_at": null
}
},
"is_new_conversation": true,
"phone_number_id": "123456789012345"
}whatsapp.message.sent
{
"message": {
"id": "wamid.456",
"timestamp": "1730092860",
"type": "text",
"text": { "body": "On my way" },
"kapso": {
"direction": "outbound",
"status": "sent",
"processing_status": "completed",
"origin": "cloud_api",
"has_media": false,
"statuses": [
{
"id": "wamid.456",
"status": "sent",
"timestamp": "1730092860",
"recipient_id": "15551234567"
}
]
}
},
"conversation": {
"id": "conv_123",
"phone_number": "+15551234567",
"status": "active",
"last_active_at": "2025-10-28T14:31:00Z",
"created_at": "2025-10-28T13:40:00Z",
"updated_at": "2025-10-28T14:31:00Z",
"metadata": {},
"phone_number_id": "123456789012345",
"kapso": {
"contact_name": "John Doe",
"messages_count": 2,
"last_message_id": "wamid.456",
"last_message_type": "text",
"last_message_timestamp": "2025-10-28T14:31:00Z",
"last_message_text": "On my way",
"last_inbound_at": "2025-10-28T14:25:01Z",
"last_outbound_at": "2025-10-28T14:31:00Z"
}
},
"is_new_conversation": false,
"phone_number_id": "123456789012345"
}whatsapp.message.delivered
{
"message": {
"id": "wamid.456",
"timestamp": "1730092888",
"type": "text",
"text": { "body": "On my way" },
"kapso": {
"direction": "outbound",
"status": "delivered",
"processing_status": "completed",
"origin": "cloud_api",
"has_media": false,
"statuses": [
{
"id": "wamid.456",
"status": "sent",
"timestamp": "1730092860",
"recipient_id": "15551234567"
},
{
"id": "wamid.456",
"status": "delivered",
"timestamp": "1730092888",
"recipient_id": "15551234567"
}
]
}
},
"conversation": {
"id": "conv_123",
"phone_number": "+15551234567",
"status": "active",
"last_active_at": "2025-10-28T14:31:28Z",
"created_at": "2025-10-28T13:40:00Z",
"updated_at": "2025-10-28T14:31:28Z",
"metadata": {},
"phone_number_id": "123456789012345"
},
"is_new_conversation": false,
"phone_number_id": "123456789012345"
}whatsapp.message.failed
{
"message": {
"id": "wamid.789",
"timestamp": "1730093200",
"type": "text",
"text": { "body": "This message failed" },
"kapso": {
"direction": "outbound",
"status": "failed",
"processing_status": "completed",
"origin": "cloud_api",
"has_media": false,
"statuses": [
{
"id": "wamid.789",
"status": "sent",
"timestamp": "1730093100",
"recipient_id": "15551234567"
},
{
"id": "wamid.789",
"status": "failed",
"timestamp": "1730093200",
"recipient_id": "15551234567",
"errors": [
{
"code": 131047,
"title": "Re-engagement message",
"message": "More than 24 hours have passed since the recipient last replied"
}
]
}
]
}
},
"conversation": {
"id": "conv_123",
"phone_number": "+15551234567",
"status": "active",
"last_active_at": "2025-10-28T15:00:00Z",
"created_at": "2025-10-28T13:40:00Z",
"updated_at": "2025-10-28T15:00:00Z",
"metadata": {},
"phone_number_id": "123456789012345"
},
"is_new_conversation": false,
"phone_number_id": "123456789012345"
}whatsapp.conversation.created
{
"conversation": {
"id": "conv_789",
"phone_number": "+15551234567",
"status": "active",
"last_active_at": "2025-10-28T14:00:00Z",
"created_at": "2025-10-28T14:00:00Z",
"updated_at": "2025-10-28T14:00:00Z",
"metadata": {},
"phone_number_id": "123456789012345",
"kapso": {
"contact_name": "John Doe",
"messages_count": 0,
"last_message_id": null,
"last_message_type": null,
"last_message_timestamp": null,
"last_message_text": null,
"last_inbound_at": null,
"last_outbound_at": null
}
},
"phone_number_id": "123456789012345"
}whatsapp.conversation.ended
{
"conversation": {
"id": "conv_789",
"phone_number": "+15551234567",
"status": "ended",
"last_active_at": "2025-10-28T15:10:45Z",
"created_at": "2025-10-28T14:00:00Z",
"updated_at": "2025-10-28T15:10:45Z",
"metadata": {},
"phone_number_id": "123456789012345",
"kapso": {
"contact_name": "John Doe",
"messages_count": 15,
"last_message_id": "wamid.999",
"last_message_type": "text",
"last_message_timestamp": "2025-10-28T15:10:45Z",
"last_message_text": "Thanks!",
"last_inbound_at": "2025-10-28T15:10:45Z",
"last_outbound_at": "2025-10-28T15:10:30Z"
}
},
"phone_number_id": "123456789012345"
}whatsapp.conversation.inactive
{
"conversation": {
"id": "conv_789",
"phone_number": "+15551234567",
"status": "active",
"last_active_at": "2025-10-28T13:00:00Z",
"created_at": "2025-10-28T12:00:00Z",
"updated_at": "2025-10-28T13:00:00Z",
"metadata": {},
"phone_number_id": "123456789012345"
},
"since_message": {
"id": "msg_anchor",
"whatsapp_message_id": "wamid.ANCHOR",
"direction": "inbound",
"created_at": "2025-10-28T13:00:00Z"
},
"inactivity": {
"minutes": 60
},
"phone_number_id": "123456789012345"
}Multiple inactivity timeouts
Create separate webhooks for different timeout thresholds:
// First webhook: 5 minute warning
{
"events": ["whatsapp.conversation.inactive"],
"inactive_after_minutes": 5
}
// Second webhook: 30 minute escalation
{
"events": ["whatsapp.conversation.inactive"],
"inactive_after_minutes": 30
}Each webhook fires independently when its threshold is reached.
Message origin
The message.kapso.origin field indicates how the message entered the system:
- cloud_api - Sent via Kapso API (outbound jobs, flow actions, API calls)
- business_app - Echoed from WhatsApp Business App (when using the Business App)
- history_sync - Backfilled during message history imports (only if project ran sync)
Status history
The message.kapso.statuses array contains the complete history of raw Meta status events for a message, ordered chronologically. Each entry is the unmodified payload from Meta's webhook.
Status object structure
Each status object in the array follows Meta's webhook format:
{
"id": "<WHATSAPP_MESSAGE_ID>",
"status": "<STATUS>",
"timestamp": "<UNIX_TIMESTAMP>",
"recipient_id": "<PHONE_NUMBER>",
"pricing": {
"billable": true,
"pricing_model": "<PRICING_MODEL>",
"category": "<PRICING_CATEGORY>"
},
"errors": [
{
"code": 131031,
"title": "<ERROR_TITLE>",
"message": "<ERROR_MESSAGE>",
"error_data": {
"details": "<ERROR_DETAILS>"
},
"href": "<ERROR_CODES_URL>"
}
],
...
}| Field | Included when |
|---|---|
pricing | Sent status, plus delivered or read |
errors | Failed to send or deliver |
See Meta's status webhook reference for the complete schema.
Use this field to track the full lifecycle of outbound messages and understand failure causes. The array only appears when status events have been recorded.
Message types
The message.type field can be one of:
text- Plain text messageimage- Image attachmentvideo- Video attachmentaudio- Audio/voice messagedocument- Document attachmentlocation- Location sharingtemplate- WhatsApp template messageinteractive- Interactive message (buttons, lists)reaction- Message reactioncontacts- Contact card sharing
Message type-specific data
Media messages (image/video/document)
{
"message": {
"id": "wamid.789",
"timestamp": "1730093000",
"type": "image",
"image": {
"caption": "Photo description",
"id": "media_id_123"
},
"kapso": {
"direction": "inbound",
"status": "received",
"processing_status": "pending",
"origin": "cloud_api",
"has_media": true,
"content": "Photo description Image attached (photo.jpg) [Size: 200 KB | Type: image/jpeg] URL: https://api.kapso.ai/media/...",
"media_url": "https://api.kapso.ai/media/...",
"media_data": {
"url": "https://api.kapso.ai/media/...",
"filename": "photo.jpg",
"content_type": "image/jpeg",
"byte_size": 204800
},
"message_type_data": {
"caption": "Photo description"
}
}
}
}Audio messages
{
"message": {
"id": "wamid.790",
"timestamp": "1730093100",
"type": "audio",
"audio": {
"id": "media_id_456"
},
"kapso": {
"direction": "inbound",
"status": "received",
"processing_status": "pending",
"origin": "cloud_api",
"has_media": true,
"content": "[Audio attached] (voice.ogg) [Size: 50 KB | Type: audio/ogg] URL: https://api.kapso.ai/media/...\nTranscript: Hello, I need help with my order",
"transcript": {
"text": "Hello, I need help with my order"
},
"media_url": "https://api.kapso.ai/media/...",
"media_data": {
"url": "https://api.kapso.ai/media/...",
"filename": "voice.ogg",
"content_type": "audio/ogg",
"byte_size": 51200
}
}
}
}Location messages
{
"message": {
"type": "location",
"location": {
"latitude": 37.7749,
"longitude": -122.4194,
"name": "San Francisco",
"address": "San Francisco, CA, USA"
}
}
}Template messages
{
"message": {
"type": "template",
"template": {
"name": "order_confirmation",
"language": {
"code": "en_US"
},
"components": [...]
}
}
}Interactive messages
{
"message": {
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {
"id": "btn_1",
"title": "Confirm"
}
}
}
}Reaction messages
{
"message": {
"type": "reaction",
"reaction": {
"message_id": "wamid.HBgNNTU0MTIzNDU2Nzg5MA",
"emoji": "👍"
}
}
}Webhooks Overview
Webhook types
Project webhooks
Project-wide events (for example, whatsapp.phone_number.created). Use project webhooks for connection lifecycle and workflow events only.
WhatsApp webhooks
Message and conversation events for a specific phone_number_id. Use phone-number webhooks for whatsapp.message.* and whatsapp.conversation.* events only. WhatsApp message events cannot be delivered via project webhooks.
Kinds:
- Kapso webhooks (default): event-based payloads, filtering, buffering.
- Meta webhooks: raw Meta payloads, no filtering or buffering. One meta webhook per phone number.
Meta webhooks include X-Idempotency-Key (SHA256 hash of payload) for deduplication.
Response requirements
- Your endpoint must return
200 OKwithin 10 seconds. - Non-200 responses trigger retries.
Retry schedule (Kapso webhooks):
- 10 seconds
- 40 seconds
- 90 seconds
Signature verification
Kapso signs webhook requests:
- Header:
X-Webhook-Signature - Value:
HMAC-SHA256(webhook_secret_key, raw_request_body)as hex
Verify against raw request bytes before parsing JSON.
Headers (Kapso webhooks)
X-Webhook-EventX-Webhook-SignatureX-Idempotency-KeyX-Webhook-Payload-VersionContent-Type: application/json
Batched payloads may include:
X-Webhook-Batch: trueX-Batch-Size: <n>
Webhook Reference
Scopes
- Config-level: attach to a specific WhatsApp phone number (use
phone_number_id). - Project-level: receive lifecycle/workflow events across all numbers.
- Use config-level for any
whatsapp.message.*andwhatsapp.conversation.*events. - WhatsApp message/conversation events are not delivered via project webhooks.
Signature verification
Kapso signs outbound webhook requests:
- Header:
X-Webhook-Signature - Value:
HMAC-SHA256(webhook_secret_key, raw_request_body)as hex
Verify against the raw request body bytes before JSON parsing.
Event catalog
Message events (config-level):
whatsapp.message.receivedwhatsapp.message.sentwhatsapp.message.deliveredwhatsapp.message.readwhatsapp.message.failed
Conversation events:
whatsapp.conversation.createdwhatsapp.conversation.endedwhatsapp.conversation.inactive
Lifecycle events (project-level only):
whatsapp.config.createdwhatsapp.phone_number.createdwhatsapp.phone_number.deleted
Workflow events:
workflow.execution.handoffworkflow.execution.failed
Payload versions
v1: legacy payloads with nestedwhatsapp_config.v2: modern payloads withphone_number_idat root (recommended).
Buffering (message.received)
Use buffering to batch rapid inbound messages:
buffer_enabled: truebuffer_window_seconds: 1-60max_buffer_size: 1-100
WhatsApp Cloud API (Kapso Meta Proxy)
REST API reference for sending messages, managing templates, and querying history via Kapso's Meta proxy.
Base URL and auth
Base URL: ${KAPSO_API_BASE_URL}/meta/whatsapp/v24.0
Auth header: X-API-Key: <api_key>All payloads mirror the Meta Cloud API. Kapso adds storage and query features.
Send messages
POST /{phone_number_id}/messages
All payloads require messaging_product: "whatsapp".
Text
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "text",
"text": { "body": "Hello!", "preview_url": true }
}Image
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "image",
"image": { "link": "https://example.com/photo.jpg", "caption": "Photo" }
}Use id instead of link for uploaded media.
Video
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "video",
"video": { "link": "https://example.com/clip.mp4", "caption": "Video" }
}Audio
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "audio",
"audio": { "link": "https://example.com/audio.mp3" }
}Voice message
Voice messages require .ogg files with OPUS codec. Set voice: true:
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "audio",
"audio": { "id": "<MEDIA_ID>", "voice": true }
}Document
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "document",
"document": { "link": "https://example.com/file.pdf", "filename": "report.pdf", "caption": "Report" }
}Sticker
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "sticker",
"sticker": { "id": "<MEDIA_ID>" }
}Location
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "location",
"location": { "latitude": 37.7749, "longitude": -122.4194, "name": "SF Office", "address": "123 Main St" }
}Contacts
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "contacts",
"contacts": [{
"name": { "formatted_name": "John Doe", "first_name": "John", "last_name": "Doe" },
"phones": [{ "phone": "+15551234567", "type": "MOBILE", "wa_id": "15551234567" }],
"emails": [{ "email": "john@example.com", "type": "WORK" }]
}]
}Reaction
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "reaction",
"reaction": { "message_id": "wamid......", "emoji": "👍" }
}Note: Reactions only trigger sent status webhook (not delivered/read).
Reply to a message
Add context to reply to a specific message:
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "text",
"context": { "message_id": "wamid......" },
"text": { "body": "Thanks for your message!" }
}Interactive messages
Require an active 24-hour session window. Use templates for outbound notifications outside the window.
Buttons
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Choose an option" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "yes", "title": "Yes" } },
{ "type": "reply", "reply": { "id": "no", "title": "No" } }
]
}
}
}Max 3 buttons. Button titles max 20 chars.
List
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Shipping Options" },
"body": { "text": "Choose your preferred shipping" },
"footer": { "text": "Estimates may vary" },
"action": {
"button": "View Options",
"sections": [{
"title": "Fast",
"rows": [
{ "id": "express", "title": "Express", "description": "1-2 days" },
{ "id": "priority", "title": "Priority", "description": "2-3 days" }
]
}]
}
}
}Max 10 sections, 10 rows total. Button text max 20 chars.
CTA URL
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "cta_url",
"body": { "text": "Track your order" },
"action": {
"name": "cta_url",
"parameters": { "display_text": "Track Order", "url": "https://example.com/track/123" }
}
}
}Location request
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "location_request_message",
"body": { "text": "Please share your location for delivery." },
"action": { "name": "send_location" }
}
}Flow
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "flow",
"header": { "type": "text", "text": "Book Appointment" },
"body": { "text": "Schedule your visit" },
"action": {
"name": "flow",
"parameters": {
"flow_message_version": "3",
"flow_id": "123456789",
"flow_cta": "Book Now",
"mode": "published",
"flow_token": "session_abc123",
"flow_action": "navigate",
"flow_action_payload": {
"screen": "WELCOME_SCREEN",
"data": { "customer_id": "cust_123" }
}
}
}
}
}Product
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "product",
"body": { "text": "Check out this item" },
"action": {
"catalog_id": "CATALOG_ID",
"product_retailer_id": "SKU_1234"
}
}
}Product list
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "product_list",
"header": { "type": "text", "text": "Our Bestsellers" },
"body": { "text": "Choose a product" },
"action": {
"catalog_id": "CATALOG_ID",
"sections": [{
"title": "Popular",
"product_items": [
{ "product_retailer_id": "SKU_1234" },
{ "product_retailer_id": "SKU_2345" }
]
}]
}
}
}Max 10 sections, 30 products total.
Catalog message
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "catalog_message",
"body": { "text": "Browse our catalog." },
"action": {
"name": "catalog_message",
"parameters": { "thumbnail_product_retailer_id": "SKU_THUMBNAIL" }
}
}
}Template messages
Send with named parameters
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "template",
"template": {
"name": "order_confirmation",
"language": { "code": "en_US" },
"components": [{
"type": "body",
"parameters": [
{ "type": "text", "parameter_name": "customer_name", "text": "Jessica" },
{ "type": "text", "parameter_name": "order_number", "text": "ORD-12345" }
]
}]
}
}Send with positional parameters
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "template",
"template": {
"name": "order_confirmation",
"language": { "code": "en_US" },
"components": [{
"type": "body",
"parameters": [
{ "type": "text", "text": "Jessica" },
{ "type": "text", "text": "ORD-12345" }
]
}]
}
}Send with media header
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "template",
"template": {
"name": "seasonal_promotion",
"language": { "code": "en_US" },
"components": [
{
"type": "header",
"parameters": [{ "type": "image", "image": { "link": "https://example.com/promo.jpg" } }]
},
{
"type": "body",
"parameters": [
{ "type": "text", "parameter_name": "sale_name", "text": "Summer Sale" },
{ "type": "text", "parameter_name": "discount", "text": "25%" }
]
}
]
}
}Send with URL button variable
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "template",
"template": {
"name": "order_tracking",
"language": { "code": "en_US" },
"components": [
{
"type": "body",
"parameters": [{ "type": "text", "parameter_name": "order_id", "text": "ORD-123" }]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "ORD-123" }]
}
]
}
}See templates-reference.md for full component rules.
Template CRUD
List templates
GET /{business_account_id}/message_templates
| Param | Description |
|---|---|
name | Filter by template name |
status | APPROVED, PENDING, REJECTED |
category | AUTHENTICATION, MARKETING, UTILITY |
language | Language code (e.g., en_US) |
limit | Max 100 |
Create template
POST /{business_account_id}/message_templates
{
"name": "order_confirmation",
"language": "en_US",
"category": "UTILITY",
"parameter_format": "NAMED",
"components": [
{
"type": "BODY",
"text": "Thank you, {{customer_name}}! Your order {{order_number}} is confirmed.",
"example": {
"body_text_named_params": [
{ "param_name": "customer_name", "example": "Pablo" },
{ "param_name": "order_number", "example": "ORD-12345" }
]
}
}
]
}Response includes id and status (usually PENDING).
Update template
PUT /{business_account_id}/message_templates?hsm_id=<template_id>
Same body structure as create.
Delete template
DELETE /{business_account_id}/message_templates?name=<name> or ?hsm_id=<template_id>
Mark as read
POST /{phone_number_id}/messages
{
"messaging_product": "whatsapp",
"status": "read",
"message_id": "wamid......"
}With typing indicator
{
"messaging_product": "whatsapp",
"status": "read",
"message_id": "wamid......",
"typing_indicator": { "type": "text" }
}Typing indicator dismisses on send or after ~25s.
Media
Upload
POST /{phone_number_id}/media (multipart/form-data)
| Field | Value |
|---|---|
file | Binary file |
messaging_product | whatsapp |
Returns { "id": "<MEDIA_ID>" } for use in send payloads.
Get URL
GET /{media_id}?phone_number_id=<phone_number_id>
Returns temporary download URL (valid 5 minutes).
Delete
DELETE /{media_id}?phone_number_id=<phone_number_id>
Format limits
| Type | Formats | Max Size |
|---|---|---|
| Image | JPEG, PNG | 5 MB |
| Video | MP4, 3GP (H.264 + AAC) | 16 MB |
| Audio | AAC, AMR, MP3, M4A, OGG | 16 MB |
| Voice | OGG (OPUS codec only) | 16 MB |
| Document | PDF, DOC, DOCX, PPT, PPTX, XLS, XLSX, TXT | 100 MB |
| Sticker (static) | WEBP | 100 KB |
| Sticker (animated) | WEBP | 500 KB |
Query history (Kapso)
These endpoints are Kapso-specific for stored conversation data.
List messages
GET /{phone_number_id}/messages
| Param | Description |
|---|---|
conversation_id | Filter by conversation UUID |
direction | inbound or outbound |
status | pending, sent, delivered, read, failed |
since / until | ISO 8601 timestamps |
limit | Max 100 |
before / after | Cursor pagination |
fields | Use kapso(...) for extra fields |
List conversations
GET /{phone_number_id}/conversations
| Param | Description |
|---|---|
status | active or ended |
last_active_since / last_active_until | ISO 8601 timestamps |
phone_number | Filter by customer phone (E.164) |
limit | Max 100 |
before / after | Cursor pagination |
fields | Use kapso(...) for extra fields |
Get conversation
GET /{phone_number_id}/conversations/{conversation_id}
List contacts
GET /{phone_number_id}/contacts
| Param | Description |
|---|---|
wa_id | Filter by WhatsApp ID |
customer_id | Filter by associated customer |
has_customer | true or false |
limit | Max 100 |
before / after | Cursor pagination |
Get contact
GET /{phone_number_id}/contacts/{wa_id}
Response format
Successful send returns:
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "15551234567", "wa_id": "15551234567" }],
"messages": [{ "id": "wamid.HBgN..." }]
}Errors
| Code | Description |
|---|---|
| 131047 | 24-hour window expired. Use template instead. |
| 1026 | Receiver incapable (e.g., address_message not supported) |
| 409 | Another message in-flight for this conversation. Retry shortly. |
Kapso extensions
Add fields=kapso(...) to list endpoints:
kapso(default)orkapso(*)- all default fieldskapso(direction,media_url,contact_name)- specific fieldskapso()- omit Kapso fields
Common fields: direction, status, media_url, contact_name, flow_response, flow_token, content, message_type_data.
Notes
- Discover
phone_number_id+business_account_idvianode scripts/list-platform-phone-numbers.mjs - All send payloads require
messaging_product: "whatsapp" - Graph version controlled by
META_GRAPH_VERSION(defaultv24.0)
whatsapp-cloud-api-js
Use the @kapso/whatsapp-cloud-api SDK for typed WhatsApp Cloud API calls.
Install
npm install @kapso/whatsapp-cloud-apiCreate a client
Kapso proxy setup:
import { WhatsAppClient } from "@kapso/whatsapp-cloud-api";
const client = new WhatsAppClient({
baseUrl: "https://api.kapso.ai/meta/whatsapp",
kapsoApiKey: process.env.KAPSO_API_KEY!
});Direct Meta setup:
const client = new WhatsAppClient({
accessToken: process.env.WHATSAPP_TOKEN!
});Send a text message
await client.messages.sendText({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
body: "Hello from Kapso"
});Send a raw payload
await client.messages.sendRaw({
phoneNumberId: "<PHONE_NUMBER_ID>",
payload: {
messaging_product: "whatsapp",
recipient_type: "individual",
to: "+15551234567",
type: "text",
text: { body: "Hello from a raw payload" }
}
});Send a template message
await client.messages.sendTemplate({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
template: {
name: "order_ready_named",
language: { code: "en_US" },
components: [
{
type: "body",
parameters: [
{ type: "text", parameterName: "order_id", text: "ORDER-123" }
]
}
]
}
});Send an interactive button message
await client.messages.sendInteractiveButtons({
phoneNumberId: "<PHONE_NUMBER_ID>",
to: "+15551234567",
bodyText: "Choose an option",
buttons: [
{ id: "accept", title: "Accept" },
{ id: "decline", title: "Decline" }
]
});List conversations
const conversations = await client.conversations.list({
phoneNumberId: "<PHONE_NUMBER_ID>",
status: "active",
limit: 20
});Get a conversation
const conversation = await client.conversations.get({
conversationId: "123e4567-e89b-12d3-a456-426614174000"
});List messages
const messages = await client.messages.query({
phoneNumberId: "<PHONE_NUMBER_ID>",
conversationId: "123e4567-e89b-12d3-a456-426614174000",
limit: 50
});Get a single message
const message = await client.messages.get({
phoneNumberId: "<PHONE_NUMBER_ID>",
messageId: "wamid.HBgL..."
});List messages for a conversation (shortcut)
const messages = await client.messages.listByConversation({
phoneNumberId: "<PHONE_NUMBER_ID>",
conversationId: "123e4567-e89b-12d3-a456-426614174000",
limit: 50
});Get a template
const template = await client.templates.get({
businessAccountId: "<BUSINESS_ACCOUNT_ID>",
templateId: "564750795574598"
});Notes
- Use
phoneNumberIdfrom the connected WhatsApp number (discover viakapso whatsapp numbers resolve --phone-number "<display-number>" --output jsonornode scripts/list-platform-phone-numbers.mjs). - With Kapso proxy, keep
baseUrlandkapsoApiKeyset. - Template rules still apply (examples, button ordering, media headers).
- History endpoints (
messages.query,messages.get,messages.listByConversation,conversations.list/get) require Kapso proxy; they are not available with a direct Meta access token. - Requests use camelCase keys and the SDK converts to snake_case for the API; responses come back camelCase.
#!/usr/bin/env node
const { parseArgs, getStringFlag, getBooleanFlag, readFlagJson } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const phoneNumberId = getStringFlag(flags, 'phone-number-id') || getStringFlag(flags, 'phone_number_id');
if (!phoneNumberId) {
throw new Error('Missing required flag --phone-number-id');
}
const name = getStringFlag(flags, 'name');
const publish = getBooleanFlag(flags, 'publish');
const flowJson = await readFlagJson(flags, 'flow-json', 'flow-json-file');
const body = {
phone_number_id: phoneNumberId
};
if (name) body.name = name;
if (flowJson) body.flow_json = flowJson;
if (publish) body.publish = true;
return platformRequest({
method: 'POST',
path: '/platform/v1/whatsapp/flows',
body
});
});
#!/usr/bin/env node
const {
parseArgs,
requireStringFlag,
getStringFlag,
getBooleanFlag,
readFlagText,
readFlagJson
} = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const name = requireStringFlag(flags, 'name');
const code = await readFlagText(flags, 'code', 'code-file');
if (!code) {
throw new Error('Missing --code or --code-file');
}
const runtimeConfig = await readFlagJson(flags, 'runtime-config', 'runtime-config-file');
const publicEndpoint = getBooleanFlag(flags, 'public-endpoint');
const body = {
function: {
name,
code,
description: getStringFlag(flags, 'description'),
runtime_config: runtimeConfig
}
};
if (publicEndpoint !== undefined) {
body.function.public_endpoint = publicEndpoint;
}
return platformRequest({
method: 'POST',
path: '/platform/v1/functions',
body
});
});
import { loadJsonPayload, parseArgs, requireFlag } from './lib/args.mjs';
import { metaProxyRequest } from './lib/request.mjs';
import { err, ok, printResult } from './lib/output.mjs';
function usage() {
return {
usage: 'node scripts/create-template.mjs --business-account-id <WABA_ID> --json <payload> | --file <path>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY', 'META_GRAPH_VERSION (optional)'],
hints: [
'To discover business_account_id (WABA), run: node scripts/list-platform-phone-numbers.mjs',
'Start from an asset payload in assets/ (template definitions) and adjust name/text/examples.'
]
};
}
async function main() {
const { flags, errors } = parseArgs(process.argv.slice(2));
if (flags.help) {
return printResult(ok(usage()));
}
if (errors.length > 0) {
return printResult(err('Invalid arguments', { errors, ...usage() }));
}
try {
const businessAccountId = requireFlag(flags, ['business-account-id', 'business_account_id'], 'business-account-id');
const payload = await loadJsonPayload(flags);
const response = await metaProxyRequest({
method: 'POST',
path: `${businessAccountId}/message_templates`,
body: JSON.stringify(payload)
});
if (!response.ok) {
return printResult(err('Meta proxy request failed', { response }));
}
return printResult(ok({ response }));
} catch (error) {
return printResult(err('Failed to create template', { message: String(error?.message || error), ...usage() }));
}
}
main().then((code) => process.exit(code));
const { kapsoConfigFromEnv, kapsoRequest } = require('./lib/webhooks/kapso-api');
const { hasHelpFlag, parseFlags, requireFlag } = require('./lib/webhooks/args');
const { buildWebhookPayload } = require('./lib/webhooks/webhook');
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
function resolveScope(flags) {
const raw = flags.scope;
if (!raw || raw === true) {
return 'config';
}
const scope = String(raw);
if (scope !== 'config' && scope !== 'project') {
throw new Error(`Invalid --scope value: ${scope}`);
}
return scope;
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/create.js --url <https://...> --events <csv|json-array> [--phone-number-id <id>] [--scope config|project] [--kind <kapso|meta>] [--payload-version v1|v2] [--buffer-enabled true|false] [--buffer-window-seconds <n>] [--max-buffer-size <n>] [--inactivity-minutes <n>] [--headers <json>] [--active true|false]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const scope = resolveScope(flags);
const payload = buildWebhookPayload(flags);
if (!payload.url) {
throw new Error('Missing required flag --url');
}
if (!payload.events) {
throw new Error('Missing required flag --events');
}
const config = kapsoConfigFromEnv();
let path = '';
const body = { whatsapp_webhook: payload };
if (scope === 'project') {
const phoneNumberId = flags['phone-number-id'];
if (phoneNumberId && phoneNumberId !== true) {
body.whatsapp_webhook.phone_number_id = phoneNumberId;
}
path = '/platform/v1/whatsapp/webhooks';
} else {
const phoneNumberId = requireFlag(flags, 'phone-number-id');
path = `/platform/v1/whatsapp/phone_numbers/${encodeURIComponent(phoneNumberId)}/webhooks`;
}
const data = await kapsoRequest(config, path, {
method: 'POST',
body: JSON.stringify(body)
});
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
#!/usr/bin/env node
const { parseArgs } = require('./lib/cli');
const { metaRequest } = require('./lib/http');
const { run } = require('./lib/run');
const { requireFlowId, buildScopeQuery } = require('./lib/whatsapp-flow');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const flowId = requireFlowId(flags);
const query = buildScopeQuery(flags);
return metaRequest({
method: 'DELETE',
path: `/flows/${flowId}`,
query
});
});
const { kapsoConfigFromEnv, kapsoRequest } = require('./lib/webhooks/kapso-api');
const { hasHelpFlag, parseFlags, requireFlag } = require('./lib/webhooks/args');
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
function resolveScope(flags) {
const raw = flags.scope;
if (!raw || raw === true) {
return 'config';
}
const scope = String(raw);
if (scope !== 'config' && scope !== 'project') {
throw new Error(`Invalid --scope value: ${scope}`);
}
return scope;
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/delete.js --webhook-id <id> [--phone-number-id <id>] [--scope config|project]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const scope = resolveScope(flags);
const webhookId = requireFlag(flags, 'webhook-id');
const config = kapsoConfigFromEnv();
let path = '';
if (scope === 'project') {
path = `/platform/v1/whatsapp/webhooks/${encodeURIComponent(webhookId)}`;
} else {
const phoneNumberId = requireFlag(flags, 'phone-number-id');
path = `/platform/v1/whatsapp/phone_numbers/${encodeURIComponent(phoneNumberId)}/webhooks/${encodeURIComponent(webhookId)}`;
}
const data = await kapsoRequest(config, path, { method: 'DELETE' });
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
#!/usr/bin/env node
const { parseArgs } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
const { requireFlowId } = require('./lib/whatsapp-flow');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const flowId = requireFlowId(flags);
return platformRequest({
method: 'POST',
path: `/platform/v1/whatsapp/flows/${flowId}/data_endpoint/deploy`
});
});
#!/usr/bin/env node
const { parseArgs, requireStringFlag } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const functionId = requireStringFlag(flags, 'function-id');
return platformRequest({
method: 'POST',
path: `/platform/v1/functions/${functionId}/deploy`
});
});
#!/usr/bin/env node
const { parseArgs } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
const { requireFlowId } = require('./lib/whatsapp-flow');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const flowId = requireFlowId(flags);
return platformRequest({
method: 'GET',
path: `/platform/v1/whatsapp/flows/${flowId}/data_endpoint`
});
});
#!/usr/bin/env node
const { parseArgs, requireStringFlag } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const phoneNumberId = requireStringFlag(flags, 'phone-number-id');
return platformRequest({
method: 'GET',
path: `/platform/v1/whatsapp/phone_numbers/${phoneNumberId}`
});
});
#!/usr/bin/env node
const { parseArgs } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
const { requireFlowId } = require('./lib/whatsapp-flow');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const flowId = requireFlowId(flags);
return platformRequest({
method: 'GET',
path: `/platform/v1/whatsapp/flows/${flowId}`
});
});
#!/usr/bin/env node
const { parseArgs, requireStringFlag } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const functionId = requireStringFlag(flags, 'function-id');
return platformRequest({
method: 'GET',
path: `/platform/v1/functions/${functionId}`
});
});
const { kapsoConfigFromEnv, kapsoRequest } = require('./lib/webhooks/kapso-api');
const { hasHelpFlag, parseFlags, requireFlag } = require('./lib/webhooks/args');
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
function resolveScope(flags) {
const raw = flags.scope;
if (!raw || raw === true) {
return 'config';
}
const scope = String(raw);
if (scope !== 'config' && scope !== 'project') {
throw new Error(`Invalid --scope value: ${scope}`);
}
return scope;
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/get.js --webhook-id <id> [--phone-number-id <id>] [--scope config|project]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const scope = resolveScope(flags);
const webhookId = requireFlag(flags, 'webhook-id');
const config = kapsoConfigFromEnv();
let path = '';
if (scope === 'project') {
path = `/platform/v1/whatsapp/webhooks/${encodeURIComponent(webhookId)}`;
} else {
const phoneNumberId = requireFlag(flags, 'phone-number-id');
path = `/platform/v1/whatsapp/phone_numbers/${encodeURIComponent(phoneNumberId)}/webhooks/${encodeURIComponent(webhookId)}`;
}
const data = await kapsoRequest(config, path);
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
import { readFile } from 'node:fs/promises';
export function parseArgs(argv) {
const flags = {};
const positionals = [];
const errors = [];
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith('--')) {
positionals.push(value);
continue;
}
const key = value.slice(2);
if (!key) {
errors.push('Invalid flag');
continue;
}
if (key === 'help' || key === 'h') {
flags.help = 'true';
continue;
}
if (key.includes('=')) {
const [flag, ...rest] = key.split('=');
flags[flag] = rest.join('=');
continue;
}
const nextValue = argv[index + 1];
if (!nextValue || nextValue.startsWith('--')) {
errors.push(`Missing value for --${key}`);
continue;
}
flags[key] = nextValue;
index += 1;
}
return { flags, positionals, errors };
}
export function getFlag(flags, names) {
for (const name of names) {
if (flags[name]) return flags[name];
}
return undefined;
}
export function requireFlag(flags, names, label) {
const value = getFlag(flags, names);
if (!value) {
throw new Error(`Missing required flag: ${label}`);
}
return value;
}
export async function loadJsonPayload(flags) {
const jsonValue = getFlag(flags, ['json', 'payload']);
const fileValue = getFlag(flags, ['file']);
if (jsonValue && fileValue) {
throw new Error('Provide either --json or --file, not both');
}
if (jsonValue) {
return JSON.parse(jsonValue);
}
if (fileValue) {
const contents = await readFile(fileValue, 'utf8');
return JSON.parse(contents);
}
throw new Error('Missing required JSON payload: use --json or --file');
}
export function parseJsonFlag(flags, name) {
const value = flags[name];
if (!value) return undefined;
return JSON.parse(value);
}
const { readFile } = require('fs/promises');
function parseArgs(argv) {
const flags = {};
const positionals = [];
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith('--')) {
positionals.push(token);
continue;
}
const [rawKey, rawValue] = token.slice(2).split('=');
if (rawValue !== undefined) {
flags[rawKey] = rawValue;
continue;
}
const next = argv[index + 1];
if (next && !next.startsWith('--')) {
flags[rawKey] = next;
index += 1;
} else {
flags[rawKey] = true;
}
}
return { flags, positionals };
}
function getFlag(flags, name) {
return flags[name];
}
function getStringFlag(flags, name) {
const value = getFlag(flags, name);
if (value === undefined) return undefined;
if (value === true) return 'true';
return String(value);
}
function requireStringFlag(flags, name) {
const value = getStringFlag(flags, name);
if (!value) throw new Error(`Missing required flag --${name}`);
return value;
}
function getBooleanFlag(flags, name) {
const raw = getFlag(flags, name);
if (raw === undefined) return undefined;
if (raw === true) return true;
const value = String(raw).toLowerCase();
if (['true', '1', 'yes'].includes(value)) return true;
if (['false', '0', 'no'].includes(value)) return false;
throw new Error(`Invalid boolean for --${name}: ${raw}`);
}
function getEnumFlag(flags, name, allowedValues) {
const raw = getStringFlag(flags, name);
if (raw === undefined) return undefined;
if (allowedValues.includes(raw)) return raw;
throw new Error(`Invalid value for --${name}: ${raw}. Expected one of: ${allowedValues.join(', ')}`);
}
function getNumberFlag(flags, name) {
const raw = getStringFlag(flags, name);
if (raw === undefined) return undefined;
const value = Number(raw);
if (Number.isNaN(value)) throw new Error(`Invalid number for --${name}: ${raw}`);
return value;
}
async function readFlagText(flags, name, fileName) {
const inlineValue = getStringFlag(flags, name);
const fileValue = getStringFlag(flags, fileName);
if (inlineValue && fileValue) {
throw new Error(`Provide only one of --${name} or --${fileName}`);
}
if (fileValue) {
return (await readFile(fileValue, 'utf8')).toString();
}
return inlineValue;
}
async function readFlagJson(flags, name, fileName) {
const text = await readFlagText(flags, name, fileName);
if (!text) return undefined;
return JSON.parse(text);
}
module.exports = {
parseArgs,
getStringFlag,
requireStringFlag,
getBooleanFlag,
getEnumFlag,
getNumberFlag,
readFlagText,
readFlagJson
};
const DEFAULT_GRAPH_VERSION = 'v24.0';
function requireEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
}
function normalizeBaseUrl(raw) {
const trimmed = raw.replace(/\/+$/, '');
const metaMatch = trimmed.match(/^(.*)\/meta(?:\/whatsapp)?\/v\d+\.\d+$/);
if (metaMatch) return metaMatch[1];
if (trimmed.endsWith('/platform/v1')) return trimmed.slice(0, -'/platform/v1'.length);
return trimmed;
}
function normalizeGraphVersion(version) {
if (!version) return DEFAULT_GRAPH_VERSION;
return version.startsWith('v') ? version : `v${version}`;
}
function getConfig() {
const baseUrl = normalizeBaseUrl(requireEnv('KAPSO_API_BASE_URL'));
return {
baseUrl,
apiKey: requireEnv('KAPSO_API_KEY'),
graphVersion: normalizeGraphVersion(process.env.META_GRAPH_VERSION)
};
}
module.exports = {
getConfig
};
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function normalizeMetaBase(raw) {
const trimmed = raw.replace(/\/+$/, '');
if (trimmed.endsWith('/platform/v1')) {
return `${trimmed.slice(0, -'/platform/v1'.length)}/meta/whatsapp`;
}
const metaMatch = trimmed.match(/^(.*)\/meta\/whatsapp(?:\/v\d+\.\d+)?$/);
if (metaMatch) {
return `${metaMatch[1]}/meta/whatsapp`;
}
const rawMetaMatch = trimmed.match(/^(.*)\/meta$/);
if (rawMetaMatch) {
return `${rawMetaMatch[1]}/meta/whatsapp`;
}
return `${trimmed}/meta/whatsapp`;
}
function normalizeGraphVersion(value) {
if (!value) return 'v24.0';
return value.startsWith('v') ? value : `v${value}`;
}
export function metaProxyConfig() {
const rawBase = process.env.KAPSO_META_BASE_URL || requireEnv('KAPSO_API_BASE_URL');
const baseUrl = normalizeMetaBase(rawBase);
const apiKey = requireEnv('KAPSO_API_KEY');
const graphVersion = normalizeGraphVersion(process.env.META_GRAPH_VERSION || 'v24.0');
return {
baseUrl,
apiKey,
graphVersion
};
}
const { getConfig } = require('./env');
class RequestError extends Error {
constructor(message, status, body) {
super(message);
this.status = status;
this.body = body;
}
}
function buildUrl(baseUrl, path, query) {
const trimmed = baseUrl.replace(/\/+$/, '');
const safePath = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${trimmed}${safePath}`);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null) return;
url.searchParams.set(key, String(value));
});
}
return url.toString();
}
function isFormData(body) {
return typeof FormData !== 'undefined' && body instanceof FormData;
}
function isBlob(body) {
return typeof Blob !== 'undefined' && body instanceof Blob;
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && value.constructor === Object;
}
async function request({ baseUrl, path, method, query, body, headers }) {
const config = getConfig();
const url = buildUrl(baseUrl, path, query);
const finalHeaders = new Headers(headers || {});
finalHeaders.set('X-API-Key', config.apiKey);
let finalBody = body;
if (body !== undefined && body !== null) {
if (isPlainObject(body)) {
finalBody = JSON.stringify(body);
if (!finalHeaders.has('Content-Type')) {
finalHeaders.set('Content-Type', 'application/json');
}
} else if (typeof body === 'string') {
finalBody = body;
} else if (isFormData(body) || isBlob(body)) {
finalBody = body;
}
}
const response = await fetch(url, {
method,
headers: finalHeaders,
body: finalBody
});
const contentType = response.headers.get('content-type') || '';
const text = await response.text();
const parsed = contentType.includes('application/json') ? safeJson(text) : text;
if (!response.ok) {
throw new RequestError(`Request failed (${response.status})`, response.status, parsed);
}
return parsed;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function metaBaseUrl() {
const config = getConfig();
return `${config.baseUrl}/meta/whatsapp/${config.graphVersion}`;
}
function platformBaseUrl() {
const config = getConfig();
return config.baseUrl;
}
async function metaRequest(options) {
return request({ baseUrl: metaBaseUrl(), ...options });
}
async function platformRequest(options) {
return request({ baseUrl: platformBaseUrl(), ...options });
}
module.exports = {
RequestError,
metaRequest,
platformRequest
};
function printOk(data) {
// eslint-disable-next-line no-console
console.log(JSON.stringify({ ok: true, data }, null, 2));
}
function printError(message, details) {
// eslint-disable-next-line no-console
console.error(JSON.stringify({ ok: false, error: { message, details } }, null, 2));
}
module.exports = {
printOk,
printError
};
export function ok(data) {
return { ok: true, data };
}
export function blocked(reason, details) {
return { ok: true, blocked: true, ...details, reason };
}
export function err(message, details) {
return { ok: false, error: { message, details } };
}
export function printResult(result) {
const json = JSON.stringify(result, null, 2);
// Skill runners sometimes only surface stdout. Print errors there too so
// agents don't see only "exit status 2" with no details.
// eslint-disable-next-line no-console
console.log(json);
return result.ok ? 0 : 2;
}
import { metaProxyConfig } from './env.mjs';
function buildUrl(path, query) {
const { baseUrl, graphVersion } = metaProxyConfig();
const cleanedPath = path.replace(/^\/+/, '');
const url = new URL(`${baseUrl}/${graphVersion}/${cleanedPath}`);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, String(value));
});
}
return url;
}
function shouldParseJson(contentType) {
return contentType && contentType.toLowerCase().includes('application/json');
}
export async function metaProxyRequest({ method, path, query, headers, body }) {
const { apiKey } = metaProxyConfig();
const url = buildUrl(path, query);
const finalHeaders = new Headers(headers || {});
finalHeaders.set('X-API-Key', apiKey);
if (body && !(body instanceof FormData) && !finalHeaders.has('Content-Type')) {
finalHeaders.set('Content-Type', 'application/json');
}
const response = await fetch(url, {
method,
headers: finalHeaders,
body
});
const contentType = response.headers.get('content-type') || '';
const text = await response.text();
let data = text;
if (text && shouldParseJson(contentType)) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
return {
ok: response.ok,
status: response.status,
url: url.toString(),
data
};
}
const { printOk, printError } = require('./output');
const { RequestError } = require('./http');
async function run(action) {
try {
const data = await action();
printOk(data);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof RequestError) {
const blocked = error.status === 404;
const details = {
status: error.status,
body: error.body,
blocked
};
if (blocked) {
details.hint = 'Endpoint not available in Meta proxy or Platform API yet.';
}
printError(message, details);
return blocked ? 3 : 1;
}
printError(message, { blocked: false });
return 1;
}
}
module.exports = {
run
};
function hasHelpFlag(argv) {
return argv.includes('--help') || argv.includes('-h');
}
function parseFlags(argv) {
const flags = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith('--')) {
continue;
}
const trimmed = arg.slice(2);
const eqIndex = trimmed.indexOf('=');
if (eqIndex >= 0) {
const key = trimmed.slice(0, eqIndex);
const value = trimmed.slice(eqIndex + 1);
flags[key] = value;
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith('--')) {
flags[trimmed] = true;
continue;
}
flags[trimmed] = next;
index += 1;
}
return flags;
}
function requireFlag(flags, name) {
const value = flags[name];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`Missing required flag --${name}`);
}
return value;
}
function parseBoolean(value, name) {
if (value === undefined) {
return undefined;
}
if (value === true) {
return true;
}
const normalized = String(value).toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) {
return true;
}
if (['false', '0', 'no'].includes(normalized)) {
return false;
}
throw new Error(`Invalid boolean for --${name}: ${value}`);
}
function parseNumber(value, name) {
if (value === undefined || value === true) {
return undefined;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid number for --${name}: ${value}`);
}
return parsed;
}
function parseJsonObject(value, name) {
if (value === undefined || value === true) {
return undefined;
}
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Expected JSON object');
}
return parsed;
} catch (error) {
throw new Error(`Invalid JSON for --${name}: ${String(error)}`);
}
}
function parseStringArray(value, name) {
if (value === undefined || value === true) {
return undefined;
}
const trimmed = String(value).trim();
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed)) {
throw new Error('Expected JSON array');
}
return parsed.map((entry) => String(entry));
} catch (error) {
throw new Error(`Invalid JSON array for --${name}: ${String(error)}`);
}
}
return trimmed.split(',').map((entry) => entry.trim()).filter(Boolean);
}
module.exports = {
hasHelpFlag,
parseFlags,
requireFlag,
parseBoolean,
parseNumber,
parseJsonObject,
parseStringArray
};
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function normalizeBaseUrl(raw) {
return raw.replace(/\/+$/, '');
}
function kapsoConfigFromEnv() {
return {
baseUrl: normalizeBaseUrl(requireEnv('KAPSO_API_BASE_URL')),
apiKey: requireEnv('KAPSO_API_KEY')
};
}
async function kapsoRequest(config, path, init = {}) {
const url = `${config.baseUrl}${path}`;
const headers = new Headers(init.headers || undefined);
headers.set('X-API-Key', config.apiKey);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(url, { ...init, headers });
const text = await response.text();
if (!response.ok) {
throw new Error(`Kapso API request failed (status=${response.status}) body=${text}`);
}
return text ? JSON.parse(text) : {};
}
module.exports = {
kapsoConfigFromEnv,
kapsoRequest
};
const { parseBoolean, parseNumber, parseJsonObject, parseStringArray } = require('./args');
function buildWebhookPayload(flags) {
const payload = {};
if (typeof flags.url === 'string' && flags.url.length > 0) {
payload.url = flags.url;
}
const events = parseStringArray(flags.events, 'events');
if (events) {
payload.events = events;
}
const active = parseBoolean(flags.active, 'active');
if (active !== undefined) {
payload.active = active;
}
if (typeof flags.kind === 'string' && flags.kind.length > 0) {
payload.kind = flags.kind;
}
if (typeof flags['payload-version'] === 'string' && flags['payload-version'].length > 0) {
payload.payload_version = flags['payload-version'];
}
const bufferEnabled = parseBoolean(flags['buffer-enabled'], 'buffer-enabled');
if (bufferEnabled !== undefined) {
payload.buffer_enabled = bufferEnabled;
}
const bufferWindowSeconds = parseNumber(flags['buffer-window-seconds'], 'buffer-window-seconds');
if (bufferWindowSeconds !== undefined) {
payload.buffer_window_seconds = bufferWindowSeconds;
}
const maxBufferSize = parseNumber(flags['max-buffer-size'], 'max-buffer-size');
if (maxBufferSize !== undefined) {
payload.max_buffer_size = maxBufferSize;
}
const inactivityMinutes = parseNumber(flags['inactivity-minutes'], 'inactivity-minutes');
if (inactivityMinutes !== undefined) {
payload.inactivity_minutes = inactivityMinutes;
}
const headers = parseJsonObject(flags.headers, 'headers');
if (headers) {
payload.headers = headers;
}
return payload;
}
module.exports = {
buildWebhookPayload
};
const { getStringFlag, requireStringFlag } = require('./cli');
function requireScope(flags) {
const phoneNumberId = getStringFlag(flags, 'phone-number-id');
const businessAccountId = getStringFlag(flags, 'business-account-id');
if (!phoneNumberId && !businessAccountId) {
throw new Error('Provide --phone-number-id or --business-account-id');
}
if (phoneNumberId && businessAccountId) {
throw new Error('Provide only one of --phone-number-id or --business-account-id');
}
return { phoneNumberId, businessAccountId };
}
function requireScopeId(flags) {
const scope = requireScope(flags);
return scope.phoneNumberId || scope.businessAccountId;
}
function buildScopeQuery(flags) {
const scope = requireScope(flags);
return {
phone_number_id: scope.phoneNumberId,
business_account_id: scope.businessAccountId
};
}
function requireFlowId(flags) {
return requireStringFlag(flags, 'flow-id');
}
module.exports = {
requireScope,
requireScopeId,
buildScopeQuery,
requireFlowId
};
import { parseArgs, requireFlag } from './lib/args.mjs';
import { metaProxyRequest } from './lib/request.mjs';
import { err, ok, printResult } from './lib/output.mjs';
function usage() {
return {
usage: 'node scripts/list-connected-numbers.mjs --business-account-id <WABA_ID>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY', 'META_GRAPH_VERSION (optional)'],
hints: [
'To discover business_account_id (WABA) and phone_number_id, run: node scripts/list-platform-phone-numbers.mjs',
'Platform API equivalent: GET /platform/v1/whatsapp/phone_numbers'
]
};
}
async function main() {
const { flags, errors } = parseArgs(process.argv.slice(2));
if (flags.help) {
return printResult(ok(usage()));
}
if (errors.length > 0) {
return printResult(err('Invalid arguments', { errors, ...usage() }));
}
try {
const businessAccountId = requireFlag(flags, ['business-account-id', 'business_account_id'], 'business-account-id');
const response = await metaProxyRequest({
method: 'GET',
path: `${businessAccountId}/phone_numbers`
});
if (!response.ok) {
return printResult(err('Meta proxy request failed', { response }));
}
return printResult(ok({ response }));
} catch (error) {
return printResult(err('Failed to list connected numbers', { message: String(error?.message || error), ...usage() }));
}
}
main().then((code) => process.exit(code));
#!/usr/bin/env node
const { parseArgs, getNumberFlag } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
const { requireFlowId } = require('./lib/whatsapp-flow');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const flowId = requireFlowId(flags);
const query = {
page: getNumberFlag(flags, 'page'),
per_page: getNumberFlag(flags, 'per-page')
};
return platformRequest({
method: 'GET',
path: `/platform/v1/whatsapp/flows/${flowId}/responses`,
query
});
});
#!/usr/bin/env node
const { parseArgs, getStringFlag, getNumberFlag } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const phoneNumberId = getStringFlag(flags, 'phone-number-id') || getStringFlag(flags, 'phone_number_id');
const query = {
status: getStringFlag(flags, 'status'),
business_account_id: getStringFlag(flags, 'business-account-id'),
phone_number_id: phoneNumberId,
name_contains: getStringFlag(flags, 'name-contains'),
created_after: getStringFlag(flags, 'created-after'),
created_before: getStringFlag(flags, 'created-before'),
page: getNumberFlag(flags, 'page'),
per_page: getNumberFlag(flags, 'per-page')
};
return platformRequest({
method: 'GET',
path: '/platform/v1/whatsapp/flows',
query
});
});
#!/usr/bin/env node
const { parseArgs, getStringFlag, getNumberFlag } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const functionId = getStringFlag(flags, 'function-id');
const flowId = getStringFlag(flags, 'flow-id');
if (!functionId && !flowId) {
throw new Error('Provide --function-id or --flow-id');
}
if (functionId && flowId) {
throw new Error('Provide only one of --function-id or --flow-id');
}
const query = {
status: getStringFlag(flags, 'status'),
limit: getNumberFlag(flags, 'limit')
};
return platformRequest({
method: 'GET',
path: functionId
? `/platform/v1/functions/${functionId}/invocations`
: `/platform/v1/whatsapp/flows/${flowId}/function_invocations`,
query
});
});
#!/usr/bin/env node
const { parseArgs, getNumberFlag } = require('./lib/cli');
const { platformRequest } = require('./lib/http');
const { run } = require('./lib/run');
const { requireFlowId } = require('./lib/whatsapp-flow');
run(async () => {
const { flags } = parseArgs(process.argv.slice(2));
const flowId = requireFlowId(flags);
const query = {
limit: getNumberFlag(flags, 'limit')
};
return platformRequest({
method: 'GET',
path: `/platform/v1/whatsapp/flows/${flowId}/function_logs`,
query
});
});
import { getFlag, parseArgs } from './lib/args.mjs';
import { err, ok, printResult } from './lib/output.mjs';
function usage() {
return {
usage: 'node scripts/list-platform-phone-numbers.mjs [--page <n>] [--per-page <n>] [--phone-number-id <id>] [--business-account-id <id>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY'],
notes: [
'This calls the Platform API: GET /platform/v1/whatsapp/phone_numbers.',
'Use this to discover business_account_id (WABA) and phone_number_id (Meta phone number id).'
]
};
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function normalizePlatformBase(raw) {
const trimmed = raw.replace(/\/+$/, '');
if (trimmed.endsWith('/platform/v1')) {
return trimmed.slice(0, -'/platform/v1'.length);
}
const metaIndex = trimmed.indexOf('/meta');
if (metaIndex >= 0) {
return trimmed.slice(0, metaIndex);
}
return trimmed;
}
function buildUrl(path, query) {
const baseUrl = normalizePlatformBase(requireEnv('KAPSO_API_BASE_URL'));
const cleanedPath = path.replace(/^\/+/, '');
const url = new URL(`${baseUrl}/platform/v1/${cleanedPath}`);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, String(value));
});
}
return url;
}
function shouldParseJson(contentType) {
return contentType && contentType.toLowerCase().includes('application/json');
}
async function platformRequest({ method, path, query }) {
const apiKey = requireEnv('KAPSO_API_KEY');
const url = buildUrl(path, query);
const headers = new Headers();
headers.set('X-API-Key', apiKey);
const response = await fetch(url, { method, headers });
const contentType = response.headers.get('content-type') || '';
const text = await response.text();
let data = text;
if (text && shouldParseJson(contentType)) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
return {
ok: response.ok,
status: response.status,
url: url.toString(),
data
};
}
function summarizeIds(platformResponse) {
const configs = platformResponse?.data?.data;
if (!Array.isArray(configs)) return [];
return configs.map((config) => ({
name: config.name,
display_phone_number: config.display_phone_number,
phone_number_id: config.phone_number_id || config.id,
business_account_id: config.business_account_id
}));
}
async function main() {
const { flags, errors } = parseArgs(process.argv.slice(2));
if (flags.help) {
return printResult(ok(usage()));
}
if (errors.length > 0) {
return printResult(err('Invalid arguments', { errors, ...usage() }));
}
try {
const query = {
page: getFlag(flags, ['page']),
per_page: getFlag(flags, ['per-page', 'per_page']),
phone_number_id: getFlag(flags, ['phone-number-id', 'phone_number_id']),
business_account_id: getFlag(flags, ['business-account-id', 'business_account_id'])
};
const response = await platformRequest({
method: 'GET',
path: 'whatsapp/phone_numbers',
query
});
if (!response.ok) {
return printResult(err('Platform API request failed', { response, ...usage() }));
}
return printResult(ok({ response, ids: summarizeIds(response) }));
} catch (error) {
return printResult(err('Failed to list platform WhatsApp phone numbers', { message: String(error?.message || error), ...usage() }));
}
}
main().then((code) => process.exit(code));
import { parseArgs, parseJsonFlag, requireFlag } from './lib/args.mjs';
import { metaProxyRequest } from './lib/request.mjs';
import { err, ok, printResult } from './lib/output.mjs';
function usage() {
return {
usage: 'node scripts/list-templates.mjs --business-account-id <WABA_ID> [--name <name>] [--status <status>] [--category <category>] [--language <lang>] [--fields <fields>] [--limit <n>] [--after <cursor>] [--before <cursor>] [--query <json>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY', 'META_GRAPH_VERSION (optional)'],
hints: [
'To discover business_account_id (WABA) and phone_number_id, run: node scripts/list-platform-phone-numbers.mjs',
'Platform API equivalent: GET /platform/v1/whatsapp/phone_numbers'
]
};
}
async function main() {
const { flags, errors } = parseArgs(process.argv.slice(2));
if (flags.help) {
return printResult(ok(usage()));
}
if (errors.length > 0) {
return printResult(err('Invalid arguments', { errors, ...usage() }));
}
try {
const businessAccountId = requireFlag(flags, ['business-account-id', 'business_account_id'], 'business-account-id');
const query = {
name: flags.name,
status: flags.status,
category: flags.category,
language: flags.language,
fields: flags.fields,
limit: flags.limit,
after: flags.after,
before: flags.before
};
const extraQuery = parseJsonFlag(flags, 'query');
const mergedQuery = extraQuery ? { ...query, ...extraQuery } : query;
const response = await metaProxyRequest({
method: 'GET',
path: `${businessAccountId}/message_templates`,
query: mergedQuery
});
if (!response.ok) {
return printResult(err('Meta proxy request failed', { response }));
}
return printResult(ok({ response }));
} catch (error) {
return printResult(err('Failed to list templates', { message: String(error?.message || error), ...usage() }));
}
}
main().then((code) => process.exit(code));
const { kapsoConfigFromEnv, kapsoRequest } = require('./lib/webhooks/kapso-api');
const { hasHelpFlag, parseFlags, requireFlag } = require('./lib/webhooks/args');
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
function resolveScope(flags) {
const raw = flags.scope;
if (!raw || raw === true) {
return 'config';
}
const scope = String(raw);
if (scope !== 'config' && scope !== 'project') {
throw new Error(`Invalid --scope value: ${scope}`);
}
return scope;
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/list.js --phone-number-id <id> [--scope config|project] [--kind <kapso|meta>] [--page <n>] [--per-page <n>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const scope = resolveScope(flags);
const config = kapsoConfigFromEnv();
let path = '';
const params = new URLSearchParams();
if (flags.kind) params.set('kind', flags.kind);
if (flags.page) params.set('page', flags.page);
if (flags['per-page']) params.set('per_page', flags['per-page']);
if (scope === 'project') {
path = '/platform/v1/whatsapp/webhooks';
} else {
const phoneNumberId = requireFlag(flags, 'phone-number-id');
path = `/platform/v1/whatsapp/phone_numbers/${encodeURIComponent(phoneNumberId)}/webhooks`;
}
const suffix = params.toString();
const data = await kapsoRequest(config, `${path}${suffix ? `?${suffix}` : ''}`);
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
Related skills
Forks & variants (1)
Integrate Whatsapp has 1 known copy in the catalog totaling 254 installs. They canonicalize to this original listing.
- gokapso - 254 installs
FAQ
Which webhook scope receives WhatsApp message events?
whatsapp.message and whatsapp.conversation events are phone-number webhooks only, not project-level webhooks.
What is the preferred Kapso onboarding path?
Authenticate with kapso login, run kapso setup, complete the hosted URL, then list numbers with kapso whatsapp numbers list.
Which identifier is required to send messages?
Resolve and use phone_number_id from kapso whatsapp numbers resolve before sending text, templates, or media.
Is Integrate Whatsapp safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.