Errors
This page covers the failure envelope and the handling rules that apply across Akedly. Each surface's reference lists the codes you are most likely to meet on it — treat those lists as the common cases, not a closed set. Akedly adds codes as features ship, so handle an unrecognised code by falling back to the HTTP status and the message, and never branch on the assumption that you have seen them all.
Managed Vodafone integrations differ
BYO SMS here describes an integration you build and own. If Akedly operates a managed Vodafone integration for your account, several of the capabilities described below are fixed by Akedly and are not yours to set — see Managed Vodafone.
The shape of a failure
There are three tiers, and only the first is guaranteed. Write your handler against the tier you're actually calling.
message is the only field present on every error. V1.2, V2 Widgets and most Utilities send failures add status: "error" and a machine-readable code — though the utility send path can still return an uncoded 500 when an unexpected internal failure reaches its catch-all, which is exactly why the next section says to branch on code with an HTTP-status fallback:
A coded error — V1.2, V2, Utilities send
{
"status": "error",
"code": "INVALID_PHONE",
"message": "phone must be a valid E.164 number"
}
Many endpoints return status and message with no code:
Uncoded — common on dashboard-facing endpoints
{
"status": "error",
"message": "Integration not found"
}
And the legacy V1 API returns the bare message, with no status field at all:
V1 — message only
{
"message": "No OTP provided"
}
Do not assume the envelope
Reading res.status or res.code unconditionally will hand you undefined on real, routine failures — the V1 verify endpoint alone returns the bare shape in several places. Always branch on the HTTP status line as your floor, and treat status/code as enrichment when present.
V1.2's security middleware adds diagnostic fields on top. They're additive, so code written against the coded shape keeps working — but they are not uniform across V1.2: a rate-limit rejection carries the full set, while proof-of-work and Turnstile failures add only retryable.
V1.2 rate limit — the richest error we return
{
"status": "error",
"code": "RATE_LIMIT_PHONENUMBER_PERHOUR",
"message": "Rate limit exceeded for phoneNumber per hour",
"retryable": true,
"retryAfter": "2026-03-25T12:01:00.000Z",
"cooldownSeconds": 3600,
"details": { "type": "phoneNumber", "window": "perHour", "max": 5, "current": 5 }
}
Rate-limit codes are composed, not fixed
The V1.2 code is built as RATE_LIMIT_<IDENTIFIER>_<WINDOW> — so you'll see RATE_LIMIT_PHONENUMBER_PERHOUR, RATE_LIMIT_ENDUSERIP_PERMINUTE, RATE_LIMIT_PIPELINE_PERDAY and siblings rather than one generic value. V2 widgets use different suffixes for widget-attempt and OTP-request limits. Match on the RATE_LIMIT_ prefix if you want to catch them all, and read details for the limit that was hit.
Branch on code, never on message
Where a code exists it is a stable contract. message never is — it's prose written for a developer reading a log, it gets reworded without notice, and on send failures it may pass the provider's own text straight through, so its wording isn't even ours to keep stable.
Correct — code first, HTTP status as the fallback
switch (res.code) {
case 'INSUFFICIENT_BALANCE':
return notifyBilling()
case 'RECIPIENT_SUPPRESSED':
return markOptedOut()
default:
// No code, or one this build predates. Fall back to the status line.
// ⚠️ On utility /send this fallback is NOT safe as written — an unrecognised 5xx there can mean
// your provider already ACCEPTED the message, so a retry can send it twice. Acceptance is not
// delivery: on BYO SMS Akedly never learns the handset outcome, and on WhatsApp delivery is
// settled later by webhook. Never mark it delivered on a 5xx either. See "Retrying safely".
return httpStatus >= 500 ? scheduleRetry() : failPermanently()
}
Will break
if (res.message.includes('balance')) { // wording is not a contract
await notifyBilling()
}
Two failure modes to design out: a code you don't recognise, because new ones ship with new features; and no code at all, because not every endpoint defines one. Both should degrade to the status-line branch rather than fall through silently.
Not every non-delivery is an error
This is the trap worth internalising. A POST /utilities/send to a recipient who has opted out returns HTTP 200, with status: "skipped" — not an error:
HTTP 200 — opted-out recipient
{
"status": "skipped",
"code": "RECIPIENT_SUPPRESSED",
"message": "Recipient has opted out; message not sent.",
"data": {
"recipient": "+201017438478",
"skipped": true,
"transactionID": "68f1c2..."
}
}
You still get a transactionID, so the attempt is traceable in your logs and in the dashboard even though nothing was sent.
That's deliberate. The call was well-formed and correctly refused — an opt-out is a successful outcome of the consent rules, not a fault in your request. So status is three-valued, and checking response.ok or status !== 'success' alone will mis-classify it.
Do not retry a skipped send
RECIPIENT_SUPPRESSED is terminal. Retrying re-sends nothing, bills nothing, and returns the same result. Treat it as a permanent state for that recipient until they opt back in.
One inconsistency to code around: the dashboard's test-send returns the same RECIPIENT_SUPPRESSED code as a 409, not a 200 — test sends apply the consent model through a stricter guard. Match on the code, not the status line, and both paths behave the same for you.
Where the code lists live
Codes are documented alongside the surface that emits them, because the same class of failure carries different remedies per product:
| Surface | What its codes cover |
|---|---|
| V1.2 REST API | Challenge, proof-of-work, Turnstile, attempt lockout and rate limits — plus the expiry windows |
| V2.0 Widgets | Widget attempt lifecycle, callbacks and security layers |
| Utilities Send API | Send-time validation, templates and channel resolution |
| V1.0 REST API | The legacy three-step flow — deprecated, see the sunset notice on that page |
Retrying safely
Two rules cover almost every case.
Only retry when the failure is transient. V1.2 tells you directly via retryable. Elsewhere, treat 5xx and explicit rate-limit codes as transient, and every 4xx validation code as terminal — retrying INVALID_PHONE will fail identically forever.
⚠️ On utility /send, whether a retry is safe is decided by the channel, not by the error
code. The two channels store a failed request differently, and that difference — not the code you
received — is what decides whether retrying can deliver the message twice.
The mechanism, in one paragraph. Before Akedly calls your SMS provider it records that a
carrier request has started. If a request gets that far, the response is kept against your
Idempotency-Key even when it is an error, so retrying with the same key replays the stored
outcome or re-enters reconciliation instead of sending again. The WhatsApp path never records that
marker, so any non-2xx on WhatsApp releases the key — and the next request carrying it is
treated as a brand-new send.
WhatsApp: treat a 5xx as unknown unless it is a named pre-dispatch code
A WhatsApp send can fail after Meta has already accepted the message — a timeout or a dropped connection returns an error even though the message is on its way — and because the key is released, an automatic retry sends it a second time.
These WhatsApp 5xx responses are returned before anything is dispatched, so nothing was sent:
| Code | Why it happens | Worth retrying? |
|---|---|---|
503 UTILITY_BUDGET_RESERVATION_PENDING | Akedly has not finished finalising this send's own fee reservation — either it is still releasing a previous one, or another request is contending for the same reservation record. On WhatsApp each attempt gets its own transaction, so a second request of yours is not normally the cause. The reservation runs before the message is handed to Meta. | Yes, after a backoff |
503 UTILITY_BILLING_UNAVAILABLE | The message's fee could not be resolved, which is checked before the fee is reserved. | Usually a pricing-configuration gap; retrying is harmless but will keep failing until it is fixed |
500 waba_token_error | Akedly could not read the WhatsApp credentials for this send. | No — an Akedly-side configuration failure; contact support |
500 admin_waba_token_missing | An admin utility send found no system token configured. | No — same; contact support |
For any other WhatsApp 5xx — a bare 500, a 502, and every 503 not named above,
whatever its code says about budgets or reconciliation — treat the outcome as unknown. Check
whether the message arrived before sending anything else, or contact Akedly. That default is
deliberately conservative: a pre-dispatch code we have not listed here gets treated as unknown, which
costs you a support question rather than a duplicate message.
BYO SMS: a named set is safe to retry with the same key
On the BYO-SMS path these four responses are safe to retry — but only when the request that failed
already carried the Idempotency-Key you are about to reuse. Utility idempotency is opt-in (only
Vodafone utility SMS requires a key), and adding a key to the retry of a request that never had one
is not a replay: Akedly has nothing stored under it, so that retry is a brand-new send.
Two different questions matter here, and they have different answers. The first column is about delivery, the second about retrying — and for one row those come apart completely, so read both. Each of these codes covers more than one backend path, which is why a column says "unknown" where a single path would have let it say something firmer:
| Code | What to assume about delivery | Safe to retry with the same key |
|---|---|---|
503 UTILITY_BUDGET_RECONCILIATION_PENDING | Depends on the channel — do not assume "not sent". On BYO SMS it means not sent (a definitive failure, or a rollback that did not complete before dispatch). On WhatsApp it can also follow a dispatch whose outcome was never confirmed, so the message may have reached Meta | Yes |
503 UTILITY_BUDGET_RESERVATION_PENDING | Not sent — the fee is reserved, claimed and released before dispatch, and the code also appears while another request is finalising the same reservation | Yes |
503 BYO_SMS_RETRY_RECONCILIATION_PENDING | Not sent — the retry could not be re-armed, so nothing was dispatched | Yes |
503 provider_outcome_reconciliation_pending | Unknown — never report delivery on it. Three different paths return this code: your provider accepted it, your provider definitively did not receive it and only the fee is still settling, or the outcome cannot be resolved yet | Yes |
🛑 Read that last row carefully, because it answers two questions differently.
How should I retry? — with the same key, and that is safe on every path. Starting a new send (a new key, or a fresh request because you read this as a failure) is what can put a second message in front of your recipient, because on the accepted path the first one is already on its way. Retrying with the same key is what Akedly asks you to do.
Was it delivered? — you do not know yet, and you must not say that it was.
provider_outcome_reconciliation_pending does not mean nothing happened, but it does not mean the
message was sent either. On one path the response says your provider accepted it. On another it says
the opposite in as many words — "The BYO-SMS provider did not receive the message, but Akedly is
still finalizing the reserved fee" — and that path can resolve to 502 send_failed. On a third the
submission outcome is simply unresolvable so far. The answer comes from Akedly finishing
reconciliation, not from this first response. Show your end user a pending state, not a delivered
one.
503 UTILITY_BUDGET_RESERVATION_PENDING also appears when another request is still finalising the
same operation's reservation, not only while one is being released. Either way nothing was
dispatched.
Read data.certainty, not the code. 503 provider_outcome_reconciliation_pending is returned on
four different backend states, so the code alone tells you nothing about delivery. The response
carries the machine-readable answer:
data.certainty | What actually happened |
|---|---|
accepted | Your provider took the message. Only the billing reconciliation is unfinished. |
not_sent | Your provider did not receive it. Only the reserved fee is still being settled. |
unknown | Akedly cannot resolve the outcome yet. |
Then read the message, because it decides what you do next. Most say keep the same key and
retry later — reconciliation finishes on its own, and a same-key retry re-enters it. But the paths
where the send record itself is missing or incomplete say contact support instead, and those
never clear by retrying: the retry adopts the same incomplete record and returns the same 503.
If the message says contact support, escalate rather than backing off.
Two things hold on every path: never start a new send (a new key is what puts a second message
in front of your recipient), and never report the message as delivered on the strength of a
503 — a pending state can still resolve to 502 send_failed.
Anything not named above
If a response is not named in the tables above, treat its outcome as unknown and do not retry it automatically. That is an instruction, not a claim that these tables list every response Akedly can return — they do not. Defaulting to "unknown" is what stops an undocumented code from quietly being treated as safe:
| Code | Channel | Why |
|---|---|---|
502 reporting a failed WhatsApp send | Meta may already have accepted the message; nothing is stored, so a retry starts a genuinely new send. | |
502 provider_outcome_unknown | BYO SMS | Your provider gave no definitive result; the message may already have been submitted. The response says so. |
any 5xx not named above, including a bare 500 | either | Treat the outcome as unknown. A 500 can be raised after a send succeeded, when a later step failed. |
When in doubt, contact Akedly rather than retrying. A support ticket costs you a delay; an automatic retry of an unknown outcome can cost your recipient a second message.
Make retries idempotent. Utilities /send accepts an idempotency key — in the Idempotency-Key header, or as an idempotencyKey body field. When Akedly has stored a response for that key, reusing it replays that response instead of sending a second message. Whether a response is stored depends on the channel and on how far the request got, so a key on its own is not a guarantee against a second send.
On WhatsApp, any 2xx is stored for 24 hours — including a 200 with status: "skipped", which is not a send at all — while a failed send stores nothing and releases the key, so a same-key retry genuinely re-attempts it. On BYO SMS, once Akedly has called your provider the response is normally stored with no expiry even when it is an error, so a same-key retry replays it instead.
provider_outcome_reconciliation_pending is the one to think about, and the honest answer is that
you cannot tell from outside which way it went. Akedly marks most of these non-replayable on
purpose, so the stored body is discarded and a same-key retry re-enters reconciliation and can
return a different outcome later — a success, or a 502 send_failed. At least one path does not
get that marking and is stored like any other post-boundary response, so the same 503 replays.
Do not model either behaviour. Keep the same key regardless; that is safe both ways. What tells
you them apart is progress: if the identical 503 keeps coming back with no change over a long
period, treat it as the stored case and contact Akedly rather than retrying indefinitely.
The full rules are on the utility send reference.
Rate limits carry their own clock
When a response includes retryAfter or cooldownSeconds, honour it rather than backing off on your own schedule. The limit counts sends you already made, and a rejected request is not one of them — so retrying early does not extend the cooldown, it just fails again until the oldest send rolls out of the window. retryAfter is the moment that happens.
