Docs
This is the API your own applications use to send WhatsApp messages through Securifi Connect — an order system telling a customer their delivery is out, a booking system confirming an appointment, that sort of thing.
You need three things:
messages:send permission on that key.One key belongs to one WhatsApp account. That's why you never tell us which account to send from — the key already says. Use the key for your support number and it goes out from your support number.
If nobody on your team uses the console, there's a fourth thing, and it's not optional: register an encryption key with POST /api/public/v1/keys before your first message arrives. Incoming messages get locked to your team's keys, and if there are no keys there's nothing to lock them with — so we throw the message away rather than store it in plain text. You get no message and no error. The privacy guide walks through it.
| Permission | What it lets the key do |
|---|---|
messages:send | send WhatsApp messages |
messages:read | read messages back |
conversations:read | list conversations |
webhooks:manage | change webhook settings |
keys:write | register an encryption key. Not included by default — ask for it by name |
https://api.connect.securifi.com.myhttps://api.connect.dev.securifi.com.myEvery example below uses production. Swap the host when you're testing.
From the console: open the WhatsApp account, go to the Developer page, create a key.
From your own code, if you'd rather provision it programmatically:
curl -X POST "https://api.connect.securifi.com.my/api/v1/channel_accounts/<channel_account_id>/channel_api_keys" \
-H "Authorization: Bearer <workspace-jwt>" \
-H "Content-Type: application/json" \
-d '{
"label": "ERP Integration",
"scopes": ["messages:send", "conversations:read", "messages:read", "keys:write"]
}'
You get back:
{
"id": 12,
"api_key": "sc_live_0123456789abcdef0123456789abcdef0123456789abcd",
"label": "ERP Integration",
"scopes": ["messages:send", "conversations:read", "messages:read", "keys:write"],
"last_used_at": null,
"daily_count": 0,
"monthly_count": 0,
"created_at": "2026-03-18T10:12:00Z"
}
Copy the
api_keynow. It is shown once, at creation, and can never be retrieved again — we only keep a scrambled version for checking. Lose it and you have to make a new one; there's no way to look it up.
Note keys:write in the scopes list above. Leave scopes out entirely and you won't get it, so ask for it here if this integration needs to register its own encryption key.
POST /api/public/v1/messages/send
with these headers:
Authorization: Bearer <your-api-key>
Content-Type: application/json
Send either message or sealed_message — never both.
curl -X POST "https://api.connect.securifi.com.my/api/public/v1/messages/send" \
-H "Authorization: Bearer sc_live_..." \
-H "Content-Type: application/json" \
-d '{
"to": "60123456789",
"message": "Hello from Securifi Connect"
}'
Or from Node:
const res = await fetch("https://api.connect.securifi.com.my/api/public/v1/messages/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SECURIFI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ to: "60123456789", message: "Hello from Securifi Connect" }),
});
const result = await res.json();
console.log(result.status, result.message_id);
You get back:
{
"status": "queued",
"message_id": 4821,
"conversation_id": 991,
"privacy": "server_readable"
}
Things worth knowing:
60123456789.queued means we accepted it, not that WhatsApp delivered it. Delivery happens after this response. Check the status later, or use webhooks to be told.message is plain text, so we store the words and mark the conversation server_readable. To send words our database can't read, post sealed_message instead — see the privacy guide."store": false works on either shape and means keep no copy once the send finishes. It changes nothing on the recipient's phone.privacy in the response is the conversation's state after this send: server_readable once a plain message has landed in it, private while every message in it is still locked.GET /api/public/v1/encryption_key — our public key, its key_id, and how the lock is built. No special permission needed. Save the key_id and check it.POST /api/public/v1/keys — register your key so this integration can read its own conversations back. Needs keys:write.Both are explained properly in the privacy guide.
Sending is asynchronous, so to find out what happened you either poll these endpoints or use webhooks.
| Endpoint | Permission needed |
|---|---|
GET /api/public/v1/conversations | conversations:read |
GET /api/public/v1/conversations/:id | conversations:read |
GET /api/public/v1/conversations/:conversation_id/messages | messages:read |
curl "https://api.connect.securifi.com.my/api/public/v1/conversations/991/messages" \
-H "Authorization: Bearer sc_live_..."
Every message has a status:
| Status | Meaning |
|---|---|
sending | accepted, on its way, nothing confirmed yet |
sent | WhatsApp took it |
delivered | it reached the recipient's phone |
read | they opened it. A played voice note counts |
error | it won't arrive — error_message says why |
encrypted — true when the words are locked. Check this, not whether body is empty, because an empty message is empty too.sealed_body — the locked copy, opened with the private half of a key you registered.stored — false for a message sent with "store": false. Its content is wiped once the send finishes.pending_history — true for the few seconds between us accepting a locked message and the locked copy coming back. If it stays true, the copy failed and error_message says why. The message was still sent.401 Unauthorized — we don't know who you are{ "error": "Missing API key" }
{ "error": "Invalid API key" }
The Authorization header is missing, or the key isn't one of ours.
403 Forbidden — we know you, but that key can't do this{ "error": "Missing required scope messages:send" }
The key is valid but lacks the permission. Add it, or use a different key.
400 Bad Request — the locked envelope is malformedAll of these are permanent. Fix the envelope and lock it again; retrying the same body won't help.
| Error | What it means |
|---|---|
"sealed_message must be an envelope object" | It isn't a JSON object. |
"sealed_message is missing v, ctx, n, ct" | One of those four fields is absent. |
"sealed_message has no recipients" | The recipients list is empty or missing. |
"sealed_message must carry ctx message-transmit; a history envelope cannot be transmitted" | ctx is something other than message-transmit. |
"sealed_message carries no wrap for this engine's key <key_id>" | Nothing in recipients matches our current key — usually a saved key that's gone stale. Refetch GET /api/public/v1/encryption_key and lock it again. |
422 Unprocessable Entity — the destination or the content won't do| Error | What it means |
|---|---|
| "Missing to or message" | You sent neither message nor sealed_message. |
"Send either message or sealed_message, not both" | You sent both. |
"to is not a valid WhatsApp address or number" | We can't read to as a WhatsApp number. |
| "Nobody in this workspace has an encryption key…" | A locked send into a workspace with no registered key. The copy you'd read back could never be made, so we refuse rather than accept it blind. Register one with POST /api/public/v1/keys. |
402 Payment Required — over your plan{
"error": "Quota exceeded",
"detail": "...",
"docs": "https://console.connect.securifi.com.my/app/billing"
}
413 Payload Too Large — the envelope is over 64 KiB{ "error": "`sealed_message` is larger than 65536 bytes" }
429 Too Many Requests — slow down{ "error": "Rate limit exceeded" }
503 Service Unavailable — our problem, not yours{ "error": "The engine has no keypair configured; sealed messages cannot be sent" }
Locked sends can't go out right now. Plain sends still work.
Every response carries your current usage, so you can watch your limits without a separate call:
X-Billing-PlanX-Billing-Remaining-MessagesX-Billing-Remaining-API-Callsqueued is not a delivery guarantee.api/docs/openapi-public-v1.yaml — the machine-readable contractapi/docs/postman-public-api.postman_collection.json — a Postman collectionWhen your app sends a WhatsApp message through Securifi Connect, you get a choice: let us store the words, or lock them so only you can read them.
Think of it like a padlock. You make a padlock and a key that opens it. You give us the padlock and keep the key. We can snap the padlock shut on a message, but we can't open it again — only you can, because only you have the key. If someone stole our entire database, your customers' messages would look like scrambled characters.
That's the whole idea. The rest of this page is how to do it.
Set up your keys before your first message arrives, not after. Incoming WhatsApp messages get locked the moment they reach us. If you haven't given us a padlock, there's nothing to lock them with — and rather than store your customer's message in plain text, we throw it away. No message, no error, nothing in your inbox. Step 1 below is not optional for an API-only workspace; it is how receiving works at all.
You choose per message, by what you post:
| You post | What we store | What happens to the conversation |
|---|---|---|
message | the words, as sent | marked server_readable — we may hold readable copies |
sealed_message | a locked envelope we hold no key for | unchanged |
You need a matched pair. Pick whichever you have to hand.
With openssl (available on most servers):
# Make the key. Keep this file secret — treat it like a password.
openssl genpkey -algorithm X25519 -out private.pem
# Make the matching padlock, in the exact format we need.
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | base64
That prints something like UZcwrdUFeg8tjfU+mIuatyMUY927k9IkkddR3hIVMg8=. That's your padlock.
Careful — this trips people up. If you run
openssl pkey -pubouton its own you get a longer string starting withMCowBQYDK2Vu…. That's the padlock wrapped in extra formatting, and we reject it with "must be a 32-byte x25519 key". The| tail -c 32 | base64part strips the wrapper. Use the full command above.
With Node:
import { generateKeyPairSync } from "crypto";
const { publicKey, privateKey } = generateKeyPairSync("x25519");
// Your padlock — send this to us.
console.log(publicKey.export({ type: "spki", format: "der" }).subarray(-32).toString("base64"));
// Your key — store it somewhere secret, never send it anywhere.
console.log(privateKey.export({ type: "pkcs8", format: "der" }).subarray(-32).toString("base64"));
Your padlock should be 44 characters ending in =. If it's longer, or starts with MCowBQYDK2Vu, you have the wrapped version — see the warning above.
echo -n "UZcwrdUFeg8tjfU+mIuatyMUY927k9IkkddR3hIVMg8=" | base64 -d | wc -c # should print 32
curl -X POST "https://api.connect.securifi.com.my/api/public/v1/keys" \
-H "Authorization: Bearer sc_live_..." \
-H "Content-Type: application/json" \
-d '{
"public_key": "UZcwrdUFeg8tjfU+mIuatyMUY927k9IkkddR3hIVMg8=",
"label": "ERP Integration"
}'
{
"kid": "…",
"id": 7,
"reads": "every conversation in this workspace, for messages sent from now on",
"active_within_seconds": 300
}
Worth knowing:
keys:write permission, and it is not granted by default. Ask for it when the key is created. It's held back because this permission lets a key sign itself up to read the workspace's messages.active_within_seconds. Messages arriving in that window may still be locked to the older set of padlocks.DELETE /api/v1/machine_keys/:id. Use the id number, not the kid — a kid is base64 and doesn't survive being put in a URL.To send a message we can't read, you lock it with our padlock. Fetch it once when you deploy, not on every message.
curl "https://api.connect.securifi.com.my/api/public/v1/encryption_key" \
-H "Authorization: Bearer sc_live_..."
{
"key_id": "3hmYCXJMAuEF1tkyRSbAzA==",
"public_key": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
"algorithm": "x25519-hkdf-sha256-xchacha20poly1305"
}
public_key — our padlock. Holding it lets you lock things for us, never unlock anything of ours, so it is safe to cache or hard-code.key_id — a short name for that padlock. Save it in your config and compare it every time you fetch.
You can work it out yourself from the padlock and check it matches: take SHA-256 of the text
securifi-connect/kid/v1 followed by the 32 raw key bytes, keep the first 16 bytes, base64 them.
The keyId() function in Step 4 does exactly that.algorithm — how the lock is built. Step 4 has the details.Save key_id and check it. If it ever changes and we haven't announced it, stop sending and ask us.
Here's why that check matters, said plainly. This endpoint is served by Rails — and Rails is the part of our system the whole padlock scheme is designed to keep out. Someone who took control of Rails could hand you their padlock instead of ours, unlock everything you send, lock it again with our real padlock and pass it along. Nothing further down the line would notice. Comparing key_id against the one you saved is what makes that visible.
Without that check, what we can honestly promise is "our database can't read your messages" — not "we can't".
The envelope is a small fixed construction. It is not HPKE — an HPKE envelope will not open, and the send is refused. You need a library giving you X25519, HKDF-SHA256 and XChaCha20-Poly1305.
One practical warning: the popular
libsodium-wrapperspackage for Node does not expose HKDF (crypto_kdf_hkdf_sha256_extractand_expandare absent from the standard build), so you cannot build this envelope with it alone. The worked example below uses@noble, which has everything and is what our own console uses.
The shape you're producing:
{
"v": 1,
"ctx": "message-transmit",
"n": "<base64 24-byte nonce>",
"ct": "<base64 ciphertext and tag>",
"recipients": [
{
"kid": "<our key_id>",
"epk": "<base64 one-time public key>",
"wn": "<base64 24-byte nonce>",
"k": "<base64 wrapped data key and tag>"
}
]
}
In plain terms: you lock the message with a brand-new random key, then lock that key with our padlock, and send both together. We unlock the small one to get the big one.
The exact steps — these must be followed precisely or the envelope will not open:
n.ct = XChaCha20-Poly1305 over your message bytes, keyed by the DEK, nonce n, with the string message-transmit as additional authenticated data.epk is its public half.shared = X25519(one-time private key, our public key).wk = HKDF-SHA256(ikm = shared, salt = epk followed by our public key (64 raw bytes), info = "securifi-connect/wrap/v1/message-transmit", length = 32).k = XChaCha20-Poly1305 over the DEK, keyed by wk, with a fresh 24-byte nonce wn, using our key_id string as additional authenticated data.All base64 is the standard alphabet with padding. v is 1; a different version would be a wire-format change and we would announce it.
The context string is load-bearing. message-transmit is for a message we must open in order to send it. message-body is for a history copy that only your own padlocks open. Both strings are baked into the encryption and into the key derivation, so they cannot be swapped — an envelope offered in the wrong role will not open, and we reject a message-body envelope at the door.
Using the same libraries our own console uses, so this is the construction that seals real messages today:
npm install @noble/curves @noble/hashes @noble/ciphers
import { x25519 } from "@noble/curves/ed25519.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { randomBytes } from "crypto";
const utf8 = (s) => new TextEncoder().encode(s);
const b64 = (b) => Buffer.from(b).toString("base64");
const cat = (a, b) => new Uint8Array([...a, ...b]);
// Work out the short name for a padlock. Compare this against the key_id we
// publish — if they differ, do not send.
export function keyId(publicKey) {
return b64(sha256(cat(utf8("securifi-connect/kid/v1"), publicKey)).slice(0, 16));
}
export function sealForEngine(message, enginePublicKeyB64) {
const enginePub = new Uint8Array(Buffer.from(enginePublicKeyB64, "base64"));
const CTX = "message-transmit";
// 1-2. a fresh key for this one message, then lock the words with it
const dek = new Uint8Array(randomBytes(32));
const n = new Uint8Array(randomBytes(24));
const ct = xchacha20poly1305(dek, n, utf8(CTX)).encrypt(utf8(message));
// 3-4. a one-time keypair, and the shared secret with our padlock
const ephemeral = x25519.utils.randomSecretKey();
const epk = x25519.getPublicKey(ephemeral);
const shared = x25519.getSharedSecret(ephemeral, enginePub);
// 5-6. derive the wrapping key, then lock the message key with it
const wrapKey = hkdf(sha256, shared, cat(epk, enginePub), utf8("securifi-connect/wrap/v1/" + CTX), 32);
const wn = new Uint8Array(randomBytes(24));
const kid = keyId(enginePub);
const k = xchacha20poly1305(wrapKey, wn, utf8(kid)).encrypt(dek);
return {
v: 1, ctx: CTX, n: b64(n), ct: b64(ct),
recipients: [{ kid, epk: b64(epk), wn: b64(wn), k: b64(k) }],
};
}
Reading a message back is the same in reverse, with message-body as the context and your own private key in place of the one-time one.
curl -X POST "https://api.connect.securifi.com.my/api/public/v1/messages/send" \
-H "Authorization: Bearer sc_live_..." \
-H "Content-Type: application/json" \
-d '{
"to": "60123456789",
"sealed_message": { "v": 1, "ctx": "message-transmit", "n": "...", "ct": "...", "recipients": [ ... ] }
}'
{
"status": "queued",
"message_id": 4821,
"conversation_id": 991,
"privacy": "private"
}
Send message or sealed_message, never both. From here:
message-body, and forgets the words. It never keeps a copy it can open itself.sealed_body.Between your API call and the engine's reply there is a moment where the message has a transmit envelope and no history copy yet. The read endpoints report that as pending_history: true. It normally lasts seconds. If it stays true, the history lock failed and error_message says why — the words were still sent.
All of these are permanent. Retrying the same body will not help; fix the envelope and lock it again.
| Status | Error | What it means |
|---|---|---|
| 400 | "sealed_message must be an envelope object" | sealed_message is not a JSON object. |
| 400 | "sealed_message is missing v, ctx, n, ct" | One or more of those four fields is absent. |
| 400 | "sealed_message has no recipients" | recipients is empty or missing. |
| 400 | "sealed_message must carry ctx message-transmit; a history envelope cannot be transmitted" | ctx is anything other than message-transmit. |
| 400 | "sealed_message carries no wrap for this engine's key <key_id>" | Nothing in recipients matches the padlock we currently publish. Usually a stale saved key: refetch GET /api/public/v1/encryption_key, check the change, lock again. |
| 413 | "sealed_message is larger than 65536 bytes" | The envelope is over 64 KiB. |
| 422 | "Send either message or sealed_message, not both" | Both shapes were sent. |
| 422 | "Missing to or message" | Neither shape was sent. |
| 422 | "to is not a valid WhatsApp address or number" | to isn't a WhatsApp address or number we can parse. |
| 422 | "Nobody in this workspace has an encryption key…" | No padlock is registered anywhere in the workspace, so the history copy could never be locked and you could never read the message back. Register one — Step 2. |
| 503 | "The engine has no keypair configured; sealed messages cannot be sent" | Our problem, not yours. Plain sends still work. |
We can't tell you at request time whether your envelope actually opens: we hold no key for it, and the send happens afterwards. What we check at the door is the shape, the context string, the size, and whether there's a wrap for the padlock we publish.
GET /api/public/v1/conversations/:id/messages returns each message with encrypted: true and a sealed_body envelope using context message-body. Open it with your private key, running Step 4 in reverse.
Check the encrypted field, not whether body is empty — an empty message is also empty. Webhooks deliver the same shape, so a webhook for a locked conversation carries an envelope, not words.
store: false — send it and keep nothingOptional on either path:
{ "to": "60123456789", "message": "Your code is 402913", "store": false }
Once the send finishes we clear the content: the words, the transmit envelope, and no history copy is ever made. The record survives as metadata — who, when, and what the delivery receipts said — with nothing readable in it.
This never means the message disappears from the recipient's phone. Nothing on this page reaches into WhatsApp or into anyone's handset. A delivered message stays delivered, and stays quotable, screenshottable and forwardable by whoever you sent it to. store: false is a statement about our database and nothing else.
It also means the message can't be resent from our side, and you won't see it in your own history. If you need both, lock it instead.
Posting message instead of sealed_message marks the conversation server_readable — at every send, not just the first, because almost every API send lands in a conversation that already exists.
That label is a correction to a promise, not a new capability. server_readable never means the server decrypts anything. Rails holds no cryptography code and isn't getting any: that lives in the engine and in the browser. It means we may hold a readable copy of the messages that arrived in the clear, so the product stops claiming otherwise in the console and in the API.
Two things follow:
private from the console, and both directions are recorded in the audit log.Protected: a database dump, a backup copied somewhere, an over-broad admin query, a subpoena served on the database, anyone with Postgres access, and our logs. None of them hold a key that opens a locked message.
Not protected:
api/docs/openapi-public-v1.yaml — the machine-readable contractInstead of asking us over and over whether anything happened, let us tell you. A webhook is a URL of yours that we call whenever something changes — a customer replies, or a message you sent gets delivered.
You set one up per WhatsApp account, so different numbers can point at different systems.
| Event | When it fires |
|---|---|
inbound_message | someone sent you a WhatsApp message |
status_update | a message you sent moved on — accepted, delivered, read, or failed |
From the console: open the WhatsApp account, go to the Developer page, and fill in the webhook URL and which events you want.
From your own code:
curl -X PUT "https://api.connect.securifi.com.my/api/v1/channel_accounts/<channel_account_id>" \
-H "Authorization: Bearer <workspace-jwt>" \
-H "Content-Type: application/json" \
-d '{
"channel_account": {
"webhook_url": "https://example.com/webhooks/securifi",
"webhook_events": ["inbound_message", "status_update"]
}
}'
We POST a JSON body to your URL. Answer with any 2xx and we consider it delivered. Anything else and we try again.
We retry five times, waiting longer each time:
| Attempt | Wait |
|---|---|
| 1st retry | 30 seconds |
| 2nd | 2 minutes |
| 3rd | 10 minutes |
| 4th | 30 minutes |
| 5th | 1 hour |
After the fifth we stop. You can still replay it by hand — see the end of this page.
Anyone who learns your webhook URL can send it fake events, so we sign each request. When a shared secret is set up on our side, every webhook carries a header:
X-SC-Signature
It's an HMAC-SHA256 of the exact raw body we sent, using the shared secret. Recompute it on your side and compare. Use the raw body bytes, not a re-serialised copy of the parsed JSON — re-serialising changes the bytes and the signature will never match.
If no secret is configured on our side (WEBHOOK_SIGNATURE_SECRET), the header simply isn't sent — so treat a missing signature as "unverified", not as "verified".
import crypto from "crypto";
function verifySignature(rawBody, headerSignature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest();
// Decode to bytes and compare lengths FIRST. `timingSafeEqual` throws a
// RangeError on buffers of different sizes rather than returning false, so
// a request that simply omits the header — which anyone can send — would
// raise inside your handler instead of failing the check. In an async
// handler that is an unhandledRejection, which ends the process on Node 15
// and later. A missing or malformed signature must be a quiet `false`.
const received = Buffer.from(headerSignature || "", "hex");
return (
received.length === expected.length &&
crypto.timingSafeEqual(expected, received)
);
}
Webhook payloads reuse the same message shape returned by the API, then add:
eventchannel_account_idtenant_slugA webhook for a sealed conversation carries an envelope, not words: body is empty, encrypted is true, and sealed_body opens only with the private half of a key registered through POST /api/public/v1/keys. Branch on encrypted rather than on body — an inbound sealed message arrives with body as an empty string, not null, and a genuinely empty message is blank too. See public-api-privacy.md.
statusDerived, not stored, so it always reflects the furthest point the message has reached:
| value | meaning |
|---|---|
sending | accepted and on its way; no acknowledgement yet |
sent | WhatsApp accepted it |
delivered | it reached the recipient's device |
read | the recipient opened it. A played voice note counts as read |
error | it will not be delivered; error_message says why |
status on the send response is a different field: that one reports whether we accepted the request (queued), not what became of the message.
inbound_message exampleAn inbound message from a sealed conversation, which is what a workspace with registered reader keys actually receives. body is empty and the words are in sealed_body.
{
"id": 882,
"conversation_id": 991,
"channel_id": 14,
"direction": "inbound",
"sender_type": "external",
"content_type": "text",
"body": "",
"payload": {
"from": "182691014144082@lid",
"from_me": false,
"peer_name": "Eman",
"timestamp": 1773925801,
"message_id": "3AC95C1FDFAD0E2A1B44",
"tenant_slug": "acme",
"content_type": "text",
"external_user_id": "60123456789@s.whatsapp.net",
"channel_account_id": 14,
"whatsapp": {
"from": "182691014144082@lid",
"from_me": false,
"peer_name": "Eman",
"message_id": "3AC95C1FDFAD0E2A1B44",
"timestamp": 1773925801
}
},
"sent_at": "2026-03-18T09:10:01Z",
"delivered_at": null,
"read_at": null,
"error_message": null,
"status": "sent",
"ai_metadata": {},
"sender_external_id": null,
"created_at": "2026-03-18T09:10:01Z",
"sealed_body": {
"v": 1,
"ctx": "message-body",
"n": "<base64 24-byte nonce>",
"ct": "<base64 ciphertext‖tag>",
"recipients": [
{ "kid": "…", "epk": "…", "wn": "…", "k": "…" }
]
},
"encrypted": true,
"stored": true,
"pending_history": false,
"event": "inbound_message",
"channel_account_id": 14,
"tenant_slug": "acme"
}
payload.from is the address WhatsApp used, which may be a LID (182691014144082@lid) — an identifier that contains no phone number. external_user_id is the same person's number, and it is what the conversation is keyed on, so both of these arrive for one contact and one conversation_id.
Match contacts on external_user_id. Matching on payload.from will split one person into two, because the same contact reaches you under either form depending on how WhatsApp addressed the message.
status_update example{
"id": 883,
"conversation_id": 991,
"channel_id": 14,
"direction": "outbound",
"sender_type": "agent",
"content_type": "text",
"body": "Hello from my app",
"payload": {
"whatsapp": {
"accepted_at": "2026-03-18T09:11:00Z",
"state": "accepted",
"to": "60123456789@s.whatsapp.net",
"message_id": "3EB016F0C74B9A2D8E10"
}
},
"sent_at": "2026-03-18T09:11:00Z",
"delivered_at": null,
"read_at": null,
"error_message": null,
"status": "sent",
"ai_metadata": {},
"sender_external_id": null,
"created_at": "2026-03-18T09:10:59Z",
"sealed_body": null,
"encrypted": false,
"stored": true,
"pending_history": false,
"event": "status_update",
"channel_account_id": 14,
"tenant_slug": "acme"
}
2xx as soon as you've safely stored it — not once you've finished processing it.The order matters: reply when the event is safe, not when it's done. If you reply after processing and your processing is slow, we retry and you handle the same event twice.
Every attempt is recorded, so you can see what we sent and what came back — from the Developer page in the console, or:
GET /api/v1/channel_accounts/:channel_account_id/webhook_deliveriesTo replay one failed delivery:
curl -X POST "https://api.connect.securifi.com.my/api/v1/webhook_deliveries/<delivery_id>/replay" \
-H "Authorization: Bearer <workspace-jwt>"