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.
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/balancereturns it asprice. -
The client price is what a client pays per template message. Set it with
pricewhen 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
creditand take it back withwithdraw. 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/jsonandAccept: 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) andpageand returnmetawithcurrent_page,last_pageandtotal. 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).
balance, and required where it is known.
X-Client-Id on a reseller endpoint).
Allow header.
message and code).
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.
-
creditandwithdraw: theIdempotency-Keyheader (oridempotency_keyin the body). Keys are kept per client and direction. The same key with another amount gets 422 withcode: idempotency_key_reused. -
messages/send:idempotency_keyin 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 withcode: idempotency_key_reused. -
conversations/{id}/reply: theIdempotency-Keyheader (oridempotency_keyin the body), per conversation. A replayed answer carriesIdempotent-Replayed: true; while the first request is still running you get 409 withcode: in_progress. -
otp/sendandcampaignstake 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
Open a client account
Keep the returned
id: from now on you send it asX-Client-Id. Your ownexternal_idlets you find the client again.Example request —
POST /api/v1/reseller/clientscurl -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
Send it credit
Sends 50,000 credits from your balance to the client’s.
Example request —
POST /api/v1/reseller/clients/{client}/creditcurl -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
Connect its WhatsApp number
Send the returned
urllink to the client, or open it in the client’s browser. When it is done you receive theclient.connectedwebhook and the client comes back to yourreturn_urlwithstatus=connected.Example request —
POST /api/v1/reseller/clients/{client}/connect-linkcurl -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
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/sendcurl -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
Receive the reply by webhook
When the customer writes back, we POST
message.inboundto your webhook. It names the client (client_id,external_id) and theconversation_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
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}/replycurl -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
X-Client-Id.external_id
string|null
name
string
email
string|null
null when the client has no panel login.panel_login
boolean
active
boolean
false while the client is suspended.balance
integer
price
integer
currency
string
PODI on your calls.whatsapp
array
phone_number_id, display_phone_number, verified_name and status.created_at
string
GET
/api/v1/reseller/balance
Your balance, your price per template message and how many clients you have.
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.
Parameters
external_id
external_id (exact match).per_page
page
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.
Parameters
name
external_id
email
password. Leave both out for an API-only client. It must not be used by another account.password
email.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
{
"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.
Parameters
client
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
{
"message": "Client account not found."
}
PATCH
/api/v1/reseller/clients/{client}
Change a client. Only the fields you send change.
Parameters
client
id.name
external_id
null clears it.email
password. Leave both out for an API-only client. It must not be used by another account.password
email.active
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
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
POST
/api/v1/reseller/clients/{client}/credit
Send credit from your balance to the client’s.
Parameters
client
id.amount
idempotency_key
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
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"
}
Idempotency-Key is still running.
{
"message": "A request with this idempotency key is still being processed. Try again shortly.",
"code": "in_progress"
}
{
"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.
Parameters
client
id.amount
idempotency_key
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
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"
}
Idempotency-Key is still running.
{
"message": "A request with this idempotency key is still being processed. Try again shortly.",
"code": "in_progress"
}
{
"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.
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
id.from
to
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
POST
/api/v1/reseller/clients/{client}/connect-link
A single-use link where the client connects its WhatsApp number (see Number connection). Valid for 7 days.
Parameters
client
id.return_url
status=connected added.Example request
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();
Response · HTTP 200
{
"url": "https://podimessage.io/connect/57?expires=1794208500&nonce=Qm9X…&return=https%3A%2F%2Fcrm.example.com%2Fclinics%2F1042%2Fwhatsapp&signature=3b1f…",
"expires_at": "2026-11-09T10:15:00+03:00"
}
Other responses
{
"message": "A suspended client cannot connect a number. Reactivate it first.",
"code": "client_inactive"
}
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
name
string
template
string
language
string
category
string
marketing, utility or authentication.status
string
scheduled, queued, processing, completed, failed or cancelled.scheduled_at
string|null
null when sent at once.total_recipients
integer
sent
integer
failed
integer
delivered
integer
read
integer
started_at
string|null
completed_at
string|null
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.
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.
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
template
language
tr or en_US. The default is en_US, so set it for Turkish templates.params
{{1}}, {{2}}… in the template body, in order.phone_number_id
idempotency_key
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
{
"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"
}
phone_number_id you named).
{
"message": "No active WhatsApp number is connected. Connect one in the panel."
}
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.
Parameters
to
template
code
language
tr or en_US. The default is en_US, so set it for Turkish templates.include_button
true. Set it to false if the template has no button.button_sub_type
url (default; works for one-tap and copy-code buttons) or copy_code.phone_number_id
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
phone_number_id you named).
{
"message": "No active WhatsApp number is connected. Connect one in the panel."
}
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).
Parameters
status
ALL (default), APPROVED, PENDING, REJECTED, PAUSED or DISABLED.phone_number_id
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
phone_number_id you named).
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.
Parameters
name
_, up to 512 characters.language
tr.category
MARKETING, UTILITY or AUTHENTICATION.components
example.phone_number_id
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
phone_number_id you named).
code is Meta’s error code.
DELETE
/api/v1/templates/{name}
Delete a template on Meta.
Parameters
name
phone_number_id
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
phone_number_id you named).
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.
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
template
language
recipients
recipients.*.phone
recipients.*.name
recipients.*.params
{{1}}, {{2}}… for this recipient.scheduled_at
phone_number_id
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
{
"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"
}
phone_number_id you named).
{
"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.
Parameters
campaign
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
{
"message": "Campaign not found."
}
GET
/api/v1/campaigns
The client’s campaigns (from the panel and the API), newest first.
Parameters
status
scheduled, queued, processing, completed, failed or cancelled.per_page
page
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.
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
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
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
wa_id
string
name
string|null
phone_number_id
string|null
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
last_message_preview
string|null
last_message_at
string|null
last_inbound_at
string|null
can_reply
boolean
true while the 24-hour reply window is open and the number is active.reply_window_expires_at
string|null
null if the customer has never written.updated_at
string|null
updated_since.Message object
id
integer
direction
string
inbound (from the customer) or outbound.type
string
text, image, video, audio, document, sticker, location, button, interactive…text
string|null
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
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
GET
/api/v1/conversations
The client’s conversations, most recent first.
Parameters
status
pool (in Waiting, not yet taken by anyone), assigned, closed or all (default).updated_since
wa_id
per_page
page
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.
Parameters
conversation
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
{
"message": "Conversation not found."
}
GET
/api/v1/conversations/{conversation}/messages
A conversation’s messages, newest first.
To page back, pass meta.next_before_id as before_id while has_more is true.
Parameters
conversation
id.per_page
before_id
after_id
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
{
"message": "Conversation not found."
}
POST
/api/v1/conversations/{conversation}/reply
Answer with free text inside the 24-hour reply window. Not charged.
Parameters
conversation
id.text
Idempotency-Key
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
{
"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"
}
{
"message": "No active WhatsApp number is connected. Connect one in the panel.",
"code": "no_account"
}
Idempotency-Key is still running.
{
"message": "A request with this idempotency key is still being processed. Try again shortly.",
"code": "in_progress"
}
retryable says whether trying again may help. Nothing was stored.
{
"message": "(#131026) Message undeliverable",
"code": "131026",
"retryable": false
}
{
"message": "Conversation not found."
}
POST
/api/v1/conversations/{conversation}/read
Mark the conversation as read (unread count 0), in open panel inboxes as well.
Parameters
conversation
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
{
"message": "Conversation not found."
}
GET
/api/v1/messages/{message}/media
Download a message’s photo, video, voice note, document or sticker, streamed from WhatsApp.
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
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
{
"message": "Message media not found."
}
{
"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).
Later calls return 200 and keep the signing secret. Store it the first time: you can only replace it, with rotate-secret.
Parameters
url
client_low_balance_threshold
null to turn the alert off. Leave it out to keep the current value.reseller_low_balance_threshold
null to turn the alert off. Leave it out to keep the current value.active
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
{
"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."
]
}
}
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.
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
{
"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.
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
{
"message": "No webhook is set up yet. Create one with PUT /api/v1/reseller/webhook."
}
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.
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
{
"message": "Too many test requests. Try again in 42 seconds."
}
{
"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.
Parameters
event
message.inbound.status
pending, delivered or failed.per_page
page
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.
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
{
"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
User-Agent
Webhooks/1.0
X-Webhook-Id
9b2f7c1e-3d4a-4f6b-8c5d-1e2f3a4b5c6d
id in the body). Use it to skip duplicates.X-Webhook-Event
message.inbound
event in the body).X-Webhook-Timestamp
1793603700
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
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 Read the raw request body before parsing it: re-encoded JSON will not match.
-
2
Compute
v1=+ the hex HMAC-SHA256 of{X-Webhook-Timestamp}.{raw body}with your signing secret. -
3
Compare it with
X-Webhook-Signaturein constant time. - 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 withPUTand"active": true. -
Events can arrive more than once and out of order. Skip an
X-Webhook-Idyou have already handled and order by thetimestampindata.message.statuscan also repeat for the samewamidand status, so dedupe on those too. -
Every delivery is kept for 30 days:
GET …/deliveriesshows 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
Create a link with
POST /api/v1/reseller/clients/{client}/connect-link, optionally with areturn_urlin your software. -
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
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
We save the number, close the link and send you the
client.connectedwebhook. The number also appears underwhatsappin the client object. -
5
The page sends the client back to your
return_urlwithstatus=connectedadded to the query string.
- 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=connectedas a hint for your screen only, since anyone can type it. Rely on the webhook or onGET /api/v1/reseller/clients/{client}. - A suspended client’s links do not open until you activate it again.