Skip to content
WhatsApp

API reference

WhatsApp

Send approved templates to up to 1,000 recipients, reply free-form inside a customer's 24-hour window, look up where each message is and list the templates you can send: POST /api/v1/json/whatsapp/template, /session and /status, GET /templates.

WhatsApp uses the same API key, base URL and response envelope as SMS and email, through four endpoints: send a template, send a session message, look up status and list your templates. A business refusal is HTTP 200 with success: false and a code in message; a malformed request is 400 with a sentence naming the field.

How WhatsApp differs from SMS

  • Two kinds of message. A template is a message Meta has approved in advance, with slots you fill per recipient; it can be sent to any opted-in customer at any time. A session message is free-form (text, media, reply buttons or a list, a location, contact cards or a reaction) and is only allowed inside the customer's 24-hour window, which opens each time the customer messages you.
  • The sender is a number. from is the WhatsApp number of one of your approved WhatsApp senders, not a brand name, and is optional when you have only one.
  • Recipients are judged one by one. A template send returns a result per recipient: a bad number or a missing parameter is rejected on its own and the rest go out.
  • Status goes further. Meta reports delivered and read, not only sent, and customers can write back. Both reach you as signed callbacks, and the status endpoint answers the same journey on demand.

Pricing by Meta category

Every message carries a category: MARKETING, UTILITY or AUTHENTICATION for a template (set by Meta when it approves the template), SERVICE for a session message. MARKETING and AUTHENTICATION templates are always charged. A UTILITY template is free when the recipient's 24-hour window is open at the time of sending, and a session message is always free. A message that fails is refunded. The WhatsApp bundle can hold fractions of a unit, so Balance can report a value such as 37.5.

Send a template

POST/api/v1/json/whatsapp/template

Send one approved template to up to 1,000 recipients, each with its own parameters. Requires an API key (see Authentication).

Parameters

templateRequiredstring
The name of a template that Meta has approved and that is active on your account. GET /api/v1/json/whatsapp/templates lists them with the parameters each one takes.
Example: order_ready
fromOptionalstring
The number of one of your approved WhatsApp senders, as the portal shows it or as digits only. Optional when your account has exactly one approved WhatsApp sender; with more than one, a request without it is refused with SENDER_REQUIRED.
Example: 260960123456
languageOptionalstring
The language the template was approved in. Optional when the name is approved in only one language; otherwise the request is refused with LANGUAGE_REQUIRED.
Example: en_US
callback_urlOptionalstring
HTTPS URL of at most 300 characters that receives a signed message.status callback for each message in this request. Without it, callbacks go to the URL configured in the portal under Developer > Webhooks. See Delivery webhooks.
Example: https://example.com/webhooks/unicom360/whatsapp
recipientsRequiredobject[]
1 to 1,000 recipients. Each is an object with to and, as the template requires, user_reference_id, body, header, buttons and otp. Recipients are accepted or rejected one by one; data.results says which.
Example: [{"to": "260971234567", "body": ["Ada", "ZM-4821"]}]
recipients[].toRequiredstring
The recipient's number in international format (260971234567). A number that does not parse is rejected on its own with INVALID_NUMBER; the other recipients go ahead.
Example: 260971234567
recipients[].user_reference_idOptionalstring
Your own reference for this message, 1 to 100 characters. A reference already used for the same number from the same sender is rejected with DUPLICATE_MSG_REF, and the result carries the message_id it originally went out as, which makes retries safe.
Example: inv-42
recipients[].bodyOptionalstring[] | object
The body variables: a list in the template's variable order, or an object keyed by variable name as the template list's body.variables gives them. Give exactly the variables the template has, each 1 to 1,024 characters; otherwise BODY_PARAMETERS_INVALID.
Example: ["Ada", "ZM-4821"]
recipients[].headerOptionalstring | object
For a text header with a variable, its value (at most 60 characters). For a media header, {"type": "image" | "video" | "document", "url": "https://...", "filename"?}: url must be an https URL Meta can fetch, and filename is used for documents only. A header the template has no slot for is rejected with HEADER_PARAMETER_INVALID.
Example: {"type": "image", "url": "https://example.com/receipt.png"}
recipients[].buttonsOptionalobject[]
Values for the template's buttons: [{"index": 0, "type": "url" | "quick_reply", "value": "..."}]. A url button whose URL holds a {{1}} placeholder needs its value; every other button is optional. Otherwise BUTTON_PARAMETERS_INVALID.
Example: [{"index": 0, "type": "url", "value": "ZM-4821"}]
recipients[].otpOptionalstring
The one-time code for an AUTHENTICATION template: 1 to 15 letters and digits. Missing on that category it is OTP_REQUIRED; given for any other category, or malformed, it is OTP_INVALID.
Example: 482193

