Skip to content

Migrate from Twilio

Move WhatsApp, SMS, voice and phone numbers from Twilio to Zernio, with a mapping for every send call, webhook and registration that changes and a list of what stays on Twilio.

When you finish this page your WhatsApp, SMS and voice traffic runs through Zernio, on numbers you ported or bought. You need your Twilio Account SID and auth token (to export data and wind down), a Zernio account and an API key. Zernio replaces Twilio's messaging surfaces (WhatsApp Business API, SMS and MMS, voice calling, phone numbers); it does not replace TwiML voice apps, Verify's voice and email channels, or SendGrid, listed under what does not map.

What changes

WhatTwilioZernio
AuthHTTP Basic (Account SID + token, or API key)Authorization: Bearer KEY
Wire formatapplication/x-www-form-urlencodedJSON
Per-customer unitAccount and subaccountsTeam, split into profiles: a profile groups the numbers and accounts of one customer or brand
WhatsApp senderWhatsApp SenderAccount (accountId), from GET /v1/accounts?platform=whatsapp
Phone numberIncomingPhoneNumbers (PN...) + Regulatory Bundles/v1/phone-numbers purchase + KYC, or port your numbers. One number carries WhatsApp, SMS and voice together
WhatsApp sendPOST /2010-04-01/.../Messages.json with From=whatsapp:+E164POST /v1/inbox/conversations (then /{conversationId}/messages)
SMS sendThe same Messages.json endpointPOST /v1/sms/messages
One messageMessage resource (SM...)A message inside an inbox conversation (GET /v1/inbox/conversations)
WhatsApp templatesContent API (ContentSid, HX...)/v1/whatsapp/templates, addressed by name. The approved templates stay on your WABA at Meta
Conversation automationStudio flowWorkflow (/v1/workflows): trigger and node graph with AI nodes, conditions and handoff
Inbound webhooksForm-encoded POST, reply with TwiMLJSON events, reply with any 2xx
Webhook signatureX-Twilio-Signature (HMAC-SHA1 of URL + sorted params)X-Zernio-Signature (HMAC-SHA256 of the raw body)

The send model is the biggest shift. Twilio is stateless: every send is To plus From on one endpoint. Zernio is conversation-centric for WhatsApp: a business-initiated template send creates or reuses a conversation, and free-form replies go into it. SMS keeps the familiar shape, from and to on POST /v1/sms/messages, and replies thread into the same inbox.

Step 1: Create a profile

Call POST /v1/profiles with a name, once per Twilio subaccount (or once, if the app serves one customer):

bash
curl -X POST https://zernio.com/api/v1/profiles \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Corporation"}'

Response (201):

json
{
  "message": "Profile created successfully",
  "profile": {
    "_id": "66a1f0c2a4b9d3e8f1a2b3c4",
    "name": "Acme Corporation",
    "isDefault": false
  }
}

profile._id is the profileId for everything below.

Step 2: Move the numbers

The numbers your customers know can move with you.

Check portability

Call POST /v1/phone-numbers/port-in/check with phoneNumbers; the check is free:

bash
curl -X POST https://zernio.com/api/v1/phone-numbers/port-in/check \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phoneNumbers": ["+13025551234"]}'

Response (200):

json
{
  "results": [
    {
      "phoneNumber": "+13025551234",
      "portable": true,
      "fastPortable": true,
      "lineType": "landline",
      "countryCode": "US",
      "phoneNumberType": "local",
      "notPortableReason": null
    }
  ]
}

Upload the paperwork

Call POST /v1/phone-numbers/port-in/documents once for the Letter of Authorization and once for a recent Twilio invoice; each call returns a documentId.

Submit the port

Call POST /v1/phone-numbers/port-in with the numbers, the endUser and both document ids:

bash
curl -X POST https://zernio.com/api/v1/phone-numbers/port-in \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumbers": ["+13025551234"],
    "endUser": {
      "entityName": "Acme Corporation",
      "authPersonName": "Jane Smith",
      "streetAddress": "123 Market St",
      "locality": "San Francisco",
      "administrativeArea": "CA",
      "postalCode": "94103",
      "countryCode": "US"
    },
    "loaDocumentId": "66d4a1b2c3d4e5f6a7b8c9d0",
    "invoiceDocumentId": "66d4a1b2c3d4e5f6a7b8c9d1"
  }'

