Request
curl -X POST https://api.beaconbox.eu/api/v1/messages \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f2b1c04-3d6a-4e55-9a12-77c0b4e9d100" \
-d '{
"recipient_email": "buyer@example.com",
"subject": "Your order has shipped",
"body": "Tracking XY123456789EE."
}'client.messages.push(recipient_email=..., subject=..., body=...)$client->messages->push(new MessagePush(...))Body
| Field | Type | Description |
|---|---|---|
recipient_emailrequired | email | |
recipient_phoneoptional | string | null | Mobile number for the SMS nudge, E.164 preferred (e.g. +372 5555 0134). Stored against this recipient for your business and reused on later pushes, so it is optional once you have sent it once, or set it via PUT /recipients/{email}/phone. |
channelsoptional | "email" | "sms" | "whatsapp"[] | null | Which paid channels this one push may use. Unset follows your account's channel settings. Naming "sms" or "whatsapp" asks for that channel; leaving one out of the list suppresses it for this push; listing both may send two messages and cost two credits. Email is always sent regardless. Naming a channel can only narrow, never switch one on: a channel set to off, or not yet enabled for your account, stays silent and the outcome reports the skip reason. A channel in on_request mode sends only when the push names it. A request never overrides a country restriction, a recipient who has opted out, or (on WhatsApp) a recipient who never opted in. |
whatsapp_opt_inoptional | object | null | Record that this recipient agreed to be messaged on WhatsApp, as part of the push that carries them. Independent of channels: recording consent is not sending, so this works while your WhatsApp channel is still switched off, and a push that both records consent and asks for whatsapp sends on the consent it just recorded. The result is reported in whatsapp_opt_in on the response and never fails the push, except when there is no phone number for it to be consent about, which is refused before anything is stored. Consent is per business: an opt-in you record is yours and reaches no other merchant. |
sms_if_whatsapp_failsoptional | boolean | Text this recipient if the WhatsApp nudge proves *undeliverable*: Meta refuses it (the usual case: the number is not on WhatsApp), the delivery status comes back failed, or no status arrives at all within five minutes. It does not fire for a message that was delivered and not read: a read receipt is not delivery, and someone who read it on another device would get the same update twice. The SMS costs its own credit and appears as its own send. It also texts at once when WhatsApp cannot carry the message at all: the number is already known not to be on WhatsApp (not_reachable), or the push has no template (template_not_sendable because it lacks reference or order_status, or the reference or your business name cannot be shown); not while a template is still in review at Meta. Otherwise ignored unless a WhatsApp message was actually queued, and skipped when this push already queued an SMS of its own; at most one text either way. With escalate_if_unread_after_minutes, the fallback is held with the WhatsApp message: nothing is sent on either channel unless the message is still unread at the deadline, including for a number already known not to be on WhatsApp. |
subjectrequired | string | |
referenceoptional | string | null | Your own order number for what this message is about, e.g. #A-10294. Optional. Letters, digits and # - _ . / only, no spaces. It is shown in the WhatsApp and SMS nudges, so it must be an identifier rather than a sentence. A nudge shows it only when it starts with a letter or digit (after an optional #), holds only letters, digits and _ / # -, and has at most two dots, each directly before a digit (INV.2026): anything a phone could turn into a link, such as shop.com, is stored and echoed but never shown, so such a push sends no WhatsApp message. |
order_statusoptional | "confirmed" | "shipped" | "ready_for_pickup" | "delivered" | "cancelled" | "refunded" | "delayed" | null | What happened to the order this message is about. Optional. One of: confirmed, shipped, ready_for_pickup, delivered, cancelled, refunded, delayed. It chooses the wording of the WhatsApp and SMS nudges, which BeaconBox writes (for example "Your order #A-10294 from PhonicBloom has shipped."): your subject and body never appear in either. A WhatsApp nudge is sent only when the push carries both reference and order_status, because Meta accepts an order notification only when it says what happened to which order; without them WhatsApp is skipped with template_not_sendable and nothing is charged. An in-place update replaces it, like reference. More values may be added, so treat one you do not recognise as no status. |
bodyrequired | string | Plain text and clickable links only. At most 10,000 characters, which is a ceiling rather than a target: an update longer than that is a document, and the message is the thing that tells someone to come and look. |
kindoptional | "one_off" | "updateable" | one_off: always a new message + nudge. updateable: matched on subject; creates + nudges, or updates in place quietly. |
notifyoptional | boolean | null | Override the default nudge behaviour. Unset applies the default (create → nudge, in-place update → silent). true forces a nudge even on an in-place update; false suppresses the nudge even on first create. |
send_atoptional | date-time | null | Hold the nudge until this moment (RFC 3339, timezone required). The message is stored and readable immediately; only the email/SMS ping waits, because the inbox is the source of truth and the nudge is what should land at a civilised hour. Must be in the future and at most 90 days out. Ignored when the push sends no nudge (notify: false, or a quiet in-place update). |
escalate_if_unread_after_minutesoptional | integer | null | Hold the paid channel back until the recipient has had a chance to read the email. With this set, the SMS or WhatsApp message named in channels is not sent now; it is sent only if the recipient has still not opened the message in their inbox after this many minutes (5 minutes to 7 days). If they open it first, nothing is sent and nothing is charged.
The minutes count from the email: from send_at when you set one, otherwise from the push. If the email has still not gone out at the deadline, the deadline moves to this many minutes after it does. An email that bounced, failed or was skipped (for example, the address is suppressed) counts as unread, so the paid channel is still sent, subject to its own consent and settings.
That inverts what a paid channel usually costs you: instead of paying to interrupt everybody, you pay only for the people the email did not reach. escalates_at on the channel's block says when the deadline falls.
Ignored when the push sends no nudge, and when no paid channel was requested. |
obsoletesoptional | string[] | public_ids of the caller's earlier messages to mark obsolete (grey out). At most 100. |
Response
Returns 201.
| Field | Type | Description |
|---|---|---|
id | string | |
recipient_email | string | null | The recipient this message was stored for, as we normalised it (trimmed, lower case). Null only when replaying a response recorded before this field was added. |
subject | string | |
kind | "one_off" | "updateable" | |
status | "active" | "obsolete" | "retracted" | |
created | boolean | True if a new message was created, False if updated in place. |
scheduled_for | date-time | null | When the nudge will be sent, if it was deferred with send_at. Null means it was queued immediately (or that nothing was nudged). |
nudged | boolean | Whether a nudge was queued for delivery. Actual delivery/bounce arrives asynchronously via webhook; query the message status to see it. |
email | object | null | Present when an email nudge was queued for this push. Absent when none was : an update that does not re-nudge, or notify=false, because there is then no email outcome to report and the caller already knows why. |
sms | object | null | Present when SMS was in play for this push, either queued or explicitly refused. Absent when the account has SMS off and the request didn't ask for it. |
sms_units | integer | Carrier segments the SMS costs. Always 1 when one was queued (the nudge is fixed-length), 0 otherwise. Charged credits may differ by destination; see sms.credits. |
whatsapp | object | null | Present when WhatsApp was in play for this push, either queued or explicitly refused. Absent when the account has WhatsApp off and the request didn't ask for it. |
whatsapp_opt_in | object | null | Present exactly when you sent whatsapp_opt_in, and absent otherwise. Deliberately top-level rather than inside whatsapp: recording consent is not sending, and the whatsapp block is absent for an account with the channel switched off, which is precisely the account collecting consent ahead of turning it on. |
reference | string | null | The order reference you sent, echoed back. Null if you sent none. |
order_status | string | null | The order status you sent, echoed back. Null if you sent none. A plain string rather than the request's enum, so a status added later never breaks a client reading it. |
created_at | date-time | |
updated_at | date-time |
Errors
Failures are { "error_code": "..." } with a stable dotted code. See Errors for the codes worth branching on and the three outcomes that look like failures and are not.
This is a write, so it requires an Idempotency-Key header. A request without one is refused with 422. See Idempotency.
