Developers

WhatsApp API reference

Send approved WhatsApp template messages from your own application: one message, the same template to up to 500 recipients, or a batch where every message has its own header file. Reply with free-form text and files while the customer's 24-hour window is open. Authenticate with the same token as the SMS API and post JSON.

Quick Start

Base URL

https://rest.api.fortwave.io/v1/whatsapp

All API requests must be made over HTTPS.

Send your first WhatsApp message

  1. In the dashboard, copy the API sender ID of a connected sender and the API template ID of one of its approved templates.
  2. Set your API token in the FORTWAVE_API_KEY environment variable.
  3. Replace the IDs, phone number and variables below with your own, then run the snippet. Pick a language once and every example on this page follows.
curl -X POST https://rest.api.fortwave.io/v1/whatsapp/messages \
  -H "Authorization: Bearer $FORTWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "sender_id": "01K6TQ2X8Y3M4N5P6Q7R8S9T0V",
    "template_id": "01K6TQ5A1B2C3D4E5F6G7H8J9K",
    "phone_number": "+25779123456",
    "variables": {
      "customer_name": "Pablo",
      "order_number": "860198"
    }
  }'

If your template has an image, video or document header, upload the file first and add header_media_id to the request. See Header media

Prerequisites

Set these up once in the FortWave dashboard before your first request:

  1. An API token — the same token as the SMS API. If you already send SMS through the API, reuse it. Where: Settings → API keys
  2. A connected WhatsApp sender — the business phone number messages are sent from. Copy its API sender ID. Where: WhatsApp → Business Accounts → your account → Senders → Manage
  3. An approved message template — WhatsApp only lets businesses start a conversation with a template that Meta has approved. Copy its API template ID; the template page also lists its variables and header type. Where: WhatsApp → Business Accounts → your account → Senders → Templates
  4. WhatsApp wallet credit — each message is paid from your WhatsApp wallet when the API accepts it. Where: Credits → WhatsApp

Authentication

Send your API token as a Bearer token on every request, and ask for JSON responses so errors come back as JSON too. A missing, malformed or revoked token returns 401 Unauthorized with the body "message": "Unauthenticated.".

Required headers
Authorization: Bearer {your_api_token}
Accept: application/json

Keep the token on your server and out of source control. The examples on this page read it from the FORTWAVE_API_KEY environment variable.

Key concepts

Read these once: every endpoint below relies on them.

IDs

Senders, templates, media, messages and batches are identified by 26-character string IDs, for example 01K6TQ2X8Y3M4N5P6Q7R8S9T0V

Use the API sender ID and API template ID shown in the dashboard, not the numbers in dashboard URLs: those are internal and are not accepted by the API.

Phone numbers

Use the international format with the country code, with or without the leading plus sign: both +25779123456 and 25779123456 work.

Template variables

Most templates contain placeholders that you fill in for each message. The name between the double braces is the key you send in variables:

Template body
Hi {{customer_name}}, your order {{order_number}} has shipped.
Request
"variables": {
  "customer_name": "Pablo",
  "order_number": "860198"
}
  • Every variable of the template is required, with a non-empty value.
  • Unknown keys are rejected, so send exactly the template's variables.
  • Each value is a string of at most 255 characters. Send numbers as text, for example "860198".
  • Omit variables entirely for templates without variables.
  • Authentication templates have one variable for the verification code, named code unless the template defines another name.
Not sure what a template expects? Open the template in the dashboard: its "Use via API" panel lists the variable names, the header type and a ready-to-copy request body.

Header media

A template can have an image, video or document header. Every message sent with such a template needs a header file, and files cannot be attached to a send request directly:

  1. Upload the file with Upload media.
  2. Keep the id from the response.
  3. Send it as header_media_id. Its type must match the template's header type.
Header typeAccepted filesMax size
ImageJPEG, PNG5 MB
VideoMP416 MB
DocumentPDF50 MB

Uploads also accept files that can only be sent as free-form attachments, such as MP3, Word, Excel or PDFs over 50 MB. Header rules are checked when you send: using such a file as header_media_id is rejected with the header format's error.

Uploaded media can be reused in as many messages as you like for 30 days. After that it can no longer be sent: upload the file again.

Free-form messages