Response (201):

json
{
  "id": "66d4a1b2c3d4e5f6a7b8c9d2",
  "status": "pending",
  "phoneNumbers": ["+13025551234"]
}

GET /v1/phone-numbers/port-in reports the status (draft, pending, foc_confirmed, ported, exception, cancelled). Ported numbers arrive voice-ready, and SMS-ready where supported (see Phone Numbers).

To test before moving a number, the WhatsApp sandbox replaces Twilio's join <code> flow: POST /v1/whatsapp/sandbox/sessions with your phone, then reply to the verification message.

Step 3: Get the account ids

Twilio addresses everything by phone number string. Zernio addresses WhatsApp by accountId; call GET /v1/accounts?platform=whatsapp:

bash
curl "https://zernio.com/api/v1/accounts?platform=whatsapp" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"

Response (200):

json
{
  "accounts": [
    {
      "_id": "66b2e19d8c3f5a7e9d0b1c2d",
      "platform": "whatsapp",
      "username": "+1 555-765-4321",
      "displayName": "Acme Corp",
      "isActive": true
    }
  ]
}

Store the mapping from each whatsapp:+E164 sender to its _id. SMS keeps raw E.164 numbers in from and to. To turn SMS on for a number you bought or ported, call POST /v1/phone-numbers/{id}/sms once (idempotent); US numbers also need registration (Step 6).

Step 4: Change the send calls

A business-initiated send (outside the 24-hour window) swaps ContentSid for the template name.

Twilio, form-encoded to Messages.json:

To=whatsapp:+15551234567
From=whatsapp:+15557654321
ContentSid=HX0123456789abcdef0123456789abcdef
ContentVariables={"1": "John", "2": "ORD-123"}

Zernio:

bash
curl -X POST https://zernio.com/api/v1/inbox/conversations \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "participantId": "15551234567",
    "templateName": "order_update",
    "templateLanguage": "en_US",
    "templateParams": ["John", "ORD-123"]
  }'

Response (201):

json
{
  "success": true,
  "data": {
    "messageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI...",
    "conversationId": "66c3d2ae7b4f6c8d0e1f2a3b",
    "participantId": "15551234567"
  }
}

Store conversationId for replies. Sending to the same participant again reuses the conversation.

Step 5: Change the webhooks

TwilioZernio
PayloadForm-encoded params (From, Body, WaId, ...)JSON events with nested objects
ResponseTwiML (<Response/>)Any 2xx, body ignored
RegistrationPer number or Messaging Service URLPOST /v1/webhooks/settings, team-wide and filtered by event
SignatureX-Twilio-Signature: HMAC-SHA1 over URL + sorted params, base64X-Zernio-Signature: HMAC-SHA256 over the raw JSON body, hex
Delivery statusStatusCallback per messagemessage.sent, message.delivered, message.read, message.failed events
Retries1 retry, only on connection timeout (by default)7 attempts over about 51 hours, then dead-letter
DedupMessageSidpayload.id (also the X-Zernio-Event-Id header)

Call POST /v1/webhooks/settings once:

bash
curl -X POST https://zernio.com/api/v1/webhooks/settings \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "messaging-events",
    "url": "https://yourapp.com/webhooks/zernio",
    "events": [
      "message.received", "message.sent", "message.delivered",
      "message.read", "message.failed", "conversation.started",
      "whatsapp.template.status_updated", "call.received", "call.ended"
    ],
    "secret": "'"$ZERNIO_WEBHOOK_SECRET"'"
  }'

Response (200):

json
{
  "success": true,
  "webhook": {
    "_id": "507f1f77bcf86cd799439011",
    "name": "messaging-events",
    "url": "https://yourapp.com/webhooks/zernio",
    "events": ["message.received", "message.sent", "message.delivered", "message.read", "message.failed", "conversation.started", "whatsapp.template.status_updated", "call.received", "call.ended"],
    "isActive": true
  }
}

