Documentation

WhatsApp API

Create your WhatsApp templates, send them once approved from your software and track their delivery with the same API key as your SMS. The API requires an active Coussema WhatsApp subscription and your own WhatsApp Business number; Meta charges messages to the payment method of your WhatsApp Business account.

Before the first send

  • An active Coussema WhatsApp subscription (monthly or trial). Without one, sends are refused: 402 Payment Required, code whatsapp_subscription_inactive (WA402).
  • Your WhatsApp Business number, connected by you from the Coussema WhatsApp space. Each organization sends from its own numbers.
  • A valid payment method on your WhatsApp Business account: Meta charges you directly for messages, Coussema charges the subscription.
  • An API key with the whatsapp:read and whatsapp:send scopes, created in the Developers space. These scopes are granted only to an organization with a connected WhatsApp number.
  • A template approved by Meta, in the utility (notification), marketing (promotion) or authentication (verification code) category. Create it from the WhatsApp space or with POST /v1/whatsapp/templates (see “Create a template”).
  • A recipient who has not stopped your messages. Coussema asks for no recorded consent for a send to one person: as WhatsApp’s rules provide, you remain responsible for having their consent. A recipient who replied STOP, or whose consent was withdrawn for the template category, is refused (400, whatsapp_opt_in_required). You can record a consent with POST /v1/whatsapp/opt-ins (number, categories, evidence) and a withdrawal with POST /v1/whatsapp/opt-ins/opt-out; START opts them back in for utility templates.

Create a template

POST /v1/whatsapp/templates submits your template straight to Meta, on the WhatsApp Business account of your number. Nothing goes through the Coussema team: Meta reviews it, often within minutes, sometimes up to 24 hours.

Write the text in message with {{1}}, {{2}}… for the parts that change with each send, in order, and give a realistic example of each in examples, in the same order. The message neither starts nor ends with a personalized part, and two parts are separated by at least one word. When sending, you fill these parts in template.parameters, in the same order.

At the top, either title (one line, 60 characters) or media (a JPEG or PNG image up to 5 MB, an MP4 video or a PDF document up to 16 MB): { "type": "image", "url": "https://…" }. The public https link is Meta’s example, and Coussema keeps a copy of the file, sent with every message unless the send gives another link in template.media.url.

footer (a note at the bottom, 60 characters) and buttons are optional. Buttons: quick_reply, website (link, two at most) and call (one only), 25 characters of text each, ten at most. A personalized link ends with {{1}} (https://example.com/orders/{{1}}) with an example of its end (4471): when sending, give the end of each personalized link in template.urlParameters, in button order.

category is utility (notification: invoice, delivery, appointment, tracking), marketing (promotion: offers, news, follow-ups) or authentication (verification code). Meta classifies as promotion any template that mixes a notification and an offer. A promotion needs the recipient’s consent for the marketing category. For a code, WhatsApp writes the text itself: you only choose verificationCode (security notice, validity from 1 to 90 minutes, button text). When sending, the code goes in template.parameters; Coussema also fills the “Copy code” button.

The name + language pair identifies the template: name in lowercase letters, digits and _, language in Meta’s format (fr, en, sw, pt_PT, pt_BR, ar…). A second request for a template in review or approved is refused (409); for a rejected template, it sends the corrected version back for review under the same name.

201 returns Meta’s review state, usually pending. 202 means Meta’s answer is being confirmed (draft): read the template again with GET /v1/whatsapp/templates/{templateId}, or send the same request again, with no risk of a duplicate. The status then becomes approved, rejected (with rejectionReason) or paused. Meta may also move a template to another category: the category returned is then Meta’s.

Three promotion formats (category: "marketing") besides the standard template: a { "type": "copy_code", "example": "WINTER25" } button (a code to copy, 20 characters, one per template), a limited-time offer offer: { "text": "Flash offer", "expires": true } (a 16-character headline, an image or video at the top, no footer, a 600-character message, copy_code and website buttons only) and a cards carousel (2 to 10 cards: one image or video each, all of the same type, a 160-character message on all or none, up to 2 buttons that are the same from one card to the next). Coussema keeps the card files like the one at the top.

When sending: template.couponCode (the code to copy), template.offerExpiresAt (the end of the offer, ISO 8601, when it is shown) and template.cards in card order (parameters, urlParameters, and media.url to replace the kept file). A campaign gives the same code and end to all its recipients; its cards cannot have personalized parts.

If your numbers are on several WhatsApp Business accounts, set phoneNumberId (the Coussema identifier of the number) to choose the account.

bash
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "invoice_ready",
    "language": "fr",
    "category": "utility",
    "title": "Votre facture",
    "message": "Bonjour {{1}}, votre facture {{2}} est disponible.",
    "examples": ["Mireille", "F-2026-114"],
    "footer": "Atlas Logistics",
    "buttons": [
      { "type": "website", "text": "Voir la facture", "url": "https://example.com/invoices" },
      { "type": "quick_reply", "text": "Merci" }
    ]
  }'

