Guides
Compatible with the Meta Cloud API
If you already have code built for the WhatsApp Cloud API (graph.facebook.com/v21.0/{phone_number_id}/messages), you don't need to rewrite it: swap the base URL for heysteff's and the token for your API key, and it keeps working. Every message goes through heysteff just like one sent with POST /v1/messages: it shows up in the web and mobile inbox, counts toward your plan, respects the 24-hour window and appears in the API Logs.
The only change#
| Meta directly | heysteff | |
|---|---|---|
| Base URL | https://graph.facebook.com/v21.0 | https://app.heysteff.com/api/meta/whatsapp/v21.0 |
| Token | Authorization: Bearer EAAG… (Meta system token) | Authorization: Bearer sk_live_… (your heysteff API key) |
| Version | Whichever you use | Any v<n>.<n>: v21.0, v22.0, v24.0… heysteff answers the same for all of them. |
curl https://graph.facebook.com/v21.0/106540352242922/messages \
-H "Authorization: Bearer $META_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "messaging_product": "whatsapp", "to": "56912345678", "type": "text", "text": { "body": "Hi Ana" } }'curl https://app.heysteff.com/api/meta/whatsapp/v21.0/106540352242922/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "messaging_product": "whatsapp", "to": "56912345678", "type": "text", "text": { "body": "Hi Ana" } }'The response has Meta's shape:
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "56912345678", "wa_id": "56912345678" }],
"messages": [{ "id": "6f1d2c1e-6a1b-4c43-9a51-2f9f6a0e8b7d" }]
}With a Cloud API client that lets you change the base URL (Meta's Node SDK, whatsapp-api-js or your own fetch wrapper) you only set that URL and the token.
The phone_number_id#
It is the same Meta id of your WhatsApp Cloud number, now connected to heysteff: see it in the console, Numbers section, and with GET /{phone_number_id}. Any WhatsApp Cloud number in your workspace works: one you connected, a heysteff number or one a customer of yours connected with their setup link. An id outside your workspace returns the same error Graph returns for someone else's object (code 100, subcode 33).
Sandbox. With an sk_test_ key, the phone_number_id is the heysteff shared number's (the console shows it in the Sandbox section) and you can only write to phones that joined. You also only see sandbox media: what you uploaded with an sk_test_ key and what was sent to the shared number; any other file answers 403 test_key_not_allowed. See Sandbox.
What it covers#
| Route | What for | Scope |
|---|---|---|
POST /{phone_number_id}/messages | Send text, image, audio, video, document, sticker, location, contacts, reaction, interactive (buttons, list, cta_url, location_request_message) and templates. Also mark as read with status: "read" and typing_indicator. | messages:write |
POST /{phone_number_id}/media | Upload a file (multipart, file field). Returns { "id": "…" }. | messages:write |
GET /{media_id} | url, mime_type, sha256 and file_size. The url downloads with the same Bearer. | messages:write |
DELETE /{media_id} | Delete a file you uploaded with POST /{phone_number_id}/media. The rest of your workspace media cannot be deleted here. | messages:write |
GET /{phone_number_id} | Verified name, display number, quality and platform. | messages:write |
GET and POST /{phone_number_id}/whatsapp_business_profile | Read and change the business profile (about, address, description, email, websites, vertical). | messages:write to read, numbers:write to change |
GET /{waba_id}/phone_numbers | The numbers of that WABA connected to your workspace. | messages:write |
GET, POST and DELETE /{waba_id}/message_templates | List, create (they go to Meta review) and delete templates (?name=). | messages:write to list, templates:write to create and delete |
Meta account administration routes are not here (registering a number, two-step verification, app subscriptions, WABA analytics): heysteff does that for you when the number is connected. For everything that is not WhatsApp (Instagram, Messenger, SMS, email) use the heysteff API.
Message ids#
- What you send.
messages[0].idis the message id in heysteff (a UUID), not the wamid: the send to WhatsApp goes through a queue, so the wamid does not exist yet when we answer. Status webhooks carry that same id, so you match sends and statuses as you did with the wamid. - What you receive. An inbound message carries its real wamid, like in Meta.
- Wherever the API asks for an id (
context.message_idto quote,reaction.message_id,message_idwhen marking as read) either one works.
How sends behave#
- 24-hour window. Outside the window only a template goes out, like in Meta. A free-form message outside the window is accepted and then the
failedstatus arrives with code 131047, like in Meta. - Idempotency. On top of Meta's behavior you can send
Idempotency-Key: a retry with the same key never sends the message twice. - Files.
image,audio,video,documentandstickertake theidreturned byPOST /{phone_number_id}/media, theidof a file a customer sent you (to forward it) or a public httpslink. heysteff keeps a copy for the inbox. - Templates.
template.componentsgoes to Meta as is. An image, video or document header takes alinkor theidof an uploaded file. Before accepting the send, heysteff checks the template exists, is approved and has the right variables, and otherwise answers with Meta's code (132001 or 132000). - Interactive. Buttons and lists with no header or footer go out the same way as
/v1. The rest (header, footer,cta_url, location request, products) is sent to Meta exactly as you wrote it. - Mark as read.
{ "messaging_product": "whatsapp", "status": "read", "message_id": "wamid…" }marks the conversation read in heysteff and sends the receipt; with"typing_indicator": { "type": "text" }it also shows "typing…". It answers{ "success": true }.
Webhooks in Meta format#
To keep your Meta webhook receiver unchanged, create the webhook with format: "meta" (in the console, Webhooks section, choose "Meta Cloud API", or by API):
curl https://app.heysteff.com/api/v1/webhooks \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.yourapp.com/whatsapp/webhook", "format": "meta" }'That webhook receives the Cloud API payload: object: "whatsapp_business_account" and entry[].changes[].value with metadata.phone_number_id, contacts and messages (inbound) or statuses (sent, delivered, read, failed). Only WhatsApp Cloud message events arrive (message:received, message:sent, message.delivered, message.read, message.failed); other channels and events stay in the heysteff format.
Signature. It comes in X-Hub-Signature-256: sha256=<hex>, the HMAC-SHA256 of the raw body, like Meta's. The difference is the key: instead of your Meta app secret, use the secret heysteff gave you when you created the webhook.
import crypto from 'node:crypto';
// rawBody: the body as it arrived. secret: the heysteff webhook's.
function isFromHeysteff(rawBody, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}heysteff does not run Meta's initial verification (the GET with hub.challenge): the URL is live once created. The console's "Send test event" button sends a sample text message in Meta's shape with the same signature.
Errors#
Errors have the Graph API shape, with Cloud API codes wherever there is an equivalent:
{
"error": {
"message": "(#100) Invalid parameter",
"type": "OAuthException",
"code": 100,
"error_data": { "messaging_product": "whatsapp", "details": "text.body: Required" },
"fbtrace_id": "Az8or2yhqkZfEZ-_4Qn_Bam",
"heysteff_code": "validation_error"
}
}| Code | When |
|---|---|
190 (401) | The API key is missing, unknown, revoked or expired. |
200 (403) | The key lacks the scope, or it is a test key used outside the sandbox. |
100 | Invalid parameter; with subcode 33, an id that does not exist or is not in your workspace. |
130429 (429) | The key's per-minute call limit, or the sandbox daily cap. |
131030 | Sandbox: the phone has not joined. |
131042 | Your plan reached its monthly message cap. |
131047 | The 24-hour window is closed. |
131053 | The file does not exist or could not be downloaded. |
132000, 132001 | The template does not exist, is not approved or is missing variables. |
2 (424) | Meta did not answer when reading or changing the profile or a template. Retry. |
heysteff_code is heysteff's code for the same error (see Errors and limits); clients built for Meta ignore it.
Which API to use#
This API is for moving what you already have without touching it. For new code, prefer POST /v1/messages: with the same body you send through WhatsApp, Instagram, Messenger, SMS or email by changing only channel. Both live in the same workspace with the same key.