The shape of a response
{
"id": "v55favd767qu",
"recipient_email": "buyer@example.com",
"subject": "Action needed on your order",
"kind": "one_off",
"status": "active",
"created": true,
"scheduled_for": null,
"nudged": true,
"email": {
"sending": true,
"skipped_reason": null
},
"sms": {
"queued": false,
"credits": 0,
"skipped_reason": "insufficient_credit",
"escalates_at": null
},
"sms_units": 0,
"whatsapp": null,
"whatsapp_opt_in": null,
"reference": "#A-10294",
"order_status": null,
"created_at": "2026-10-02T20:11:10.674762Z",
"updated_at": "2026-10-02T20:11:10.674762Z"
}That response is a success. The update is in the inbox, the email is queued, and the text was not sent because the balance was empty.
| Field | Means |
|---|---|
created |
true made a new update, false rewrote one in place. |
nudged |
A nudge was queued. false on a quiet rewrite or notify: false. |
scheduled_for |
When a deferred nudge will go. null means immediately, or not at all. |
email |
Whether we expect to email, and why not if we do not. |
sms, whatsapp |
The channel’s outcome, or null when the channel was not in play. |
sms_units |
Carrier segments. Always 1 when one was queued, 0 otherwise. |
reference, order_status |
Echoed from the push, null when it carried none. A status you do not recognise is a newer one: treat it as no status. |
null on sms is not “skipped”. It means the account has that channel off and the request did not
ask for it, so there is no outcome to report. An object with queued: false and a
skipped_reason is a channel that was genuinely considered and refused.
Handling a skip
# Read .sms.skipped_reason, not the status code.
curl -s 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.",
"channels": ["sms"]}' \
| jq -r '.sms.skipped_reason // "queued"'from beaconbox import SkipReason
result = client.messages.push(
recipient_email="buyer@example.com",
recipient_phone="+37255550134",
subject="Action needed on your order",
body="We could not take payment.",
channels=["sms"],
idempotency_key="order-4711-payment-failed",
)
if result.sms and result.sms.skipped_reason == SkipReason.INSUFFICIENT_CREDIT:
alert_ops("BeaconBox credits exhausted")
# Top up, then resend just the text for this update:
# client.messages.resend_sms(result.id)use BeaconBox\Enum\Channel;
use BeaconBox\Enum\SkipReason;
$result = $client->messages->push(
new BeaconBox\Model\MessagePush(
recipientEmail: 'buyer@example.com',
subject: 'Action needed on your order',
body: 'We could not take payment.',
recipientPhone: '+37255550134',
channels: [Channel::Sms],
),
idempotencyKey: 'order-4711-payment-failed',
);
if ($result->sms?->skippedReason === SkipReason::InsufficientCredit->value) {
alertOps('BeaconBox credits exhausted');
// $client->messages->resendSms($result->id);
}Neither SDK raises on a skip, deliberately.
What a skip can say
| Reason | Yours to fix? |
|---|---|
no_phone |
Yes. Send a number, or set one on the recipient. |
insufficient_credit |
Yes. Top up and resend. |
country_not_allowed |
Usually. The destination is outside your allowed countries. Texts to the United States and Canada are not available yet, whatever your settings say: carriers there accept business texts only from a registered number. Nothing is charged. |
phone_conflict |
Yes. The number belongs to another of your recipients. |
disabled_by_request |
Yes. Your own channels excluded it. |
sms_not_enabled, whatsapp_not_enabled |
Yes, by asking. The channel has not been switched on for your account yet; request it from the SMS or WhatsApp screen. |
sms_disabled, whatsapp_disabled |
Yes. The channel is off for the account, including on a scheduled or armed send you switched the channel off after. |
sms_warmup_cap_reached |
No, it passes. A newly enabled SMS account has a daily limit for its first two weeks. Decided when the send runs, so it appears on the message, not in the push response. |
stopped |
No. They opted out of this channel. On SMS it holds until they text START. |
unsubscribed |
No. They stopped all your updates. Permanent. |
not_opted_in |
WhatsApp only. Collect consent first. |
not_reachable |
No. Meta says the number is not on WhatsApp. |
template_not_sendable |
WhatsApp only. Yours when the push did not carry both reference and order_status, or the reference or your business name cannot be shown: fix them (see order status); with sms_if_whatsapp_fails the text goes instead. Otherwise not yours: the template for that status is under review at Meta. Nothing is charged either way. |
sending_paused |
No. Sending is paused on the account. |
rate_limited, platform_limited |
No. Try later. |
The email outcome, and why it has two halves
email on the push response is the gate’s verdict as known at push time, so the stable,
recipient-shaped reasons reach you in the response to the call you just made rather than only
through a webhook you may not have configured.
| Means | |
|---|---|
sending: false |
Reliable. No email will be sent, and skipped_reason says why. |
sending: true |
A strong expectation, not a promise. |
The asymmetry is deliberate. At push time we can see a suppressed address, an unsubscribe or a lapsed plan. We cannot see the day’s cap filling up between now and the send, a pause applied in between, or a retraction. The nudge is queued and re-checked by the worker regardless.
skipped_reason here is one of sending_paused, business_inactive, plan_lapsed,
unsubscribed, suppressed or platform_suppressed, and null when sending.
business_inactive means the account has been deactivated. The last of those means an address another
business already proved undeliverable, which gates the shared sending domain only.
So for what actually happened, read the message back.
curl https://api.beaconbox.eu/api/v1/messages/v55favd767qu \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
| jq '.delivery'message = client.messages.get("v55favd767qu")
if message.delivery.not_sent:
# No email was attempted, and none will be. Stop waiting for delivery.
reason = message.delivery.not_sent.reason
if reason == "plan_lapsed":
alert_ops("BeaconBox subscription lapsed: no email is being sent")$message = $client->messages->get('v55favd767qu');
if ($message->delivery->notSent !== null) {
// No email attempted, and none will be.
if ($message->delivery->notSent->reason === 'plan_lapsed') {
alertOps('BeaconBox subscription lapsed: no email is being sent');
}
}{
"delivered": false,
"delivered_at": null,
"opened": false,
"opened_at": null,
"bounced": false,
"bounced_at": null,
"sms": null,
"not_sent": {
"reason": "plan_lapsed",
"at": "2026-10-02T20:14:03.118221Z"
},
"failed": null
}not_sent is what tells delivered: false apart from itself. Without it that flag meant both “on
its way” and “never attempted, and never will be”, so a loop waiting for delivery had nothing to
stop on. It is absent in the ordinary case, and a later successful send withdraws it.
daily_cap_reached is final for that send: the nudge is not retried when the cap resets, so resend
it the next day if it still matters.
failed is the other end of the same question: the email was attempted and will never be
delivered. Its reason is esp_refused (the email provider rejected it outright),
retries_exhausted (every attempt failed and BeaconBox stopped trying) or outcome_unknown (the
provider stopped answering partway through the send). Either way, stop waiting. outcome_unknown
means the email may in fact have arrived: BeaconBox will not send it a second time, because a
duplicate would carry a second sign-in link, and if the provider later reports a delivery the
message shows as delivered. Treat a reason you do not recognise as failed.
The paid channels follow the same rule. A text or WhatsApp message whose submission got no answer
is never submitted again: it ends failed with the code sms.submit_outcome_unknown or
whatsapp.submit_outcome_unknown, and its credit is kept, because the provider may have delivered
it. The code arrives as error_code on the sms.failed or whatsapp.failed webhook, and both
show in the dashboard’s send logs. A WhatsApp send first waits up to five minutes for Meta’s own
status, and then falls back to a text if you asked for sms_if_whatsapp_fails. A message the
provider refused on every attempt was never sent: it ends rejected with the code
sms.submit_refused or whatsapp.submit_refused, its credit is refunded, and a WhatsApp one still
falls back to a text when asked. One given up on before it ever reached the provider ends rejected
and refunded the same way, with the code sms.never_submitted or whatsapp.never_submitted. Every text and WhatsApp message that ends undelivered sends its
*.failed or *.rejected webhook once, whether the provider reported the outcome or BeaconBox
decided it.
plan_lapsed is the one to alert on. Your pushes keep succeeding, because the updates are still
reaching the inbox; it is only the email that has stopped, and there is no way to learn that from
your own side. Subscribe to the plan.lapsed webhook.