201 Created
{
  "template": {
    "id": "6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10",
    "name": "invoice_ready",
    "language": "fr",
    "category": "utility",
    "status": "pending",
    "rejectionReason": null,
    "createdAt": "2026-10-25T10:00:00.000Z",
    "updatedAt": "2026-10-25T10:00:00.000Z"
  }
}
bash
# A promotion with an image at the top and a personalized link
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "weekend_offer",
    "language": "fr",
    "category": "marketing",
    "media": { "type": "image", "url": "https://example.com/images/collection.jpg" },
    "message": "Bonjour {{1}}, -20 % ce week-end sur la nouvelle collection.",
    "examples": ["Mireille"],
    "buttons": [
      { "type": "website", "text": "Voir l’offre", "url": "https://example.com/offers/{{1}}", "example": "red-dress" },
      { "type": "quick_reply", "text": "Plus de promotions" }
    ]
  }'

# Once approved, send it: the kept image goes with it, the link ends with each customer's value
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: weekend-offer-243810000000' \
  -d '{
    "type": "template",
    "to": "+243810000000",
    "phoneNumberId": "11111111-1111-4111-8111-111111111111",
    "template": {
      "name": "weekend_offer",
      "language": "fr",
      "category": "marketing",
      "parameters": ["Mireille"],
      "urlParameters": ["red-dress"]
    }
  }'
bash
# A limited-time offer with a code to copy
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "flash_sale",
    "language": "fr",
    "category": "marketing",
    "media": { "type": "image", "url": "https://example.com/images/flash.jpg" },
    "offer": { "text": "Offre flash", "expires": true },
    "message": "Bonjour {{1}}, -20 % sur tout jusqu’à ce soir.",
    "examples": ["Mireille"],
    "buttons": [
      { "type": "copy_code", "example": "FLASH20" },
      { "type": "website", "text": "J’en profite", "url": "https://example.com/flash" }
    ]
  }'

# Its send gives the code and the end of the offer
#   "template": { "name": "flash_sale", "language": "fr", "category": "marketing",
#                 "parameters": ["Mireille"], "couponCode": "FLASH20",
#                 "offerExpiresAt": "2026-11-28T23:59:00+01:00" }

# A carousel: the message, then 2 to 10 cards of the same shape
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "new_collection",
    "language": "fr",
    "category": "marketing",
    "message": "La nouvelle collection est arrivée.",
    "cards": [
      { "media": { "type": "image", "url": "https://example.com/images/red.jpg" },
        "message": "Robe rouge, -20 %",
        "buttons": [{ "type": "website", "text": "Voir", "url": "https://example.com/red" }] },
      { "media": { "type": "image", "url": "https://example.com/images/blue.jpg" },
        "message": "Robe bleue, -20 %",
        "buttons": [{ "type": "website", "text": "Voir", "url": "https://example.com/blue" }] }
    ]
  }'
