POST /api/v1/messages writes an update to a customer’s inbox and queues a nudge about it. It
requires an Idempotency-Key.
The body
| Field | Required | What it does |
|---|---|---|
recipient_email |
yes | Who it is for. Creates the recipient if you have not messaged them before. The response repeats it as stored: trimmed and lower case. |
subject |
yes | Up to 300 characters. Also the matching key for updateable. |
body |
yes | Plain text and clickable links. No HTML. Up to 10,000 characters. |
kind |
no | one_off (default) or updateable. |
reference |
no | Your order number, up to 32 characters. |
order_status |
no | What happened to the order: confirmed, shipped, ready_for_pickup, delivered, cancelled, refunded or delayed. Chooses the wording of the WhatsApp and SMS nudges. See order status. |
notify |
no | Force or suppress the nudge. |
recipient_phone |
no | Stored against the recipient and reused on later pushes. |
channels |
no | Which nudge channels this push wants. On an account set to send a channel only on request, naming it is the request, and an escalation keeps it. |
send_at |
no | Hold the nudge until a moment, up to 90 days out. |
escalate_if_unread_after_minutes |
no | Hold the paid channel back. See paid channels. |
obsoletes |
no | Ids of your earlier updates this one replaces. |
whatsapp_opt_in |
no | Record consent as part of the push. |
sms_if_whatsapp_fails |
no | Text them if WhatsApp proves undeliverable, or cannot carry this push at all. See paid channels. |
Order status
order_status says what happened to the order this update is about. BeaconBox writes the WhatsApp
and SMS nudges from it, in its own words: your subject and body never appear in either, because
a nudge is a notice to come and read the update, and the update is yours.
order_status |
The nudge says |
|---|---|
confirmed |
Your order #A-10294 from PhonicBloom is confirmed. |
shipped |
Your order #A-10294 from PhonicBloom has shipped. |
ready_for_pickup |
Your order #A-10294 from PhonicBloom is ready for pickup. |
delivered |
Your order #A-10294 from PhonicBloom was delivered. |
cancelled |
Your order #A-10294 from PhonicBloom was cancelled. |
refunded |
Your order #A-10294 from PhonicBloom has been refunded. |
delayed |
Your order #A-10294 from PhonicBloom is delayed. |
WhatsApp needs both reference and order_status. Meta accepts a business notification only
when it says what happened to which order, so a push missing either skips WhatsApp with
template_not_sendable, and nothing is charged. Email is unaffected, and with
sms_if_whatsapp_fails the text goes at once instead (see paid channels).
A nudge shows the reference exactly as you sent it, or not at all. It may start with one #, then a
letter or digit, followed only by letters, digits, _, /, # and -, at most 32 characters.
A dot is allowed only directly before a digit, and at most twice (INV.2026 and 2026.001 are
fine, shop.com is not). Long runs of digits and leading zeros are fine. A reference the push
accepts but that falls outside this is stored and echoed back but never shown: such a push sends no
WhatsApp message, and its text leaves the order number out.
Your business name is held to a similar rule. It may use the letters A to Z, these accented letters
(Ä Å Æ Ç É Ñ Ö Ø Ü ß à ä å æ è é ì ñ ò ö ø ù ü), digits, single spaces and & ' ’ - . , ( ), at
most 40 characters. It starts with a letter or digit and ends with a letter, a digit, . or ). A
dot only ends a word (it is followed by a space, ), , or the end of the name) or sits in a run
of initials such as S.A. or e.V.. Brackets must pair, and the name has no www, @, : or /
and fewer than six digits in all. A name outside that sends no WhatsApp message at all (the WhatsApp
screen says so, and WhatsApp cannot be switched on until it changes), and the SMS leaves it out. The
SMS shows a name only when it is plain ASCII, so Café 24 appears on WhatsApp but not in the text.
SMS uses the same words when they fit one text. A text is one credit because it is one segment of 160 GSM-7 characters, so BeaconBox sends the first of these that fits:
| The text says | |
|---|---|
| 1 | Your order #A-10294 from PhonicBloom has shipped: (link) |
| 2 | Your order #A-10294 has shipped: (link) |
| 3 | Your order has shipped: (link) |
| 4 | You have a new order update: (link) |
Without a status the text is the last one, as it always has been. When the account has an opt-out number, each form carries a last line, “Reply STOP to (number) to opt out”, and the generic one reads “New order update: (link)”. That line counts toward the 160 characters, so with it a long reference or business name is the first thing left out. Either way it is one text and one credit. The email nudge is unchanged: it already carries your subject.
An updateable push replaces the status and the reference along with the body, so the update that
says delivered nudges as delivered. A rewrite without order_status clears it: the next nudge
about that update has no status wording and no WhatsApp message. More statuses may be added: the
response echoes the value as a plain string, so treat one you do not recognise as no status.
One-off or updateable
This is the decision that shapes the integration.
one_off always creates a new update, and nudges unless you set notify: false. Right for
things that are genuinely separate events: a receipt, a refund confirmation.
updateable is matched on (recipient, subject). If no live update exists with that subject,
it creates one and nudges. If one does, it rewrites that update in place and, by default, says
nothing.
So the useful pattern for an order is one subject per thread of meaning, pushed repeatedly:
# Three pushes, one subject, one update in the customer's inbox.
for STEP in "confirmed|Packed and waiting for collection." \
"shipped|Collected by the courier. Tracking XY123456789EE." \
"delivered|Delivered and signed for at 14:20."; do
STATUS="${STEP%%|*}"
BODY="${STEP#*|}"
curl https://api.beaconbox.eu/api/v1/messages \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-$STATUS" \
-d "{\"recipient_email\": \"buyer@example.com\",
\"subject\": \"Your order is on its way\",
\"body\": \"$BODY\",
\"reference\": \"#4711\",
\"order_status\": \"$STATUS\",
\"kind\": \"updateable\"}"
donefrom beaconbox import OrderStatus
SUBJECT = "Your order is on its way"
def post_order_step(email: str, body: str, order_id: str, status: OrderStatus) -> None:
client.messages.push(
recipient_email=email,
subject=SUBJECT,
body=body,
reference=f"#{order_id}",
order_status=status,
kind="updateable",
# One key per intent. Derived from the order and the step, so a retry
# from the job runner collapses onto it and a genuinely new step does not.
idempotency_key=f"order-{order_id}-{status.value}",
)
post_order_step("buyer@example.com", "Packed and waiting for collection.", "4711", OrderStatus.CONFIRMED)
post_order_step("buyer@example.com", "Collected by the courier.", "4711", OrderStatus.SHIPPED)
post_order_step("buyer@example.com", "Delivered and signed for at 14:20.", "4711", OrderStatus.DELIVERED)<?php
use BeaconBox\Enum\OrderStatus;
const SUBJECT = 'Your order is on its way';
function postOrderStep(
BeaconBox\BeaconBoxClient $client,
string $email,
string $body,
string $orderId,
OrderStatus $status,
): void {
$client->messages->push(
new BeaconBox\Model\MessagePush(
recipientEmail: $email,
subject: SUBJECT,
body: $body,
reference: "#{$orderId}",
orderStatus: $status,
kind: BeaconBox\Enum\MessageKind::Updateable,
),
// One key per intent, derived from the order and the step.
idempotencyKey: "order-{$orderId}-{$status->value}",
);
}
postOrderStep($client, 'buyer@example.com', 'Packed and waiting for collection.', '4711', OrderStatus::Confirmed);
postOrderStep($client, 'buyer@example.com', 'Collected by the courier.', '4711', OrderStatus::Shipped);
postOrderStep($client, 'buyer@example.com', 'Delivered and signed for at 14:20.', '4711', OrderStatus::Delivered);The customer is nudged once, on the first push, and then holds an update that stays current.
Only one live updateable update can exist per (business, recipient, subject). Superseding or
retracting one frees that slot.
Controlling the nudge
notify overrides the default:
kind |
Subject exists | notify unset |
notify: true |
notify: false |
|---|---|---|---|---|
one_off |
not checked | nudge | nudge | silent |
updateable |
no, creates | nudge | nudge | silent |
updateable |
yes, rewrites | silent | nudge | silent |
The row that matters is the last one. A rewrite is quiet by default, which is right for a tracking
number that changed. When the revision is one they need now, send notify: true.
notify: false on a first push is for an update you want readable without interrupting anybody:
somewhere to point a customer who asks, rather than something to announce.
Scheduling
send_at holds the nudge until a moment you name. RFC 3339 with a timezone, in the future, at
most 90 days out.
{
"recipient_email": "buyer@example.com",
"subject": "Your collection slot is tomorrow",
"body": "Tomorrow between 09:00 and 11:00. Reply to the email to change it.",
"send_at": "2026-10-03T07:00:00Z"
}The update is readable immediately. Only the nudge waits, because the inbox is the source of truth and the nudge is what should land at a civilised hour.
It is ignored when the push sends no nudge: a notify: false push, or a quiet rewrite.
With escalate_if_unread_after_minutes, the unread window counts from send_at, not from the
push, so a scheduled update is never texted about before its email has gone. escalates_at on the
response says when.
A scheduled nudge is decided when it goes out, not when you push it. Credits are charged then, so a balance that drains overnight turns a scheduled text into a skip you can retry; and switching SMS or WhatsApp off calls off anything still waiting on that channel, however far ahead it was scheduled. To call off one update’s nudges on every channel at once, retract it (below).
Replacing earlier updates
obsoletes takes ids of your own earlier updates and greys them out in the customer’s inbox.
{
"recipient_email": "buyer@example.com",
"subject": "Your refund has been issued",
"body": "£42.00 is on its way back to the card you paid with.",
"reference": "#A-10294",
"order_status": "refunded",
"obsoletes": ["v55favd767qu"]
}Use it when a new update makes an old one wrong rather than out of date. The delivery estimate for an order that has since been cancelled should not still read as live.
Taking one back
POST /api/v1/messages/{id}/retract withdraws an update, cancels any queued nudge, and refunds
nothing because nothing was charged for a send that had not happened.
curl -X POST https://api.beaconbox.eu/api/v1/messages/v55favd767qu/retract \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Idempotency-Key: 2c9e5b71-0a44-4d9f-8e21-6b3fd0cc9a55"result = client.messages.retract("v55favd767qu")
if result.already_notified:
# The email or text is already with them. The inbox says withdrawn,
# but something about it has gone out and cannot be recalled.
...$result = $client->messages->retract('v55favd767qu');
if ($result->alreadyNotified) {
// Already with them. The inbox says withdrawn; the nudge cannot be recalled.
}Read already_notified. It is the difference between “nobody ever knew” and “they were told, and
now they have been told it is withdrawn”.
Retracting something already withdrawn succeeds with retracted: false. That is not a failure, it
is the operation being idempotent about its own meaning.
Listing and reading back
GET /api/v1/messages is cursor paginated and filterable by recipient. Both SDKs expose it as a
generator so a year of history is not held in memory.
curl "https://api.beaconbox.eu/api/v1/messages?recipient_email=buyer@example.com&limit=50" \
-H "Authorization: Bearer $BEACONBOX_API_KEY"for message in client.messages.iterate(recipient_email="buyer@example.com"):
print(message.id, message.status, message.delivery.opened)foreach ($client->messages->each(recipientEmail: 'buyer@example.com') as $message) {
echo $message->id, ' ', $message->status->value, PHP_EOL;
}For more than a handful of pushes at a time, see Batches.
