Send API
One POST call sends a utility message — WhatsApp over your own Business Account, or SMS over your own BYO provider. This page is the reference for the endpoint, its responses, and the errors it returns by name — an unexpected internal failure can still surface as an uncoded 500.
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.
Before you call this
You need a utility pipeline, your pipeline's request-signing secret, and — unless you send free-text SMS — a sendable template. See Get set up.
The send call
Send from your backend only — the request carries your API key and is signed with your pipeline's signing secret, which must never reach a browser or mobile app. The Request Signing guide covers the scheme in depth, with reusable helpers.
Headers
- Name
Content-Type- Type
- string
- Description
application/json.
- Name
x-akedly-timestamp- Type
- string
- Description
Unix time in milliseconds. Accepted within ±5 minutes of server time. Required when request signing is enabled on the pipeline (the default).
- Name
x-akedly-nonce- Type
- string
- Description
A unique random value per request. A nonce is single-use per pipeline for 10 minutes — longer than the timestamp window, so a captured request can never be replayed.
- Name
x-akedly-signature- Type
- string
- Description
Hex HMAC-SHA256 of
timestamp.nonce.bodyHashusing your request-signing secret, wherebodyHashis the hex SHA-256 digest of the exact body bytes you send.
- Name
Idempotency-Key- Type
- string
- Description
Optional, up to 255 characters. Makes retries safe — see idempotent retries.
Body
- Name
APIKey- Type
- string
- Description
Your account API key, from Company Profile → View API Key in the dashboard.
- Name
pipelineID- Type
- string
- Description
The utility pipeline to send through.
- Name
phone- Type
- string
- Description
Recipient phone number in E.164 (
+201234567890).00prefixes and Egyptian local01…forms are accepted and canonicalized.
- Name
templateId- Type
- string
- Description
The sendable template to render — its ID from your dashboard's Templates list. Template mode — provide this or
text, not both.
- Name
variableValues- Type
- object
- Description
Values for the template's
{{variable}}placeholders, keyed by variable name. Keys must match the template's declared variable names exactly — matching is case-sensitive. A variable with no value and no stored fallback fails the send.
- Name
text- Type
- string
- Description
Free-text mode: the literal message body. SMS-only, so the destination must be covered by your BYO SMS routing.
- Name
customerUserId- Type
- string
- Description
Optional. Your internal user ID for the recipient; echoed back in the response and recorded with the message.
Request
const crypto = require("crypto");
async function sendUtilityMessage() {
const body = JSON.stringify({
APIKey: process.env.AKEDLY_API_KEY,
pipelineID: "<YOUR_PIPELINE_ID>", // your utility pipeline
templateId: "<TEMPLATE_ID>", // Order shipped (en)
variableValues: {
orderNumber: "1042", // {{orderNumber}}
},
phone: "+201234567890", // recipient, E.164
customerUserId: "user_123", // your internal user id
});
// sign the exact body bytes with this pipeline's signing secret
const timestamp = Date.now().toString();
const nonce = crypto.randomUUID();
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const signature = crypto
.createHmac("sha256", process.env.AKEDLY_SIGNING_SECRET)
.update(`${timestamp}.${nonce}.${bodyHash}`)
.digest("hex");
const res = await fetch("https://api.akedly.io/api/v1/utilities/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-akedly-timestamp": timestamp,
"x-akedly-nonce": nonce,
"x-akedly-signature": signature,
},
body,
});
return res.json();
}
Body only
{
"APIKey": "<AKEDLY_API_KEY>",
"pipelineID": "<YOUR_PIPELINE_ID>",
"templateId": "<TEMPLATE_ID>",
"variableValues": {
"orderNumber": "1042"
},
"phone": "+201234567890",
"customerUserId": "user_123"
}
Two body modes
- Template mode —
templateId+variableValues. Works on WhatsApp and SMS; the only mode that can reach WhatsApp. - Free-text mode —
textinstead of a template. SMS-only; useful for content that does not fit a fixed template.
Provide one or the other. A body with neither returns MISSING_TEXT.
FYI — how a template picks its channel
A template enabled for both WhatsApp and SMS doesn't go out on both. Akedly resolves one channel per send, before sending: it walks your pipeline's channel order and takes the first channel that is feasible for that recipient. WhatsApp is feasible when the number is WhatsApp-usable, the template is Meta-approved, your WABA is active and the destination is allowed; SMS is feasible when your BYO routing resolves for the destination. The default order is WhatsApp first — but the order is the rule, not a tiebreak, so a pipeline ordered SMS-first sends SMS even when WhatsApp is also feasible. This is decided up front, and the send itself never retries on the other channel. One case reaches SMS later: a pipeline can opt in to a WhatsApp → BYO-SMS fallback (utilityConfig.smsFallback, off by default), which runs after the send — and only on two specific Meta failure codes, not on every failed delivery. See Channel resolution.
Template variables
Template variables are the {{name}} placeholders in a template body; your send call fills them via variableValues (keys are case-sensitive and must match the declared names). Each variable can also carry two optional attributes — set when you author the template.
FYI — template variables can carry a fallback and a max length
- Fallback value — used automatically when your send call omits that variable, so the message still renders. With no value and no fallback, the send fails with
TEMPLATE_MISSING_VARIABLE. - Max length — a longer value you pass is silently truncated to fit.
The whole template body is capped at 1024 characters — checked with your example values when the template is created, the same limit Meta applies to WhatsApp bodies. (SMS segment count grows with the rendered length, but is not a hard cap — and does not affect what you pay: the BYO fee is flat per message.)
Channel resolution
The channel is decided before sending, and a send goes out on exactly one channel — the send call itself never retries on the other channel.
One case does reach SMS afterwards — and it can bill you
A pipeline can opt in to a WhatsApp → BYO-SMS fallback (utilityConfig.smsFallback, off by
default). It is not a send-time retry: it runs later, from Meta's delivery webhook, and re-renders the
message as SMS over your BYO chain. If your provider accepts that SMS, it is billed as one. So with
the fallback enabled, a 200 on WhatsApp followed by a failing delivery can still produce a charged SMS
your code never asked for.
It does not fire on every failed delivery — only on two Meta error codes: 131026, where the
recipient cannot receive the WhatsApp message, and 131042, a business eligibility or payment-method
problem on your WABA. Only the first error Meta reports on that delivery is checked. Every other
failure stays terminal, including the ones you might most expect to fall back: template not approved or
unavailable (131045), spam rate limit (131048) and WABA on hold (131057).
Reaching one of those two codes is necessary, not sufficient. The attempt is abandoned — leaving the
failure terminal, exactly as with the fallback off — if it is a test send, if the recipient is
suppressed for sms or ALL, if your BYO routing does not resolve for the destination, if the SMS fee cannot
be reserved against your utility budget, if the template does not re-render as SMS, or if your provider
does not accept the dispatch. Nothing is billed unless that last step succeeds. It is also
one-shot: attempted once and never retried, so it can never send twice.
With the fallback off, which is the default, a failed WhatsApp delivery is terminal and nothing else is sent.
WhatsApp is used when all of these hold:
- WhatsApp is enabled on the pipeline
- The send is template mode and the template is WhatsApp-usable and Meta-approved
- Your WhatsApp Business Account is connected and active
- The destination country is in the pipeline's allowed-countries list
SMS is feasible when your BYO routing resolves for the destination. Akedly takes the first feasible channel in your pipeline's channel order — WhatsApp first by default, but an SMS-first order sends SMS even when WhatsApp is also feasible. A destination with no feasible WhatsApp path and no BYO route fails with UTILITY_SMS_REQUIRES_BYO or UTILITY_SMS_COUNTRY_NOT_ROUTED — both 409.
Responses
A 200 means the call was handled — it does not on its own mean a message went out. Read the top-level status for the outcome: success (accepted), skipped (the recipient has opted out — see below), or error.
On success, Akedly accepted the message and recorded it as a transaction. For WhatsApp that is not yet a delivery confirmation — delivery settles asynchronously. For SMS it means your provider accepted the submission and Akedly finished billing it. There is no later delivery settlement: Akedly does not ingest delivery receipts from a BYO provider. Your provider may well publish them — take them from its own dashboard or callbacks, not from Akedly. If the provider accepts an SMS but billing does not complete, you do not get a 200: the endpoint returns 503 provider_outcome_reconciliation_pending instead, so an accepted-but-unbilled SMS always arrives as an error to retry, never as a success to trust. Either way the message is visible in the dashboard's Send History under the returned transactionID. The fields below live under data on accepted sends.
Branch on status, not on the HTTP status code. An opted-out recipient returns 200 with
status: "skipped" and code: "RECIPIENT_SUPPRESSED" — nothing was sent and you are not
billed. Akedly persists a Skipped transaction and returns its transactionID, so the outcome
stays visible in Send History. Code that treats every 2xx as "on its way" will silently record
a message that never went out.
Response fields
- Name
transactionID- Type
- string
- Description
The message's transaction ID — your handle for tracking it.
- Name
status- Type
- string
- Description
The message's lifecycle state. For WhatsApp:
Pendingon acceptance, thenSuccessfulon delivery orFailedif delivery fails. For SMS:Successfulas soon as your provider accepts the submission — there is no later delivery settlement, because Akedly does not ingest delivery receipts from a BYO chain — your provider may publish them, but nothing here consumes them.Successfulon an SMS send means accepted, not delivered.
- Name
channel- Type
- string
- Description
whatsapporsms.
- Name
provider- Type
- string
- Description
own-wabafor WhatsApp,own-smsfor SMS.
- Name
wamid- Type
- string
- Description
WhatsApp only — Meta's message ID.
- Name
segments- Type
- number
- Description
SMS only — how many segments the rendered text occupies. Informational: the BYO fee is flat per message, so this does not change what you are charged.
- Name
encoding- Type
- string
- Description
SMS only —
GSM7orUCS2.
- Name
billed- Type
- boolean
- Description
Whether this message has been charged. On a
200SMS response this is alwaystrue— an accepted SMS whose billing did not complete is returned as503 provider_outcome_reconciliation_pendingrather than a success, sobilled: falsenever reaches you on a200. WhatsApp returnsfalsewithbilling.status: "deferred"— it bills on the delivery outcome.
- Name
customerUserId- Type
- string
- Description
Echo of the value you sent, or
null.
Response
{
"status": "success",
"message": "Utility WhatsApp accepted",
"data": {
"transactionID": "AKDLYTXN…",
"status": "Pending",
"channel": "whatsapp",
"provider": "own-waba",
"recipient": "+201234567890",
"customerUserId": "user_123",
"wamid": "wamid.HBgM…",
"billed": false,
"billing": { "status": "deferred" }
}
}
Response
{
"status": "success",
"message": "Utility SMS accepted",
"data": {
"transactionID": "AKDLYTXN…",
"status": "Successful",
"channel": "sms",
"provider": "own-sms",
"segments": 1,
"encoding": "GSM7",
"recipient": "+201234567890",
"customerUserId": "user_123",
"billed": true,
"billing": { "…": "…" }
}
}
Response
{
"status": "skipped",
"code": "RECIPIENT_SUPPRESSED",
"message": "Recipient has opted out; message not sent.",
"data": {
"recipient": "+201234567890",
"skipped": true,
"transactionID": "AKDLYTXN…"
}
}
Idempotent retries
Send an idempotency key to make retries safer — in the Idempotency-Key header, or as an idempotencyKey field in the request body if a header is inconvenient. Akedly reads the header first and falls back to the body field, for every integration and not just Vodafone; the header wins when both are present. The value must be a string of at most 255 characters. The stored-response reservation uses your key exactly as sent — it is not trimmed, so "a" and " a " reserve separately and a whitespace-only key is reserved like any other. BYO SMS separately trims the key before deriving the identities it sends with — both the transaction identity and the provider's dispatch key — so those two spellings can still converge there; and Vodafone trims it before requiring it, so a whitespace-only key is refused as missing (see below). Send the same exact string every time and none of this can bite you. When Akedly has stored a response for that key, retrying with the same key replays it instead of sending again — but whether a response is stored depends on the channel and on how far the request got, so a key is not on its own a guarantee against a second send.
Two rules that are always safe. First: never mint a new key after a failure — a new key can submit the message a second time. Second: if the outcome is ambiguous — a timeout, a network error, or any response that does not clearly tell you the message was not sent — do not retry automatically. Contact Akedly. On the WhatsApp path a retry starts a genuinely new attempt, so an automatic retry after an ambiguous failure can send twice — as can minting a new key, above.
The exception is narrow, explicit, and BYO-SMS only: a handful of 503 reconciliation codes
state in their own message that the operation should be retried later with the same key. Do not read
that as "nothing was sent" — but do not read it as "it was sent" either.
provider_outcome_reconciliation_pending is returned on four different states, and the response
tells you which: read data.certainty (accepted, not_sent, or unknown). Retrying with the
same key is the right move on all of them, and is safe because the retry re-enters reconciliation
instead of dispatching again — except where the message says contact support, which is what the
missing-send-record paths say and which retrying will not clear. Those codes, the
precondition that the failed request already carried that key, and the WhatsApp rules are all in
Retrying safely. If a response is not one of them, treat it as ambiguous.
Beyond that, what a retry actually does depends on the channel and on how far the original request got.
SMS through your own provider (BYO SMS)
-
Akedly marks a carrier boundary the moment it hands your message to your provider's API.
-
Before that boundary, a non-
2xxfailure releases the key: retrying with the same key starts a fresh attempt. Any2xxpre-boundary response is stored and replayed for 24 hours — including a200withstatus: "skipped", which is not a send at all. -
After it, the record no longer expires, and what a retry gets depends on the outcome:
- Most post-boundary responses — success or error — are stored and replayed verbatim. A retry replays rather than re-attempts. That is deliberate: past that point a new key could submit the message a second time and bill you twice.
503 provider_outcome_reconciliation_pendingis the one to think about, and you cannot tell from outside which way it will go. Akedly marks most of these non-replayable on purpose: the stored body is discarded, so a same-key retry re-enters reconciliation and can return a different outcome later — a success, or a502 send_failed. At least one path does not get that marking and is stored like any other post-boundary response, so the same503replays. Do not model either behaviour; keeping the same key is safe both ways. What separates them is progress — an identical503returning unchanged over a long period is the stored case, and needs Akedly rather than more retries.
-
All of this needs a key. Idempotency here is opt-in for generic integrations — but Vodafone utility SMS requires one, and a Vodafone send without one is refused with
400 VODAFONE_IDEMPOTENCY_KEY_REQUIREDbefore anything is dispatched. What is required is the key, not specifically the header — either source above satisfies Vodafone, but the value is trimmed before it is checked, so" "is refused exactly like an absent one. Without a key nothing is stored and nothing replays, so do not retry an unclear outcome automatically. WhatsApp -
The WhatsApp path sets no carrier boundary, so none of the BYO SMS storage rules apply to it.
-
A failed send is never stored: the key is released and a same-key retry starts a genuinely new attempt.
-
⚠️ That includes ambiguous failures. A timeout or network error is reported as a send failure even though Meta may already have accepted the message, so retrying can deliver it twice. Treat an unclear WhatsApp failure as a support case, not a retry case.
-
Any 2xx response is stored and replayed for 24 hours — including a
200withstatus: "skipped", which is not a send.
Both channels
- Keys are scoped to your account.
- Reusing a key with different request parameters returns
422 IDEMPOTENCY_KEY_REUSE. The comparison ignores your API key and theidempotencyKeybody field, so changing only those is not a reuse — changing the recipient, the text or the template is. - Retrying before the carrier boundary returns
409 IDEMPOTENCY_IN_PROGRESSwhile the reservation is younger than 60 seconds. The clock, not the first request, is what decides: Akedly cannot see whether that request is still alive, so a retry after a crash is still refused for the rest of the minute, and past it the reservation is treated as abandoned and reclaimed by the retry, which becomes the live attempt. Back off briefly and retry. - A BYO SMS reservation past the boundary never returns
409and is never reclaimed, even inside those 60 seconds: the retry adopts the in-flight record and continues into reconciliation instead of being turned away. ⚠️ WhatsApp never sets that marker, so an expired lease is not evidence that Meta was never reached — do not retry a WhatsApp request automatically just because the 60 seconds elapsed.
Because a retried request is a new HTTP request, sign it with a fresh timestamp and nonce; only the Idempotency-Key stays the same.
Rate limits
Rate limits are configured per pipeline in the dashboard, as per-recipient and per-pipeline windows (per minute, hour, and day). Exceeding one returns 429 with a machine-readable cooldown:
{
"status": "error",
"code": "RATE_LIMIT_PHONENUMBER_PERMINUTE",
"message": "Rate limit exceeded for phoneNumber per minute",
"retryable": true,
"retryAfter": "2026-07-14T12:01:00.000Z",
"cooldownSeconds": 42,
"details": { "type": "phoneNumber", "window": "perMinute", "max": 1, "current": 1 }
}
The code combines the limited identifier (PHONENUMBER or PIPELINE) with the window (PERMINUTE, PERHOUR, PERDAY). Honor retryAfter — retrying earlier only re-hits the limit.
Error reference
Errors share one structure:
{
"status": "error",
"code": "ERROR_CODE",
"message": "Human-readable description"
}
Rate-limit errors add retryable, retryAfter, cooldownSeconds, and details; template variable errors add missing (SMS) or variable (WhatsApp).
Key limits
| Limit | Value |
|---|---|
| Signature timestamp skew | ±5 minutes |
| Nonce | Single use per pipeline |
| Idempotency replay window | 24 hours, unless a BYO SMS request crossed the carrier boundary — then no expiry |
| Idempotency-Key length | 255 characters |
| Rate limits | Per pipeline config (per phone and per pipeline) |
Authentication and pipeline errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | MISSING_REQUIRED_FIELDS | pipelineID is missing from an API-key-authenticated request. If the API key itself is missing, the route returns INVALID_REQUEST_AUTH instead. |
| 401 | INVALID_API_KEY | API key does not match any account. Verify it in the dashboard. |
| 404 | PIPELINE_NOT_FOUND | No pipeline with that ID, or the pipeline belongs to another account. Check the pipeline's basic details and use one from the same account as the API key. |
| 403 | NOT_A_UTILITY_PIPELINE | The pipeline is an OTP pipeline. Create a utility pipeline and use its ID. |
| 403 | PIPELINE_INACTIVE | The pipeline is switched off. Activate it in the dashboard. |
Request signing errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | SIGNING_NOT_CONFIGURED | Signing is enabled but the pipeline has no signing secret yet. Reveal one in the pipeline's settings. |
| 401 | MISSING_SIGNATURE | One of the three signing headers is absent. Send x-akedly-timestamp, x-akedly-nonce, and x-akedly-signature. |
| 401 | SIGNATURE_TIMESTAMP_SKEW | Timestamp outside the ±5-minute window. Sync your server clock and send milliseconds, not seconds. |
| 401 | INVALID_SIGNATURE | Signature verification failed — usually the body bytes were re-serialized after signing, or the wrong secret was used. |
| 401 | SIGNATURE_REPLAY | The nonce was already used on this pipeline. Generate a fresh nonce per request. |
Request validation errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | INVALID_PHONE | phone is missing or is not a valid E.164 number after canonicalization. |
Template and content errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | MISSING_TEXT | The body has neither templateId nor text. Provide one. |
| 404 | TEMPLATE_NOT_FOUND | No template with that ID on your account. |
| 409 | TEMPLATE_NOT_APPROVED | The template is not Akedly-approved yet. Submit it and wait for approval. |
| 409 | TEMPLATE_NOT_SMS | The send resolved to SMS but the template is not usable on SMS. |
| 409 | TEMPLATE_NOT_WHATSAPP | The send resolved to WhatsApp but the template is not enabled for WhatsApp. Enable WhatsApp on the template, or send it on SMS — the SMS route is only an option where your BYO integration covers the destination, which for a managed Vodafone integration means Egypt only. |
| 409 | TEMPLATE_NOT_WHATSAPP_APPROVED | The template is enabled for WhatsApp but still awaiting Meta approval, so it can't send on WhatsApp yet. |
| 400 | TEMPLATE_MISSING_VARIABLE | SMS render failed: a {{variable}} has no value and no fallback. The missing array lists the names. |
| 400 | TEMPLATE_VAR_MISSING | WhatsApp render failed: a template variable has no value and no fallback. The variable field names it. |
Channel errors
| Status | Code | Cause and Solution |
|---|---|---|
| 409 | UTILITY_SMS_REQUIRES_BYO | SMS was the resolved channel but BYO SMS is off for this pipeline, or no integration is eligible in this routing scope. If you build your own, add and test one — see BYO SMS. On a managed Vodafone integration, adding another provider is not the fix: check that BYO SMS routing is on for the pipeline, and that the integration is active with a passing test. An integration that exists but is inactive or has not passed its test is never eligible to be chosen, so it produces this code — not UTILITY_SMS_PROVIDER_NOT_READY. That one applies only after an active, test-passing integration has already been selected and then fails Akedly's send-time readiness check. |
| 409 | UTILITY_SMS_COUNTRY_NOT_ROUTED | You have a BYO integration, but no chain routes this destination. On an integration you built, add a per-country chain or an ALL catch-all — see coverage and failure behavior. On a managed Vodafone integration you cannot: its coverage is fixed to Egypt, so this destination is out of scope for it. |
| 409 | UTILITY_SMS_PROVIDER_NOT_READY | Today this code is specific to managed Vodafone: it is the only integration whose readiness Akedly re-checks at send time, and it fails closed rather than falling through to another provider. Do not change or deactivate the integration. By the time you see this code, the integration was already active and test-passed — that is what let it be selected at all — so your own setup is not what is failing. Most of the readiness checks behind this code are Akedly-owned and invisible to you: whether the Vodafone pilot is switched on for your account, whether Akedly's outbound IPs are verified, and whether the adapter endpoint is confirmed. Contact Akedly. Only if the dashboard specifically flags one of them should you revisit credentials, the approved sender name, or the IP-whitelist acknowledgement. |
| 409 | FREE_TEXT_REQUIRES_SMS | A free-text (text) send needs SMS, which is off on this pipeline. Enable SMS, or send an approved WhatsApp template instead. |
| 409 | NO_CHANNEL_ENABLED | The pipeline has neither WhatsApp nor SMS enabled. Turn on at least one channel. |
| 403 | DESTINATION_NOT_ALLOWED | WhatsApp isn't enabled for this destination country on the pipeline. Add the country to the pipeline's allowed destinations. |
| 409 | WHATSAPP_NOT_CONNECTED | The send resolved to WhatsApp but no active WABA is connected. Connect your WhatsApp Business Account. |
| 409 | no_active_waba | No active WhatsApp Business Account connected. Connect one in the dashboard. |
| 409 | no_waba_phone | The connected WABA has no active sender number. |
| 500 | admin_waba_token_missing | An admin utility send found no system token configured. Akedly-side configuration; nothing was sent. |
| 500 | waba_token_error | Akedly could not read the WhatsApp credentials for this send. This is an Akedly-side configuration failure, not something you can fix by reconnecting your WABA — contact support. Nothing was sent, so retrying is harmless, but it will keep failing until the configuration is corrected. |
Billing and delivery errors
| Status | Code | Cause and Solution |
|---|---|---|
| 402 | UTILITY_FEE_UNAVAILABLE | Akedly could not resolve a valid fee for this message before dispatch. Nothing was sent. |
| 402 | INSUFFICIENT_BALANCE | Account quota or balance exhausted. Top up in the dashboard. Checked on both channels before dispatch, in two stages — but the first stage is not the same on both. On SMS the pre-check already knows the message's flat BYO fee, so it rejects as soon as your available quota will not cover that fee, even while some quota remains. On WhatsApp the fee is not known that early, so the pre-check only rejects an account with no quota left at all. Both channels then make an atomic reservation of this message's own fee, which rejects if it will not fit — so WhatsApp goes through both stages too; it only bills on delivery. One exception, BYO SMS only: a message whose fee resolves to exactly 0 — which needs an Akedly-set integration fee override of 0 — is not balance-checked at all and can send on an account with no quota left, because there is no amount to fail against. WhatsApp cannot reach this case; its fee is clamped to a 0.25 EGP floor. |
| 402 | SPEND_CAP_EXCEEDED | The pipeline's daily spend cap is reached. Raise the cap or wait for the daily reset. This gates both channels in two stages: a pre-check that rejects once the day's spend has already reached the cap, then a separate atomic reservation of this message's own fee that rejects if it would carry the day past it. WhatsApp is not exempt — it prices the message and reserves that amount against the cap before dispatching. A BYO-SMS message whose fee resolves to exactly 0 is exempt from both stages and will send after the cap is reached; a zero fee cannot carry the day's spend anywhere. WhatsApp cannot resolve to zero — its fee is clamped to a 0.25 EGP floor. |
| 503 | UTILITY_BUDGET_RESERVATION_PENDING | Akedly is finalizing this message's fee reservation. On both channels the reservation completes before the message is handed to the provider, so nothing was dispatched. Do not start a new send — retry with the same Idempotency-Key after a backoff, and contact Akedly if it keeps repeating. If the original request carried no key, there is nothing to replay: adding one now starts a new operation rather than resuming the old, so treat the outcome as unconfirmed and check before resending. |
| 503 | UTILITY_BUDGET_RECONCILIATION_PENDING | Akedly could not finish settling the fee it had reserved. It arrives on three different paths — a reservation rollback that did not complete before dispatch, a BYO SMS send that definitively did not reach your provider, and a WhatsApp dispatch failure whose outcome may be either uncertain or a definite rejection — so do not assume the message's fate from this code alone; read the response body. Do not start a new send. On BYO SMS retry with the same Idempotency-Key — but only if the original request carried one; idempotency on this endpoint is opt-in, and adding a key to a retry starts a new operation instead of resuming the old. On WhatsApp do not retry automatically — the key is released on any non-2xx there, so a retry starts a new send after Meta may already have accepted the original. See Retrying safely. Contact Akedly if it keeps repeating. |
| 503 | UTILITY_BILLING_UNAVAILABLE | WhatsApp leg — this message's fee could not be resolved, checked before the fee is reserved, so nothing was sent. Retry shortly with the same Idempotency-Key; if it keeps repeating it is a pricing-configuration gap rather than a transient failure, and will not clear on its own. |
| 503 | provider_outcome_reconciliation_pending | BYO SMS only — this code never appears on a WhatsApp send. The code does not tell you whether the message was sent; data.certainty does. Four backend states return it, and that field carries accepted (your provider took it and only billing reconciliation is unfinished — the response says so and carries submissionStatus: "accepted"), not_sent (your provider did not receive it and only the reserved fee is still settling), or unknown. The safe action is the same on all of them: keep the same Idempotency-Key and retry later. One exception — read the message: where the send record itself is missing or incomplete it says contact support, and retrying that adopts the same incomplete record and returns the same 503, so escalate instead. 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. If the original request carried no key there is nothing to replay, so check whether the message arrived before retrying at all. Never report delivery on the strength of this code; it can still resolve to 502 send_failed. |
| 502 | provider_outcome_unknown | BYO SMS only — this code never appears on a WhatsApp send. Akedly could not determine whether your provider accepted the submission. Never re-send with a new key — the message may already be on its way. With an Idempotency-Key, a BYO SMS response past the carrier boundary is stored, so a same-key retry replays it rather than re-attempting; without a key nothing is stored, so do not retry automatically. Contact Akedly if it repeats. |
| 502 | send_failed | The provider rejected the send. WhatsApp failures may carry a provider-specific code instead of send_failed. The response includes the recorded transactionID; nothing is billed. |
Idempotency errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | INVALID_IDEMPOTENCY_KEY | The key is not a string or exceeds 255 characters. |
| 400 | VODAFONE_IDEMPOTENCY_KEY_REQUIRED | A Vodafone utility SMS send arrived with no idempotency key in either the Idempotency-Key header or the idempotencyKey body field. A key is optional for generic integrations and required here; the send is refused before anything is dispatched. |
| 422 | IDEMPOTENCY_KEY_REUSE | Same key, different request parameters. Your API key and the idempotencyKey body field are excluded from the comparison. Use a fresh key for a new request. |
| 409 | IDEMPOTENCY_IN_PROGRESS | A reservation for this key is younger than 60 seconds and has not reached the provider. Not a liveness check — Akedly cannot see whether the original request is still running, so this is returned after a crash too. Back off and retry; past 60 seconds the reservation is treated as abandoned and a retry can reclaim it and become the live attempt, and losing that reclaim race returns this same 409. It is not channel-specific — but WhatsApp never records reaching the provider, so a WhatsApp request stays reclaimable and the 60 seconds elapsing is not evidence that Meta was never reached. BYO SMS stops being reclaimable once it reaches your carrier: past that boundary there is no 409 at all, and the retry adopts the in-flight record instead. |
| 409 | IDEMPOTENCY_CONFLICT | Two requests raced for the same operation and this one lost the insert. The operation is already being processed. Back off and retry with the same Idempotency-Key — a new key here would start a second send. This one is not the 60-second lease described above — it is a raw duplicate-key collision raised when two requests hit the same operation at once, so there is no window to wait out. Retry after a short backoff. |
