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.


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.bodyHash using your request-signing secret, where bodyHash is 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). 00 prefixes and Egyptian local 01… 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

POST
https://api.akedly.io/api/v1/utilities/send
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

JSON
both modes
{
  "APIKey": "<AKEDLY_API_KEY>",
  "pipelineID": "<YOUR_PIPELINE_ID>",
  "templateId": "<TEMPLATE_ID>",
  "variableValues": {
    "orderNumber": "1042"
  },
  "phone": "+201234567890",
  "customerUserId": "user_123"
}

Two body modes

  • Template modetemplateId + variableValues. Works on WhatsApp and SMS; the only mode that can reach WhatsApp.
  • Free-text modetext instead 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.


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.

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:

  1. WhatsApp is enabled on the pipeline
  2. The send is template mode and the template is WhatsApp-usable and Meta-approved
  3. Your WhatsApp Business Account is connected and active
  4. 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.

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: Pending on acceptance, then Successful on delivery or Failed if delivery fails.

  • Name
    channel
    Type
    string
    Description

    whatsapp or sms.

  • Name
    provider
    Type
    string
    Description

    own-waba for WhatsApp, cequens for 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 — GSM7 or UCS2.

  • Name
    billed
    Type
    boolean
    Description

    true when billed at send time (SMS). WhatsApp returns false with billing.status: "deferred" — it bills on the delivery outcome.

  • Name
    customerUserId
    Type
    string
    Description

    Echo of the value you sent, or null.

Response

JSON
200 WhatsApp accepted
{
  "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

JSON
200 SMS accepted
{
  "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

JSON
200 recipient opted out
{
  "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

LimitValue
Signature timestamp skew±5 minutes
NonceSingle use per pipeline
Idempotency replay window24 hours
Idempotency-Key length255 characters
Rate limitsPer pipeline config (per phone and per pipeline)

Authentication and pipeline errors

StatusCodeCause and Solution
400MISSING_REQUIRED_FIELDSAPIKey or pipelineID missing from the body.
401INVALID_API_KEYAPI key does not match any account. Verify it in the dashboard.
404PIPELINE_NOT_FOUNDNo pipeline with that ID. Check the pipeline's basic details.
403PIPELINE_OWNERSHIP_MISMATCHThe pipeline belongs to another account. Use a pipeline from the same account as the API key.
403NOT_A_UTILITY_PIPELINEThe pipeline is an OTP pipeline. Create a utility pipeline and use its ID.
403PIPELINE_INACTIVEThe pipeline is switched off. Activate it in the dashboard.

Request signing errors

StatusCodeCause and Solution
400SIGNING_NOT_CONFIGUREDSigning is enabled but the pipeline has no signing secret yet. Reveal one in the pipeline's settings.
401MISSING_SIGNATUREOne of the three signing headers is absent. Send x-akedly-timestamp, x-akedly-nonce, and x-akedly-signature.
401SIGNATURE_TIMESTAMP_SKEWTimestamp outside the ±5-minute window. Sync your server clock and send milliseconds, not seconds.
401INVALID_SIGNATURESignature verification failed — usually the body bytes were re-serialized after signing, or the wrong secret was used.
401SIGNATURE_REPLAYThe nonce was already used on this pipeline. Generate a fresh nonce per request.

Recipient and eligibility errors

StatusCodeCause and Solution
400MISSING_PHONEphone missing from the body.
403RECIPIENT_NOT_ELIGIBLEThe 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

StatusCodeCause and Solution
400MISSING_TEXTThe body has neither templateId nor text. Provide one.
404TEMPLATE_NOT_FOUNDNo template with that ID on your account.
409TEMPLATE_NOT_APPROVEDThe template is not Akedly-approved yet. Submit it and wait for approval.
409TEMPLATE_NOT_SMSThe send resolved to SMS but the template is not usable on SMS.
409TEMPLATE_NOT_WHATSAPPThe send resolved to WhatsApp but the template is not enabled for WhatsApp. Enable WhatsApp on the template, or send it on SMS.
409TEMPLATE_NOT_WHATSAPP_APPROVEDThe template is enabled for WhatsApp but still awaiting Meta approval, so it can't send on WhatsApp yet.
400TEMPLATE_MISSING_VARIABLESMS render failed: a {{variable}} has no value and no fallback. The missing array lists the names.
400TEMPLATE_VAR_MISSINGWhatsApp render failed: a template variable has no value and no fallback. The variable field names it.

Channel errors

StatusCodeCause and Solution
400utility_international_requires_whatsappInternational destination with no feasible WhatsApp path. Enable WhatsApp, use a Meta-approved template, and add the destination country to the pipeline's allowed list.
409FREE_TEXT_REQUIRES_SMSA free-text (text) send needs SMS, which is off on this pipeline. Enable SMS, or send an approved WhatsApp template instead.
409NO_CHANNEL_ENABLEDThe pipeline has neither WhatsApp nor SMS enabled. Turn on at least one channel.
403DESTINATION_NOT_ALLOWEDWhatsApp isn't enabled for this destination country on the pipeline. Add the country to the pipeline's allowed destinations.
409WHATSAPP_NOT_CONNECTEDThe send resolved to WhatsApp but no active WABA is connected. Connect your WhatsApp Business Account.
409no_active_wabaNo active WhatsApp Business Account connected. Connect one in the dashboard.
409no_waba_phoneThe connected WABA has no active sender number.
500waba_token_errorAkedly could not read your WhatsApp credentials. Reconnect the WABA; contact support if it persists.
400utility_international_sms_blockedDefense-in-depth guard: SMS resolved for a non-Egyptian destination. Use WhatsApp for international recipients.
400unsupported_channelThe SMS leg resolved to an unsupported provider. Contact support.

Billing and delivery errors

StatusCodeCause and Solution
402INSUFFICIENT_BALANCESMS leg only — account quota or balance exhausted. Top up in the dashboard. WhatsApp sends are not pre-checked; they bill on delivery.
402SPEND_CAP_EXCEEDEDSMS 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.
503BILLING_UNAVAILABLESMS leg only — billing is temporarily unavailable. Retry shortly.
502send_failedThe 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

StatusCodeCause and Solution
400INVALID_IDEMPOTENCY_KEYThe key is not a string or exceeds 255 characters.
422IDEMPOTENCY_KEY_REUSESame key, different body. Use a fresh key for a new request.
409IDEMPOTENCY_IN_PROGRESSThe original request with this key is still running. Back off and retry.

Was this page helpful?