An inbound WhatsApp message maps field by field:

Twilio paramZernio (message.received payload)
MessageSidmessage.platformMessageId (the wamid)
From (whatsapp:+1...) / WaIdmessage.sender.id / message.sender.phoneNumber
ProfileNamemessage.sender.name
Bodymessage.text
MediaUrl0..Nmessage.attachments[].url
ButtonText / ButtonPayloadmetadata.interactiveType + metadata.interactiveId
Latitude / Longitude / Address / Labelmetadata.location
ReferralSourceId, ReferralBody, ...metadata.referral (CTWA, including ctwa_clid)
Cart orders (not available)metadata.order

Inbound SMS fires message.received with platform: "sms" (and conversation.started the first time a thread appears); delivery states fire message.delivered and message.failed, the latter with the carrier's error code in error. The same data is readable with GET /v1/inbox/conversations/{conversationId}/messages (each message carries deliveryStatus and any deliveryError).

Twilio's URL-plus-sorted-params HMAC-SHA1 becomes a plain HMAC over the body:

javascript
// Twilio (before): twilio.validateRequest(authToken, signature, url, params)

// Zernio (after):
const expected = crypto
  .createHmac('sha256', process.env.ZERNIO_WEBHOOK_SECRET)
  .update(rawBody)
  .digest('hex');
const valid = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(req.headers['x-zernio-signature'])
);

GET /v1/webhooks/logs keeps 30 days of deliveries and POST /v1/webhooks/test sends a test event (see Webhooks).

Step 6: Register for US A2P

Brand and Campaign registrations made through Twilio belong to Twilio's CSP account at The Campaign Registry and do not transfer, so US long-code SMS needs a registration with Zernio (same registry, similar fields). Call POST /v1/sms/registrations:

bash
curl -X POST https://zernio.com/api/v1/sms/registrations \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "registrationType": "standard_10dlc",
    "phoneNumbers": ["+13025551234"],
    "brand": {
      "entityType": "PRIVATE_PROFIT",
      "displayName": "Acme",
      "companyName": "Acme Corporation",
      "ein": "12-3456789",
      "country": "US",
      "vertical": "TECHNOLOGY",
      "street": "123 Market St", "city": "San Francisco",
      "state": "CA", "postalCode": "94103"
    },
    "campaign": {
      "usecase": "CUSTOMER_CARE",
      "description": "Order updates and customer support replies for Acme customers.",
      "messageFlow": "Customers opt in at checkout by checking the SMS updates box.",
      "sample1": "Your Acme order ORD-123 has shipped.",
      "helpMessage": "Acme support: reply with your question or email help@acme.com.",
      "optinKeywords": "START", "optinMessage": "You are subscribed to Acme updates. Reply STOP to opt out.",
      "optoutKeywords": "STOP", "optoutMessage": "You have been unsubscribed from Acme updates.",
      "helpKeywords": "HELP"
    }
  }'

Response (200):

json
{
  "registrationId": "66d4a1b2c3d4e5f6a7b8c9d3",
  "status": "pending",
  "awaitingOtp": false
}
  • sole_prop_10dlc (no EIN) returns awaitingOtp: true and texts an OTP for POST /v1/sms/registrations/{id}/verify-otp; toll_free covers toll-free verification.
  • Poll GET /v1/sms/registrations/{id} from pending to approved, and appeal a rejection with POST /v1/sms/registrations/{id}/appeal.
  • POST /v1/sms/registrations/share mints a single-use link so a customer fills in their own legal details, and POST /v1/phone-numbers/{id}/sms/reuse-registration puts more numbers on an approved campaign (see SMS).

A US number delivers SMS only once its registration is approved. Start the Zernio registration while Twilio still carries the traffic, and switch sending after status: "approved".

Step 7: Configure voice

