Docs

API guide

WhatsApp public API

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.

Before you start

You need three things:

  1. A WhatsApp account connected in Securifi Connect.
  2. An API key for that account.
  3. The 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.

Permissions you can put on a key

PermissionWhat it lets the key do
messages:sendsend WhatsApp messages
messages:readread messages back
conversations:readlist conversations
webhooks:managechange webhook settings
keys:writeregister an encryption key. Not included by default — ask for it by name

Where to send requests

  • Production: https://api.connect.securifi.com.my
  • Development: https://api.connect.dev.securifi.com.my

Every example below uses production. Swap the host when you're testing.

Getting an API key

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_key now. 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.

Sending a message

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:

  • Write the number in international format, digits only where you can — 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.

Encryption endpoints

  • 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.

Reading messages back

Sending is asynchronous, so to find out what happened you either poll these endpoints or use webhooks.

EndpointPermission needed
GET /api/public/v1/conversationsconversations:read
GET /api/public/v1/conversations/:idconversations:read
GET /api/public/v1/conversations/:conversation_id/messagesmessages:read
curl "https://api.connect.securifi.com.my/api/public/v1/conversations/991/messages" \
  -H "Authorization: Bearer sc_live_..."

How far a message got

Every message has a status:

StatusMeaning
sendingaccepted, on its way, nothing confirmed yet
sentWhatsApp took it
deliveredit reached the recipient's phone
readthey opened it. A played voice note counts
errorit won't arrive — error_message says why

Four fields about the message itself

  • 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.

When something goes wrong

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 malformed

All of these are permanent. Fix the envelope and lock it again; retrying the same body won't help.

ErrorWhat 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

ErrorWhat 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.

Headers we send back

Every response carries your current usage, so you can watch your limits without a separate call:

  • X-Billing-Plan
  • X-Billing-Remaining-Messages
  • X-Billing-Remaining-API-Calls

Things that catch people out

  • Sending needs the WhatsApp account to be connected. If the phone has been unlinked, sends stop.
  • A message can be accepted and still fail later. If the WhatsApp session isn't ready, we queue it first and the failure comes during delivery — which is why queued is not a delivery guarantee.
  • Polling is the slow way to find out. If you want to be told instead of asking, set up webhooks.

Also in the repo

  • public-api-privacy.md — how to send messages we can't read
  • webhooks.md — get pushed events instead of polling
  • api/docs/openapi-public-v1.yaml — the machine-readable contract
  • api/docs/postman-public-api.postman_collection.json — a Postman collection

Message privacy

What this page is about

When 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 postWhat we storeWhat happens to the conversation
messagethe words, as sentmarked server_readable — we may hold readable copies
sealed_messagea locked envelope we hold no key forunchanged

Step 1: Make your padlock and key

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 -pubout on its own you get a longer string starting with MCowBQYDK2Vu…. That's the padlock wrapped in extra formatting, and we reject it with "must be a 32-byte x25519 key". The | tail -c 32 | base64 part 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"));

Check you got it right

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

Step 2: Send us the padlock

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:

  • Your API key needs the 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.
  • Only ever send the padlock. We don't want your key, we don't generate one for you, and we have no use for one.
  • It takes up to 5 minutes to take effect — that's active_within_seconds. Messages arriving in that window may still be locked to the older set of padlocks.
  • It only works going forward. Messages that arrived before you registered stay unreadable, because we never had a padlock of yours to use.
  • To remove it, delete it from the console or call 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.

Step 3: Get our padlock, and check it hasn't changed

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".

Step 4: Lock the message

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-wrappers package for Node does not expose HKDF (crypto_kdf_hkdf_sha256_extract and _expand are 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:

  1. Generate a random 32-byte data key (the DEK) and a random 24-byte nonce n.
  2. ct = XChaCha20-Poly1305 over your message bytes, keyed by the DEK, nonce n, with the string message-transmit as additional authenticated data.
  3. Generate a one-time x25519 keypair. epk is its public half.
  4. shared = X25519(one-time private key, our public key).
  5. wk = HKDF-SHA256(ikm = shared, salt = epk followed by our public key (64 raw bytes), info = "securifi-connect/wrap/v1/message-transmit", length = 32).
  6. 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.

Worked example in Node

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.

Step 5: Send it

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:

  • Rails files the locked envelope and holds no key that opens it.
  • The engine opens it, hands the words to WhatsApp, locks a history copy with your padlocks using context message-body, and forgets the words. It never keeps a copy it can open itself.
  • That history copy comes back in the same exchange and lands in the message's 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.

When a sealed send is refused

All of these are permanent. Retrying the same body will not help; fix the envelope and lock it again.

StatusErrorWhat 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.

Step 6: Read your own messages back

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 nothing

Optional 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.

What a plain send costs

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:

  • It isn't retroactive. Anything locked before it stays locked and stays unreadable by us.
  • It's reversible. A workspace admin can set the conversation back to private from the console, and both directions are recorded in the audit log.

What this does and doesn't protect

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:

  • Rails, if someone takes control of it while requests are running — see the key-checking warning in Step 3.
  • Metadata. Who was messaged, when, how often, and roughly how long the message was.
  • The engine, for the moment it sends an outbound message. You can't send a message you can't read.
  • The recipient's phone, which was never ours to protect.

See also

  • public-api.md — the rest of the endpoints
  • webhooks.md — get pushed events instead of polling
  • api/docs/openapi-public-v1.yaml — the machine-readable contract

Webhooks

Instead 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.

What we'll tell you about

EventWhen it fires
inbound_messagesomeone sent you a WhatsApp message
status_updatea message you sent moved on — accepted, delivered, read, or failed

Setting one up

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"]
    }
  }'

How delivery works

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:

AttemptWait
1st retry30 seconds
2nd2 minutes
3rd10 minutes
4th30 minutes
5th1 hour

After the fifth we stop. You can still replay it by hand — see the end of this page.

Checking the request really came from us

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".

Checking it in Node

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)
  );
}

Payload shape

Webhook payloads reuse the same message shape returned by the API, then add:

  • event
  • channel_account_id
  • tenant_slug

A 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.

status

Derived, not stored, so it always reflects the furthest point the message has reached:

valuemeaning
sendingaccepted and on its way; no acknowledgement yet
sentWhatsApp accepted it
deliveredit reached the recipient's device
readthe recipient opened it. A played voice note counts as read
errorit 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 example

An 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"
}

One person, two addresses

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"
}

Writing a good receiver

  1. Answer fast. Don't do your real work before replying — we're waiting, and a slow reply looks like a failure.
  2. Check the signature before you trust anything in the body.
  3. Do the work afterwards, on your own queue.
  4. Reply 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.

When one fails anyway

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_deliveries

To 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>"
Securifi Connect | WhatsApp Inbox & API