Example request

curl -X POST https://api.unicom360.com/api/v1/json/whatsapp/template \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "template": "order_ready",
    "from": "260960123456",
    "language": "en_US",
    "callback_url": "https://example.com/webhooks/unicom360/whatsapp",
    "recipients": [
      {
        "to": "260971234567",
        "user_reference_id": "inv-42",
        "header": {
          "type": "image",
          "url": "https://example.com/receipts/ZM-4821.png"
        },
        "body": ["Ada", "ZM-4821"],
        "buttons": [
          {
            "index": 0,
            "type": "url",
            "value": "ZM-4821"
          }
        ]
      },
      {
        "to": "12",
        "user_reference_id": "inv-43",
        "body": ["Joseph", "ZM-4822"]
      }
    ]
  }'

Responses

200 accepted
{
  "api_reference": "1764184470578998353",
  "data": {
    "accepted": 1,
    "rejected": 1,
    "results": [
      {
        "category": "UTILITY",
        "message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f",
        "reason": null,
        "status": "ACKNOWLEDGED",
        "to": "260971234567",
        "user_reference_id": "inv-42"
      },
      {
        "category": "UTILITY",
        "message_id": null,
        "reason": "INVALID_NUMBER",
        "status": "REJECTED",
        "to": "12",
        "user_reference_id": "inv-43"
      }
    ]
  },
  "message": "successful",
  "success": true
}

One entry per recipient, in request order. ACKNOWLEDGED means the message is queued for Meta, not delivered; REJECTED names the reason, and nothing was queued for that recipient. Keep message_id: the status endpoint, the callbacks and the portal refer to it. category is the template's Meta category, which sets the price.

200 all recipients rejected
{
  "api_reference": "1764184470578998353",
  "data": {
    "accepted": 0,
    "rejected": 2,
    "results": [
      {
        "category": "UTILITY",
        "message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f",
        "reason": "DUPLICATE_MSG_REF",
        "status": "REJECTED",
        "to": "260971234567",
        "user_reference_id": "inv-42"
      },
      {
        "category": "UTILITY",
        "message_id": null,
        "reason": "BODY_PARAMETERS_INVALID",
        "status": "REJECTED",
        "to": "260961234567",
        "user_reference_id": "inv-44"
      }
    ]
  },
  "message": "NO_VALID_RECIPIENTS",
  "success": false
}

When no recipient is left to send to, success is false and message is NO_VALID_RECIPIENTS, but data still lists each recipient's reason. A DUPLICATE_MSG_REF entry carries the message_id the reference originally went out as.

200 business error
{
  "message": "TEMPLATE_NOT_APPROVED",
  "success": false,
  "api_reference": "1764184470578998353"
}

The sender, the template or your balance refused the whole request and nothing was queued. Codes: INVALID_SENDER_ID, SENDER_ID_NOT_ALLOWED, SENDER_REQUIRED, TEMPLATE_NOT_FOUND, TEMPLATE_NOT_APPROVED, LANGUAGE_REQUIRED, TEMPLATE_SENDER_MISMATCH, INSUFFICIENT_BUNDLE_LIMIT, NO_VALID_RECIPIENTS, SEND_FAILED.

400 validation error
{
  "message": "recipients must hold between 1 and 1000 entries",
  "success": false,
  "api_reference": "1764184470578998353"
}

The request's shape is wrong and nothing was queued. The message is a sentence naming the field: recipients must be a list, recipients must hold between 1 and 1000 entries, template is required, from must be a string, language must be a string, or callback_url must be an https URL of at most 300 characters.

401 authentication error
{
  "message": "AUTHENTICATION_FAILED",
  "success": false,
  "api_reference": "1764184470578998353"
}

See Authentication for every code. A credential over its per-minute limit gets 429 with RATE_LIMIT_EXCEEDED.

Notes

  • POST /api/v1/json/bulk/whatsapp is an alias of this endpoint: same request, same response.
  • The sender, the template and your balance are checked first and refuse the whole request. Only then is each recipient accepted or rejected on its own, so one bad number or missing parameter never blocks the others.
  • The balance check covers the recipients that passed, at the template's category. Units are deducted when the message is sent, and a message that fails is refunded.
  • Pricing follows Meta's category, returned on every result. MARKETING and AUTHENTICATION templates are always charged; a UTILITY template is free when the recipient's 24-hour window is open at the time of sending.