Templates start conversations. Once a customer writes to one of your senders, you can reply with your own text and files, without a template, for 24 hours after their last message.

  • The window is per customer and per sender, and reopens each time the customer writes again.
  • There is no endpoint that reports whether the window is open. Each whatsapp.message.received webhook gives the time it closes in reply_window_expires_at. Otherwise send the free-form message, and if it returns 400, fall back to a template.
  • Files are uploaded first with Upload media, then referenced by id: up to 5 per message, 100 MB in total. Accepted files:
File typeAccepted filesMax size
ImagePNG, JPG/JPEG5 MB
VideoMP416 MB
AudioMP316 MB
DocumentPDF, DOC/DOCX, XLS/XLSX100 MB

Each file is delivered as its own message and billed as one, at the free-form price for the destination country.

Pricing and wallet

  • Messages are paid from your WhatsApp wallet at the moment the API accepts them. A successful response tells you the cost and your balance afterwards.
  • The price depends on the destination country and the template category: marketing, utility or authentication.
  • Free-form messages are billed at the free-form price for the destination country. The text and each file count as one message each.
  • cost and balance are strings in your wallet currency, given in currency (USD, BIF or RWF). USD amounts have 6 decimals; other currencies have 2. Parse them as decimals, not floats, if you do arithmetic on them.
  • If the balance does not cover the whole request, it is rejected with a wallet error and nothing is sent.

Asynchronous delivery

A successful response, with "status": "queued" , means the message was accepted, paid for and queued. It is then delivered to WhatsApp in the background, so a 200 does not mean the recipient has received it yet.

Endpoints

One endpoint uploads files; four send messages. Each section below is self-contained: headers, parameters, code samples and every response you can get back.

EndpointUse it when
POST/mediaYour template has an image, video or document header, or you want to attach files to a free-form message. Upload the file once and reuse its id.
POST/messagesOne message to one recipient, such as an order update or a verification code.
POST/messages/bulkUp to 500 recipients get the same template, each with their own variables, and all share one header file.
POST/messages/batchUp to 500 messages where each one has its own header file, such as each customer's own invoice PDF.
POST/messages/free-formReply to a customer who wrote to you in the last 24 hours, with text and up to 5 files, without a template.

Which send endpoint should I use?

  • One recipient at a time (for example triggered by an event in your app): Send a message.
  • Many recipients, same header (or no header): Send in bulk. You only pass header_media_id once.
  • Many recipients, a different header file for each: Send a batch. Each message carries its own header_media_id.
  • Replying to a customer who wrote in the last 24 hours, with your own text or files: Send a free-form message. If the window is closed, fall back to a template.

Upload media

POSThttps://rest.api.fortwave.io/v1/whatsapp/media120 req/min

Upload an image, video, audio file or document to use as a template's media header or as a free-form attachment. Upload a file once and reuse its id in as many messages as you like until it expires.

Good to know

  • Needed for templates with an image, video or document header, and for files sent with Send a free-form message. Text-only messages never need it.
  • Upload a shared file (for example a promotion banner) once and reuse its id, rather than uploading it for every message.
  • Uploaded media expires 30 days after upload. After that it can no longer be sent; upload it again.
  • Template headers are stricter than free-form attachments: an image header takes PNG or JPEG up to 5 MB, a video header MP4 up to 16 MB, a document header a PDF up to 50 MB. Any accepted file uploads successfully; the header rules are checked when you send.

Headers

HeaderValueDescription
AuthorizationBearer {token}Your API token.
Content-Typemultipart/form-dataThe body is sent as multipart form data. Most HTTP clients set this header, with its boundary, for you.
Acceptapplication/jsonEnsures errors are returned as JSON.

Form fields

ParameterTypeRequiredDescription
filefileYesThe file: a PNG or JPEG image (up to 5 MB), an MP4 video (up to 16 MB), an MP3 audio file (up to 16 MB) or a PDF, Word or Excel document (up to 100 MB).

Request

Set FORTWAVE_API_KEY in your environment, then run:

curl -X POST https://rest.api.fortwave.io/v1/whatsapp/media \
  -H "Authorization: Bearer $FORTWAVE_API_KEY" \
  -H "Accept: application/json" \
  -F "file=@/path/to/banner.png"

Responses

