Send a WhatsApp message with PHP
This page sends a WhatsApp message from plain PHP with one HTTP request, then receives the reply. It uses PHP's own cURL extension, so it needs no Composer package.
What you need
- An account on Threadwire.
- A linked number. In your panel, click "Add instance" and scan the QR code with WhatsApp on the phone (Settings → Linked devices). Then copy the Instance ID from the instance's page.
- An API key. Make it under Developers → API keys → New key (admins only).
- PHP with the cURL extension. The sample reads the key from an environment variable,
THREADWIRE_KEY.
An instance is one WhatsApp number linked to Threadwire. It is linked as a device, the same way WhatsApp Web is linked, so this is not Meta's official API. WhatsApp API without Meta approval says what that means.
Send the message
Send the first message to yourself. Write "hi" to your linked number from your own WhatsApp on another phone, then send to that phone. Only one phone? Use the linked number's own number as to.
// PHP
$request = curl_init('https://threadwire.net/api/v1/messages');
curl_setopt_array($request, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer '.getenv('THREADWIRE_KEY'), 'Content-Type: application/json', 'Accept: application/json'],
CURLOPT_POSTFIELDS => json_encode(['instance_id' => 'YOUR_INSTANCE_ID', 'to' => 'YOUR_PHONE', 'text' => 'Hello from Threadwire']),
]);
$answer = json_decode(curl_exec($request), true);
if ($answer === null || curl_getinfo($request, CURLINFO_HTTP_CODE) >= 400) {
throw new RuntimeException($answer['message'] ?? 'Threadwire refused the request');
}
$message = $answer['data'];
echo $message['id'].' '.$message['status'].PHP_EOL;
Authorization: your API key as a bearer token, sent on every request.instance_id: the Instance ID you copied.to: that phone's number in international format, like201012345678: country code, no+needed, no leading 0.text: the message, up to 4,096 characters.
Read the answer
The answer comes at once, as JSON, with the result under data:
202 {"data": {"id": "01K…", "status": "queued", "phone": "201012345678", "body": "Hello from Threadwire", "…": "…"}}
id: keep it.GET /v1/messages/{id}returns the message with its status at that moment.status:queuedmeans the message was accepted and waits its turn. It then goes out the way a person would send it.phoneandbody: who the message is for, and its text.
The status then moves to sent, delivered and read, or to failed with the reason in error.
You wrote first, so this message is a reply, and it arrives in seconds. A first message to someone who never wrote to your number is the slowest kind. Such messages are spaced out, capped per day and not sent at night, so let people write first. How sending protects your number has the figures.
Receive the reply
Under Developers → Webhooks (admins only), set your endpoint's URL: a public https:// address. Every reply and status change is posted there as JSON:
{"id": "evt_01K…", "type": "message.received", "created_at": "2026-10-01T10:00:00+00:00",
"data": {"id": "01K…", "instance_id": "01K…", "phone": "201012345678", "body": "Hi!", "…": "…"}}
A reply arrives as message.received, and a delivery update as message.status.
Every event is signed per the Standard Webhooks spec. The signing secret starts with whsec_, and is shown when you save the URL. This function, from the webhooks guide, checks the signature:
// PHP: $id, $timestamp and $signatures are the webhook-id, webhook-timestamp and
// webhook-signature headers ($_SERVER['HTTP_WEBHOOK_ID'] ?? '' and so on); $body
// is file_get_contents('php://input').
function threadwireEventIsGenuine(string $id, string $timestamp, string $signatures, string $body, string $secret): bool
{
// Reject old or future timestamps: a replayed request is not a new event.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = 'v1,'.base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
foreach (explode(' ', $signatures) as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
Give it the raw body exactly as it arrived, before any JSON parsing:
// The endpoint: check the raw body first, then read the event.
$body = file_get_contents('php://input');
// No secret set means no event can be trusted: refuse them all.
$secret = getenv('THREADWIRE_WEBHOOK_SECRET');
$genuine = $secret && threadwireEventIsGenuine(
$_SERVER['HTTP_WEBHOOK_ID'] ?? '',
$_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
$_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
$body,
$secret,
);
if (! $genuine) {
http_response_code(401);
exit;
}
http_response_code(200);
$event = json_decode($body, true);
if ($event['type'] === 'message.received') {
error_log($event['data']['phone'].': '.$event['data']['body']);
}
Answer with any 2xx within 15 seconds. An event sent again keeps its webhook-id, so use that id to drop repeats. No public URL yet? Fetch the same events with GET /v1/events.
Good to know
- Retries are safe with an
Idempotency-Keyheader. Add a line such as'Idempotency-Key: order-1042-shipped'toCURLOPT_HTTPHEADER. A retry with the same key returns the first message instead of sending a second. - An error has a
code. A refused request carries amessagein plain words and a stablecode, such asvalidation_failed. Let your program act on$answer['code'], and log the message. - Retry only what can pass. Retry
429,502and503, afterRetry-Afterwhen present. Fix the key, the id or the request before retrying401,403,404or422. See what to retry. - A message that cannot go is not an error. It is answered
202and later becomesfailed, with the reason inerror. Never send a failed message again in a loop.
Next
- Sending messages: files, voice notes, polls and the rest, as fields on the same request.
- Webhooks and events: every event, retries, and polling.
- The API reference: every endpoint, and the errors.
- Tools and code samples: the Postman collection, and this request in other languages.