Migrate from Kapso
Step-by-step guide to migrate your WhatsApp integration from Kapso to Zernio API
This guide walks you through migrating a WhatsApp integration from Kapso to Zernio. Both platforms are built on Meta's WhatsApp Cloud API, so the fundamentals you already know (approved templates, the 24-hour service window, webhooks, WABA-level assets) carry over unchanged. What changes is the API shape: Kapso exposes Meta's raw Cloud API envelope behind a proxy plus a separate platform API, while Zernio gives you a single flat, conversation-centric API.
Most integrations move in half a day to a day.
Quick Reference
The main differences at a glance:
| What | Kapso | Zernio |
|---|---|---|
| Base URL | api.kapso.ai/meta/whatsapp/v24.0 + api.kapso.ai/platform/v1 | zernio.com/api/v1 |
| Auth | X-API-Key: KEY (default) | Authorization: Bearer KEY |
| Request shape | Meta Cloud API envelope (messaging_product, type) | Flat JSON fields (message, attachmentUrl, template) |
| Send target | {phone_number_id}/messages + to | accountId + conversationId |
| Business-initiated send | POST .../messages with type: "template" | POST /v1/inbox/conversations |
| Templates | POST /{waba_id}/message_templates | POST /v1/whatsapp/templates |
| Webhook signature | X-Webhook-Signature (HMAC-SHA256 hex) | X-Zernio-Signature (same algorithm) |
| Multi-tenant unit | Customer + setup link | Profile + connect URL |
Before You Start
What you'll need:
- Your Kapso project API key (to export conversations and templates before cutover)
- A Zernio account (sign up here)
- A Zernio API key from your dashboard
How the concepts map
Kapso splits its surface across two layers (a Meta-compatible proxy and a platform API). Zernio has one API for everything. The objects map one-to-one:
| Kapso | Zernio | Notes |
|---|---|---|
| Project + API key | Team + API key | Zernio keys can be scoped to specific profiles |
| Customer | Profile | The multi-tenant unit that groups accounts |
WhatsApp phone number (phone_number_id) | WhatsApp account (accountId) | Returned by /v1/accounts |
| Setup link | Connect URL (GET /v1/connect/whatsapp) | Both are hosted Meta embedded signup |
| Conversation | Conversation | Zernio IDs are 24-hex strings |
| Contact | Contact (/v1/contacts) |
Profile: "Acme Corp" <- Kapso "Customer"
└── WhatsApp (+1 555 123 4567) <- accountId (Kapso phone_number_id)
├── Conversations <- threads with end users
└── Templates, flows, business profileWABA assets travel with the number, message history does not. Approved templates, your display name, quality rating, and messaging tier live on the WABA at Meta and carry over when you reconnect the same number through Zernio. Stored conversations and messages live in Kapso's database. Export anything you need (via Kapso's GET /platform/v1/whatsapp/messages) before you cancel.
Step 1: Set Up Zernio Profiles
For each Kapso Customer (or just once, if you run a single number), create a Zernio profile:
curl -X POST https://zernio.com/api/v1/profiles \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Corporation"}'Save the returned profile._id. You'll pass it as profileId when connecting numbers.
Step 2: Move Your WhatsApp Numbers
Pick the path that matches how the number was connected to Kapso:
For numbers connected through Kapso setup links or "Bring Your Own SIM" (the WABA belongs to your or your customer's Meta Business), request a connect URL:
curl -X GET "https://zernio.com/api/v1/connect/whatsapp?profileId=PROFILE_ID&redirect_url=https://yourapp.com/callback" \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY"The response includes an authUrl. Open it (or send it to your customer, exactly like a Kapso setup link) to run Meta's embedded signup with the Facebook login that owns the WABA. If the WABA has multiple numbers, the flow includes a phone number selection step.
Because the WABA stays the same, approved templates, display name, and quality rating come with it.
Coexistence numbers: pairing with the WhatsApp Business App is exclusive to one provider. Disconnect from Kapso first (in the app: Settings, then Account, then Business Platform, then Disconnect), then run the Zernio connect flow and scan the QR code again. See Connection & Setup.
If you connected to Kapso by posting Meta credentials directly, Zernio has the same headless flow:
Kapso:
curl -X POST https://api.kapso.ai/platform/v1/customers/CUSTOMER_ID/whatsapp/phone_numbers \
-H "X-API-Key: KAPSO_API_KEY" \
-d '{
"whatsapp_phone_number": {
"name": "Support Line",
"kind": "production",
"phone_number_id": "1234567890",
"business_account_id": "98765432109",
"access_token": "EAABsbCS...long-lived-token"
}
}'Zernio:
curl -X POST https://zernio.com/api/v1/connect/whatsapp/credentials \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{
"profileId": "PROFILE_ID",
"accessToken": "EAABsbCS...system-user-token",
"wabaId": "98765432109",
"phoneNumberId": "1234567890"
}'The token must be a permanent Meta System User token with whatsapp_business_management and whatsapp_business_messaging. Zernio subscribes its webhooks and returns the new account.
If you used a Kapso-managed number (Instant Setup), the number belongs to Kapso's pool, so purchase a dedicated number through Zernio instead:
curl -X POST https://zernio.com/api/v1/phone-numbers/purchase \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{"profileId": "PROFILE_ID", "country": "US"}'Your first purchase returns a Stripe checkoutUrl to complete payment; subsequent purchases provision inline. Numbers start at $2/month and are available in dozens of countries (list them via GET /v1/phone-numbers/countries). Regulated countries require KYC, which you can complete via API or hand off to your customer with a white-label hosted KYC link (POST /v1/phone-numbers/kyc/share, with your branding).
Track provisioning with the whatsapp.number.activated / whatsapp.number.action_required webhooks or by polling GET /v1/phone-numbers/{id}.
Just testing? Zernio has a sandbox too: POST /v1/whatsapp/sandbox/sessions with {"phone": "+15551234567"}, then reply to the verification message. See Sandbox.
Step 3: Get Your Account IDs
After connecting, fetch the WhatsApp accounts. The _id values replace Kapso's phone_number_id in every call:
curl -X GET "https://zernio.com/api/v1/accounts?platform=whatsapp" \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY"{
"accounts": [
{
"_id": "abc123",
"platform": "whatsapp",
"username": "+1 555-123-4567",
"displayName": "Acme Corp",
"metadata": {
"wabaId": "98765432109",
"phoneNumberId": "1234567890",
"qualityRating": "GREEN",
"messagingLimitTier": "TIER_1K"
}
}
]
}Store the phone_number_id to accountId mapping in your database. Quality rating and messaging tier are the same values you saw in Kapso's health endpoint; live WABA health is at GET /v1/whatsapp/number-info?accountId=....
Step 4: Update Message Sending
This is the biggest conceptual shift. Kapso is recipient-centric: every send goes to POST /{phone_number_id}/messages with a to phone number. Zernio is conversation-centric:
- Business-initiated (template) messages create or reuse a conversation:
POST /v1/inbox/conversationswithparticipantId(the recipient's phone number). - Free-form messages inside the 24-hour window go into an existing conversation:
POST /v1/inbox/conversations/{conversationId}/messages.
You get the conversationId from the create call's response, from every webhook, or from GET /v1/inbox/conversations. It's stable per participant and account, so store it alongside your contact records.
Business-initiated send (starts or re-engages a conversation):
Kapso:
curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/PHONE_NUMBER_ID/messages \
-H "X-API-Key: KAPSO_API_KEY" \
-d '{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "template",
"template": {
"name": "order_update",
"language": {"code": "en_US"},
"components": [
{"type": "body", "parameters": [{"type": "text", "text": "John"}]}
]
}
}'Zernio:
curl -X POST https://zernio.com/api/v1/inbox/conversations \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{
"accountId": "abc123",
"participantId": "15551234567",
"templateName": "order_update",
"templateLanguage": "en_US",
"templateParams": ["John"]
}'templateParams fills body variables in order and works with both positional ({{1}}) and named ({{name}}) placeholders. Media-header templates are auto-filled from the approved template definition. The response returns the conversationId for follow-ups.
To send a template into an existing thread with full Meta-style components (for example a custom media header), use the messages endpoint instead:
{
"accountId": "abc123",
"template": {
"elements": [
{
"name": "order_update",
"language": "en_US",
"components": [
{"type": "body", "parameters": [{"type": "text", "text": "John"}]}
]
}
]
}
}The components array is forwarded to Meta verbatim, so your existing Kapso component payloads drop in unchanged.
Free-form reply inside the 24-hour window:
Kapso:
curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/PHONE_NUMBER_ID/messages \
-H "X-API-Key: KAPSO_API_KEY" \
-d '{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "text",
"text": {"body": "Your order #12345 has shipped!"}
}'Zernio:
curl -X POST https://zernio.com/api/v1/inbox/conversations/CONVERSATION_ID/messages \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{
"accountId": "abc123",
"message": "Your order #12345 has shipped!"
}'To quote-reply, pass replyTo with the WhatsApp message ID (wamid...), which arrives on webhooks as message.platformMessageId.
Kapso (link or a previously uploaded media id):
curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/PHONE_NUMBER_ID/messages \
-H "X-API-Key: KAPSO_API_KEY" \
-d '{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "image",
"image": {"link": "https://example.com/product.jpg"}
}'Zernio (public URL):
curl -X POST https://zernio.com/api/v1/inbox/conversations/CONVERSATION_ID/messages \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{
"accountId": "abc123",
"attachmentUrl": "https://example.com/product.jpg",
"attachmentType": "image"
}'attachmentType is image, video, audio, or file. For documents, add attachmentName so WhatsApp shows a filename. For voice notes, send an .ogg OPUS audio attachment with voiceNote: true (or upload any browser recording via multipart and Zernio transcodes it).
There is no Meta media-ID step. If your file isn't hosted anywhere, either send it as multipart/form-data (field attachment, up to 25MB) or upload it first:
curl -X POST https://zernio.com/api/v1/media/upload-direct \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-F "file=@invoice.pdf"This returns a URL (valid 7 days) to pass as attachmentUrl.
Reply buttons use Zernio's shorthand instead of the interactive envelope:
Kapso:
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "interactive",
"interactive": {
"type": "button",
"body": {"text": "Confirm your appointment?"},
"action": {
"buttons": [
{"type": "reply", "reply": {"id": "btn_yes", "title": "Yes"}},
{"type": "reply", "reply": {"id": "btn_no", "title": "No"}}
]
}
}
}Zernio:
{
"accountId": "abc123",
"message": "Confirm your appointment?",
"buttons": [
{"type": "postback", "title": "Yes", "payload": "btn_yes"},
{"type": "postback", "title": "No", "payload": "btn_no"}
]
}Everything else (lists, CTA URLs, flows, location requests) keeps Meta's interactive object shape verbatim, so your Kapso payloads move as-is; just drop the outer envelope and add accountId:
{
"accountId": "abc123",
"interactive": {
"type": "list",
"body": {"text": "Pick an option"},
"action": {
"button": "View Options",
"sections": [
{"title": "Options", "rows": [{"id": "opt_1", "title": "Option 1"}]}
]
}
}
}Supported interactive.type values: list, cta_url, flow, location_request_message, voice_call. Button and list taps arrive on the message.received webhook with metadata.interactiveType (button_reply / list_reply) and metadata.interactiveId.
Location pins keep the same fields (Zernio takes numbers, not strings):
{
"accountId": "abc123",
"location": {
"latitude": 37.7749,
"longitude": -122.4194,
"name": "San Francisco Office",
"address": "123 Market St, San Francisco, CA 94103"
}
}Contact cards mirror Meta's shape via the contacts field.
Reactions are their own endpoint instead of a message type. The path takes the WhatsApp message ID (the wamid, from webhook message.platformMessageId, the same ID replyTo uses):
curl -X POST https://zernio.com/api/v1/inbox/conversations/CONVERSATION_ID/messages/MESSAGE_ID/reactions \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{"accountId": "abc123", "emoji": "👍"}'Remove with DELETE on the same path, passing accountId as a query parameter.
Mark read and typing indicators
Kapso overloads the messages endpoint ("status": "read" + typing_indicator). Zernio has dedicated endpoints:
# Mark the conversation read (sends blue ticks)
curl -X POST https://zernio.com/api/v1/inbox/conversations/CONVERSATION_ID/read \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{"accountId": "abc123"}'
# Show "typing..." for up to 25 seconds
curl -X POST https://zernio.com/api/v1/inbox/conversations/CONVERSATION_ID/typing \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{"accountId": "abc123"}'On coexistence numbers the phone app owns read state, so /read doesn't send blue ticks there.
Step 5: Update Webhooks
Kapso registers webhooks per phone number (plus project webhooks). Zernio webhooks are team-wide with event filtering. Message and template payloads carry an account object, so scope by account.id in your handler; whatsapp.number.* lifecycle events carry a number object with the profileId instead:
curl -X POST https://zernio.com/api/v1/webhooks/settings \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{
"name": "whatsapp-events",
"url": "https://yourapp.com/webhooks/zernio",
"events": [
"message.received", "message.sent", "message.delivered",
"message.read", "message.failed", "reaction.received",
"conversation.started", "whatsapp.template.status_updated"
],
"secret": "your-signing-secret"
}'Event mapping
| Kapso event | Zernio event |
|---|---|
whatsapp.message.received | message.received |
whatsapp.message.sent | message.sent |
whatsapp.message.delivered | message.delivered |
whatsapp.message.read | message.read |
whatsapp.message.failed | message.failed |
| Reaction messages | reaction.received |
whatsapp.conversation.created | conversation.started |
whatsapp.conversation.ended / .inactive | No equivalent (derive from conversation timestamps) |
whatsapp.phone_number.created | account.connected, plus whatsapp.number.* lifecycle for purchased numbers |
whatsapp.phone_number.deleted | account.disconnected |
| Template status changes | whatsapp.template.status_updated |
Payloads are Zernio-shaped rather than Meta-shaped: the message text is at message.text, the wamid at message.platformMessageId, the sender at message.sender (with phoneNumber, and businessScopedUserId as the durable identity anchor), and WhatsApp extras (button taps, flow responses, CTWA referral with ctwa_clid) under metadata. Full shapes in the webhooks guide.
Signature verification
Same algorithm, different header. Your Kapso verification code needs a one-line change:
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
// Kapso: req.headers['x-webhook-signature']
// Zernio: req.headers['x-zernio-signature']
const valid = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers['x-zernio-signature'])
);Delivery behavior
| Kapso | Zernio | |
|---|---|---|
| Retries | 3 (10s / 40s / 90s) | Up to 7 attempts over ~51 hours, then dead-letter |
| Batching | whatsapp.message.received can batch | One event per request, always |
| Dedup key | X-Idempotency-Key | payload.id (also X-Zernio-Event-Id header) |
| Raw Meta forwarding | kind: "meta" webhooks | Not offered |
Delivery is at-least-once, so dedupe on the event ID. Debug with GET /v1/webhooks/logs (30-day retention) and POST /v1/webhooks/test.
Step 6: Templates
Templates belong to the WABA at Meta. If you reconnected the same number in Step 2, your approved templates are already there; verify with:
curl -X GET "https://zernio.com/api/v1/whatsapp/templates?accountId=abc123" \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY"If you moved to a new number, recreate them. The components array is the same Meta shape with lowercase types:
Kapso:
curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/WABA_ID/message_templates \
-H "X-API-Key: KAPSO_API_KEY" \
-d '{
"name": "order_confirmation",
"language": "en_US",
"category": "UTILITY",
"components": [
{"type": "BODY", "text": "Thank you! Your order {{1}} is confirmed."}
]
}'Zernio:
curl -X POST https://zernio.com/api/v1/whatsapp/templates \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{
"accountId": "abc123",
"name": "order_confirmation",
"language": "en_US",
"category": "UTILITY",
"components": [
{
"type": "body",
"text": "Thank you! Your order {{1}} is confirmed.",
"example": {"body_text": [["ORD-12345"]]}
}
]
}'Categories (AUTHENTICATION, MARKETING, UTILITY), statuses, and Meta's review (up to 24h) are identical on both platforms. Approval outcomes arrive on the whatsapp.template.status_updated webhook.
Skip review entirely with library templates. Zernio can instantiate Meta's pre-approved template library (library_template_name on the create call). These come back APPROVED immediately. Introspect a library template's structure and required button inputs first via GET /v1/whatsapp/template-library?accountId=...&name=... (exact-name lookup). See Templates.
WhatsApp Flows
Zernio has a full Flows API: create (POST /v1/whatsapp/flows), upload Flow JSON (PUT /v1/whatsapp/flows/{flowId}/json, Meta-validated), browser preview, publish, and version history. Your existing Flow JSON moves unchanged.
Sending gets simpler. Instead of Kapso's interactive envelope, there's a dedicated endpoint that accepts a raw phone number and auto-generates the flow_token:
curl -X POST https://zernio.com/api/v1/whatsapp/flows/send \
-H "Authorization: Bearer YOUR_ZERNIO_API_KEY" \
-d '{
"accountId": "abc123",
"to": "+15551234567",
"flow_id": "1197715005513101",
"flow_cta": "Book now",
"body": "Book your appointment",
"flow_action_payload": {"screen": "BOOKING"}
}'(The interactive.type: "flow" shape from Kapso also works on the messages endpoint, and template buttons support type: flow.)
Responses arrive two ways: live on message.received webhooks (metadata.interactiveType: "nfm_reply" with metadata.flowResponseData), or in bulk via GET /v1/whatsapp/flow-responses. See Flows.
If your flows use data_exchange with Kapso-hosted data endpoint functions and managed encryption, you'll need to host that data endpoint yourself after migrating. Reach out and we'll help you wire it up.
Feature Map
| Feature | Kapso | Zernio |
|---|---|---|
| Send / receive all message types | Meta proxy | Inbox API |
| Conversations & message history | Platform API | GET /v1/inbox/conversations (+ full-text search) |
| Contacts | Platform API | /v1/contacts |
| Broadcasts (bulk template sends) | Broadcasts API | /v1/broadcasts with recipients, schedule, progress |
| Conversation automation | Workflow builder, AI agent nodes | Workflows (/v1/workflows): visual builder with AI nodes (bring your own provider key), branching, webhooks, human handoff |
| Business calling | WebRTC (SDP offers via API) | Calling API: forward to any phone/SIP, recording, per-call cost breakdown |
| Flows | Proxy + hosted data endpoints | Full Flows API |
| Business profile / display name / username | Proxy + platform | /v1/whatsapp/business-profile |
| Block users | Proxy | /v1/whatsapp/block-users |
| CTWA attribution | Referral fields | metadata.referral on webhooks + conversion events API |
| Sandbox | 6-character code | Verification reply |
| Number health | Health endpoint | GET /v1/whatsapp/number-info |
| MCP server | api.kapso.ai/mcp | mcp.zernio.com/mcp |
You also gain: group chats API, sequences (drip campaigns), SMS and voice calling on the same purchased number, numbers in dozens of countries with API-driven KYC and white-label hosted KYC links, and a unified inbox that also covers Instagram, Facebook, Telegram, and SMS.
No Zernio equivalent (plan around these):
- Hosted serverless functions. Custom code runs on your own infrastructure; workflow
webhooknodes call out to your endpoints instead. - Embeddable inbox iframe. Zernio's inbox lives in the dashboard; there's no embed widget.
- Raw Meta webhook forwarding (
kind: "meta"). Zernio delivers its own payload shape only. - Interactive product and carousel messages (single product, product list, catalog message, interactive carousel). Template-based equivalents do work: carousel template components (2 to 10 cards) plus
catalogandmpmtemplate buttons. - Automatic audio transcription of voice notes.
Cutover Strategy
Migrate number by number, not gradually per number. A WhatsApp number's registration and webhook routing move with it, so plan a hard cutover for each number: export history from Kapso, reconnect through Zernio, verify webhooks, then cancel on Kapso. For multi-tenant platforms, migrate one customer (one Zernio profile) at a time.
| Phase | Actions |
|---|---|
| Prep | Create profiles, build and test your integration against a Zernio sandbox number |
| Pilot | Move one low-traffic production number end-to-end; run it for a few days |
| Rollout | Migrate remaining numbers/customers in batches |
| Cutoff | Cancel the Kapso plan; keep your export of message history |
Two things stay exactly the same and need no migration: Meta's per-template conversation charges (billed by Meta to your WABA payment method on both platforms) and template approvals (they live on the WABA).
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 Unauthorized | Kapso-style X-API-Key header | Use Authorization: Bearer YOUR_API_KEY |
TEMPLATE_REQUIRED | Free-form send to a new participant | Business-initiated messages need an approved template; free-form only works inside an existing conversation's 24h window |
| Fields ignored on send | Posting the Meta envelope (messaging_product, type) | Use flat fields: message, attachmentUrl, interactive, template |
| Media send fails | Passing a Meta media id | Zernio takes URLs; get one via /v1/media/upload-direct |
| Template send does nothing | Meta-style template object | Wrap it as template.elements[0] with name, language, components |
| Blue ticks not sent | Coexistence account | The phone app owns read state on coexistence numbers |
| Quote-reply not quoting | Using the Zernio message ID in replyTo | replyTo takes the wamid, from webhook message.platformMessageId |
Code Example: Node.js
const ZERNIO = 'https://zernio.com/api/v1';
const headers = {
'Authorization': `Bearer ${process.env.ZERNIO_API_KEY}`,
'Content-Type': 'application/json',
};
// Business-initiated: send a template to any phone number.
// Creates (or reuses) the conversation and returns its id.
const sendTemplate = async ({ accountId, to, templateName, templateParams = [] }) => {
const res = await fetch(`${ZERNIO}/inbox/conversations`, {
method: 'POST',
headers,
body: JSON.stringify({
accountId,
participantId: to,
templateName,
templateLanguage: 'en_US',
templateParams,
}),
});
const { data } = await res.json();
return data; // { messageId, conversationId, ... }
};
// Free-form reply inside the 24-hour window.
const reply = async ({ accountId, conversationId, message }) => {
const res = await fetch(`${ZERNIO}/inbox/conversations/${conversationId}/messages`, {
method: 'POST',
headers,
body: JSON.stringify({ accountId, message }),
});
const { data } = await res.json();
return data.messageId;
};
// Usage: template first, then free-form once they reply
const { conversationId } = await sendTemplate({
accountId: 'abc123',
to: '15551234567',
templateName: 'order_update',
templateParams: ['John', 'ORD-123'],
});
await reply({ accountId: 'abc123', conversationId, message: 'Anything else I can help with?' });Need Help?
- WhatsApp platform guides: /platforms/whatsapp
- Support: support@zernio.com
- Rate Limits: 60-1,200 req/min, bucketed by your team's connected-account count (0-2: 60, 3-2,000: 600, 2,001+: 1,200), see /guides/rate-limits