Skip to content
Akedly

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:

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"
}

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:

V1 — message only

{
  "message": "No OTP provided"
}

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 }
}

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": "+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.

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:

SurfaceWhat its codes cover
V1.2 REST APIChallenge, proof-of-work, Turnstile, attempt lockout and rate limits — plus the expiry windows
V2.0 WidgetsWidget attempt lifecycle, callbacks and security layers
Utilities Send APISend-time validation, templates and channel resolution
V1.0 REST APIThe legacy three-step flow — deprecated, see the sunset notice on that page
OTP rails — V1, V1.2, V2 create-attempt, VLiteTemplate 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.

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.

StatusErrorCause & Solution
409OTP_DISPATCH_IN_PROGRESSAnother 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.
409OTP_DISPATCH_OUTCOME_UNKNOWNThe 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:

CodeWhy it happensWorth retrying?
503 UTILITY_BUDGET_RESERVATION_PENDINGAkedly 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_UNAVAILABLEThe 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_errorAkedly could not read the WhatsApp credentials for this send.No — an Akedly-side configuration failure; contact support
500 admin_waba_token_missingAn 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:

CodeWhat to assume about deliverySafe to retry with the same key
503 UTILITY_BUDGET_RECONCILIATION_PENDINGNot 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 codeYes
503 UTILITY_BUDGET_RESERVATION_PENDINGNot sent — the fee is reserved, claimed and released before dispatch, and the code also appears while another request is finalising the same reservationYes
503 BYO_SMS_RETRY_RECONCILIATION_PENDINGNot sent — the retry could not be re-armed, so nothing was dispatchedYes
503 provider_outcome_reconciliation_pendingUnknown — 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 yetYes

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.certaintyWhat actually happened
acceptedYour provider took the message. Only the billing reconciliation is unfinished.
not_sentYour provider did not receive it. Only the reserved fee is still being settled.
unknownAkedly 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:

CodeChannelWhy
502 reporting a failed WhatsApp sendWhatsAppWith 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_unknownBYO SMSYour provider gave no definitive result; the message may already have been submitted. The response says so.
503 PROVIDER_OUTCOME_UNKNOWNWhatsAppAkedly 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 500eitherTreat 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.