Send a session message

POST/api/v1/json/whatsapp/session

Send a free-form text, media, interactive, location, contact or reaction message to a customer whose 24-hour window is open. Requires an API key (see Authentication).

Parameters

toRequiredstring
The customer's number in international format. There must be an open 24-hour window with this number on the sender, which opens each time the customer messages you; otherwise the request is refused with SESSION_WINDOW_CLOSED.
Example: 260971234567
typeRequiredstring
One of text, image, document, audio, video, sticker, interactive, location, contacts, reaction. The field the content goes in is named after the type: text, media (for the five media types), interactive, location, contacts or reaction.
Example: text
textOptionalstring
Required when type is text: the message, 1 to 4,096 characters.
Example: Hi Ada, your order ZM-4821 is ready for collection.
mediaOptionalobject
Required when type is image, document, audio, video or sticker: {"url": "https://...", "caption"?, "filename"?}. url is an https URL Meta can fetch. caption is at most 1,024 characters and is not allowed on audio or sticker. filename is the name a document is shown with and is used for documents only.
Example: {"url": "https://example.com/invoice-4821.pdf", "filename": "invoice-4821.pdf"}
interactiveOptionalobject
Required when type is interactive: {"type": "button" | "list", "body", "header"?, "footer"?}. With button, add "buttons": 1 to 3 {"id", "title"} (titles up to 20 characters; ids and titles unique). With list, add "button" (the text that opens the list, up to 20 characters) and "sections": 1 to 10 {"title"?, "rows": [{"id", "title", "description"?}]}, at most 10 rows in total, row titles up to 24 characters, and a title on every section when there is more than one. body is up to 1,024 characters; header and footer up to 60.
Example: {"type": "button", "body": "Collect today or deliver?", "buttons": [{"id": "collect", "title": "Collect"}, {"id": "deliver", "title": "Deliver"}]}
locationOptionalobject
Required when type is location: {"latitude", "longitude", "name"?, "address"?}. Coordinates are numbers, latitude between -90 and 90 and longitude between -180 and 180.
Example: {"latitude": -15.4167, "longitude": 28.2833, "name": "Cairo Road branch"}
contactsOptionalobject[]
Required when type is contacts: 1 to 10 contact cards in Meta's contact format, each with name.formatted_name and at least one entry in phones ({"phone", "type"?, "wa_id"?}). emails, urls, addresses, org and birthday are kept when given; fields Meta does not know are dropped.
Example: [{"name": {"formatted_name": "Unicom360 Support"}, "phones": [{"phone": "+260960123456"}]}]
reactionOptionalobject
Required when type is reaction: {"message_id", "emoji"}. message_id is a message_id this API returned (already sent) or the WhatsApp id of a message the customer sent; anything else is refused with REACTION_TARGET_NOT_FOUND. emoji is one emoji, or an empty string to take a reaction back. reply_to is not allowed on a reaction.
Example: {"message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f", "emoji": "👍"}
fromOptionalstring
The number of one of your approved WhatsApp senders, as the portal shows it or as digits only. Optional when your account has exactly one approved WhatsApp sender; with more than one, a request without it is refused with SENDER_REQUIRED.
Example: 260960123456
reply_toOptionalstring
Quote an earlier message: a message_id this API returned (the message must already have been sent), or the WhatsApp id (wamid. followed by Meta's id) of a message the customer sent, as a message.received callback delivers it. Anything else is refused with REPLY_TO_NOT_FOUND. Not allowed on a reaction.
Example: wamid.HBgMMjYwOTcxMjM0NTY3FQIAEhggQTVCMEM3Qjc5OUZCOEU5NEM3REMxMjI5NzJBODJEQTVEAA==
user_reference_idOptionalstring
Your own reference, 1 to 100 characters. A reference already used for the same number from the same sender is refused with DUPLICATE_MSG_REF, and data.message_id names the message it went out as.
Example: s-1
callback_urlOptionalstring
HTTPS URL of at most 300 characters that receives a signed message.status callback for each message in this request. Without it, callbacks go to the URL configured in the portal under Developer > Webhooks. See Delivery webhooks.
Example: https://example.com/webhooks/unicom360/whatsapp

Example request

curl -X POST https://api.unicom360.com/api/v1/json/whatsapp/session \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "260971234567",
    "type": "text",
    "text": "Hi Ada, your order ZM-4821 is ready for collection at the Cairo Road branch.",
    "from": "260960123456",
    "reply_to": "wamid.HBgMMjYwOTcxMjM0NTY3FQIAEhggQTVCMEM3Qjc5OUZCOEU5NEM3REMxMjI5NzJBODJEQTVEAA==",
    "user_reference_id": "s-1",
    "callback_url": "https://example.com/webhooks/unicom360/whatsapp"
  }'