Every response is JSON. Select a status code to see when it happens and what the body contains.

The file was stored. Send its id as header_media_id, or in media_ids of a free-form message.

200 OK
{
  "id": "01K6TR0B9C8D7E6F5G4H3J2K1M",
  "name": "banner.png",
  "mime_type": "image/png",
  "size": 48213,
  "expires_at": "2026-11-04T10:15:30+00:00"
}

Response fields

FieldTypeDescription
idstringMedia ID. Send it as header_media_id or in media_ids.
namestringOriginal file name.
mime_typestringDetected file type.
sizeintegerFile size in bytes.
expires_atstringWhen the media expires (ISO 8601). It cannot be sent after this date.
Next, send the id as header_media_id or in media_ids: Send a message · Send a batch · Send a free-form message

Send a message

POSThttps://rest.api.fortwave.io/v1/whatsapp/messages500 req/min

Send one template message to one phone number. Use it for messages triggered by events in your app, such as order updates, reminders or verification codes.

Good to know

  • The template must belong to the same WhatsApp business account as the sender. A template from another account is reported as not found.
  • variables must contain exactly the template's variables, each with a non-empty string value of up to 255 characters.
  • Templates with an image, video or document header need header_media_id. Upload the file first with Upload media.
  • The message is paid from your WhatsApp wallet when it is accepted. The response gives the cost and your new balance.

Headers

HeaderValueDescription
AuthorizationBearer {token}Your API token.
Content-Typeapplication/jsonThe body is sent as JSON.
Acceptapplication/jsonEnsures errors are returned as JSON.

Body parameters

ParameterTypeRequiredDescription
sender_idstringYesAPI sender ID of a connected sender.
template_idstringYesAPI template ID of an approved template on the same WhatsApp business account as the sender.
phone_numberstringYesRecipient, in international format with the country code.
variablesobjectNoTemplate variable values, keyed by variable name. Required when the template has variables; omit it otherwise.
variables.<name>stringNoValue for one variable: a non-empty string of at most 255 characters.
header_media_idstringNoid returned by Upload media. Required when the template has an image, video or document header; not allowed otherwise. Its type must match the header.

Request

Set FORTWAVE_API_KEY in your environment, then run:

curl -X POST https://rest.api.fortwave.io/v1/whatsapp/messages \
  -H "Authorization: Bearer $FORTWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "sender_id": "01K6TQ2X8Y3M4N5P6Q7R8S9T0V",
    "template_id": "01K6TQ5A1B2C3D4E5F6G7H8J9K",
    "phone_number": "+25779123456",
    "variables": {
      "customer_name": "Pablo",
      "order_number": "860198"
    },
    "header_media_id": "01K6TR0B9C8D7E6F5G4H3J2K1M"
  }'

Responses

Every response is JSON. Select a status code to see when it happens and what the body contains.

The message was accepted, paid for and queued for delivery.

200 OK
{
  "id": "01K6TR3N4P5Q6R7S8T9V0W1X2Y",
  "status": "queued",
  "cost": "0.002000",
  "balance": "9.998000",
  "currency": "USD"
}

Response fields

FieldTypeDescription
idstringMessage ID.
statusstringAlways queued.
coststringPrice of the message, in your wallet currency.
balancestringYour WhatsApp wallet balance after this request was paid.
currencystringWallet currency: USD, BIF or RWF.
A 200 means the request was accepted, paid for and queued, not delivered yet. See Receiving events · Tracking messages · Pricing and wallet

Send in bulk

POSThttps://rest.api.fortwave.io/v1/whatsapp/messages/bulk500 req/min

Send the same template to up to 500 recipients in one request, each with their own variables. All messages share the same header media, if the template has one.

Good to know

  • All or nothing: if any item fails validation or a business rule, nothing is queued and no credits are used. Fix the request and send the whole batch again.
  • header_media_id is set once, at the top level, and used as the header of every message. To send a different file to each recipient, use Send a batch.
  • variables must contain exactly the template's variables, each with a non-empty string value of up to 255 characters.
  • The whole request is paid from your wallet at once. cost is the total for every message.

Headers

HeaderValueDescription
AuthorizationBearer {token}Your API token.
Content-Typeapplication/jsonThe body is sent as JSON.
Acceptapplication/jsonEnsures errors are returned as JSON.