bash
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "login_code",
    "language": "fr",
    "category": "authentication",
    "verificationCode": { "securityNotice": true, "expiresInMinutes": 10 }
  }'

# Read Meta's review again:
curl https://api.coussema.com/v1/whatsapp/templates/6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10 \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx'

Send a template

POST /v1/whatsapp/messages is asynchronous. 202 Accepted means Coussema validated the request, stored the message and queued it; it is handed to Meta afterwards. Keep messageId: provider.messageId is always null in this response.

phoneNumberId is the Coussema identifier of your number, read with GET /v1/whatsapp/phone-numbers, not the Meta identifier.

Send one unique Idempotency-Key per message. Replaying the same key with the same content returns the same message instead of creating a new one; the same key with different content is refused.

A free-form reply (text, file, buttons, list or place, with conversationId) is accepted only in a conversation your customer opened, during the 24-hour customer service window: see “Reply within the 24-hour window”.

bash
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: invoice-12345-wa' \
  -d '{
    "type": "template",
    "to": "+243810000000",
    "phoneNumberId": "11111111-1111-4111-8111-111111111111",
    "template": {
      "name": "payment_notice",
      "language": "fr",
      "category": "utility",
      "parameters": ["12345"]
    },
    "clientReference": "invoice-12345"
  }'

202 Accepted
{
  "success": true,
  "queued": true,
  "status": "accepted",
  "messageId": "a2f7e5cb-bbe9-4d7d-bd17-80d1d7a5e5af",
  "provider": { "code": "whatsapp", "messageId": null, "httpStatus": 202 }
}
javascript
const apiKey = process.env.COUSSEMA_API_KEY ?? '';
const response = await fetch('https://api.coussema.com/v1/whatsapp/messages', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'invoice-12345-wa',
  },
  body: JSON.stringify({
    type: 'template',
    to: '+243810000000',
    phoneNumberId: '11111111-1111-4111-8111-111111111111',
    template: { name: 'payment_notice', language: 'fr', category: 'utility', parameters: ['12345'] },
    clientReference: 'invoice-12345',
  }),
});

const body = await response.json();
if (response.status === 402) throw new Error(body.error); // WhatsApp subscription inactive
if (!response.ok) throw new Error(`WhatsApp request failed: ${response.status}`);
console.log(body.messageId);

Reply within the 24-hour window

When your customer writes to you, you can reply freely for 24 hours, without a template. Give the conversationId of their conversation (GET /v1/whatsapp/conversations or the whatsapp.message.received webhook) with the same to and phoneNumberId. After 24 hours the reply is refused (400, whatsapp_service_window_closed): send an approved template.

type: "text": a text of up to 4,096 characters (text).

type: "image", "video", "document" or "audio": the file by a public https link in media.url, which Meta downloads at send time (JPEG or PNG image up to 5 MB, MP4 video and audio up to 16 MB, document up to 100 MB per Meta). media.caption (1,024 characters) for an image, video or document; media.filename for the document’s name. A link Meta cannot read makes the message failed.

type: "interactive": up to 3 reply buttons (interactive.type: "button", bodyText, buttons with an id and a 20-character title) or a list (interactive.type: "list", buttonText, sections and rows, 10 rows at most, a title on each section when there are several). footerText is optional. The customer’s answer arrives with the whatsapp.message.received webhook.

type: "location": a place on the map, location.latitude and location.longitude, with optional name and address.

These replies are asynchronous like a template (202, messageId), idempotent by Idempotency-Key and need the whatsapp:send scope. GET /v1/whatsapp/messages/{messageId} returns their messageType: text, image, video, document, audio, interactive or location.

bash
# Inside the 24-hour window of a conversation your customer opened:
# the same route, the same Idempotency-Key rule, a 202 with the Coussema message id.