Responses

200 accepted
{
  "api_reference": "1764184470578998353",
  "data": {
    "category": "SERVICE",
    "message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f",
    "status": "ACKNOWLEDGED",
    "to": "260971234567",
    "user_reference_id": "s-1"
  },
  "message": "successful",
  "success": true
}

The message is queued for Meta. category is always SERVICE: a session message costs nothing.

200 window closed
{
  "message": "SESSION_WINDOW_CLOSED",
  "success": false,
  "api_reference": "1764184470578998353"
}

No open window with that number on the sender: send an approved template instead. Other codes: INVALID_SENDER_ID, SENDER_ID_NOT_ALLOWED, SENDER_REQUIRED, INVALID_NUMBER, INVALID_USER_REFERENCE_ID, MEDIA_URL_INVALID, REPLY_TO_NOT_FOUND, REACTION_TARGET_NOT_FOUND, SEND_FAILED.

200 duplicate reference
{
  "api_reference": "1764184470578998353",
  "data": {
    "message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f"
  },
  "message": "DUPLICATE_MSG_REF",
  "success": false
}

user_reference_id was already used for this number from this sender; data.message_id is the message it went out as. Nothing was sent again.

400 validation error
{
  "message": "text must hold 1 to 4096 characters",
  "success": false,
  "api_reference": "1764184470578998353"
}

The request's shape is wrong and nothing was queued. The message is a sentence naming the field: to is required, type must be one of text, image, document, audio, video, sticker, interactive, location, contacts, reaction, text is required for a text message, text must hold 1 to 4096 characters, media.url is required when type is <type>, media.caption is not allowed on <type>, media.caption must be a string, media.caption must hold at most 1024 characters, media.filename must be a string, from must be a string, reply_to must be a string, reply_to is not allowed on a reaction, callback_url must be an https URL of at most 300 characters, or, for a structured type, one that names the field and its limit (interactive.body must hold 1 to 1024 characters, location.latitude must be a number between -90 and 90, contacts[].phones must hold at least one phone). The Errors page lists them.

Notes

  • The 24-hour window is per customer number and sender. It opens when the customer messages that number and runs 24 hours from their latest message; each message.received callback carries window_expires_at, so you know how long you have. Outside it, only an approved template can be sent.
  • A session message is free whatever its type: it is Meta's SERVICE category and nothing is deducted from your bundle.
  • Media is fetched by Meta from media.url, which must therefore be a public https URL. The caption is what the customer reads under the image, video or document.
  • The structured types are sent to Meta as given: interactive shows reply buttons or a list the customer taps (their choice reaches you as a message.received callback with the chosen title in text and payload null; the id you gave the button or row is not passed on, so tell choices apart by title), location drops a pin, contacts shares cards, and reaction puts an emoji on one message, yours or theirs.

Message status

POST/api/v1/json/whatsapp/status

Where up to 100 of your WhatsApp messages are, by message_id. Requires an API key (see Authentication).

Parameters

message_idsRequiredstring[]
1 to 100 message_id values returned by the template or session endpoint. Each must be a non-empty string; a repeated id is answered once.
Example: ["7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f"]

Example request

curl -X POST https://api.unicom360.com/api/v1/json/whatsapp/status \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message_ids": ["7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f", "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e60", "00000000-0000-4000-8000-000000000000"]
  }'

Responses

200 statuses
{
  "api_reference": "1764184470578998353",
  "data": {
    "results": [
      {
        "billable": true,
        "category": "UTILITY",
        "delivered_at": "2026-10-24T08:16:02Z",
        "error": null,
        "from": "260960123456",
        "message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f",
        "read_at": "2026-10-24T08:20:45Z",
        "refunded": false,
        "sent_at": "2026-10-24T08:15:31Z",
        "status": "READ",
        "to": "260971234567",
        "user_reference_id": "inv-42"
      },
      {
        "billable": true,
        "category": "MARKETING",
        "delivered_at": null,
        "error": {
          "code": 131026,
          "message": "Message undeliverable"
        },
        "from": "260960123456",
        "message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e60",
        "read_at": null,
        "refunded": true,
        "sent_at": "2026-10-24T08:15:31Z",
        "status": "FAILED",
        "to": "260961234567",
        "user_reference_id": null
      },
      {
        "message_id": "00000000-0000-4000-8000-000000000000",
        "status": "NOT_FOUND"
      }
    ]
  },
  "message": "successful",
  "success": true
}

