API documentation

Reseller

API documentation

Run your clients from your own software: open client accounts and send them credit, connect their WhatsApp numbers, send messages, answer their inbox and receive events by webhook.

Base URL

https://podimessage.io/api/v1
Create an API token →

Headers on every request

Authorization: Bearer podi_xxxxxxxxxxxxxxxx
Accept: application/json
Content-Type: application/json
# only when acting as a client
X-Client-Id: 57

Overview

Your account and your clients

Your reseller account holds your own balance and your API token. Each business you serve is a client account under it, with its own balance, WhatsApp number, templates, campaigns and inbox. A client can have its own panel login, but it does not need one: you can drive it entirely through the API.

Credits and prices

Every amount, price and threshold in the API is a whole number of credits: 1 unit of your account currency is 1,000 credits. Amounts, prices and thresholds you send must be multiples of 10.

Responses name the unit in currency: PODI on your own calls and CREDIT on calls made for a client with X-Client-Id, so nothing you pass on to a client carries the platform’s name.

  • Your price is what one template message costs you. GET /api/v1/reseller/balance returns it as price.
  • The client price is what a client pays per template message. Set it with price when you create or update the client; without one, the client pays your price. It can never be lower than yours.
  • When a client sends a template message, its balance is charged the client price and the difference (client price minus your price) is added to your balance in the same step. Example: with your price at 10 and the client price at 20, the client pays 20 and you earn 10 per template message.
  • A message WhatsApp refuses when we send it is not charged: the client gets the charge back and your margin is taken back. A message WhatsApp has accepted stays charged, even if it is later reported as failed. Free-text replies inside the 24-hour reply window (inbox replies) are not charged.
  • You send credit from your balance to a client’s with credit and take it back with withdraw. A client can only spend what is in its own balance.

Authentication

Send your reseller API token in the Authorization: Bearer … header of every request. Create and revoke tokens on the API & OTP page, and keep them on your server, never in a browser or mobile app.

To act as a client, add the X-Client-Id header with the client’s id. The request then runs exactly as that client: its balance, its number, its templates and its inbox.

An id that is not one of your clients (or does not exist) gives 404 on every endpoint, and a suspended client gives 403. Without X-Client-Id the token acts as your own account. The /api/v1/reseller/… endpoints work only without it; called with one of your clients’ ids they answer 403.

Rate limits

  • Message, template, campaign and inbox endpoints: 60 requests per minute per account.
  • /api/v1/reseller/… endpoints: 120 requests per minute.
  • Webhook test pings: 5 per minute.

Calls with X-Client-Id count against that client’s own budget, so one busy client does not slow down the others; your own calls share one counter, and the /api/v1/reseller/… endpoints have their own. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit you get 429 with a Retry-After header in seconds.

Formats

  • Send JSON with Content-Type: application/json and Accept: application/json.
  • Times are ISO 8601 with an offset, for example 2026-11-02T10:15:00+03:00. In a query string, encode + as %2B.
  • Phone numbers are digits with the country code, without + or a leading 0: 905321234567.
  • Lists take per_page (1–100, default 50) and page and return meta with current_page, last_page and total. Conversation messages page by id instead.

Errors

Errors are JSON with a message; some add a machine-readable code and other fields. Act on the HTTP status and code, not on the text: messages are written for people and may change. Messages are always in the platform’s default language; Accept-Language does not change it. Send endpoints check in this order: input (422), an active WhatsApp number (409), credit (402).

200 Success.
201 Created.
204 Deleted; no body.
401 Missing or unknown API token.
402 Not enough credit. The body shows balance, and required where it is known.
403 Suspended client, or the wrong kind of call for the endpoint (for example one of your clients’ X-Client-Id on a reseller endpoint).
404 Unknown id, or one that belongs to another account.
405 Right path, wrong HTTP method; the allowed methods are in the Allow header.
409 No active WhatsApp number, or a request with the same idempotency key is still running.
422 Invalid input, or WhatsApp refused the request (see message and code).
429 Too many requests.
502 WhatsApp did not deliver the media file.

Invalid input returns 422 with the failing fields under errors:

# HTTP 422
{
  "message": "The amount field must be a multiple of 10.",
  "errors": {
    "amount": [
      "The amount field must be a multiple of 10."
    ]
  }
}

Safe retries (idempotency)

Networks fail. On the endpoints below, send a unique key with each operation: repeating the request with the same key within 24 hours returns the first result instead of doing the work twice.

  • credit and withdraw: the Idempotency-Key header (or idempotency_key in the body). Keys are kept per client and direction. The same key with another amount gets 422 with code: idempotency_key_reused.
  • messages/send: idempotency_key in the body, per account. Only successful sends are remembered, so a failed send can be retried with the same key. The same key for another recipient or template gets 422 with code: idempotency_key_reused.
  • conversations/{id}/reply: the Idempotency-Key header (or idempotency_key in the body), per conversation. A replayed answer carries Idempotent-Replayed: true; while the first request is still running you get 409 with code: in_progress.
  • otp/send and campaigns take no key: check the outcome before you retry them.

Quick start

From a new client to a two-way conversation in six calls. Set up your webhook first (see Webhooks) so step 5 reaches you.

  1. 1

    Open a client account

    Keep the returned id: from now on you send it as X-Client-Id. Your own external_id lets you find the client again.

    Example request — POST /api/v1/reseller/clients

    curl -X POST 'https://podimessage.io/api/v1/reseller/clients' \
      -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
      -H 'Accept: application/json' \
      -H 'Content-Type: application/json' \
      -d '{
        "name": "Pati Klinik",
        "external_id": "crm-1042",
        "price": 20
      }'
    <?php
    $ch = curl_init('https://podimessage.io/api/v1/reseller/clients');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
            'Accept: application/json',
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'name' => 'Pati Klinik',
            'external_id' => 'crm-1042',
            'price' => 20,
        ]),
    ]);
    $response = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    const res = await fetch('https://podimessage.io/api/v1/reseller/clients', {
      method: 'POST',
      headers: {
        Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: 'Pati Klinik',
        external_id: 'crm-1042',
        price: 20,
      }),
    });
    const data = await res.json();
  2. 2

    Send it credit

    Sends 50,000 credits from your balance to the client’s.

    Example request — POST /api/v1/reseller/clients/{client}/credit

    curl -X POST 'https://podimessage.io/api/v1/reseller/clients/57/credit' \
      -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
      -H 'Accept: application/json' \
      -H 'Content-Type: application/json' \
      -d '{
        "amount": 50000,
        "idempotency_key": "topup-crm-1042-0001"
      }'
    <?php
    $ch = curl_init('https://podimessage.io/api/v1/reseller/clients/57/credit');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
            'Accept: application/json',
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'amount' => 50000,
            'idempotency_key' => 'topup-crm-1042-0001',
        ]),
    ]);
    $response = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    const res = await fetch('https://podimessage.io/api/v1/reseller/clients/57/credit', {
      method: 'POST',
      headers: {
        Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        amount: 50000,
        idempotency_key: 'topup-crm-1042-0001',
      }),
    });
    const data = await res.json();
  3. 3

    Connect its WhatsApp number

    Send the returned url link to the client, or open it in the client’s browser. When it is done you receive the client.connected webhook and the client comes back to your return_url with status=connected.

    Example request — POST /api/v1/reseller/clients/{client}/connect-link

    curl -X POST 'https://podimessage.io/api/v1/reseller/clients/57/connect-link' \
      -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
      -H 'Accept: application/json' \
      -H 'Content-Type: application/json' \
      -d '{
        "return_url": "https://crm.example.com/clinics/1042/whatsapp"
      }'
    <?php
    $ch = curl_init('https://podimessage.io/api/v1/reseller/clients/57/connect-link');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
            'Accept: application/json',
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'return_url' => 'https://crm.example.com/clinics/1042/whatsapp',
        ]),
    ]);
    $response = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    const res = await fetch('https://podimessage.io/api/v1/reseller/clients/57/connect-link', {
      method: 'POST',
      headers: {
        Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        return_url: 'https://crm.example.com/clinics/1042/whatsapp',
      }),
    });
    const data = await res.json();
  4. 4

    Send an approved template

    Messages you start must use a template Meta has approved (create one with POST /api/v1/templates). The answer shows the cost and the client’s new balance.

    Example request — POST /api/v1/messages/send

    curl -X POST 'https://podimessage.io/api/v1/messages/send' \
      -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
      -H 'X-Client-Id: 57' \
      -H 'Accept: application/json' \
      -H 'Content-Type: application/json' \
      -d '{
        "to": "905321234567",
        "template": "appointment_reminder",
        "language": "tr",
        "params": [
          "Ayşe",
          "12.11.2026 14:30"
        ],
        "idempotency_key": "reminder-8812"
      }'
    <?php
    $ch = curl_init('https://podimessage.io/api/v1/messages/send');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
            'X-Client-Id: 57',
            'Accept: application/json',
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'to' => '905321234567',
            'template' => 'appointment_reminder',
            'language' => 'tr',
            'params' => ['Ayşe', '12.11.2026 14:30'],
            'idempotency_key' => 'reminder-8812',
        ]),
    ]);
    $response = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    const res = await fetch('https://podimessage.io/api/v1/messages/send', {
      method: 'POST',
      headers: {
        Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id': '57',
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        to: '905321234567',
        template: 'appointment_reminder',
        language: 'tr',
        params: ['Ayşe', '12.11.2026 14:30'],
        idempotency_key: 'reminder-8812',
      }),
    });
    const data = await res.json();
  5. 5

    Receive the reply by webhook

    When the customer writes back, we POST message.inbound to your webhook. It names the client (client_id, external_id) and the conversation_id.

    POST https://crm.example.com/webhooks/whatsapp
    X-Webhook-Event: message.inbound
    {
      "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
      "event": "message.inbound",
      "created_at": "2026-11-02T10:15:00+03:00",
      "data": {
        "client_id": 57,
        "external_id": "crm-1042",
        "conversation_id": 12,
        "message_id": 345,
        "phone_number_id": "106540352242922",
        "wa_id": "905321234567",
        "name": "Ayşe Yılmaz",
        "type": "text",
        "text": "Yarın 14:30 uygun mu?",
        "has_media": false,
        "timestamp": "2026-11-02T10:12:00+03:00"
      }
    }
  6. 6

    Answer it

    For 24 hours after the customer’s last message you can reply with free text. These replies are not charged to the balance; Meta charges its own fee after the first 1,000 service messages a month on each number.

    Example request — POST /api/v1/conversations/{conversation}/reply

    curl -X POST 'https://podimessage.io/api/v1/conversations/12/reply' \
      -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
      -H 'X-Client-Id: 57' \
      -H 'Idempotency-Key: reply-77f1c2' \
      -H 'Accept: application/json' \
      -H 'Content-Type: application/json' \
      -d '{
        "text": "Merhaba Ayşe, yarın 14:30 için randevunuzu oluşturduk."
      }'
    <?php
    $ch = curl_init('https://podimessage.io/api/v1/conversations/12/reply');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
            'X-Client-Id: 57',
            'Idempotency-Key: reply-77f1c2',
            'Accept: application/json',
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'text' => 'Merhaba Ayşe, yarın 14:30 için randevunuzu oluşturduk.',
        ]),
    ]);
    $response = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    const res = await fetch('https://podimessage.io/api/v1/conversations/12/reply', {
      method: 'POST',
      headers: {
        Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id': '57',
        'Idempotency-Key': 'reply-77f1c2',
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        text: 'Merhaba Ayşe, yarın 14:30 için randevunuzu oluşturduk.',
      }),
    });
    const data = await res.json();