# A document by its public https link (image and video: the same, with a caption)
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: facture-2231-pdf' \
  -d '{
    "type": "document",
    "to": "+243810000000",
    "phoneNumberId": "11111111-1111-4111-8111-111111111111",
    "conversationId": "22222222-2222-4222-8222-222222222222",
    "media": {
      "url": "https://example.com/factures/F-2231.pdf",
      "filename": "Facture-F-2231.pdf",
      "caption": "Votre facture"
    }
  }'

# Up to 3 reply buttons
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: rdv-5512-confirm' \
  -d '{
    "type": "interactive",
    "to": "+243810000000",
    "phoneNumberId": "11111111-1111-4111-8111-111111111111",
    "conversationId": "22222222-2222-4222-8222-222222222222",
    "interactive": {
      "type": "button",
      "bodyText": "Confirmez-vous le rendez-vous de demain à 10 h ?",
      "buttons": [{ "id": "confirm", "title": "Je confirme" }, { "id": "move", "title": "Changer" }]
    }
  }'

# A list (up to 10 rows)
#   "interactive": { "type": "list", "bodyText": "Choisissez un créneau.", "buttonText": "Créneaux",
#                    "sections": [{ "title": "Lundi", "rows": [{ "id": "mon-9", "title": "9 h", "description": "Gombe" }] }] }

# A place
#   "type": "location",
#   "location": { "latitude": -4.3217, "longitude": 15.3125, "name": "Agence Gombe", "address": "Boulevard du 30 Juin" }
javascript
const apiKey = process.env.COUSSEMA_API_KEY ?? '';
// conversationId: from GET /v1/whatsapp/conversations or the whatsapp.message.received webhook.
async function reply(message, idempotencyKey) {
  const response = await fetch('https://api.coussema.com/v1/whatsapp/messages', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({
      to: '+243810000000',
      phoneNumberId: '11111111-1111-4111-8111-111111111111',
      conversationId: '22222222-2222-4222-8222-222222222222',
      ...message,
    }),
  });
  const body = await response.json();
  if (!response.ok) throw new Error(`${response.status} ${body.code ?? ''}`); // whatsapp_service_window_closed: send a template
  return body.messageId;
}

await reply({ type: 'image', media: { url: 'https://example.com/colis.jpg', caption: 'Votre colis est parti' } }, 'colis-4471-photo');
await reply({ type: 'location', location: { latitude: -4.3217, longitude: 15.3125, name: 'Agence Gombe' } }, 'colis-4471-agence');

Multichannel campaigns by API

POST /v1/whatsapp/campaigns creates from your software the same campaign as the dashboard: one approved template, up to 2,000 numbers per request (split a larger list), sent now or at scheduledAt, spread with spreadMinutes if needed. Each personalized value is at most 200 characters; a request body is at most 5 MB.

Each number gives its values in the template’s order: the message’s {{1}}, {{2}}…, then the end of each personalized link. defaults gives one text per part, used when a value is empty; it is required as soon as the template has personalized parts.

Consent follows the dashboard rules: a number without a recorded opt-in for the template’s category is left out (missing_opt_in). With declaredConsent: true, you declare these numbers agreed; it is recorded as their opt-in, with your key as author, before the creation.

SMS relay (smsRelay): the numbers Meta reports without WhatsApp get one SMS, once per number, from an approved Sender ID of your account. Without smsTemplateId, the SMS carries the WhatsApp template’s text; with an SMS template, its fields take the WhatsApp values of the same name, and a field the WhatsApp template does not have takes its value from smsRelay.fieldValues (the same for the whole campaign, 60 characters, 10 fields at most). A carousel needs an SMS template. The SMS are taken from your SMS balance; WhatsApp messages stay billed by Meta on your card. The key also needs the sms:send scope.

dryRun: true checks everything (template, values, date, relay) and returns the accepted and left-out numbers and the most SMS the relay may send, without creating anything: use it to validate a list before sending. A creation needs an Idempotency-Key (or clientReference): the same key returns the same campaign, never a second one.