One entry per id. Timestamps are ISO 8601 in UTC and null until that stage is reached. error is null except on FAILED and REJECTED, where code is Meta's error code when Meta reported one. billable says whether the message was charged when it was sent and does not change afterwards; refunded says whether that charge was given back, as it is for every FAILED message and for one Meta reports as free.

400 validation error
{
  "message": "message_ids must hold between 1 and 100 entries",
  "success": false,
  "api_reference": "1764184470578998353"
}

Also: message_ids must be a list, and message_ids must be strings.

Notes

  • An id that belongs to another account, or that never existed, answers NOT_FOUND rather than an error. Messages archived after they settled are still found.
  • The message.status callback carries the same journey as it happens. Poll this endpoint to reconcile; use callbacks for real time.

Status values

  • ACCEPTED

    Meaning
    Queued, or being handed to Meta.
  • SENT

    Meaning
    Meta accepted the message.
  • DELIVERED

    Meaning
    Meta reported it delivered to the handset.
  • READ

    Meaning
    Meta reported it read.
  • FAILED

    Meaning
    It could not be sent or delivered. error says why, and any charge was refunded.
  • REJECTED

    Meaning
    Never sent: your WhatsApp balance did not cover it when it came to be sent. error says so.
  • NOT_FOUND

    Meaning
    No message of yours has this id.

List your templates

GET/api/v1/json/whatsapp/templates

The templates you can send now, with the parameters each one takes. Requires an API key (see Authentication).

Example request

curl -X GET https://api.unicom360.com/api/v1/json/whatsapp/templates \
  -H "X-API-Key: YOUR_API_KEY"

Responses

200 templates
{
  "api_reference": "1764184470578998353",
  "data": {
    "templates": [
      {
        "authentication": null,
        "body": {
          "text": "Hi {{1}}, your order {{2}} is ready for collection.",
          "variables": ["1", "2"]
        },
        "buttons": [
          {
            "index": 0,
            "text": "Track order",
            "type": "url",
            "url": "https://example.com/orders/{{1}}"
          }
        ],
        "category": "UTILITY",
        "footer": "Reply STOP to opt out",
        "from": "260960123456",
        "header": {
          "type": "image",
          "variables": []
        },
        "language": "en_US",
        "name": "order_ready"
      },
      {
        "authentication": {
          "code_expiration_minutes": 10,
          "otp_type": "COPY_CODE"
        },
        "body": {
          "text": "{{1}} is your verification code.",
          "variables": []
        },
        "buttons": [
          {
            "index": 0,
            "otp_type": "COPY_CODE",
            "type": "otp"
          }
        ],
        "category": "AUTHENTICATION",
        "footer": null,
        "from": "260960123456",
        "header": null,
        "language": "en",
        "name": "login_code"
      }
    ]
  },
  "message": "successful",
  "success": true
}

Only templates that Meta has APPROVED and that are active on your account are listed; a template missing here is refused with TEMPLATE_NOT_FOUND or TEMPLATE_NOT_APPROVED. category is what Meta currently bills the template as, which can differ from the category it was submitted under.

Notes

  • header is null when the template has no header or a fixed text header; {type: text, variables} when the header text has a variable; {type: image | video | document, variables: []} when it takes media. Send it as recipients[].header.
  • body.variables are the names to give in recipients[].body, in order. An empty list means the body takes no parameters; an AUTHENTICATION template's body is filled from otp instead.
  • buttons: a url button whose url contains {{1}} needs a value in recipients[].buttons; phone_number and quick_reply buttons need nothing; the otp button on an AUTHENTICATION template is filled from recipients[].otp.
  • from is the number the template was created on. Any of your senders on the same WhatsApp Business Account can send it; a sender on another account is refused with TEMPLATE_SENDER_MISMATCH.

Callbacks

Each status change (sent, delivered, read, failed) is POSTed as a message.status event, and each message a customer sends you as message.received, to the callback_url you gave with the send or to the URL configured in the portal under Developer, Webhooks. Callbacks are signed and retried. The payloads, headers, verification and the retry schedule are on Delivery webhooks.