Body parameters

ParameterTypeRequiredDescription
sender_idstringYesAPI sender ID of a connected sender.
template_idstringYesAPI template ID of an approved template on the same WhatsApp business account as the sender.
recipientsarrayYes1 to 500 recipients.
recipients[].phone_numberstringYesRecipient, in international format with the country code.
recipients[].variablesobjectNoThis recipient's variable values. Required when the template has variables.
header_media_idstringNoOne uploaded media, sent as the header of every message. Required when the template has a media header.

Request

Set FORTWAVE_API_KEY in your environment, then run:

curl -X POST https://rest.api.fortwave.io/v1/whatsapp/messages/bulk \
  -H "Authorization: Bearer $FORTWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "sender_id": "01K6TQ2X8Y3M4N5P6Q7R8S9T0V",
    "template_id": "01K6TQ5A1B2C3D4E5F6G7H8J9K",
    "header_media_id": "01K6TR0B9C8D7E6F5G4H3J2K1M",
    "recipients": [
      {
        "phone_number": "+25779123456",
        "variables": {
          "customer_name": "Pablo",
          "order_number": "1001"
        }
      },
      {
        "phone_number": "+25779654321",
        "variables": {
          "customer_name": "Ana",
          "order_number": "1002"
        }
      }
    ]
  }'

Responses

Every response is JSON. Select a status code to see when it happens and what the body contains.

Every message was accepted, paid for and queued as one batch.

200 OK
{
  "batch_id": "01K6TR6Z7A8B9C0D1E2F3G4H5J",
  "status": "queued",
  "messages_count": 2,
  "cost": "0.004000",
  "balance": "9.994000",
  "currency": "USD"
}

Response fields

FieldTypeDescription
batch_idstringBatch ID. The batch appears in your message history with the source API.
messages_countintegerNumber of messages queued.
statusstringAlways queued.
coststringTotal price of every message in the batch, in your wallet currency.
balancestringYour WhatsApp wallet balance after this request was paid.
currencystringWallet currency: USD, BIF or RWF.
A 200 means the request was accepted, paid for and queued, not delivered yet. See Receiving events · Tracking messages · Pricing and wallet

Send a batch

POSThttps://rest.api.fortwave.io/v1/whatsapp/messages/batch500 req/min

Send the same template in up to 500 messages, each with its own variables and its own header media. For example, send each customer their own invoice PDF.

Good to know

  • All or nothing: if any item fails validation or a business rule, nothing is queued and no credits are used. Fix the request and send the whole batch again.
  • Each message carries its own header_media_id. Several messages may use the same media.
  • variables must contain exactly the template's variables, each with a non-empty string value of up to 255 characters.
  • The whole request is paid from your wallet at once. cost is the total for every message.

Headers

HeaderValueDescription
AuthorizationBearer {token}Your API token.
Content-Typeapplication/jsonThe body is sent as JSON.
Acceptapplication/jsonEnsures errors are returned as JSON.

Body parameters

ParameterTypeRequiredDescription
sender_idstringYesAPI sender ID of a connected sender.
template_idstringYesAPI template ID of an approved template on the same WhatsApp business account as the sender.
messagesarrayYes1 to 500 messages.
messages[].phone_numberstringYesRecipient, in international format with the country code.
messages[].variablesobjectNoThis message's variable values. Required when the template has variables.
messages[].header_media_idstringNoThis message's uploaded header media. Required when the template has a media header.

Request

Set FORTWAVE_API_KEY in your environment, then run:

curl -X POST https://rest.api.fortwave.io/v1/whatsapp/messages/batch \
  -H "Authorization: Bearer $FORTWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "sender_id": "01K6TQ2X8Y3M4N5P6Q7R8S9T0V",
    "template_id": "01K6TQ9M8N7P6Q5R4S3T2V1W0X",
    "messages": [
      {
        "phone_number": "+25779123456",
        "variables": {
          "customer_name": "Pablo",
          "order_number": "1001"
        },
        "header_media_id": "01K6TRA1B2C3D4E5F6G7H8J9K0"
      },
      {
        "phone_number": "+25779654321",
        "variables": {
          "customer_name": "Ana",
          "order_number": "1002"
        },
        "header_media_id": "01K6TRB2C3D4E5F6G7H8J9K0M1"
      }
    ]
  }'