Follow the campaign with GET /v1/whatsapp/campaigns/{campaignId} (send, read and SMS relay counts) and the WhatsApp and SMS message webhooks.

bash
curl -X POST https://api.coussema.com/v1/whatsapp/campaigns \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: rappels-2026-10-29' \
  -d '{
    "name": "Rappel fin d’abonnement",
    "templateId": "6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10",
    "recipients": [
      { "phone": "+243810000000", "values": ["Mireille", "31/10/2026"] },
      { "phone": "+243970000000", "values": ["Jean", "02/11/2026"] }
    ],
    "defaults": ["cher client", "bientôt"],
    "smsRelay": {
      "senderId": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
      "smsTemplateId": "7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918",
      "fieldValues": { "Bouquet": "Famille" }
    }
  }'

202 Accepted
{
  "dryRun": false,
  "campaign": {
    "id": "d4c3b2a1-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
    "name": "Rappel fin d’abonnement",
    "status": "scheduled",
    "scheduledAt": "2026-10-29T10:00:00.000Z",
    "createdAt": "2026-10-29T10:00:00.000Z"
  },
  "recipients": { "requested": 2, "accepted": 2, "excluded": 0, "excludedRecipients": [] },
  "smsRelay": { "senderName": "MONENTREPRISE", "smsTemplateName": "rappel_sms", "maxSmsUnits": null }
}

# Follow it (the counts of the SMS relay included):
curl https://api.coussema.com/v1/whatsapp/campaigns/d4c3b2a1-0f9e-4d8c-b7a6-5f4e3d2c1b0a \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx'
javascript
const apiKey = process.env.COUSSEMA_API_KEY ?? '';
const campaign = {
  name: 'Rappel fin d’abonnement',
  templateId: '6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10',
  recipients: subscribers.map((s) => ({ phone: s.phone, values: [s.firstName, s.endDate] })),
  defaults: ['cher client', 'bientôt'],
  smsRelay: { senderId: '9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f' },
};
const call = (body, idempotencyKey) => fetch('https://api.coussema.com/v1/whatsapp/campaigns', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
    ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
  },
  body: JSON.stringify(body),
});

// 1. Check: counts and the most SMS the relay may send, nothing created.
const check = await (await call({ ...campaign, dryRun: true })).json();
console.log(check.recipients.accepted, check.smsRelay?.maxSmsUnits);

// 2. Create: the same key never creates a second campaign.
const response = await call(campaign, 'rappels-2026-10-29');
if (!response.ok) throw new Error(`WhatsApp campaign failed: ${response.status}`);
const { campaign: created } = await response.json();
console.log(created.id);

Multichannel by API

For a code or a notification, WhatsApp first costs less than an SMS, and Meta reliably says when a number has no WhatsApp. Two options use it, with the same engine: the SMS leaves only if Meta reports the number is not on WhatsApp (error 131026), once per message, from an approved Sender ID of your account, in at most 4 SMS.

WhatsApp message with an SMS fallback: add smsFallback to a template send on POST /v1/whatsapp/messages. With nothing else, the SMS carries the WhatsApp template’s text with its values (for a code, « 482913 est votre code de vérification. », or « 482913 is your verification code. » for an English template); smsTemplateId sends one of your SMS templates, text a text of your own. A [Field] takes the value of the WhatsApp field of the same name, else its fieldValues entry. The key also needs the sms:send scope, and the request an Idempotency-Key (or clientReference).

SMS « WhatsApp first »: add whatsappFirst (phoneNumberId, templateId, fieldValues by field name or place) to POST /v1/sms/send. The approved WhatsApp template goes first; your SMS leaves, as it is and from the same sender, only if the number has no WhatsApp. The 202 response (channel: "whatsapp_first") gives whatsapp.messageId; sms.messageId stays null while the SMS does not exist. The key also needs the whatsapp:send scope. The SMS balance is checked at the request, as for any API SMS.