Reseller endpoints

Manage your clients and your balance. Use your token without X-Client-Id.

Client object
id integer
Client id. Send it as X-Client-Id.
external_id string|null
Your own id for the client.
name string
Client name.
email string|null
Panel login email, or null when the client has no panel login.
panel_login boolean
Whether the client can sign in to the panel.
active boolean
false while the client is suspended.
balance integer
The client’s balance in credits.
price integer
What the client pays per template message, in credits.
currency string
Unit of the amounts: PODI on your calls.
whatsapp array
Connected numbers, each with phone_number_id, display_phone_number, verified_name and status.
created_at string
When the account was opened.
GET /api/v1/reseller/balance Your balance, your price per template message and how many clients you have.
Reseller token

Example request

curl 'https://podimessage.io/api/v1/reseller/balance' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/balance');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/balance', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "balance": 250000,
  "price": 10,
  "currency": "PODI",
  "clients": 12
}
GET /api/v1/reseller/clients Your clients, oldest first.
Reseller token

Parameters

external_id
string · query
Return only the client with this external_id (exact match).
per_page
integer · query
1–100, default 50.
page
integer · query
Page number, default 1.

Example request

curl 'https://podimessage.io/api/v1/reseller/clients?external_id=crm-1042' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/clients?external_id=crm-1042');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/clients?external_id=crm-1042', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "data": [
    {
      "id": 57,
      "external_id": "crm-1042",
      "name": "Pati Klinik",
      "email": null,
      "panel_login": false,
      "active": true,
      "balance": 50000,
      "price": 20,
      "currency": "PODI",
      "whatsapp": [
        {
          "phone_number_id": "106540352242922",
          "display_phone_number": "+90 216 555 01 42",
          "verified_name": "Pati Klinik",
          "status": "active"
        }
      ],
      "created_at": "2026-10-28T09:30:00+03:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
POST /api/v1/reseller/clients Open a client account. It starts with 0 credit; send it credit with credit.
Reseller token

Parameters

name
string · body required
Up to 255 characters. Shown on the number connection page.
external_id
string · body
Your own id for the client, up to 100 characters and unique among your clients. Use it to find the client and to match webhook events.
email
string · body
Panel login email, sent together with password. Leave both out for an API-only client. It must not be used by another account.
password
string · body
Panel password, 8–255 characters. Required with email.
price
integer · body
What the client pays per template message, in credits: 10–1,000,000, a multiple of 10 and not below your price. Leave it out to charge your price.

Example request

curl -X POST 'https://podimessage.io/api/v1/reseller/clients' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Pati Klinik",
    "external_id": "crm-1042",
    "price": 20
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/clients');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'name' => 'Pati Klinik',
        'external_id' => 'crm-1042',
        'price' => 20,
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/clients', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Pati Klinik',
    external_id: 'crm-1042',
    price: 20,
  }),
});
const data = await res.json();

Response · HTTP 201

{
  "id": 57,
  "external_id": "crm-1042",
  "name": "Pati Klinik",
  "email": null,
  "panel_login": false,
  "active": true,
  "balance": 0,
  "price": 20,
  "currency": "PODI",
  "whatsapp": [],
  "created_at": "2026-10-28T09:30:00+03:00"
}

Other responses

422 The price is below your own price.
{
  "message": "A client’s price cannot be below your own message price (10 Podi). Enter at least 10 Podi, or set no price to charge your own."
}
GET /api/v1/reseller/clients/{client} One client, with its connected numbers.
Reseller token

Parameters

client
integer · path required
The client’s id.

Example request

curl 'https://podimessage.io/api/v1/reseller/clients/57' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/clients/57');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/clients/57', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "id": 57,
  "external_id": "crm-1042",
  "name": "Pati Klinik",
  "email": null,
  "panel_login": false,
  "active": true,
  "balance": 50000,
  "price": 20,
  "currency": "PODI",
  "whatsapp": [
    {
      "phone_number_id": "106540352242922",
      "display_phone_number": "+90 216 555 01 42",
      "verified_name": "Pati Klinik",
      "status": "active"
    }
  ],
  "created_at": "2026-10-28T09:30:00+03:00"
}

Other responses

404 Not one of your clients.
{
  "message": "Client account not found."
}
PATCH /api/v1/reseller/clients/{client} Change a client. Only the fields you send change.
Reseller token

Parameters

client
integer · path required
The client’s id.
name
string · body
New name, up to 255 characters.
external_id
string|null · body
New external id, unique among your clients; null clears it.
email
string · body
Panel login email, sent together with password. Leave both out for an API-only client. It must not be used by another account.
password
string · body
Panel password, 8–255 characters. Required with email.
active
boolean · body
false suspends the client: calls with its X-Client-Id get 403, its panel login stops working, its connection links do not open and no messages are sent; messages waiting to be sent and campaigns that fall due while it is suspended fail and are not sent when it is restored. true restores it.
price
integer|null · body
New price per template message in credits, with the same rules; null goes back to your price.

Example request

curl -X PATCH 'https://podimessage.io/api/v1/reseller/clients/57' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "price": 30,
    "active": true
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/clients/57');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'price' => 30,
        'active' => true,
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/clients/57', {
  method: 'PATCH',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    price: 30,
    active: true,
  }),
});
const data = await res.json();

Response · HTTP 200