Responses

Every response is JSON. Select a status code to see when it happens and what the body contains.

Every message was accepted, paid for and queued as one batch. Same shape as Send in bulk.

200 OK
{
  "batch_id": "01K6TRC3D4E5F6G7H8J9K0M1N2",
  "status": "queued",
  "messages_count": 2,
  "cost": "0.004000",
  "balance": "9.990000",
  "currency": "USD"
}

Response fields

FieldTypeDescription
batch_idstringBatch ID. The batch appears in your message history with the source API.
messages_countintegerNumber of messages queued.
statusstringAlways queued.
coststringTotal price of every message in the batch, in your wallet currency.
balancestringYour WhatsApp wallet balance after this request was paid.
currencystringWallet currency: USD, BIF or RWF.
A 200 means the request was accepted, paid for and queued, not delivered yet. See Receiving events · Tracking messages · Pricing and wallet

Send a free-form message

POSThttps://rest.api.fortwave.io/v1/whatsapp/messages/free-form500 req/min

Reply to a customer with plain text, files, or both, without a template. Only possible within 24 hours of the customer's last message to this sender.

Good to know

  • WhatsApp only allows free-form messages for 24 hours after the customer last wrote to this sender. Outside the window the request returns 400: send a template with Send a message instead.
  • Files are not sent in this request. Upload each one with Upload media first, then list their ids in media_ids. The request itself is plain JSON.
  • WhatsApp delivers each file as its own message. One request creates the text message (when there is a body), then one message per file, in the order of media_ids.
  • Each message is billed at the free-form price for the destination country. cost is the total for every message. If your wallet can't pay for all of them, nothing is sent.
  • A message can still fail after it is queued, for example when Meta refuses a file. Its status then becomes failed and it is refunded automatically.

Headers

HeaderValueDescription
AuthorizationBearer {token}Your API token.
Content-Typeapplication/jsonThe body is sent as JSON.
Acceptapplication/jsonEnsures errors are returned as JSON.

Body parameters

ParameterTypeRequiredDescription
sender_idstringYesAPI sender ID of a connected sender.
phone_numberstringYesRecipient, in international format with the country code.
bodystringNoMessage text, up to 4096 characters. Required when there are no media_ids.
media_idsarrayNoUp to 5 ids returned by Upload media, each used once, 100 MB in total. Sent in the given order. Required when there is no body.

Request

Set FORTWAVE_API_KEY in your environment, then run:

curl -X POST https://rest.api.fortwave.io/v1/whatsapp/messages/free-form \
  -H "Authorization: Bearer $FORTWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "sender_id": "01K6TQ2X8Y3M4N5P6Q7R8S9T0V",
    "phone_number": "+25779123456",
    "body": "Here are your documents.",
    "media_ids": [
      "01K6TR0B9C8D7E6F5G4H3J2K1M",
      "01K6TRD4E5F6G7H8J9K0M1N2P3"
    ]
  }'

Responses

Every response is JSON. Select a status code to see when it happens and what the body contains.

Every message was accepted, paid for and queued for delivery.

200 OK
{
  "messages": [
    {
      "id": "01K6TRE5F6G7H8J9K0M1N2P3Q4",
      "type": "text",
      "status": "queued"
    },
    {
      "id": "01K6TRE5F6G7H8J9K0M1N2P3Q5",
      "type": "document",
      "status": "queued"
    },
    {
      "id": "01K6TRE5F6G7H8J9K0M1N2P3Q6",
      "type": "image",
      "status": "queued"
    }
  ],
  "messages_count": 3,
  "cost": "0.006000",
  "balance": "9.994000",
  "currency": "USD"
}

Response fields

FieldTypeDescription
messagesarrayThe messages created: the text first (when there is a body), then one per file.
messages[].idstringMessage ID.
messages[].typestringtext, image, video, audio or document.
messages[].statusstringAlways queued.
messages_countintegerNumber of messages queued.
coststringTotal price of every message, in your wallet currency.
balancestringYour WhatsApp wallet balance after this request was paid.
currencystringWallet currency: USD, BIF or RWF.
A 200 means the request was accepted, paid for and queued, not delivered yet. See Receiving events · Tracking messages · Pricing and wallet

