Skip to content
luftflow

Your team and tools

Messages API

Send one message on SMS or WhatsApp through a single endpoint, and read back where it got to.

The Messages API sends one message to one number, on the channel you name or on several tried in order, and answers with the same shape whichever channel carried it. Every message shows under Messaging → Activity.

It takes the same keys as the SMS API.

Send an SMS

curl https://luftflow.com/api/messages \
  -H "Authorization: Bearer $LUFTFLOW_KEY" \
  -H "Idempotency-Key: otp-991" \
  -H "Content-Type: application/json" \
  -d '{"to": "+<country code><number>", "channel": "sms",
       "from": "YOURSENDER", "text": "Your code is 4821"}'

text, from, validity_minutes and transactional mean what they mean in the SMS API.

Send a WhatsApp template

curl https://luftflow.com/api/messages \
  -H "Authorization: Bearer $LUFTFLOW_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+<country code><number>", "channel": "whatsapp",
       "template": "otp", "variables": {"code": "4821"}}'
Field
template An approved template on your team's WhatsApp number
language Optional when the template is approved in one language only
variables The template's values by name
from Optional. Which WhatsApp number, when your team has more than one

The number sends only while it answers one of your flows' deployments. A reply goes to that flow as any first message would.

Send by template

A template under Messaging → Templates holds what a message says on each channel: SMS text, a WhatsApp template, or both. Values are named once, as {{code}}, and every channel fills them from the same variables:

curl https://luftflow.com/api/messages \
  -H "Authorization: Bearer $LUFTFLOW_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+<country code><number>", "channel": "sms",
       "from": "YOURSENDER", "template": "otp", "variables": {"code": "4821"}}'

The same request with "channel": "whatsapp" sends the template's WhatsApp side. A template with nothing written for the channel is refused as not_on_channel, and a value it needs and was not given as missing_value. On WhatsApp, a name that is none of your templates is sent as the Meta template of that name.

Fall back to another channel

List the channels in the order to try them, and luftflow moves to the next only when one cannot get the message there:

curl https://luftflow.com/api/messages \
  -H "Authorization: Bearer $LUFTFLOW_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+<country code><number>", "channels": ["whatsapp", "sms"],
       "from": "YOURSENDER", "template": "otp", "variables": {"code": "4821"}}'

It moves on when a channel refuses the message before it goes out (no WhatsApp on the number, an opt-out, no credit), or when the message goes out and fails. A message that failed was not delivered, so the next channel cannot make it arrive twice. An SMS to a number the operator says does not exist is not tried elsewhere. Each channel is tried once. With a list, from names the SMS sender and whatsapp_from the WhatsApp number. sms_from always names the SMS sender, whatever the list, so a send that may be pinned to WhatsApp never reads it as a number.

fallback_after, from 10s to 10m, also moves on when the message has not been delivered in that time. Neither network can take a message back, so the person may get both: use it for a passcode, where a second copy does no harm.

Each channel tried is an entry in attempts, and a channel that refused is listed under refused. A message refused on every channel is answered 422 and kept, as filtered when an opt-out stopped it everywhere, otherwise blocked. The webhook hears message.routed for each channel tried, and message.failed only once nothing is left to try.

Fields on every message

Field
to The phone number with its country code, starting with +
channel sms or whatsapp; or channels, a list to try in order
ref Optional. Your own reference; it comes back on the message

What comes back

A message that is taken is answered 202:

{
  "id": "msg_1042",
  "status": "accepted",
  "channel": "sms",
  "channels": ["sms"],
  "to": "26097****567",
  "ref": "otp-991",
  "attempts": [{ "channel": "sms", "id": 88213, "status": "queued" }]
}

status is accepted, sent, delivered, read (WhatsApp only), failed, cancelled, filtered or blocked. Each entry in attempts is the message on one channel, with that channel's own status and id. Send the same Idempotency-Key again and you get the first message back with 200.

Read it again with its id:

curl https://luftflow.com/api/messages/msg_1042 \
  -H "Authorization: Bearer $LUFTFLOW_KEY"

The answer never carries the text or the whole number.

Webhook

An admin adds one address under Messaging → API, and luftflow posts each message's progress to it as it happens: message.accepted, message.routed (handed to a channel), message.sent, message.delivered, message.read, message.failed, message.cancelled, message.filtered and message.blocked.

{
  "id": "evt_4kQ9…",
  "type": "message.delivered",
  "created_at": "2026-10-15T10:42:09Z",
  "data": { "id": "msg_1042", "status": "delivered", "channel": "sms", "ref": "otp-991", "attempt": 1, … }
}

data is the message as GET answers it, with the attempt the event is about. Answer with any 2xx. An event that is not answered is tried again for a day. A repeat carries the same id, so drop one you already have.

Every delivery is signed with the secret shown when the webhook was added:

Luftflow-Signature: t=1760524929,v1=5257a869…

v1 is the hex HMAC-SHA256 of the time, a dot and the raw body, keyed with the secret. Work it out on your side, compare the two in constant time, and refuse a time more than five minutes old.

Refusals

A refusal is answered 422 with reason and error in words. A number without its country code is refused rather than guessed. The SMS refusals are the SMS API's. Meta's own refusal of a WhatsApp template comes back as whatsapp_refused, with the message it kept.