{
  "id": 57,
  "external_id": "crm-1042",
  "name": "Pati Klinik",
  "email": null,
  "panel_login": false,
  "active": true,
  "balance": 50000,
  "price": 30,
  "currency": "PODI",
  "whatsapp": [
    {
      "phone_number_id": "106540352242922",
      "display_phone_number": "+90 216 555 01 42",
      "verified_name": "Pati Klinik",
      "status": "active"
    }
  ],
  "created_at": "2026-10-28T09:30:00+03:00"
}

Other responses

404 Not one of your clients.
422 The price is below your own price.
POST /api/v1/reseller/clients/{client}/credit Send credit from your balance to the client’s.
Reseller token

Parameters

client
integer · path required
The client’s id.
amount
integer · body required
Credits to send or take back: 10–1,000,000,000 and a multiple of 10.
idempotency_key
string · body
Optional unique key, up to 128 characters. Repeating it within 24 hours returns the first result and moves no credit a second time.

Example request

curl -X POST 'https://podimessage.io/api/v1/reseller/clients/57/credit' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": 50000,
    "idempotency_key": "topup-crm-1042-0001"
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/clients/57/credit');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 50000,
        'idempotency_key' => 'topup-crm-1042-0001',
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/clients/57/credit', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: 50000,
    idempotency_key: 'topup-crm-1042-0001',
  }),
});
const data = await res.json();

Response · HTTP 200

{
  "status": "ok",
  "amount": 50000,
  "balance": 200000,
  "client": {
    "id": 57,
    "external_id": "crm-1042",
    "name": "Pati Klinik",
    "email": null,
    "panel_login": false,
    "active": true,
    "balance": 50000,
    "price": 20,
    "currency": "PODI",
    "whatsapp": [
      {
        "phone_number_id": "106540352242922",
        "display_phone_number": "+90 216 555 01 42",
        "verified_name": "Pati Klinik",
        "status": "active"
      }
    ],
    "created_at": "2026-10-28T09:30:00+03:00"
  }
}

Other responses

402 Your balance does not cover this amount; balance is yours.
{
  "message": "Insufficient balance. Top up and retry the request. The balance field in the response shows the current balance, and required (if present) the amount needed.",
  "balance": 20000,
  "currency": "PODI"
}
404 Not one of your clients.
409 A request with the same Idempotency-Key is still running.
{
  "message": "A request with this idempotency key is still being processed. Try again shortly.",
  "code": "in_progress"
}
422 The same key was already used with a different amount. Use a new key for every transfer.
{
  "message": "This idempotency key was already used for a different request. Use a new key.",
  "code": "idempotency_key_reused"
}
POST /api/v1/reseller/clients/{client}/withdraw Take unused credit back from the client to your balance.
Reseller token

Parameters

client
integer · path required
The client’s id.
amount
integer · body required
Credits to send or take back: 10–1,000,000,000 and a multiple of 10.
idempotency_key
string · body
Optional unique key, up to 128 characters. Repeating it within 24 hours returns the first result and moves no credit a second time.

Example request

curl -X POST 'https://podimessage.io/api/v1/reseller/clients/57/withdraw' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": 10000,
    "idempotency_key": "refund-crm-1042-0001"
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/clients/57/withdraw');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 10000,
        'idempotency_key' => 'refund-crm-1042-0001',
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/clients/57/withdraw', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: 10000,
    idempotency_key: 'refund-crm-1042-0001',
  }),
});
const data = await res.json();

Response · HTTP 200

{
  "status": "ok",
  "amount": 10000,
  "balance": 210000,
  "client": {
    "id": 57,
    "external_id": "crm-1042",
    "name": "Pati Klinik",
    "email": null,
    "panel_login": false,
    "active": true,
    "balance": 40000,
    "price": 20,
    "currency": "PODI",
    "whatsapp": [
      {
        "phone_number_id": "106540352242922",
        "display_phone_number": "+90 216 555 01 42",
        "verified_name": "Pati Klinik",
        "status": "active"
      }
    ],
    "created_at": "2026-10-28T09:30:00+03:00"
  }
}

Other responses

402 The client has less than amount; balance is the client’s.
{
  "message": "Insufficient balance. Top up and retry the request. The balance field in the response shows the current balance, and required (if present) the amount needed.",
  "balance": 4000,
  "currency": "PODI"
}
404 Not one of your clients.
409 A request with the same Idempotency-Key is still running.
{
  "message": "A request with this idempotency key is still being processed. Try again shortly.",
  "code": "in_progress"
}
422 The same key was already used with a different amount. Use a new key for every transfer.
{
  "message": "This idempotency key was already used for a different request. Use a new key.",
  "code": "idempotency_key_reused"
}
GET /api/v1/reseller/clients/{client}/usage Template messages the client sent in a period, by status, and what it was charged for them. Default: this month.
Reseller token

by_status counts messages per status (sent, delivered, read, failed…) and is an empty list when there were none. charged leaves out refunded messages.

Parameters

client
integer · path required
The client’s id.
from
date · query
First day (YYYY-MM-DD). Default: the first day of this month.
to
date · query
Last day, inclusive and not before from. Default: today.

Example request

curl 'https://podimessage.io/api/v1/reseller/clients/57/usage?from=2026-11-01&to=2026-11-30' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/clients/57/usage?from=2026-11-01&to=2026-11-30');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/clients/57/usage?from=2026-11-01&to=2026-11-30', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "client_id": 57,
  "from": "2026-11-01T00:00:00+03:00",
  "to": "2026-11-30T23:59:59+03:00",
  "messages": {
    "total": 120,
    "by_status": {
      "delivered": 84,
      "failed": 2,
      "read": 30,
      "sent": 4
    }
  },
  "charged": 2360,
  "currency": "PODI"
}

Other responses

404 Not one of your clients.

Acting as a client

Everything a client account does: send templates and verification codes, manage templates and run campaigns. Send your token with X-Client-Id; amounts come back as CREDIT.

Campaign object
id integer
Campaign id.
name string
Campaign name.
template string
Template name.
language string
Template language.
category string
Billing category: marketing, utility or authentication.
status string
scheduled, queued, processing, completed, failed or cancelled.
scheduled_at string|null
Planned start, or null when sent at once.
total_recipients integer
Number of recipients.
sent integer
Messages WhatsApp accepted.
failed integer
Messages that could not be sent.
delivered integer
Messages delivered to the phone.
read integer
Messages read.
started_at string|null
When sending started.
completed_at string|null
When every recipient had been processed.
GET /api/v1/me Check your token and X-Client-Id header: returns the account the request runs as and its balance. For a client without a panel login, email is a placeholder address.
Token + X-Client-Id

Example request

curl 'https://podimessage.io/api/v1/me' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/me');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/me', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "id": 57,
  "name": "Pati Klinik",
  "email": "client-0b7d6c1e-8a51-4c43-9f0e-2f4d1c9a7e10@clients.invalid",
  "balance": 50000,
  "currency": "CREDIT"
}
POST /api/v1/messages/send Send one approved template to one number. It is sent at once and charged to the client.
Token + X-Client-Id

To answer a customer who wrote in the last 24 hours, use the inbox reply instead: it is not charged to the balance.

Parameters

to
string · body required
Recipient number: digits with the country code, up to 20 characters.
template
string · body required
Name of an approved template on the client’s account.
language
string · body
Template language code, such as tr or en_US. The default is en_US, so set it for Turkish templates.
params
array · body
Up to 10 strings (1,000 characters each) that fill {{1}}, {{2}}… in the template body, in order.
phone_number_id
string · body
Which of the client’s connected numbers to use. Default: the most recently connected active number.
idempotency_key
string · body
Optional unique key, up to 128 characters. Repeating it within 24 hours returns the first successful result and sends nothing.

Example request

curl -X POST 'https://podimessage.io/api/v1/messages/send' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "to": "905321234567",
    "template": "appointment_reminder",
    "language": "tr",
    "params": [
      "Ayşe",
      "12.11.2026 14:30"
    ],
    "idempotency_key": "reminder-8812"
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/messages/send');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'to' => '905321234567',
        'template' => 'appointment_reminder',
        'language' => 'tr',
        'params' => ['Ayşe', '12.11.2026 14:30'],
        'idempotency_key' => 'reminder-8812',
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/messages/send', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: '905321234567',
    template: 'appointment_reminder',
    language: 'tr',
    params: ['Ayşe', '12.11.2026 14:30'],
    idempotency_key: 'reminder-8812',
  }),
});
const data = await res.json();

Response · HTTP 200

