Email is not optional, because it carries the link: there is no way to push an update that nudges
on SMS alone. It can still be refused by the send path, which is what email on the response and
delivery.not_sent on the message report. SMS and WhatsApp are additions to it, cost one credit
each from one shared balance, and are refused, never raised, when they cannot send.
Asking for a channel
channels is declarative, not additive. A list names all the channels this push wants, so
["sms"] asks for SMS and excludes WhatsApp.
{
"recipient_email": "buyer@example.com",
"recipient_phone": "+37255550134",
"subject": "Action needed on your order",
"body": "We could not take payment. Tap to fix it.",
"channels": ["sms"]
}Omit channels entirely to follow the account’s own settings, which is what most integrations
should do: the shop owner then controls spend from the dashboard without a deploy.
Listing both sends two messages and costs two credits. There is no automatic fallback between
them; sms_if_whatsapp_fails is how you ask for one explicitly.
Phone numbers
A number arrives either on the push, as recipient_phone, or on the recipient. Either way it is
stored against that recipient for your business and reused, so it only needs sending once.
curl -X PUT https://api.beaconbox.eu/api/v1/recipients/buyer@example.com/phone \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone": "+37255550134"}'client.recipients.set_phone("buyer@example.com", "+37255550134")
# Reads come back masked: a leaked key must not dump a phone book.
sms = client.recipients.sms("buyer@example.com")
print(sms.phone) # "+372 •••• 0134"$client->recipients->setPhone('buyer@example.com', '+37255550134');
// Reads come back masked: a leaked key must not dump a phone book.
$sms = $client->recipients->sms('buyer@example.com');
echo $sms->phone; // "+372 •••• 0134"E.164 is preferred. A number already held by another of your recipients is refused with
phone_conflict and not moved, because the usual cause is a typo and the cost of guessing is
texting the wrong person about somebody else’s order.
Paying only for the people email missed
escalate_if_unread_after_minutes holds the paid channel back. The text is sent only if the
recipient still has not opened the update after that long. If they open it first, nothing is sent
and nothing is charged.
curl https://api.beaconbox.eu/api/v1/messages \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-payment-failed" \
-d '{
"recipient_email": "buyer@example.com",
"recipient_phone": "+37255550134",
"subject": "Action needed on your order",
"body": "We could not take payment. Tap to fix it.",
"channels": ["sms"],
"escalate_if_unread_after_minutes": 120
}'result = client.messages.push(
recipient_email="buyer@example.com",
recipient_phone="+37255550134",
subject="Action needed on your order",
body="We could not take payment. Tap to fix it.",
channels=["sms"],
escalate_if_unread_after_minutes=120,
idempotency_key="order-4711-payment-failed",
)
# Nothing was refused and nothing charged: the deadline is simply armed.
assert result.sms.queued is False
assert result.sms.skipped_reason is None
print(result.sms.escalates_at)$result = $client->messages->push(
new BeaconBox\Model\MessagePush(
recipientEmail: 'buyer@example.com',
subject: 'Action needed on your order',
body: 'We could not take payment. Tap to fix it.',
recipientPhone: '+37255550134',
channels: [BeaconBox\Enum\Channel::Sms],
escalateIfUnreadAfterMinutes: 120,
),
idempotencyKey: 'order-4711-payment-failed',
);
// queued === false, skippedReason === null: the deadline is armed.
echo $result->sms?->escalatesAt?->format(DATE_ATOM);Between 5 minutes and 7 days. It is ignored when the push sends no nudge, and when no paid channel was asked for.
The clock starts when the email does. escalates_at is send_at plus the window, or the push
time plus the window when there is no send_at, so a scheduled update is never texted about before
its email exists. If the email is still waiting to go when the deadline arrives (a backlog, a
retry), the deadline moves to the same window after it.
Any outcome of the email counts as not read. An email that bounced, failed or was not sent (a suppressed address, a daily limit) escalates like one that was delivered and ignored: that recipient is exactly who the paid channel is for. The only message that never escalates is one for which no email was ever going to be sent.
Everything is decided again at the deadline, not at the push: whether they have read it, whether
they still consent, whether the balance covers it, and whether the channel is still on for your
account. A channel you switch off is not used by a deadline you armed last week. The skip shows
on the message as sms_disabled or whatsapp_disabled, and POST /messages/{id}/sms is one call
away if you change your mind.
On an account set to send a channel only on request, naming it in the push’s channels is the
request, and the deadline keeps it: the channel you named is sent at the deadline as it would have
been at the push. Switching the channel off, a country restriction and an opt-out still win.
For a shop sending a few thousand updates a month this is usually the difference between texting everybody and texting the fraction email did not reach.
WhatsApp needs an order and a status
A WhatsApp nudge is a template BeaconBox writes and Meta approves, one per
order status: “Your order #A-10294 from PhonicBloom has
shipped.” So a push goes out on WhatsApp only when it carries both reference and order_status,
and both the reference and your business name can be shown (the
display rules keep out anything a phone could turn into a link or
a number to call). Missing either, or with a reference or a name outside those rules, WhatsApp
reports template_not_sendable and nothing is charged; the email and any SMS go as usual. A name
outside the rules stops WhatsApp for every push, and the WhatsApp screen will not let you switch the
channel on until the name changes.
template_not_sendable also appears, through no fault of yours, while the template for that status
is still in review at Meta. That one is ours to fix.
This is a change for integrations written before order statuses: their pushes stop going out on
WhatsApp until they send order_status. With sms_if_whatsapp_fails, such a push is texted
instead, once, the same way as for a number that is not on WhatsApp. So is an unread deadline, or a
send_at send, whose update lost its status in an in-place update. A template still in review
sends no text.
WhatsApp consent
WhatsApp needs recorded consent before anything is sent, separate from SMS and from email, because Meta can audit it.
curl https://api.beaconbox.eu/api/v1/messages \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-dispatched" \
-d '{
"recipient_email": "buyer@example.com",
"recipient_phone": "+37255550134",
"subject": "Your order is on its way",
"body": "Collected by the courier.",
"reference": "#A-10294",
"order_status": "shipped",
"channels": ["whatsapp"],
"whatsapp_opt_in": {"source": "checkout tickbox"},
"sms_if_whatsapp_fails": true
}'result = client.messages.push(
recipient_email="buyer@example.com",
recipient_phone="+37255550134",
subject="Your order is on its way",
body="Collected by the courier.",
reference="#A-10294",
order_status="shipped",
channels=["whatsapp"],
whatsapp_opt_in_source="checkout tickbox",
sms_if_whatsapp_fails=True,
idempotency_key="order-4711-dispatched",
)
if result.whatsapp_opt_in:
print(result.whatsapp_opt_in.recorded, result.whatsapp_opt_in.status)$result = $client->messages->push(
new BeaconBox\Model\MessagePush(
recipientEmail: 'buyer@example.com',
subject: 'Your order is on its way',
body: 'Collected by the courier.',
reference: '#A-10294',
orderStatus: BeaconBox\Enum\OrderStatus::Shipped,
recipientPhone: '+37255550134',
channels: [BeaconBox\Enum\Channel::WhatsApp],
whatsAppOptInSource: 'checkout tickbox',
smsIfWhatsAppFails: true,
),
idempotencyKey: 'order-4711-dispatched',
);
echo $result->whatsappOptIn?->status->value;Consent is per business. One you record is yours and reaches no other merchant. BeaconBox stamps the time: the push API cannot backdate, and an opt-out is permanent and cannot be reversed by recording consent again.
whatsapp_opt_in on the response reports what happened, and never fails the push. The one
exception is a push with no number anywhere for the consent to be about, which is refused with
422 whatsapp.phone_required before anything is stored, so your idempotency key is not spent.
Falling back to SMS
sms_if_whatsapp_fails texts the recipient when the WhatsApp message proves undeliverable: Meta
refuses it, the delivery status comes back failed, or no status arrives within five minutes.
It also texts at once when WhatsApp cannot carry the message at all: the number is known not to be
on WhatsApp (not_reachable), or the push is template_not_sendable because it lacks reference
or order_status, or the reference or your business name cannot be shown (see
WhatsApp needs an order and a status). The same check runs
at an unread deadline and at a send_at time, for an update whose status was cleared in place. It
does not text while a status’s template is still in review at Meta. Either way it is one text at
most.
It does not fire for a message that was delivered and not read. A read receipt is not delivery, and somebody who read it on another device would get the same update twice.
The text is a separate send with its own credit, under the same rules as any SMS: it counts as a request for the channel, so it goes on an account that sends SMS only on request, and not on one that has SMS off or not yet switched on. Read the message back to see it.
Resending one channel
After topping up, resend just the paid channel instead of pushing the whole update again.
curl -X POST https://api.beaconbox.eu/api/v1/messages/v55favd767qu/sms \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Idempotency-Key: 4a8c0e13-9b27-4f60-85d1-2e7fa36b0c94"outcome = client.messages.resend_sms("v55favd767qu")$outcome = $client->messages->resendSms('v55favd767qu');Refused with sms.already_sent if that update’s text is already queued, accepted or delivered,
because sending again would only add a second charge and a second interruption. WhatsApp has its
own whatsapp.already_sent, and whatsapp.escalated_to_sms for the case where the fallback text
has already been delivered. An earlier send that ended without the provider’s answer may have
arrived, so it is refused too: sms.may_have_sent and whatsapp.may_have_sent.
Credits
One balance covers both channels.
curl https://api.beaconbox.eu/api/v1/credits \
-H "Authorization: Bearer $BEACONBOX_API_KEY"balance = client.credits.balance()
if balance.low_balance:
alert_ops(f"BeaconBox credits low: {balance.balance} left")$balance = $client->credits->balance();
if ($balance->lowBalance) {
alertOps("BeaconBox credits low: {$balance->balance} left");
}{ "balance": 525, "low_balance": false, "threshold": 200 }Buying is done in the dashboard, not over the API. Subscribe to credits.low_balance so a person
does not have to be watching.