Tracking: GET /v1/whatsapp/messages/{messageId} gives relay: requested, status (none while no SMS is decided, queued once the SMS is queued, refused otherwise), reason (sms_balance_insufficient, sender_unavailable, recipient_not_covered, organization_inactive, relay_unavailable) and sms (messageId, delivery status). The SMS also sends its message.* events to your SMS webhooks.

What does not relay: a WhatsApp sent but not yet delivered (phone off, no data), a Meta refusal for another reason (payment, promotion cap, a person who stopped your promotions), a number outside +243, a message older than 24 hours. The SMS fallback covers +243 numbers only.

Billing: the SMS is taken from your SMS balance when it is sent, by its SMS count; an SMS that left and was not delivered by the operator is not refunded. WhatsApp messages stay billed by Meta on the payment method of your WhatsApp Business account.

bash
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: login-482913' \
  -d '{
    "type": "template",
    "to": "+243810000000",
    "phoneNumberId": "11111111-1111-4111-8111-111111111111",
    "template": { "name": "code_connexion", "language": "fr", "category": "authentication", "parameters": ["482913"] },
    "smsFallback": { "senderId": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f" }
  }'

# 202 Accepted: the WhatsApp message. Its SMS leaves only if Meta reports the number is not on WhatsApp:
curl https://api.coussema.com/v1/whatsapp/messages/8f0aa7ed-1b2c-4d3e-8f4a-5b6c7d8e9f01 \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx'
# "relay": { "requested": true, "status": "queued", "reason": null,
#            "sms": { "messageId": "2fc1be26-…", "status": "DELIVERED" } }
bash
curl -X POST https://api.coussema.com/v1/sms/send \
  -H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: login-482913-sms' \
  -d '{
    "to": "243810000000",
    "message": "Votre code de vérification est 482913.",
    "senderId": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
    "whatsappFirst": {
      "phoneNumberId": "11111111-1111-4111-8111-111111111111",
      "templateId": "6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10",
      "fieldValues": { "Code": "482913" }
    }
  }'

# 202 Accepted
{
  "success": true, "queued": true, "status": "accepted", "channel": "whatsapp_first", "count": 1,
  "remainingQuota": 4210,
  "whatsapp": { "messageId": "8f0aa7ed-1b2c-4d3e-8f4a-5b6c7d8e9f01" },
  "sms": { "messageId": null, "billableUnits": 1 },
  "clientReference": null,
  "provider": { "code": "coussema", "messageId": null, "httpStatus": 202 }
}
javascript
// On your server (Node 18+): one login code, WhatsApp first, SMS if the number is not on WhatsApp.
const apiKey = process.env.COUSSEMA_API_KEY;
const send = (path, body, idempotencyKey) => fetch(`https://api.coussema.com${path}`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
  body: JSON.stringify(body),
});

const code = '482913';
const response = await send('/v1/sms/send', {
  to: '243810000000',
  message: `Votre code de vérification est ${code}.`,
  senderId: '9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f',
  whatsappFirst: {
    phoneNumberId: '11111111-1111-4111-8111-111111111111',
    templateId: '6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10',
    fieldValues: { Code: code },
  },
}, `login-${code}`); // the same key never sends twice