{
  "status": "sent",
  "message_id": 1234,
  "wamid": "wamid.HBgMOTA1MzIxMjM0NTY3FQIAERgSQ0Y2…",
  "to": "905321234567",
  "cost": 20,
  "currency": "CREDIT",
  "balance": 49980
}

Other responses

402 The client cannot pay for the message; nothing was sent.
{
  "message": "Insufficient balance. Top up and retry the request. The balance field in the response shows the current balance, and required (if present) the amount needed.",
  "required": 20,
  "balance": 0,
  "currency": "CREDIT"
}
409 The client has no active WhatsApp number (or not the phone_number_id you named).
{
  "message": "No active WhatsApp number is connected. Connect one in the panel."
}
422 WhatsApp refused the message: code is Meta’s error code and message_id the failed record. The charge is refunded.
{
  "message": "(#132001) Template name does not exist in the translation",
  "code": "132001",
  "message_id": 1234
}
POST /api/v1/otp/send Send a verification code with an approved Authentication template. You generate and check the code yourself.
Token + X-Client-Id

Parameters

to
string · body required
Recipient number: digits with the country code, up to 20 characters.
template
string · body required
Name of an approved template in the Authentication category.
code
string · body required
The code you generated, up to 30 characters. It fills the template body and its button.
language
string · body
Template language code, such as tr or en_US. The default is en_US, so set it for Turkish templates.
include_button
boolean · body
Default true. Set it to false if the template has no button.
button_sub_type
string · body
url (default; works for one-tap and copy-code buttons) or copy_code.
phone_number_id
string · body
Which of the client’s connected numbers to use. Default: the most recently connected active number.

Example request

curl -X POST 'https://podimessage.io/api/v1/otp/send' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "to": "905321234567",
    "template": "login_code",
    "language": "tr",
    "code": "482913"
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/otp/send');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'to' => '905321234567',
        'template' => 'login_code',
        'language' => 'tr',
        'code' => '482913',
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/otp/send', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: '905321234567',
    template: 'login_code',
    language: 'tr',
    code: '482913',
  }),
});
const data = await res.json();

Response · HTTP 200

{
  "status": "sent",
  "message_id": 1234,
  "wamid": "wamid.HBgMOTA1MzIxMjM0NTY3FQIAERgSQ0Y2…",
  "to": "905321234567",
  "cost": 20,
  "currency": "CREDIT",
  "balance": 49980
}

Other responses

402 The client cannot pay for the message; nothing was sent.
409 The client has no active WhatsApp number (or not the phone_number_id you named).
{
  "message": "No active WhatsApp number is connected. Connect one in the panel."
}
422 WhatsApp refused the message: code is Meta’s error code and message_id the failed record. The charge is refunded.
GET /api/v1/templates The client’s templates as Meta returns them (cached for up to 5 minutes).
Token + X-Client-Id

Parameters

status
string · query
ALL (default), APPROVED, PENDING, REJECTED, PAUSED or DISABLED.
phone_number_id
string · query
Which of the client’s connected numbers to use. Default: the most recently connected active number.

Example request

curl 'https://podimessage.io/api/v1/templates?status=APPROVED' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/templates?status=APPROVED');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/templates?status=APPROVED', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "data": [
    {
      "id": "1489022265523517",
      "name": "appointment_reminder",
      "language": "tr",
      "category": "UTILITY",
      "components": [
        {
          "type": "BODY",
          "text": "Merhaba {{1}}, {{2}} tarihli randevunuzu hatırlatırız.",
          "example": {
            "body_text": [
              [
                "Ayşe",
                "12.11.2026 14:30"
              ]
            ]
          }
        }
      ],
      "status": "APPROVED",
      "rejected_reason": "NONE"
    }
  ]
}

Other responses

409 The client has no active WhatsApp number (or not the phone_number_id you named).
422 WhatsApp refused the request; code is Meta’s error code.
POST /api/v1/templates Submit a new template for Meta’s review. It starts as PENDING; the template.status webhook tells you the outcome.
Token + X-Client-Id

Parameters

name
string · body required
Lowercase letters, digits and _, up to 512 characters.
language
string · body required
Language code, such as tr.
category
string · body required
MARKETING, UTILITY or AUTHENTICATION.
components
array · body required
Up to 10 components in Meta’s template format (header, body, footer, buttons). Body variables need an example.
phone_number_id
string · body
Which of the client’s connected numbers to use. Default: the most recently connected active number.

Example request

curl -X POST 'https://podimessage.io/api/v1/templates' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "appointment_reminder",
    "language": "tr",
    "category": "UTILITY",
    "components": [
      {
        "type": "BODY",
        "text": "Merhaba {{1}}, {{2}} tarihli randevunuzu hatırlatırız.",
        "example": {
          "body_text": [
            [
              "Ayşe",
              "12.11.2026 14:30"
            ]
          ]
        }
      }
    ]
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/templates');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'name' => 'appointment_reminder',
        'language' => 'tr',
        'category' => 'UTILITY',
        'components' => [
            [
                'type' => 'BODY',
                'text' => 'Merhaba {{1}}, {{2}} tarihli randevunuzu hatırlatırız.',
                'example' => [
                    'body_text' => [
                        ['Ayşe', '12.11.2026 14:30'],
                    ],
                ],
            ],
        ],
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/templates', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'appointment_reminder',
    language: 'tr',
    category: 'UTILITY',
    components: [
      {
        type: 'BODY',
        text: 'Merhaba {{1}}, {{2}} tarihli randevunuzu hatırlatırız.',
        example: {
          body_text: [
            ['Ayşe', '12.11.2026 14:30'],
          ],
        },
      },
    ],
  }),
});
const data = await res.json();

Response · HTTP 201

{
  "id": "1489022265523517",
  "status": "PENDING",
  "category": "UTILITY"
}

Other responses

409 The client has no active WhatsApp number (or not the phone_number_id you named).
422 WhatsApp refused the request; code is Meta’s error code.
DELETE /api/v1/templates/{name} Delete a template on Meta.
Token + X-Client-Id

Parameters

name
string · path required
Template name. Every language of that name is deleted.
phone_number_id
string · query
Which of the client’s connected numbers to use. Default: the most recently connected active number.

Example request

curl -X DELETE 'https://podimessage.io/api/v1/templates/appointment_reminder' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/templates/appointment_reminder');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/templates/appointment_reminder', {
  method: 'DELETE',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "status": "deleted",
  "name": "appointment_reminder"
}

Other responses

409 The client has no active WhatsApp number (or not the phone_number_id you named).
422 WhatsApp refused the request; code is Meta’s error code.
POST /api/v1/campaigns Send one approved template to up to 5,000 recipients, now or at scheduled_at. The balance is checked up front, and each message is charged when it is sent.
Token + X-Client-Id

A campaign sent at once goes queued → processing → completed; a scheduled one goes scheduled → processing → completed, or failed if the balance no longer covers it at the planned time. Cancel a scheduled one before it starts with DELETE /api/v1/campaigns/{campaign}. Each message also sends message.status webhooks with campaign_id.

Parameters

name
string · body required
Your name for the campaign, up to 120 characters.
template
string · body required
Name of an approved template.
language
string · body
Template language. Without it, the first template with that name is used.
recipients
array · body required
1 to 5,000 recipients.
recipients.*.phone
string · body required
Number with the country code, up to 20 characters; spaces and symbols are removed.
recipients.*.name
string · body
Optional, up to 120 characters.
recipients.*.params
array · body
Up to 10 strings for {{1}}, {{2}}… for this recipient.
scheduled_at
date · body
Optional time in the future (ISO 8601). Without it, sending starts at once.
phone_number_id
string · body
Which of the client’s connected numbers to use. Default: the most recently connected active number.

Example request

curl -X POST 'https://podimessage.io/api/v1/campaigns' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Kasım aşı hatırlatmaları",
    "template": "appointment_reminder",
    "language": "tr",
    "recipients": [
      {
        "phone": "905321234567",
        "name": "Ayşe",
        "params": [
          "Ayşe",
          "12.11.2026 14:30"
        ]
      },
      {
        "phone": "905339876543",
        "name": "Mehmet",
        "params": [
          "Mehmet",
          "13.11.2026 10:00"
        ]
      }
    ],
    "scheduled_at": "2026-11-10T09:00:00+03:00"
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/campaigns');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'name' => 'Kasım aşı hatırlatmaları',
        'template' => 'appointment_reminder',
        'language' => 'tr',
        'recipients' => [
            [
                'phone' => '905321234567',
                'name' => 'Ayşe',
                'params' => ['Ayşe', '12.11.2026 14:30'],
            ],
            [
                'phone' => '905339876543',
                'name' => 'Mehmet',
                'params' => ['Mehmet', '13.11.2026 10:00'],
            ],
        ],
        'scheduled_at' => '2026-11-10T09:00:00+03:00',
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/campaigns', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Kasım aşı hatırlatmaları',
    template: 'appointment_reminder',
    language: 'tr',
    recipients: [
      {
        phone: '905321234567',
        name: 'Ayşe',
        params: ['Ayşe', '12.11.2026 14:30'],
      },
      {
        phone: '905339876543',
        name: 'Mehmet',
        params: ['Mehmet', '13.11.2026 10:00'],
      },
    ],
    scheduled_at: '2026-11-10T09:00:00+03:00',
  }),
});
const data = await res.json();

