Guides
Migrate from Twilio, 360dialog, YCloud or Kapso
If you send WhatsApp today with Twilio, 360dialog, YCloud or Kapso, moving to heysteff means changing one call: everything goes through the same POST /v1/messages, and that same endpoint also reaches Instagram, Messenger, SMS and email. This guide collects the endpoint, webhook and concept equivalents, and the steps to switch without downtime.
The details for each provider come from their public documentation (reviewed in November 2026). Where we could not confirm a detail, we describe it in general terms: check your provider's documentation before deleting code.
What changes#
- One format for every channel. Instead of the Meta Cloud API body (
messaging_product,type,text.body) or Twilio's form parameters (To,From,Body), heysteff useschannel,to,contentandcontent_type. Switchingchanneltoinstagram,facebook,smsorgmailsends through another channel with nothing else to change. - Bearer authentication.
Authorization: Bearer sk_live_…on every call. See Authentication. - The number is not in the URL. With one number per channel you do not pass it; with several, send
channel_connection_id. - Idempotency on every send. The
Idempotency-Keyheader makes sure a retry never sends the message twice. - Beyond the API: a shared inbox on the web and in the mobile app, Shopify, an MCP server and a sandbox with no number of your own.
From the Meta Cloud API directly#
If you call graph.facebook.com today with your own Meta token, you don't have to rewrite anything: change the base URL to https://app.heysteff.com/api/meta/whatsapp/v21.0 (any version works) and the token to your heysteff API key (Authorization: Bearer sk_live_…). The phone_number_id stays the same. For your webhook receiver, create the webhook with format: "meta": the same Meta payload arrives, signed with X-Hub-Signature-256 and the webhook's secret. Details in Compatible with Meta. If you come from a provider that exposes the Cloud API (360dialog, Kapso), you can also use that same URL instead of moving to /v1.
Endpoint equivalents#
| Operation | heysteff | Twilio | 360dialog | YCloud | Kapso |
|---|---|---|---|---|---|
| Base and auth | https://app.heysteff.com/api/v1Authorization: Bearer | https://api.twilio.com/2010-04-01Basic auth | https://waba-v2.360dialog.ioD360-API-KEY | https://api.ycloud.com/v2X-API-Key | https://api.kapso.ai/meta/whatsapp/v24.0X-API-Key |
| Send a message | POST /v1/messages | POST /Accounts/{AccountSid}/Messages.json (form) | POST /messages | POST /whatsapp/messages or /whatsapp/messages/sendDirectly | POST /{phone_number_id}/messages |
| Send a template | The same POST /v1/messages with content_type: "template" | ContentSid and ContentVariables in the same POST | The same POST /messages with type: "template" | The same POST with type: "template" | The same POST with type: "template" |
| List templates | GET /v1/templates | GET https://content.twilio.com/v1/Content | GET /message_templates | Check their documentation (create: POST /whatsapp/templates) | GET /{business_account_id}/message_templates |
| Upload a file | POST /v1/media (multipart, field file), or a public https URL in media.url | Public URL in MediaUrl | POST /media (multipart) | POST /whatsapp/media/{phoneNumber}/upload | POST /{phone_number_id}/media |
| Contacts | GET /v1/contacts, POST /v1/contacts | Handled in your own system | Check their documentation | POST /contact/contacts | GET /{phone_number_id}/contacts |
| Conversations and history | GET /v1/conversations, GET /v1/conversations/{id}/messages | Check their documentation | Check their documentation | Check their documentation | Check their documentation |
Before and after, with a text message:
curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Messages.json" \
-u "$TWILIO_API_KEY:$TWILIO_API_SECRET" \
--data-urlencode "From=whatsapp:+14155550100" \
--data-urlencode "To=whatsapp:+56912345678" \
--data-urlencode "Body=Hola Ana, tu pedido ya va en camino"curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{ "channel": "whatsapp_cloud", "to": "+56912345678", "content": "Hi Ana, your order is on its way" }'And a template in the Cloud API format (360dialog, YCloud and Kapso use this body or one very close to it):
{
"messaging_product": "whatsapp",
"to": "56912345678",
"type": "template",
"template": {
"name": "pedido_despachado",
"language": { "code": "es" },
"components": [{ "type": "body", "parameters": [
{ "type": "text", "text": "Ana" }, { "type": "text", "text": "#1042" }
] }]
}
}{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content_type": "template",
"template": { "name": "pedido_despachado", "language": "es", "body_params": ["Ana", "#1042"] }
}If your template has an image header or buttons with variables, send template.components in the Meta Graph API shape: heysteff passes it through and it wins over body_params. More in Message types.
Webhooks and signatures#
heysteff sends one format for every channel: a JSON POST with the envelope { id, tenant_id, event, ts, data }. An inbound message carries in data everything you need to reply (channel, external_user_id, channel_connection_id).
| heysteff | Twilio | 360dialog | YCloud | Kapso | |
|---|---|---|---|---|---|
| Where you set it up | Console, Webhooks section | Twilio Console (inbound) and StatusCallback per message | POST /v1/configs/webhook or POST /waba_webhook | POST /v2/webhookEndpoints | POST /platform/v1/whatsapp/phone_numbers/{id}/webhooks |
| Inbound message | message:received | Form POST to your inbound URL | messages object in the Meta payload | whatsapp.inbound_message.received | whatsapp.message.received |
| Status of a sent message | message:sent and message.failed | MessageStatus on StatusCallback | statuses object | whatsapp.message.updated | whatsapp.message.sent, .delivered, .read, .failed |
| Signature header | X-Heysteff-Signature (v1= + hex) and X-Heysteff-Timestamp | X-Twilio-Signature | No documented signature: authenticate with custom headers you set when creating the webhook | YCloud-Signature (t=…,s=…) | X-Webhook-Signature |
| What is signed | HMAC-SHA256 of ${timestamp}.${raw body} | HMAC-SHA1 of the URL plus the POST parameters, with your auth token | Not applicable | HMAC-SHA256 of t + "." + raw body | HMAC-SHA256 of the raw body |
| Retries and duplicates | Deduplicate by X-Heysteff-Delivery-Id, repeated on every retry | Check their documentation | Meta retries if you do not answer 200 | Retries with growing delays | Deduplicate by X-Idempotency-Key |
Coming from YCloud, the signature is almost the same: change the header and the v1= prefix. Coming from Kapso, add the timestamp to the signed string. Coming from Twilio, stop signing the URL: heysteff signs the raw body.
import crypto from 'node:crypto';
// rawBody: the body exactly as received, unparsed.
function isFromHeysteff(rawBody, timestamp, signature, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}The SDK does it for you with verifyWebhook, and heysteff webhooks trigger in the CLI sends a signed event to localhost for testing. Details in Webhooks.
Concepts#
| Concept | In heysteff | What it was called before |
|---|---|---|
| Number or account | A connected channel (channel_connection_id). One workspace holds WhatsApp numbers, Instagram and Messenger accounts, SMS and email. | Twilio: the From (whatsapp:+…) or a Messaging Service. 360dialog: the API key of each number. YCloud: the from. Kapso: the phone_number_id in the URL. |
| Template | Sent by name and language (template.name, template.language) with body_params in order. GET /v1/templates lists the approved ones. | Twilio: a ContentSid from its Content API. With Cloud API format providers, the same Meta name and language. |
| 24-hour window | Same as everywhere: WhatsApp only allows free-form text within 24 hours of the customer's last message; outside it, a template. Outside the window the API answers with a clear error instead of accepting a message that will not be delivered. | It is a WhatsApp rule, not a provider rule: it does not change. |
| Sandbox | An sk_test_ key and the shared heysteff number: you join up to 5 phones with a code from the console. See Sandbox. | Twilio: shared number and join <code>. 360dialog: a START message to their sandbox number. Kapso: a 6-character code to their sandbox number. |
| Inbox and people | Every conversation also shows up in the inbox on the web and in the mobile app. POST /v1/conversations/{id}/escalate hands it to a person. | Depends on the provider. |
Steps to switch#
- Create your account and API key. Create a developer account, run
curl -fsSL https://developers.heysteff.com/install.sh | bashand thenheysteff setup: it takes you to the console, stores the key and sends a test message. - Test in the sandbox. With an
sk_test_key, switch your calls toPOST /v1/messageswithout touching your current number. - Move your webhook receiver. Create the webhook in the console, store the secret and change the signature check (see above). While you migrate, your server can accept both formats.
- Connect a number. In the console, Numbers section, connect your number through Meta's signup window or use a heysteff number. If your number lives with another provider today, follow Meta's process to move it to another account or provider, and check with your current provider what happens to its templates.
- Check your templates.
GET /v1/templatesshows the approved ones for the connected number. Coming from Twilio, replace eachContentSidwith the template name and language. - Switch to
sk_live_and turn off the old provider once your messages show up in the inbox and in the delivery log.
Using Claude Code, Cursor or Codex? Install the heysteff skill (see Coding agents) and ask it to migrate your integration with this guide.