const body = await response.json();
if (!response.ok) throw new Error(body.error);
// Later (or on the whatsapp.message.* and message.* webhooks): where did the code arrive?
const status = await fetch(`https://api.coussema.com/v1/whatsapp/messages/${body.whatsapp.messageId}`, {
  headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json());
console.log(status.message.status, status.message.relay.status, status.message.relay.sms?.status);

Endpoints and scopes

Lists are cursor-paginated and filterable by status, category, template, recipient and dates depending on the resource. Meta’s internal identifiers and errors are never returned: every failed message carries a readable publicErrorCode and failureLabel.

MethodRouteKey scope
POST/v1/whatsapp/messageswhatsapp:send (+ sms:send : smsFallback)
GET/v1/whatsapp/messageswhatsapp:read
GET/v1/whatsapp/messages/{messageId}whatsapp:read
GET/v1/whatsapp/templateswhatsapp:read
POST/v1/whatsapp/templateswhatsapp:send
GET/v1/whatsapp/templates/{templateId}whatsapp:read
GET/v1/whatsapp/phone-numberswhatsapp:read
GET/v1/whatsapp/conversationswhatsapp:read
POST/v1/whatsapp/campaignswhatsapp:send (+ sms:send : smsRelay)
GET/v1/whatsapp/campaigns/{campaignId}whatsapp:read
POST/v1/whatsapp/opt-inswhatsapp:send
POST/v1/whatsapp/opt-ins/opt-outwhatsapp:send
GET/v1/whatsapp/opt-inswhatsapp:read

Track delivery

Subscribe a webhook endpoint (key with webhooks:write and whatsapp:read) to the WhatsApp events. Events of one message arrive in order and are signed like SMS webhooks.

You can also read a message’s state with GET /v1/whatsapp/messages/{messageId}.

  • whatsapp.message.sent
  • whatsapp.message.delivered
  • whatsapp.message.read
  • whatsapp.message.failed
  • whatsapp.message.received

Errors to handle

StatusCodeWhat to do
402whatsapp_subscription_inactiveWhatsApp subscription inactive (WA402): renew it, then send the request again.
400whatsapp_opt_in_requiredThe recipient stopped this type of message (STOP, or consent withdrawn for this template category).
403api_key_scope_requiredThe key lacks the whatsapp:send or whatsapp:read scope.
404whatsapp_phone_number_not_configured, whatsapp_template_not_configuredNumber not connected, or template not approved or in a category that cannot be sent.
400whatsapp_template_invalidTemplate refused before it reaches Meta: the error message says what to fix (order of {{n}}, missing examples, personalized part at the start or end, buttons).
409whatsapp_template_existsA template with this name and language is already in review or approved: choose another name.
422whatsapp_template_rejectedMeta refused the template at creation: check the text, examples and buttons, then send it again.
400whatsapp_template_media_invalidThe example file cannot be read: public https link, type and size WhatsApp accepts.
400whatsapp_template_coupon_code_required, whatsapp_template_offer_end_required, whatsapp_template_cards_invalidA promotion send is incomplete: the code to copy, an offer end in the future, or every carousel card in order.
400whatsapp_template_parameters_invalid, whatsapp_template_url_parameters_required, whatsapp_template_media_requiredThe send does not fill the template: as many parameters as {{n}}, one link end per personalized link (urlParameters), a media.url for a file template created outside Coussema.
failedwhatsapp_marketing_limitedMeta limits how many promotions one person receives: the message is not resent automatically; try again later.
failedwhatsapp_marketing_stoppedThe person stopped your business’s promotions in WhatsApp: Coussema closes their marketing consent, so later promotions to this number are refused before the send (whatsapp_opt_in_required); their notifications go on.
429—Rate limit reached: wait Retry-After seconds.
failedwhatsapp_meta_payment_requiredMeta refused the payment for the message (Meta error 131042): add or fix the payment method of your WhatsApp Business account. The message becomes failed and the whatsapp.message.failed event is sent.

Limits

  • API request rate per organization, reported by the X-RateLimit-* headers; beyond it, 429 with Retry-After.
  • Meta limits each number by its messaging tier and quality, shown by GET /v1/whatsapp/phone-numbers.
  • Your WhatsApp plan includes a monthly message volume; beyond it, sends continue and the extra volume is billed under the plan.
  • Template: up to 32 parameters of 1,024 characters. Free text: 4,096 characters. clientReference: 128 characters.
  • Template creation: Meta accepts 100 per hour per WhatsApp Business account.