Response · HTTP 201

{
  "id": 7,
  "name": "Kasım aşı hatırlatmaları",
  "template": "appointment_reminder",
  "language": "tr",
  "category": "utility",
  "status": "scheduled",
  "scheduled_at": "2026-11-10T09:00:00+03:00",
  "total_recipients": 2,
  "sent": 0,
  "failed": 0,
  "delivered": 0,
  "read": 0,
  "started_at": null,
  "completed_at": null
}

Other responses

402 The client cannot pay for every recipient.
{
  "message": "Insufficient balance. Top up and retry the request. The balance field in the response shows the current balance, and required (if present) the amount needed.",
  "required": 40,
  "balance": 20,
  "currency": "CREDIT"
}
409 The client has no active WhatsApp number (or not the phone_number_id you named).
422 No approved template with that name (and language).
{
  "message": "No approved template with this name (and language, if given). Check the template and language values."
}
GET /api/v1/campaigns/{campaign} A campaign and its progress.
Token + X-Client-Id

Parameters

campaign
integer · path required
The campaign’s id.

Example request

curl 'https://podimessage.io/api/v1/campaigns/7' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/campaigns/7');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/campaigns/7', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "id": 7,
  "name": "Kasım aşı hatırlatmaları",
  "template": "appointment_reminder",
  "language": "tr",
  "category": "utility",
  "status": "scheduled",
  "scheduled_at": "2026-11-10T09:00:00+03:00",
  "total_recipients": 2,
  "sent": 0,
  "failed": 0,
  "delivered": 0,
  "read": 0,
  "started_at": null,
  "completed_at": null
}

Other responses

404 No such campaign on this account.
{
  "message": "Campaign not found."
}
GET /api/v1/campaigns The client’s campaigns (from the panel and the API), newest first.
Token + X-Client-Id

Parameters

status
string · query
Only campaigns with this status: scheduled, queued, processing, completed, failed or cancelled.
per_page
integer · query
1–100, default 50.
page
integer · query
Page number, default 1.

Example request

curl 'https://podimessage.io/api/v1/campaigns?status=scheduled&per_page=20' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/campaigns?status=scheduled&per_page=20');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/campaigns?status=scheduled&per_page=20', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "data": [
    {
      "id": 7,
      "name": "Kasım aşı hatırlatmaları",
      "template": "appointment_reminder",
      "language": "tr",
      "category": "utility",
      "status": "scheduled",
      "scheduled_at": "2026-11-10T09:00:00+03:00",
      "total_recipients": 2,
      "sent": 0,
      "failed": 0,
      "delivered": 0,
      "read": 0,
      "started_at": null,
      "completed_at": null
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
DELETE /api/v1/campaigns/{campaign} Cancel a scheduled campaign before it starts: nothing is sent and nothing is charged.
Token + X-Client-Id

Only a scheduled campaign can be cancelled. At its planned time it becomes processing and can no longer be stopped, so cancel before then. Once a cancel succeeds, nothing is sent.

Parameters

campaign
integer · path required
The campaign’s id.

Example request

curl -X DELETE 'https://podimessage.io/api/v1/campaigns/7' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/campaigns/7');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/campaigns/7', {
  method: 'DELETE',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "id": 7,
  "name": "Kasım aşı hatırlatmaları",
  "template": "appointment_reminder",
  "language": "tr",
  "category": "utility",
  "status": "cancelled",
  "scheduled_at": "2026-11-10T09:00:00+03:00",
  "total_recipients": 2,
  "sent": 0,
  "failed": 0,
  "delivered": 0,
  "read": 0,
  "started_at": null,
  "completed_at": null
}

Other responses

404 No such campaign on this account.
409 The campaign is no longer scheduled (it started, finished or was already cancelled); status shows where it is. A started campaign cannot be stopped.
{
  "message": "The campaign is no longer scheduled: it has started, finished or been cancelled, so it cannot be cancelled.",
  "code": "not_scheduled",
  "status": "processing"
}

Inbox

Read each client’s WhatsApp conversations and answer them from your software. Use your token with X-Client-Id.

The 24-hour reply window

WhatsApp lets a business send free text only within 24 hours of the customer’s last message. can_reply and reply_window_expires_at tell you whether the window is open. Once it has closed, reply returns 422 with code: window_closed: send an approved template with POST /api/v1/messages/send instead (charged like any template). The window opens again when the customer answers.

Staying in sync

The message.inbound webhook tells you about new messages as they arrive. As a fallback, poll GET /api/v1/conversations with updated_since and fetch new messages with after_id.

An API reply does not take the chat over from the panel: its status and assigned team member stay as they are, but the customer no longer counts as waiting for a reply. POST …/read clears the unread badge for panel users too.

Conversation object
id integer
Conversation id.
wa_id string
The customer’s WhatsApp number (digits).
name string|null
The customer’s WhatsApp profile name.
phone_number_id string|null
The client number this chat is on. Pass it to messages/send to write from the same number.
status string
pool (in Waiting, not yet taken by anyone), assigned or closed, as in the panel.
unread_count integer
Customer messages not yet read.
last_message_preview string|null
Short text of the last message.
last_message_at string|null
Time of the last message in either direction.
last_inbound_at string|null
Time of the customer’s last message.
can_reply boolean
true while the 24-hour reply window is open and the number is active.
reply_window_expires_at string|null
When the reply window closes (in the past once closed); null if the customer has never written.
updated_at string|null
Last change; use it with updated_since.
Message object
id integer
Message id.
direction string
inbound (from the customer) or outbound.
type string
text, image, video, audio, document, sticker, location, button, interactive…
text string|null
Text or media caption.
media object|null
null, or type, mime, filename (documents) and download_path, which is relative to the API host and needs the same headers.
status string|null
sent, delivered, read or failed for outbound messages; null for inbound ones.
source string|null
Where an outbound message came from: panel, api, auto_reply, template or business_app (sent from the WhatsApp Business app on the business’s phone); null for inbound ones.
created_at string
When the message was stored.
GET /api/v1/conversations The client’s conversations, most recent first.
Token + X-Client-Id

Parameters

status
string · query
pool (in Waiting, not yet taken by anyone), assigned, closed or all (default).
updated_since
date · query
Only conversations changed at or after this time (ISO 8601). New messages, replies and reads all count as changes.
wa_id
string · query
Only the conversation with this customer number; anything but digits is ignored.
per_page
integer · query
1–100, default 50.
page
integer · query
Page number, default 1.

Example request

curl 'https://podimessage.io/api/v1/conversations?status=pool&updated_since=2026-11-02T10%3A00%3A00%2B03%3A00&per_page=20' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/conversations?status=pool&updated_since=2026-11-02T10%3A00%3A00%2B03%3A00&per_page=20');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/conversations?status=pool&updated_since=2026-11-02T10%3A00%3A00%2B03%3A00&per_page=20', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "data": [
    {
      "id": 12,
      "wa_id": "905321234567",
      "name": "Ayşe Yılmaz",
      "phone_number_id": "106540352242922",
      "status": "pool",
      "unread_count": 1,
      "last_message_preview": "Yarın 14:30 uygun mu?",
      "last_message_at": "2026-11-02T10:12:00+03:00",
      "last_inbound_at": "2026-11-02T10:12:00+03:00",
      "can_reply": true,
      "reply_window_expires_at": "2026-11-03T10:12:00+03:00",
      "updated_at": "2026-11-02T10:12:00+03:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "per_page": 20,
    "total": 1
  }
}
GET /api/v1/conversations/{conversation} One conversation.
Token + X-Client-Id

Parameters

conversation
integer · path required
The conversation’s id.

Example request

curl 'https://podimessage.io/api/v1/conversations/12' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/conversations/12');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/conversations/12', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "id": 12,
  "wa_id": "905321234567",
  "name": "Ayşe Yılmaz",
  "phone_number_id": "106540352242922",
  "status": "pool",
  "unread_count": 1,
  "last_message_preview": "Yarın 14:30 uygun mu?",
  "last_message_at": "2026-11-02T10:12:00+03:00",
  "last_inbound_at": "2026-11-02T10:12:00+03:00",
  "can_reply": true,
  "reply_window_expires_at": "2026-11-03T10:12:00+03:00",
  "updated_at": "2026-11-02T10:12:00+03:00"
}

Other responses

404 No such conversation on this account.
{
  "message": "Conversation not found."
}
GET /api/v1/conversations/{conversation}/messages A conversation’s messages, newest first.
Token + X-Client-Id

To page back, pass meta.next_before_id as before_id while has_more is true.

Parameters

conversation
integer · path required
The conversation’s id.
per_page
integer · query
1–100, default 50.
before_id
integer · query
Only messages older than this id.
after_id
integer · query
Only messages newer than this id, for polling. Keep it when paging back.

Example request

curl 'https://podimessage.io/api/v1/conversations/12/messages?per_page=2' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/conversations/12/messages?per_page=2');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/conversations/12/messages?per_page=2', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "data": [
    {
      "id": 345,
      "direction": "inbound",
      "type": "text",
      "text": "Yarın 14:30 uygun mu?",
      "media": null,
      "status": null,
      "source": null,
      "created_at": "2026-11-02T10:12:00+03:00"
    },
    {
      "id": 344,
      "direction": "inbound",
      "type": "image",
      "text": "Aşı karnesi",
      "media": {
        "type": "image",
        "mime": "image/jpeg",
        "filename": null,
        "download_path": "/api/v1/messages/344/media"
      },
      "status": null,
      "source": null,
      "created_at": "2026-11-02T10:11:00+03:00"
    }
  ],
  "meta": {
    "per_page": 2,
    "has_more": true,
    "next_before_id": 344
  }
}

