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.
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 — OTP template selection, destination-policy and OTP-dispatch refusals are coded exceptions on V1 despite its other message-only errors. 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:
{
"status": "error",
"code": "INVALID_PHONE",
"message": "phone must be a valid E.164 number"
}
Many endpoints return status and message with no code:
{
"status": "error",
"message": "Integration not found"
}
For ordinary legacy V1 failures, the API returns the bare message, with no status field.
Coded exceptions include OTP-template selection, destination-policy and OTP-dispatch refusals; preserve status
and code when present. The V1 OTP template error reference
documents the template case:
{
"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.
{
"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.
RATE_LIMIT_PIPELINE_ABUSE is a fixed code rather than a composed one, and its body has no details. See the V1.2 and V2.0 error references.
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.
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()
}
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:
{
"status": "skipped",
"code": "RECIPIENT_SUPPRESSED",
"message": "Recipient has opted out; message not sent.",
"data": {
"recipient": "+201234567890",
"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 |
| OTP rails — V1, V1.2, V2 create-attempt, VLite | Template selection refusals. V1, V1.2 and V2 create-attempt also refuse invalid otpLanguage with HTTP 400 INVALID_OTP_LANGUAGE; see OTP message language. VLite ignores an explicit template and uses only a configured pipeline default. Utilities /send reuses TEMPLATE_NOT_APPROVED at HTTP 409 with a different remedy; see Template and content errors. For the OTP rails, see the OTP templates guide. |
Retrying safely
Two rules cover almost every case.
Only retry when the failure is transient. V1.2 tells you directly via retryable. On V1 activation and V2 OTP request/resend, follow the dispatch codes below. 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 Idempotency-Key and how far the
request got, not by the error code. Once Akedly has started the provider call, a retry with the
same key never sends again; a retry with no key, or with a new key, is a new send and 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 has no carrier
marker, but it records a dispatch point of its own just before Akedly calls Meta. A non-2xx before
that point releases the key, and the next request carrying it is a fresh send. After it, a definite
rejection is kept against the key for 24 hours and replayed, and an unclear outcome is kept with no
expiry as 503 IDEMPOTENCY_OUTCOME_UNKNOWN — so retrying with the same key never sends the message
a second time.
OTP dispatch conflicts
These codes apply to V1 activation and V2 OTP request/resend. Both are returned when a send for that transaction or attempt is already in flight, or when the outcome of an earlier send for it is unknown. Dev-mode test pairs never return these 409s; they send no real code.
| Status | Error | Cause & Solution |
|---|---|---|
| 409 | OTP_DISPATCH_IN_PROGRESS | Another call holds this send: it is still in flight, or it finished but its result is not readable yet. For V1 activation or V2 request-otp, read retryAfterSeconds (3) from the body; the Retry-After: 3 header may not be readable cross-origin in browsers. For V1 activation, retry the same call after that many seconds; expect 409 while in flight or while the result is not readable yet. Once the first send is accepted, a retry may send the same code once more, using the one resend, and only within the 2-minute activation window; see V1 re-activation for what later calls return. For V2 request-otp, retry the same call after that many seconds; expect 409 while in flight; the original request once the first send was accepted, instead of sending another code. If that result is not readable yet, the response remains 409. V2 resend-otp returns this code with retryable: false and no retry delay: its one resend is already consumed, so a retry returns 429 RESEND_LIMIT_EXCEEDED. |
| 409 | OTP_DISPATCH_OUTCOME_UNKNOWN | The first send's outcome could not be confirmed. retryable is false; do not retry the same transaction or attempt. Start a new transaction for V1, or a new verification attempt for V2. The code may have been delivered. V2 request-otp may include an optional redirectUrl to the pipeline's frontendCallbackURL with failure details and public metadata; the hosted widget shows a return link to this URL and follows it automatically, except for a native app-scheme URL inside an iframe, which is shown as a link only. It is omitted without a callback URL and by older origins during rolling deploys. This is a user-experience hint; this path sends no failed webhook. V2 resend-otp does not carry redirectUrl. |
The original V2 request-otp caller's provider timeout still returns 500 OTP_SEND_FAILED; it does not return the unknown-outcome 409. For resend-otp, the one resend is claimed just before dispatch, after the endpoint's own checks, and the one resend stays consumed once a send was attempted or its outcome is unknown: after a 500 OTP_RESEND_FAILED timeout or a 409 dispatch conflict, a retry returns 429 RESEND_LIMIT_EXCEEDED and is final for that attempt. A 500 body with OTP_RESEND_FAILED or RESEND_OTP_FAILED says retryable: true; for resend-otp ignore it: the one resend is normally already spent, although a failure that sent nothing may release it. Do not retry resend-otp automatically. Earlier endpoint checks can also take precedence: V2 returns 403 OTP_NOT_REQUESTED when verifying without a prior OTP request.
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. With an Idempotency-Key, Akedly
keeps that outcome against the key and a same-key retry does not send again; without a key, or with
a new key, 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
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.
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 | Not sent, on either channel. On BYO SMS it means not sent (a definitive failure, or a rollback that did not complete before dispatch). On WhatsApp it means Meta definitely rejected the message, or the fee rollback failed before Akedly called Meta; an unclear WhatsApp outcome comes back as PROVIDER_OUTCOME_UNKNOWN, never as this code | 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 | With an Idempotency-Key, a Meta rejection is stored for 24 hours and a same-key retry replays it without sending; without a key, or with a new key, a retry starts a genuinely new send. Check whether the message arrived before sending anything else. | |
502 provider_outcome_unknown | BYO SMS | Your provider gave no definitive result; the message may already have been submitted. The response says so. |
503 PROVIDER_OUTCOME_UNKNOWN | Akedly could not confirm whether Meta accepted the message, so it may already be on its way. With an Idempotency-Key the response is 503 IDEMPOTENCY_OUTCOME_UNKNOWN instead. Do not retry; see Billing and delivery errors. | |
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 failure before Akedly calls Meta releases the key (a same-key retry re-attempts it), and once the call has started a definite rejection is stored for 24 hours and an unclear outcome is stored with no expiry, so a same-key retry replays it instead of sending again. 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.
RATE_LIMIT_PIPELINE_ABUSE is the exception. It does not count your sends, so no send rolls out of a window. Its cooldownSeconds is a fixed 60-second wait; V1.2 sets retryAfter 60 seconds after the response and V2 sends no retryAfter. Wait at least that long. A retry is judged again, so it can be refused again.
