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

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

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

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:

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_PENDINGDepends 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 MetaYes
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

🛑 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.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 sendWhatsAppMeta may already have accepted the message; nothing is stored, so a retry starts a genuinely new send.
502 provider_outcome_unknownBYO SMSYour provider gave no definitive result; the message may already have been submitted. The response says so.
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 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.


Was this page helpful?