Other responses

404 No such conversation on this account.
{
  "message": "Conversation not found."
}
POST /api/v1/conversations/{conversation}/reply Answer with free text inside the 24-hour reply window. Not charged.
Token + X-Client-Id

Parameters

conversation
integer · path required
The conversation’s id.
text
string · body required
Message text, 1–4,096 characters.
Idempotency-Key
string · header
Optional unique key, up to 128 characters (or idempotency_key in the body). A repeat within 24 hours returns the first answer and sends nothing.

Example request

curl -X POST 'https://podimessage.io/api/v1/conversations/12/reply' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Idempotency-Key: reply-77f1c2' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "text": "Merhaba Ayşe, yarın 14:30 için randevunuzu oluşturduk."
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/conversations/12/reply');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Idempotency-Key: reply-77f1c2',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'text' => 'Merhaba Ayşe, yarın 14:30 için randevunuzu oluşturduk.',
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/conversations/12/reply', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    'Idempotency-Key': 'reply-77f1c2',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'Merhaba Ayşe, yarın 14:30 için randevunuzu oluşturduk.',
  }),
});
const data = await res.json();

Response · HTTP 200

{
  "status": "sent",
  "message": {
    "id": 346,
    "direction": "outbound",
    "type": "text",
    "text": "Merhaba Ayşe, yarın 14:30 için randevunuzu oluşturduk.",
    "media": null,
    "status": "sent",
    "source": "api",
    "created_at": "2026-11-02T10:15:00+03:00"
  },
  "conversation": {
    "id": 12,
    "wa_id": "905321234567",
    "name": "Ayşe Yılmaz",
    "phone_number_id": "106540352242922",
    "status": "pool",
    "unread_count": 1,
    "last_message_preview": "Yarın 14:30 uygun mu?",
    "last_message_at": "2026-11-02T10:12:00+03:00",
    "last_inbound_at": "2026-11-02T10:12:00+03:00",
    "can_reply": true,
    "reply_window_expires_at": "2026-11-03T10:12:00+03:00",
    "updated_at": "2026-11-02T10:12:00+03:00"
  }
}

Other responses

422 The 24-hour reply window has closed (also when WhatsApp reports it); send a template instead.
{
  "message": "The 24-hour reply window is closed because the customer has not written in the last 24 hours. Send an approved template with POST /api/v1/messages/send instead.",
  "code": "window_closed",
  "template_endpoint": "/api/v1/messages/send"
}
409 The conversation’s number is no longer active.
{
  "message": "No active WhatsApp number is connected. Connect one in the panel.",
  "code": "no_account"
}
409 A request with the same Idempotency-Key is still running.
{
  "message": "A request with this idempotency key is still being processed. Try again shortly.",
  "code": "in_progress"
}
422 WhatsApp refused the reply; retryable says whether trying again may help. Nothing was stored.
{
  "message": "(#131026) Message undeliverable",
  "code": "131026",
  "retryable": false
}
404 No such conversation on this account.
{
  "message": "Conversation not found."
}
POST /api/v1/conversations/{conversation}/read Mark the conversation as read (unread count 0), in open panel inboxes as well.
Token + X-Client-Id

Parameters

conversation
integer · path required
The conversation’s id.

Example request

curl -X POST 'https://podimessage.io/api/v1/conversations/12/read' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/conversations/12/read');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
    CURLOPT_POSTFIELDS => '',
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/conversations/12/read', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "id": 12,
  "wa_id": "905321234567",
  "name": "Ayşe Yılmaz",
  "phone_number_id": "106540352242922",
  "status": "pool",
  "unread_count": 0,
  "last_message_preview": "Yarın 14:30 uygun mu?",
  "last_message_at": "2026-11-02T10:12:00+03:00",
  "last_inbound_at": "2026-11-02T10:12:00+03:00",
  "can_reply": true,
  "reply_window_expires_at": "2026-11-03T10:12:00+03:00",
  "updated_at": "2026-11-02T10:12:00+03:00"
}

Other responses

404 No such conversation on this account.
{
  "message": "Conversation not found."
}
GET /api/v1/messages/{message}/media Download a message’s photo, video, voice note, document or sticker, streamed from WhatsApp.
Token + X-Client-Id

Photos, videos and voice notes come inline with their real type; anything else (documents, SVG, …) comes as an application/octet-stream attachment. WhatsApp keeps media for about 30 days.

Parameters

message
integer · path required
The message’s id (as in media.download_path).

Example request

curl 'https://podimessage.io/api/v1/messages/344/media' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'X-Client-Id: 57' \
  -H 'Accept: application/json' \
  -o media.jpg
<?php
$ch = curl_init('https://podimessage.io/api/v1/messages/344/media');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'X-Client-Id: 57',
        'Accept: application/json',
    ],
]);
file_put_contents('media.jpg', curl_exec($ch));
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
import { writeFile } from 'node:fs/promises';

const res = await fetch('https://podimessage.io/api/v1/messages/344/media', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    'X-Client-Id': '57',
    Accept: 'application/json',
  },
});
await writeFile('media.jpg', Buffer.from(await res.arrayBuffer()));

Response · HTTP 200

The file itself, with these headers:

Content-Type: image/jpeg
Content-Disposition: inline; filename="media-344"
Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'
X-Content-Type-Options: nosniff
Cache-Control: max-age=300, private

Other responses

404 No such message, or it has no media.
{
  "message": "Message media not found."
}
502 WhatsApp no longer serves the file, for example because it has expired.
{
  "message": "The media could not be fetched from WhatsApp. It may have expired.",
  "code": "100"
}

Webhooks

We POST signed events to one HTTPS address of yours: incoming messages, message delivery statuses, template review results, connected numbers and low balances. One endpoint covers your own account and all your clients.

Setting up

Create your endpoint with PUT /api/v1/reseller/webhook. The first call returns the signing secret (whsec_…) once: store it safely. The URL must be public HTTPS on port 443 with a domain name. Private and reserved addresses are refused, and the address is checked again before every delivery.

PUT /api/v1/reseller/webhook Create your endpoint (201, with the signing secret) or change it (200, without the signing secret).
Reseller token

Later calls return 200 and keep the signing secret. Store it the first time: you can only replace it, with rotate-secret.

Parameters

url
string · body required
Your endpoint, up to 2,048 characters, required on every call: HTTPS on port 443, with a domain name that resolves to a public address. A new URL resets the failure count.
client_low_balance_threshold
integer|null · body
Alert threshold in credits (a multiple of 10), or null to turn the alert off. Leave it out to keep the current value.
reseller_low_balance_threshold
integer|null · body
Alert threshold in credits (a multiple of 10), or null to turn the alert off. Leave it out to keep the current value.
active
boolean · body
false switches the endpoint off: events that happen while it is off are not sent, then or later. true switches it back on and clears an automatic shut-off.