Twilio's voice model is programmable: your server returns TwiML per call. Zernio's is configuration plus API calls, which covers the common business-phone patterns without an app server:

  • Inbound: POST /v1/phone-numbers/{id}/voice sets forwarding (tel:, sip:, or wss:// for an AI voice agent), voicemail, business hours, an IVR menu of up to 12 keys, recording and transcription (see Voice & Calls).
  • Outbound: POST /v1/voice/calls, with transfer, cost preview and a WebRTC softphone token.
  • History: GET /v1/calls unifies phone and WhatsApp calls with per-call cost, transcript and recording, and call.received, call.ended and call.failed report them live.

TwiML apps do not port. Dynamic call flows beyond a keypress menu, conferencing, SIP trunking and call queues have no Zernio equivalent. Keep that voice on Twilio and migrate messaging, or simplify to the configuration model above.

What does not map

Everything above has a Zernio equivalent. These do not; keep them on Twilio or plan around them:

  • Programmable voice at TwiML depth (dynamic call flows, conferencing, SIP trunking, queues).
  • Verify's voice and email channels, Video, SendGrid email, RCS. Zernio has managed OTP over SMS: Send and check a verification code.
  • Messaging Services sender pools (sticky sender, geomatch, short codes). Alphanumeric sender IDs do map: see Step 4.
  • Link shortening with click tracking.
  • Serverless Functions; your webhook handlers run on your own infrastructure.

Step 8: Cut over

PhaseActions
PrepCreate profiles, build against the WhatsApp sandbox, start US A2P registration early
WhatsAppReconnect senders through Zernio one number at a time: reconnect, verify webhooks, stop Twilio sends for it
SMSOnce registration is approved, port numbers in (or switch to new ones) and flip to POST /v1/sms/messages
VoiceConfigure forwarding and IVR per number, or keep complex voice on Twilio
CutoffExport message history from Twilio, then release the Twilio numbers you ported away

Meta's per-template conversation charges continue either way, because they are billed to the WABA, and template approvals survive the move for the same reason.

If it fails

A 400 with TEMPLATE_REQUIRED on POST /v1/inbox/conversations is Twilio's error 63016: the recipient has not written in the last 24 hours, so business-initiated WhatsApp needs an approved template. Other first-day errors:

SymptomTwilio equivalentFix
401 UnauthorizedBasic auth credentialsAuthorization: Bearer $ZERNIO_API_KEY
Form-encoded body rejectedTwilio wire formatSend JSON with Content-Type: application/json
SMS blocked for a US numberError 30034 (unregistered 10DLC)Complete /v1/sms/registrations and wait for approved
Recipient gets nothing after STOPError 21610The recipient must text START; check GET /v1/sms/opt-outs
Sandbox cannot message a phoneError 63015 (phone has not joined)Activate the phone with POST /v1/whatsapp/sandbox/sessions and the verification reply
Template send ignores variablesContentVariables JSON stringtemplateParams, an array in placeholder order
Webhook signature mismatchURL and params HMAC-SHA1HMAC-SHA256 of the raw body against X-Zernio-Signature

Every error uses the envelope in Error Handling.

The two sends in Node.js:

javascript
const ZERNIO = 'https://zernio.com/api/v1';
const headers = {
  'Authorization': `Bearer ${process.env.ZERNIO_API_KEY}`,
  'Content-Type': 'application/json',
};

// Twilio: client.messages.create({ to: 'whatsapp:+1...', contentSid, contentVariables })
const sendWhatsAppTemplate = async ({ accountId, to, templateName, templateParams = [] }) => {
  const res = await fetch(`${ZERNIO}/inbox/conversations`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      accountId,
      participantId: to,          // digits with country code, no whatsapp: prefix
      templateName,               // template name instead of ContentSid
      templateLanguage: 'en_US',
      templateParams,
    }),
  });
  const { data } = await res.json();
  return data; // { messageId, conversationId, ... }
};

// Twilio: client.messages.create({ to, from, body })
const sendSms = async ({ from, to, text }) => {
  const res = await fetch(`${ZERNIO}/sms/messages`, {
    method: 'POST',
    headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
    body: JSON.stringify({ from, to, text }),
  });
  return res.json(); // { id, conversationId, status: "sent" }
};