Webhooks

Fortwave can send your server an HTTP request whenever something happens to one of your WhatsApp messages: it was sent, delivered, read or failed, or a customer wrote to you. Use webhooks instead of polling to keep your records up to date and to answer customers.

Setup

  1. In the dashboard, open Settings → Webhooks and find the WhatsApp webhook card.
  2. Enter the HTTPS URL that will receive events and choose the events you want, then save.
  3. Copy the signing secret. You will use it to verify each request.
  4. Turn the webhook on. A new endpoint starts off, so nothing is sent until you do.

You can regenerate the secret at any time. The old secret stops working immediately.

Open webhook settings

Supported Events

EventTriggered When
whatsapp.message.sentMeta accepted your message.
whatsapp.message.deliveredThe message reached the customer's phone.
whatsapp.message.readThe customer opened the message. This is only reported when the customer has read receipts turned on.
whatsapp.message.failedThe message could not be sent or delivered. data.error explains why.
whatsapp.message.receivedA customer sent you a message. If it contains a file, the event is sent once the file is ready to download.
  • You receive events for every message on your account, whether it was sent through the API or from the dashboard.
  • Each event is sent at most once per message. If a read receipt arrives before the delivered one, you receive read and no later delivered. When you see read_at, you can treat the message as delivered.

Request format

Each event is a POST request with a JSON body:

Request
POST <your url>
Content-Type: application/json
X-Webhook-Event: whatsapp.message.delivered
X-Webhook-Id: 01JA2Z8Q4M3T6Y7V9W0X1B2C3D
X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c592...

Headers

HeaderDescription
Content-TypeAlways application/json.
X-Webhook-EventThe event name, the same as event in the body.
X-Webhook-IdThe event id, the same as id in the body.
X-Webhook-SignatureHex HMAC-SHA256 of the raw body, keyed with your signing secret. See Verifying the signature.

Body

Every event has the same body. The order of keys is not guaranteed.

Webhook Payload
{
  "id": "01JA2Z8Q4M3T6Y7V9W0X1B2C3D",
  "event": "whatsapp.message.delivered",
  "created_at": "2026-10-13T10:00:05+00:00",
  "data": {
    "id": "01JA2Z6K1F8N4R2P7S5T3V9W0X",
    "batch_id": null,
    "direction": "outbound",
    "status": "delivered",
    "phone_number": "+25779123456",
    "sender_id": "01K6TQ2X8Y3M4N5P6Q7R8S9T0V",
    "sender_phone": "+25761000000",
    "contact_name": null,
    "type": "template",
    "content": "Hello Jane, your order 860198 is confirmed.",
    "template_name": "order_confirmation",
    "sent_at": "2026-10-13T10:00:01+00:00",
    "delivered_at": "2026-10-13T10:00:04+00:00",
    "read_at": null,
    "failed_at": null,
    "received_at": null,
    "reply_window_expires_at": null,
    "error": null,
    "attachments": []
  }
}
FieldDescription
idA unique id for this event. Use it to ignore an event you have already handled.
eventOne of the events above.
created_atWhen the event was created, in ISO 8601.
dataThe message as it stands now. See below.

Message fields (data)

data is the message in its current state, whichever event was sent:

FieldDescription
idThe message id. It is the same id returned by Send a message and Send a free-form message.
batch_idThe batch_id returned by Send in bulk or Send a batch, otherwise null. Those endpoints don't return message ids, so match their events using batch_id and phone_number.
directionoutbound for messages you sent, inbound for messages from customers.
statusThe current status: pending, sent, delivered, read, failed or received.
phone_numberThe customer's number. It is always the customer, in either direction.
sender_id, sender_phoneYour WhatsApp number. sender_id is the value the send endpoints take as sender_id.
contact_nameThe customer's WhatsApp profile name. Inbound messages only.
typetemplate for template messages. Otherwise one of text, image, video, audio, document, sticker, location, reaction, button, interactive, and so on.
contentThe text, the caption of a file, the button the customer picked, the reaction emoji, or the location. null for a file without a caption.
template_nameTemplate messages only.
sent_at … received_atsent_at, delivered_at, read_at, failed_at and received_at: ISO 8601 timestamps, or null when the event hasn't happened.
reply_window_expires_atInbound messages only. Until this time you can reply with a free-form message.
errorWhen the message failed: an object with code (Meta's error code) and message. Otherwise null.
attachmentsFiles received from the customer or sent in a free-form reply. See Received files.
data.error
{
  "code": 131047,
  "message": "Re-engagement message"
}

A message from a customer, with a file:

whatsapp.message.received
{
  "id": "01JA2ZC7R5S8T1V4W7X0Y3Z6A9",
  "event": "whatsapp.message.received",
  "created_at": "2026-10-13T10:12:09+00:00",
  "data": {
    "id": "01JA2Z6K1F8N4R2P7S5T3V9W0X",
    "batch_id": null,
    "direction": "inbound",
    "status": "received",
    "phone_number": "+25779123456",
    "sender_id": "01K6TQ2X8Y3M4N5P6Q7R8S9T0V",
    "sender_phone": "+25761000000",
    "contact_name": "Jane",
    "type": "document",
    "content": "Here is the invoice",
    "template_name": null,
    "sent_at": null,
    "delivered_at": null,
    "read_at": null,
    "failed_at": null,
    "received_at": "2026-10-13T10:12:03+00:00",
    "reply_window_expires_at": "2026-10-14T10:12:03+00:00",
    "error": null,
    "attachments": [
      {
        "id": 42,
        "name": "invoice.pdf",
        "mime_type": "application/pdf",
        "size": 120394,
        "status": "ready",
        "download_url": "https://rest.api.fortwave.io/v1/whatsapp/messages/01JA2Z6K1F8N4R2P7S5T3V9W0X/attachments/42"
      }
    ]
  }
}

Responding and retries

Answer with any 2xx status within 10 seconds, and do slow work after you respond. Any other status, a timeout or a connection error is retried up to 5 times:

AttemptDelay
110 seconds
230 seconds
360 seconds
45 minutes
515 minutes
Retries and the queue mean that events can arrive more than once and out of order:
  • Ignore events whose id you have already handled.
  • Treat data as the latest state of the message. A whatsapp.message.delivered event can already show "status": "read" if the message was read before the event was sent.
  • Use the timestamps, not the order in which events arrive.

Verifying the signature

X-Webhook-Signature is the hex HMAC-SHA256 of the raw request body, keyed with your signing secret. Reject requests whose signature doesn't match, and compare in constant time:

const crypto = require('crypto');
const express = require('express');

const app = express();

// express.raw keeps the body as bytes: the signature is computed over the raw body.
app.post('/fortwave/whatsapp', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto
    .createHmac('sha256', process.env.FORTWAVE_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const received = req.get('X-Webhook-Signature') || '';

  if (received.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body);
  res.sendStatus(200);
  // handle event...
});

Compute the signature over the body exactly as you received it, before you parse the JSON. Parsing and re-encoding it changes the bytes, and the signature no longer matches.

Received files

Each entry in data.attachments looks like this:

Attachment
{
  "id": 42,
  "name": "invoice.pdf",
  "mime_type": "application/pdf",
  "size": 120394,
  "status": "ready",
  "download_url": "https://rest.api.fortwave.io/v1/whatsapp/messages/01JA2Z6K1F8N4R2P7S5T3V9W0X/attachments/42"
}

status is ready, or failed if the file could not be fetched from Meta. A received event is sent only after the file is ready or has failed, so you never need to poll for it.

Downloading a file

Request its download_url with your API token. It shares the 120 requests per minute limit of Upload media.

Request
GET /v1/whatsapp/messages/{message_id}/attachments/{attachment_id}
Authorization: Bearer {your_api_token}
StatusDescription
200The file, with its Content-Type and its name in Content-Disposition. Add ?inline=1 to have it shown in the browser instead of downloaded.
404The file doesn't belong to you, or it could not be fetched from Meta.
409The file is still downloading. Try again in a few seconds.

mime_type can include parameters, for example audio/ogg; codecs=opus. Compare only the part before the ;.

Replying to a customer

  1. You receive whatsapp.message.received.
  2. Reply with Send a free-form message, using the event's data.sender_id and data.phone_number, a body, and optionally media_ids from Upload media.
  3. You can send free-form replies until reply_window_expires_at, which is 24 hours after the customer's last message. After that, the API answers 400 and you must send a template instead.

Each part of a free-form reply is its own message: the text, and each file. Each one has its own id in the response, and its own sent, delivered, read and failed events.

Send a free-form message

Errors

Validation and business errors return 422 Unprocessable Entity with a message and an errors object keyed by field. In bulk and batch requests, item errors are keyed by position (starting at 0), such as recipients.1.phone_number or messages.0.header_media_id, so you can tell exactly which item to fix. A free-form message sent outside the 24-hour window returns 400 Bad Request with only a message.

A request is all or nothing. If any recipient or message is invalid, nothing is sent and nothing is charged. Fix the reported items and send the whole request again.
422 Unprocessable Entity
{
  "message": "The phone number is invalid. (and 1 more error)",
  "errors": {
    "recipients.1.phone_number": [
      "The phone number is invalid."
    ],
    "recipients.1.variables.order_number": [
      "A value is required for each template variable."
    ]
  }
}

Common errors

FieldMessage returnedWhat to do
sender_idThe WhatsApp sender was not found or is not connected.Check the API sender ID and that the sender is connected.
template_idThe WhatsApp template was not found for this sender or is not approved.Use an approved template from the sender's business account.
variablesThe template variables must only contain the template's variables.Remove keys that are not variables of the template.
variables.<name>A value is required for each template variable.Send every variable of the template, each with a value.
phone_numberThe phone number is invalid.Use the international format with the country code.
phone_numberWhatsApp messages cannot be sent to these countries yet: XX.The destination country is not supported yet. XX lists the country codes.
header_media_idThe template has a media header: a header media id is required.Upload the header file and send its id.
header_media_idThe template has no media header: a header media id cannot be sent.Remove header_media_id: this template has no media header.
header_media_idThe header media was not found.Use an id returned by one of your own uploads.
header_media_idThe header media has expired: upload it again.Media expires 30 days after upload. Upload it again.
header_media_idThe header image must be a JPEG or PNG file no larger than 5MB.The media type does not match the template's header type, or the file is too large for a header (a document header takes a PDF up to 50 MB). The video and document versions of this error work the same way.
walletInsufficient WhatsApp balance: this costs … but your balance is ….Top up your WhatsApp wallet, then retry.
recipientsA maximum of 500 recipients may be sent per request.Split the send into several requests of at most 500.
—The 24-hour conversation window is closed. Send a template instead.Free-form only: the customer hasn't written in the last 24 hours. Send a template instead.
bodyA message or at least one media id is required.Send a body, media_ids, or both.
media_idsYou can attach at most 5 files.Split the files over several free-form messages.
walletCreate a WhatsApp wallet first.Create your WhatsApp wallet in Credits → WhatsApp and top it up.

HTTP status codes

StatusDescription
200Accepted. Messages are queued for delivery.
400Free-form message outside the 24-hour window. Nothing was sent or charged. Send a template instead.
401Missing or invalid API token.
422Validation or business error. Nothing was sent or charged.
429Rate limit exceeded. Wait for Retry-After seconds.
5xxServer error. Check your message history before retrying.

Messages are localized (English or French) and may be reworded over time. Branch on the status code and on the keys in errors, not on the message text.

Rate limits

Limits are counted per user, per minute:

EndpointLimit
POST /media, GET /messages/…/attachments/…120 requests per minute
POST /messages, /messages/bulk, /messages/batch, /messages/free-form500 requests per minute, shared by the four send endpoints

Going over the limit returns 429 Too Many Requests. Wait for the number of seconds given in the Retry-After header before retrying. To send more than 500 messages a minute, use the bulk and batch endpoints: one request carries up to 500 messages.

Tracking messages

Messages sent through the API appear in the dashboard alongside the ones you send by hand.

  • Every message shows up in WhatsApp → Messages, where you can follow its delivery status.
  • Subscribe to webhooks to be told when each message is sent, delivered, read or fails, and when customers write to you.
  • Bulk and batch requests create a batch with the source API, so you can tell them apart from sends made in the dashboard.
  • Free-form messages sent through the API appear like the free-form replies you send from the dashboard, with no template.
  • Store the id or batch_id from the response to match API calls with what you see in the dashboard.
Open message history