Example request

curl -X PUT 'https://podimessage.io/api/v1/reseller/webhook' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://crm.example.com/webhooks/whatsapp",
    "client_low_balance_threshold": 5000,
    "reseller_low_balance_threshold": 100000
  }'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/webhook');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'url' => 'https://crm.example.com/webhooks/whatsapp',
        'client_low_balance_threshold' => 5000,
        'reseller_low_balance_threshold' => 100000,
    ]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/webhook', {
  method: 'PUT',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://crm.example.com/webhooks/whatsapp',
    client_low_balance_threshold: 5000,
    reseller_low_balance_threshold: 100000,
  }),
});
const data = await res.json();

Response · HTTP 201

{
  "secret": "whsec_Lr4Tq8Wn2Hc6Vy0Bk3Mz7Pd1Xs5Fg9Ja2Ue6Ko4Ri8Nt0Qw3Yb",
  "url": "https://crm.example.com/webhooks/whatsapp",
  "active": true,
  "client_low_balance_threshold": 5000,
  "reseller_low_balance_threshold": 100000,
  "events": [
    "message.inbound",
    "message.status",
    "template.status",
    "client.connected",
    "client.balance_low",
    "reseller.balance_low",
    "ping"
  ],
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "last_error": null,
  "disabled_at": null,
  "created_at": "2026-11-02T10:15:00+03:00",
  "updated_at": "2026-11-02T10:15:00+03:00"
}

Other responses

422 The URL is not allowed: not HTTPS, an IP address, a port other than 443, a name that does not resolve or a private address.
{
  "message": "The domain in the webhook URL points to a private or reserved network address. Enter the address of a server reachable from the internet.",
  "errors": {
    "url": [
      "The domain in the webhook URL points to a private or reserved network address. Enter the address of a server reachable from the internet."
    ]
  }
}
429 More than 10 URL changes in a minute (refused URLs count too); wait for Retry-After.
{
  "message": "Too many changes. Try again in 42 seconds."
}
GET /api/v1/reseller/webhook Your endpoint’s settings and health. The signing secret is never shown here.
Reseller token

Example request

curl 'https://podimessage.io/api/v1/reseller/webhook' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/webhook');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/webhook', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "url": "https://crm.example.com/webhooks/whatsapp",
  "active": true,
  "client_low_balance_threshold": 5000,
  "reseller_low_balance_threshold": 100000,
  "events": [
    "message.inbound",
    "message.status",
    "template.status",
    "client.connected",
    "client.balance_low",
    "reseller.balance_low",
    "ping"
  ],
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "last_error": null,
  "disabled_at": null,
  "created_at": "2026-11-02T10:15:00+03:00",
  "updated_at": "2026-11-02T10:15:00+03:00"
}

Other responses

404 No webhook has been set up yet.
{
  "message": "No webhook is set up yet. Create one with PUT /api/v1/reseller/webhook."
}
POST /api/v1/reseller/webhook/rotate-secret Issue a new signing secret. The old one stops working at once.
Reseller token

Example request

curl -X POST 'https://podimessage.io/api/v1/reseller/webhook/rotate-secret' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/webhook/rotate-secret');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
    CURLOPT_POSTFIELDS => '',
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/webhook/rotate-secret', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "secret": "whsec_Xc81LwQe2pTn6Rk0Vb4Ys9Hd3Jm7Fa5Gu1Zo8Ni2Ce6Ql4Wr",
  "url": "https://crm.example.com/webhooks/whatsapp",
  "active": true,
  "client_low_balance_threshold": 5000,
  "reseller_low_balance_threshold": 100000,
  "events": [
    "message.inbound",
    "message.status",
    "template.status",
    "client.connected",
    "client.balance_low",
    "reseller.balance_low",
    "ping"
  ],
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "last_error": null,
  "disabled_at": null,
  "created_at": "2026-11-02T10:15:00+03:00",
  "updated_at": "2026-11-02T11:00:00+03:00"
}

Other responses

404 No webhook has been set up yet.
{
  "message": "No webhook is set up yet. Create one with PUT /api/v1/reseller/webhook."
}
429 More than 10 secret rotations in an hour; wait for Retry-After.
{
  "message": "Too many changes. Try again in 1800 seconds."
}
POST /api/v1/reseller/webhook/test Send a ping now, once, and see what your server answered. Works while the endpoint is switched off and does not count towards its failures.
Reseller token

Example request

curl -X POST 'https://podimessage.io/api/v1/reseller/webhook/test' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/webhook/test');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
    CURLOPT_POSTFIELDS => '',
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/webhook/test', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "delivered": true,
  "response_code": 200,
  "error": null,
  "delivery": {
    "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
    "event": "ping",
    "status": "delivered",
    "attempts": 1,
    "response_code": 200,
    "last_error": null,
    "created_at": "2026-11-02T10:15:00+03:00",
    "last_attempt_at": "2026-11-02T10:15:00+03:00",
    "delivered_at": "2026-11-02T10:15:00+03:00",
    "payload": {
      "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
      "event": "ping",
      "created_at": "2026-11-02T10:15:00+03:00",
      "data": {
        "test": true
      }
    }
  }
}

Other responses

429 More than 5 test pings in a minute.
{
  "message": "Too many test requests. Try again in 42 seconds."
}
404 No webhook has been set up yet.
{
  "message": "No webhook is set up yet. Create one with PUT /api/v1/reseller/webhook."
}
GET /api/v1/reseller/webhook/deliveries Recent deliveries, newest first, with the exact payload. Kept for 30 days.
Reseller token

Parameters

event
string · query
Only this event, for example message.inbound.
status
string · query
pending, delivered or failed.
per_page
integer · query
1–100, default 50.
page
integer · query
Page number, default 1.

Example request

curl 'https://podimessage.io/api/v1/reseller/webhook/deliveries?status=failed&per_page=20' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/webhook/deliveries?status=failed&per_page=20');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/webhook/deliveries?status=failed&per_page=20', {
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
const data = await res.json();

Response · HTTP 200

{
  "data": [
    {
      "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
      "event": "message.inbound",
      "status": "failed",
      "attempts": 6,
      "response_code": 503,
      "last_error": "http_503: Service Unavailable",
      "created_at": "2026-11-02T10:15:00+03:00",
      "last_attempt_at": "2026-11-02T13:41:00+03:00",
      "delivered_at": null,
      "payload": {
        "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
        "event": "message.inbound",
        "created_at": "2026-11-02T10:15:00+03:00",
        "data": {
          "client_id": 57,
          "external_id": "crm-1042",
          "conversation_id": 12,
          "message_id": 345,
          "phone_number_id": "106540352242922",
          "wa_id": "905321234567",
          "name": "Ayşe Yılmaz",
          "type": "text",
          "text": "Yarın 14:30 uygun mu?",
          "has_media": false,
          "timestamp": "2026-11-02T10:12:00+03:00"
        }
      }
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
DELETE /api/v1/reseller/webhook Remove the endpoint and its delivery log.
Reseller token

Example request

curl -X DELETE 'https://podimessage.io/api/v1/reseller/webhook' \
  -H 'Authorization: Bearer podi_xxxxxxxxxxxxxxxx' \
  -H 'Accept: application/json'
<?php
$ch = curl_init('https://podimessage.io/api/v1/reseller/webhook');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer podi_xxxxxxxxxxxxxxxx',
        'Accept: application/json',
    ],
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
const res = await fetch('https://podimessage.io/api/v1/reseller/webhook', {
  method: 'DELETE',
  headers: {
    Authorization: 'Bearer podi_xxxxxxxxxxxxxxxx',
    Accept: 'application/json',
  },
});
console.log(res.status); // 204

Response · HTTP 204

No body.

Other responses

404 No webhook has been set up yet.
{
  "message": "No webhook is set up yet. Create one with PUT /api/v1/reseller/webhook."
}

Envelope

Every event has the same envelope. For events about a client, data starts with client_id (send it as X-Client-Id) and external_id (your id, may be null). Events about your own account have neither.

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "client.balance_low",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "balance": 4980,
    "threshold": 5000
  }
}

Request headers

Content-Type

application/json

The body is JSON.
User-Agent

Webhooks/1.0

Neutral; it carries no platform name.
X-Webhook-Id

9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d

Unique per event and the same on every retry (it equals id in the body). Use it to skip duplicates.
X-Webhook-Event

message.inbound

The event name (it equals event in the body).
X-Webhook-Timestamp

1793603700

Unix time of this attempt, in seconds. Part of the signature.
X-Webhook-Signature

v1=6f1c0b7e4a…

v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret.
X-Webhook-Attempt

1

Which attempt this is, 1 to 6.

Events

message.inbound A customer wrote to a client’s number. text is the message or the media caption; other types read like [location]. When has_media is true, download the file with GET /api/v1/messages/{message_id}/media.

Example

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "message.inbound",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "conversation_id": 12,
    "message_id": 345,
    "phone_number_id": "106540352242922",
    "wa_id": "905321234567",
    "name": "Ayşe Yılmaz",
    "type": "text",
    "text": "Yarın 14:30 uygun mu?",
    "has_media": false,
    "timestamp": "2026-11-02T10:12:00+03:00"
  }
}
message.status A message’s delivery status changed to sent, delivered, read or failed. kind: template is a template or campaign message: message_id is the one messages/send returned, with campaign_id. kind: conversation is an inbox reply, with conversation_id. A failure carries an error: Meta’s numeric code and its title, or, when the message failed before WhatsApp accepted it (wamid is null), one of our text codes: account_unavailable (no active number), insufficient_balance, job_failed (still not sent after several attempts) or header_media_required. That failure is sent once, even when messages/send already answered with the error. kind: webhook_event is a delayed message from the client’s own webhook automation that failed before it could be sent; its message_id is the queue entry’s id. A scheduled campaign that cannot start creates no messages, so it sends no events; check it with GET /api/v1/campaigns/{campaign}. A late, lower status (delivered after read) is not sent.

