Send API
One POST call sends a utility message — WhatsApp over your own Business Account, or SMS inside Egypt. This page is the complete reference for the endpoint, its responses, and every error it can return.
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 recipient must be eligible: verified via a prior Akedly OTP or on the pipeline's allowlist.
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 the dashboard's API section.
- 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 recipient must be an Egyptian number.
- 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 and Egypt-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: WhatsApp when it's feasible for that recipient (WhatsApp-usable, Meta-approved template, an active WABA, and an allowed destination), otherwise SMS for Egyptian numbers. Your pipeline's channel order — WhatsApp-first by default — breaks the tie when both are feasible. This is decided up front; there is no retry on the other channel if a send fails. 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 for billing, but is not a hard cap.)
Channel resolution
The channel is decided before sending, and a send goes out on exactly one channel — there is no cross-channel retry.
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
Otherwise SMS is used for Egyptian numbers. When both channels are feasible, the pipeline's channel order decides (default: WhatsApp first). An international destination with no feasible WhatsApp path fails with utility_international_requires_whatsapp.
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. That is not yet a delivery confirmation: delivery settles asynchronously and 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. Code that treats every 2xx as "on its way" will silently record a message that never
went out. A skipped send still returns a transactionID, so it stays visible in Send History.
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:
Pendingon acceptance, thenSuccessfulon delivery orFailedif delivery fails.
- Name
channel- Type
- string
- Description
whatsapporsms.
- Name
provider- Type
- string
- Description
own-wabafor WhatsApp,cequensfor SMS.
- Name
wamid- Type
- string
- Description
WhatsApp only — Meta's message ID.
- Name
segments- Type
- number
- Description
SMS only — billed segment count for the rendered text.
- Name
encoding- Type
- string
- Description
SMS only —
GSM7orUCS2.
- Name
billed- Type
- boolean
- Description
truewhen billed at send time (SMS). 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": "Pending",
"channel": "sms",
"provider": "cequens",
"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 the optional Idempotency-Key header (any unique string, up to 255 characters) to make retries safe: if the send succeeded, retrying with the same key replays the original response instead of sending twice.
- Only successful (
2xx) responses are stored and replayed. Errors release the key, so a failed send stays retryable with the same key. - The replay window is 24 hours per key, scoped to your account.
- Reusing a key with a different body returns
422 IDEMPOTENCY_KEY_REUSE. - Retrying while the first request is still running returns
409 IDEMPOTENCY_IN_PROGRESS— back off briefly and retry.
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 |
| 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 | APIKey or pipelineID missing from the body. |
| 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. Check the pipeline's basic details. |
| 403 | PIPELINE_OWNERSHIP_MISMATCH | The pipeline belongs to another account. Use a pipeline 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. |
Recipient and eligibility errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | MISSING_PHONE | phone missing from the body. |
| 403 | RECIPIENT_NOT_ELIGIBLE | The recipient has neither eligibility proof — add them to the pipeline's allowlist or verify them with an OTP transaction first. Also what a malformed phone number surfaces as on this endpoint: a number that fails E.164 canonicalization can never match a proof. |
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. |
| 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 |
|---|---|---|
| 400 | utility_international_requires_whatsapp | International destination with no feasible WhatsApp path. Enable WhatsApp, use a Meta-approved template, and add the destination country to the pipeline's allowed list. |
| 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 | waba_token_error | Akedly could not read your WhatsApp credentials. Reconnect the WABA; contact support if it persists. |
| 400 | utility_international_sms_blocked | Defense-in-depth guard: SMS resolved for a non-Egyptian destination. Use WhatsApp for international recipients. |
| 400 | unsupported_channel | The SMS leg resolved to an unsupported provider. Contact support. |
Billing and delivery errors
| Status | Code | Cause and Solution |
|---|---|---|
| 402 | INSUFFICIENT_BALANCE | SMS leg only — account quota or balance exhausted. Top up in the dashboard. WhatsApp sends are not pre-checked; they bill on delivery. |
| 402 | SPEND_CAP_EXCEEDED | SMS leg only — the pipeline's daily spend cap is reached. Raise the cap or wait for the daily reset. The cap does not gate WhatsApp sends. |
| 503 | BILLING_UNAVAILABLE | SMS leg only — billing is temporarily unavailable. Retry shortly. |
| 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. |
| 422 | IDEMPOTENCY_KEY_REUSE | Same key, different body. Use a fresh key for a new request. |
| 409 | IDEMPOTENCY_IN_PROGRESS | The original request with this key is still running. Back off and retry. |