Template message delivered

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "message.status",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "message_id": 1234,
    "kind": "template",
    "wamid": "wamid.HBgMOTA1MzIxMjM0NTY3FQIAERgSQ0Y2…",
    "status": "delivered",
    "wa_id": "905321234567",
    "error": null,
    "campaign_id": null,
    "timestamp": "2026-11-02T10:16:00+03:00"
  }
}

Inbox reply failed

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "message.status",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "message_id": 346,
    "kind": "conversation",
    "wamid": "wamid.HBgMOTA1MzIxMjM0NTY3FQIAERgSRTk4…",
    "status": "failed",
    "wa_id": "905321234567",
    "error": {
      "code": 131047,
      "title": "Re-engagement message"
    },
    "conversation_id": 12,
    "timestamp": "2026-11-02T10:16:00+03:00"
  }
}

Template message failed before WhatsApp accepted it

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "message.status",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "message_id": 1235,
    "kind": "template",
    "wamid": null,
    "status": "failed",
    "wa_id": "905321234567",
    "error": {
      "code": "insufficient_balance",
      "title": "Gönderilmedi: bakiye yetersiz."
    },
    "campaign_id": 7,
    "timestamp": "2026-11-02T10:17:00+03:00"
  }
}

Webhook automation message failed

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "message.status",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "message_id": 88,
    "kind": "webhook_event",
    "wamid": null,
    "status": "failed",
    "wa_id": "905321234567",
    "error": {
      "code": "account_unavailable",
      "title": "Mesaj gönderebilmek için önce aktif bir WhatsApp hesabı bağlamanız gerekir."
    },
    "timestamp": "2026-11-02T10:18:00+03:00"
  }
}
template.status Meta reviewed or changed a template. status is approved, rejected, paused, disabled and so on; reason says why when Meta gives a reason.

Example

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "template.status",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "name": "appointment_reminder",
    "language": "tr",
    "status": "rejected",
    "reason": "INVALID_FORMAT"
  }
}
client.connected A client finished connecting its number, through your connection link or from its own panel.

Example

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "client.connected",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "phone_number_id": "106540352242922",
    "display_phone_number": "+90 216 555 01 42",
    "verified_name": "Pati Klinik"
  }
}
client.balance_low A client’s balance fell below your client_low_balance_threshold. Sent once per crossing: it fires again only after the balance rises back above the threshold.

Example

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "client.balance_low",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "client_id": 57,
    "external_id": "crm-1042",
    "balance": 4980,
    "threshold": 5000
  }
}
reseller.balance_low Your own balance fell below your reseller_low_balance_threshold, with the same once-per-crossing rule.

Example

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "reseller.balance_low",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "balance": 99000,
    "threshold": 100000
  }
}
ping Sent only by POST /api/v1/reseller/webhook/test.

Example

{
  "id": "9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d",
  "event": "ping",
  "created_at": "2026-11-02T10:15:00+03:00",
  "data": {
    "test": true
  }
}

Verifying the signature

  1. 1 Read the raw request body before parsing it: re-encoded JSON will not match.
  2. 2 Compute v1= + the hex HMAC-SHA256 of {X-Webhook-Timestamp}.{raw body} with your signing secret.
  3. 3 Compare it with X-Webhook-Signature in constant time.
  4. 4 Reject timestamps more than 5 minutes away, to stop replays.

Example

<?php
function verifyWebhook(string $secret, string $body, string $timestamp, string $signature): bool
{
    // Reject a missing timestamp or one more than 5 minutes away (replays).
    if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);

    return hash_equals($expected, $signature);
}

// The raw body: decode it only after the check.
$body = file_get_contents('php://input');

if (! verifyWebhook(
    getenv('WEBHOOK_SECRET'),
    $body,
    $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// Skip $event['id'] if you have seen it, queue the work, answer at once.
http_response_code(200);
const http = require('node:http');
const crypto = require('node:crypto');

function verifyWebhook(secret, rawBody, timestamp, signature) {
  // Reject a missing timestamp or one more than 5 minutes away (replays).
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }
  const expected = Buffer.from('v1=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(rawBody)
    .digest('hex'));
  const given = Buffer.from(signature);
  return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks); // the raw bytes, not re-serialised JSON
    const ok = verifyWebhook(
      process.env.WEBHOOK_SECRET,
      rawBody,
      req.headers['x-webhook-timestamp'] || '',
      req.headers['x-webhook-signature'] || '',
    );
    res.writeHead(ok ? 200 : 401).end();
    if (ok) {
      const event = JSON.parse(rawBody.toString('utf8'));
      // Skip event.id if you have seen it, then handle the event.
    }
  });
}).listen(3000);

A new signing secret from rotate-secret applies at once. A delivery you reject during the switch is retried later, signed with the new secret.

Delivery and retries

  • Answer with any 2xx status within 10 seconds, and do the real work after answering (for example in a queue).
  • Anything else (another status, a redirect, which is never followed, a timeout or a connection error) is retried after 1 min, 5 min, 20 min, 1 h and 2 h, 6 attempts in all. After that the delivery is marked failed.
  • If 25 deliveries in a row fail for good and nothing has got through for 3 days, the endpoint is switched off (disabled_at). Switch it back on with PUT and "active": true.
  • Events can arrive more than once and out of order. Skip an X-Webhook-Id you have already handled and order by the timestamp in data. message.status can also repeat for the same wamid and status, so dedupe on those too.
  • Every delivery is kept for 30 days: GET …/deliveries shows the attempts, response codes and errors.

Number connection

A client connects its own WhatsApp Business number through Meta’s official sign-up. You send it a link; it does not need a login.

  1. 1

    Create a link with POST /api/v1/reseller/clients/{client}/connect-link, optionally with a return_url in your software.

  2. 2

    Send the client to the url: open it in a new tab or redirect the browser (it cannot be shown in an iframe). The page needs no login and shows, in the client’s language, the display name and logo you set in the Branding tab (else your account name), the platform’s name as your WhatsApp partner, and the client’s name.

  3. 3

    The client clicks the button and completes Meta’s official sign-up window: it signs in with Facebook, picks its business and number, and confirms.

  4. 4

    We save the number, close the link and send you the client.connected webhook. The number also appears under whatsapp in the client object.

  5. 5

    The page sends the client back to your return_url with status=connected added to the query string.

Meta’s window shows the name of the platform’s Meta app. Meta controls that window, so the name cannot be hidden or changed.
  • A link works once and expires after 7 days; an expired or used link, or one for a suspended client, shows a brandless “link no longer valid” page (HTTP 410). Each call creates a new link; earlier links stay valid until they are used or expire.
  • Treat status=connected as a hint for your screen only, since anyone can type it. Rely on the webhook or on GET /api/v1/reseller/clients/{client}.
  • A suspended client’s links do not open until you activate it again.