Table of Contents
Fundamentals
Connect & Send
Production
WhatsApp API PHP Guide
WhatsApp API with PHP: The Complete Integration Guide
Guzzle and cURL calls to connect a WhatsApp account, send and receive messages, manage groups and run the whole thing inside a Laravel application, without registering a Meta Business app. This is the WhatsApp API in PHP end to end: connection, webhooks, multi-tenant SaaS and the real cost per message.
No Meta Business verification
Guzzle & cURL, no abandoned SDK
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api1.unipile.com:13111',
'headers' => ['X-API-KEY' => $apiKey],
]);
// Provider WHATSAPP: no Meta app, no WABA
$res = $client->post('/api/v1/accounts', [
'json' => ['provider' => 'WHATSAPP'],
]);201 Created, account_id returned
Same pattern for
Definition
What is the WhatsApp API in PHP?
The WhatsApp API in PHP is the practice of calling a WhatsApp REST API from a PHP runtime, PHP-FPM, a CLI script or a queue worker, using an HTTP client such as Guzzle or cURL. There is no WhatsApp API built specifically for PHP: Meta and API providers expose plain JSON over HTTPS, and PHP is simply the language making the request, the same way it would call any other web API.
In practice, a WhatsApp API PHP integration is a normal HTTP call: a method, a URL, a JSON or multipart body, and an
X-API-KEY header. Nothing here depends on a PHP-only feature or a PHP-only endpoint. What changes between Cloud API, a Business Solution Provider (BSP), and a linked account is not the language, it is who owns the WhatsApp number and whether Meta Business verification sits between you and your first message. For the full breakdown of that ecosystem, see the WhatsApp API integration guide.Plain JSON over HTTPS
Every call is a request/response cycle Guzzle or cURL already know how to make.
Auth is a static header
An
X-API-KEY value, no OAuth dance, no token refresh loop to write.Runtime-agnostic
Works the same in PHP-FPM, a Symfony console command, or a Laravel queue job.
Decision
Which WhatsApp API should you use from PHP?
There are three ways to call WhatsApp from a PHP backend: Meta's Cloud API directly, a Business Solution Provider (BSP) reselling access to it, or a linked account through a unified API like Unipile. All three return JSON your PHP code parses the same way, what changes is setup time, whether Meta Business verification sits in front of your first message, and whether you get an existing conversation history or start from zero.
Criteria
Cloud API (direct)
BSP
Linked account
Meta Business verification
Required
Required
Not required
Time to first message
Days to a few weeks
Faster onboarding, same requirements underneath
Minutes
Phone number
New number registered to a WABA
New number provisioned by the BSP
The user's own existing WhatsApp number
Access to existing conversations
No, starts empty
No, starts empty
Yes, full history
Best fit
One business number broadcasting to many customers
Same, with a managed onboarding layer
A product where each user links the WhatsApp account they already have
If your PHP application is a SaaS product where every customer connects their own WhatsApp, a linked account removes the Meta verification step entirely. If you are broadcasting from a single company number, the Cloud API or a BSP is the correct starting point instead.
Build with a linked account Setup
What do you need before writing any PHP code?
You need PHP 8.1 or newer, Composer, the Guzzle HTTP client and a Unipile API key. No actively maintained PHP SDK exists for Unipile today, so every example in this guide talks to the
/api/v1 REST API directly.PHP 8.1+
Typed properties and named arguments keep the client code short.
Composer
Standard dependency manager, required to pull in Guzzle.
Guzzle 7
HTTP client for JSON and multipart requests, with a cURL fallback.
Unipile API key
Sent as
X-API-KEY on every request, generated from your dashboard.composer require guzzlehttp/guzzle:^7.0Is there a PHP SDK? Not one you should install.
A community package,
unipile/unipile-php-sdk, exists on Packagist, but it is listed as abandoned: no commit since October 17, 2023, 5 stars on GitHub, and it targets an old API base URL. Do not run composer require unipile/unipile-php-sdk. The Node.js SDK (unipile-node-sdk) is officially maintained and current, but for PHP, Guzzle straight against the REST API is the reliable path, and it is what every example below uses. See Guzzle's documentation for the client options used here.Implementation
How do you connect a WhatsApp account from PHP?
Connecting a WhatsApp account from PHP takes one
POST request with the provider set to WHATSAPP, then one user action: the end user scans the returned QR code from WhatsApp > Linked devices, or types a pairing code into their phone if you requested one instead. No Meta developer app, no WABA, no verification step.1
Request a checkpoint, QR code by default
Call the accounts endpoint with only
provider set. Unipile returns an account_id plus a checkpoint payload built for the QR flow.$response = $client->post('/api/v1/accounts', [
'json' => ['provider' => 'WHATSAPP'],
]);
$payload = json_decode($response->getBody()->getContents(), true);
$accountId = $payload['account_id'];
// $payload also carries the checkpoint to render to the
// end user: the QR code to scan from WhatsApp > Linked
// devices. var_dump($payload) once against your own key
// to see the exact shape before wiring up the frontend.2
Or request a pairing code instead
Pass
pairing_phone_number in E.164 digits, no plus sign, when scanning a QR code is not practical, for example a support agent onboarding a client by phone.$response = $client->post('/api/v1/accounts', [
'json' => [
'provider' => 'WHATSAPP',
'pairing_phone_number' => '33612345678',
],
]);
$payload = json_decode($response->getBody()->getContents(), true);
$accountId = $payload['account_id'];
// The checkpoint here is the code your UI shows the user
// to type into WhatsApp > Linked devices > Link with
// phone number instead.3
Poll until the account is fully linked
The account stays in a
CONNECTING state until the QR code is scanned or the pairing code is entered. Check sources[0].status on the account.$response = $client->get("/api/v1/accounts/{$accountId}");
$account = json_decode($response->getBody()->getContents(), true);
$status = $account['sources'][0]['status'] ?? null;
if ($status === 'OK') {
// safe to send, receive and list chats on $accountId
}Polling in a loop works but wastes requests. If your PHP application already has a webhook endpoint, register one with
source: "account_status" instead and react the moment the status flips to OK. The webhook setup itself is covered further down for incoming messages, the same endpoint accepts account status events.Core flow
How do you send a WhatsApp message with PHP and Guzzle?
Sending a message from PHP is one
POST to the chat's messages endpoint with a text field in a multipart body. The call returns a message_id the instant WhatsApp accepts it.$response = $client->post("/api/v1/chats/{$chatId}/messages", [
'multipart' => [
['name' => 'text', 'contents' => 'Hi! Your order has shipped.'],
],
]);
$result = json_decode($response->getBody()->getContents(), true);
$messageId = $result['message_id'];The same
multipart array also carries attachments, a voice_message file, or a quote_id to reply to a specific message, Guzzle attaches them as additional multipart fields next to text. For the full breakdown of every message type the WhatsApp API supports, including templates and the 24-hour service window, see our dedicated guide.Core flow
How do you start a new conversation from PHP?
Starting a conversation that does not exist yet means calling the chats endpoint with an
account_id and an attendees_ids array instead of an existing chat_id. Unipile creates the chat and, if you include a text field, sends the first message in the same call.$response = $client->post('/api/v1/chats', [
'multipart' => [
['name' => 'account_id', 'contents' => $accountId],
['name' => 'attendees_ids[]', 'contents' => '33612345678@s.whatsapp.net'],
['name' => 'text', 'contents' => 'Hi, this is Acme support.'],
],
]);
$result = json_decode($response->getBody()->getContents(), true);
$chatId = $result['chat_id'];The recipient is identified by
{phone_number}@s.whatsapp.net, international digits, no leading plus sign, no spaces. Store the returned chat_id next to that user record: every message after the first one goes through the messages endpoint from the previous section, not through this one.Core flow
How do you retrieve chats and messages in PHP?
Most WhatsApp API tutorials stop at sending. A PHP backend usually also needs to list a user's existing conversations, page through their history, and see who is in a chat. Three endpoints cover it:
GET /api/v1/chats, GET /api/v1/chats/{chat_id}/messages, and GET /api/v1/chats/{chat_id}/attendees, all paginated with a cursor.List every WhatsApp chat on an account
$response = $client->get('/api/v1/chats', [
'query' => [
'account_id' => $accountId,
'account_type' => 'WHATSAPP',
'limit' => 50,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$chats = $data['items'];
$nextCursor = $data['cursor'];Page through one chat's history
$cursor = null;
$all = [];
do {
$res = $client->get("/api/v1/chats/{$chatId}/messages", [
'query' => array_filter(['cursor' => $cursor, 'limit' => 100]),
]);
$page = json_decode($res->getBody()->getContents(), true);
$all = array_merge($all, $page['items']);
$cursor = $page['cursor'];
} while ($cursor !== null);Every list endpoint follows the same cursor contract: pass the previous response's
cursor back in, stop when it comes back null. To see who is actually in a conversation, call GET /api/v1/chats/{chat_id}/attendees: it returns each participant's id, provider_id and display name, which is what you need before you can call addParticipant or removeParticipant on a group later in this guide.Production
How do you receive WhatsApp messages with a PHP webhook?
A WhatsApp webhook in PHP is a public HTTPS endpoint that Unipile calls with a JSON payload on events like
message_received. Your script reads the raw body, confirms the call is genuine, replies fast, and hands the actual work to a queue. Polling GET /api/v1/chats for new messages works for a demo, not for a product with more than a handful of users.1. Register the webhook, with a shared secret in a custom header
$client->post('/api/v1/webhooks', [
'json' => [
'source' => 'messaging',
'request_url' => 'https://example.com/webhooks/whatsapp',
'events' => ['message_received'],
'headers' => [
['key' => 'X-Webhook-Secret', 'value' => $webhookSecret],
],
],
]);2. Verify, respond, then process
$rawBody = file_get_contents('php://input');
$provided = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';
if (!hash_equals($expectedSecret, $provided)) {
http_response_code(401);
exit;
}
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
$payload = json_decode($rawBody, true);
if (($payload['event'] ?? null) === 'message_received') {
// push $payload to a queue table or Redis list,
// never process a webhook inline
enqueue_whatsapp_message($payload);
}Unipile does not sign the webhook body with an HMAC secret you recompute, the create-webhook endpoint exposes a
headers array instead. Attach your own secret as a custom header when you register the webhook, then check it with hash_equals(), a timing-safe string comparison, before trusting the payload. Every field in $payload, account_id, account_type, event, chat_id, message_id, message and sender.attendee_provider_id, matches the JSON Unipile documents for the messaging webhook.Production
How do you integrate the WhatsApp API into a Laravel application?
Laravel replaces three things from the plain-PHP examples above: the
Http facade instead of a hand-rolled Guzzle client, a queued job instead of inline processing, and a route plus controller instead of parsing php://input by hand. The WhatsApp calls underneath are identical to every example already in this guide.1. ConfigurationOne entry in services.php, values from .env
return [
// ...existing services
'unipile' => [
'base_uri' => env('UNIPILE_BASE_URI', 'https://api1.unipile.com:13111'),
'api_key' => env('UNIPILE_API_KEY'),
'webhook_secret' => env('UNIPILE_WEBHOOK_SECRET'),
],
];2. A thin service class over the Http facade
namespace App\Services;
use Illuminate\Support\Facades\Http;
class WhatsappService
{
protected function client()
{
return Http::baseUrl(config('services.unipile.base_uri'))
->withHeaders(['X-API-KEY' => config('services.unipile.api_key')]);
}
public function sendMessage(string $chatId, string $text): array
{
return $this->client()
->asMultipart()
->post("/api/v1/chats/{$chatId}/messages", [
['name' => 'text', 'contents' => $text],
])
->json();
}
}3. Route and webhook controller
Route::post('/webhooks/whatsapp', [WhatsappWebhookController::class, 'handle']);public function handle(Request $request)
{
$provided = $request->header('X-Webhook-Secret', '');
if (!hash_equals(config('services.unipile.webhook_secret'), $provided)) {
abort(401);
}
if ($request->input('event') === 'message_received') {
ProcessWhatsappMessage::dispatch($request->all());
}
return response()->json(['status' => 'received']);
}4. A queued job, so the webhook route stays fast
class ProcessWhatsappMessage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public array $payload) {}
public function handle(): void
{
// look up the tenant by $this->payload['account_id'],
// store the message, notify the right user
}
}The route never touches Guzzle directly, it goes through
Build it in Laravel WhatsappService, which makes the WhatsApp calls mockable in tests the same way any other Laravel HTTP integration is. See Laravel's HTTP client documentation for faking these requests in your test suite.Production
How do you manage WhatsApp groups from PHP?
Managing a WhatsApp group from PHP is a single
PATCH call to the chat endpoint with an action field: addParticipant, removeParticipant, or getInviteLink. Meta's own Cloud API groups feature caps a group at 8 participants and exposes no endpoint to add someone directly, only an invite link to share.Add a participant
$client->patch("/api/v1/chats/{$groupChatId}", [
'json' => [
'action' => 'addParticipant',
'value' => '33612345678@s.whatsapp.net',
],
]);Get the invite link
$res = $client->patch("/api/v1/chats/{$groupChatId}", [
'json' => ['action' => 'getInviteLink'],
]);
$link = json_decode($res->getBody()->getContents(), true)['invite_link'];removeParticipant takes the same shape as addParticipant, with the participant's WhatsApp id in value. This is the one action Meta's own Groups API does not expose at all, adding someone happens only through the invite link on their side. For every limit, the 10,000-groups-per-number ceiling and what still is not supported, see the WhatsApp Group API reference.Scale
How do you handle many user accounts from one PHP application?
A PHP SaaS product does not have one WhatsApp account, it has one per customer. There is a single Unipile
X-API-KEY for your whole application, but a distinct account_id for every end user who links their WhatsApp. The pattern is to store that account_id next to your own tenant row, and pass it explicitly on every call that could otherwise cross accounts.// Inside the webhook handler: find which tenant this
// message belongs to before doing anything with it.
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE unipile_account_id = ?');
$stmt->execute([$payload['account_id']]);
$tenantId = $stmt->fetchColumn();
if ($tenantId === false) {
// unknown account_id, reject rather than guess the tenant
http_response_code(404);
exit;
}
// Outbound: pass account_id so a stale or reused chat_id can
// never send through the wrong tenant's WhatsApp number.
$client->post("/api/v1/chats/{$chatId}/messages", [
'multipart' => [
['name' => 'text', 'contents' => $text],
['name' => 'account_id', 'contents' => $expectedAccountId],
],
]);The optional
account_id field on the messages endpoint exists specifically to prevent sending in a chat outside that account, treat it as mandatory in a multi-tenant codebase even though the API does not require it. Encrypt the stored account_id values at rest like any other customer credential: they are not secret on their own, but they are the join key between your users table and someone's real WhatsApp conversations.Pitfalls
What are the most common PHP-specific mistakes?
Most WhatsApp API bugs in a PHP codebase come from PHP itself, not from the API. These five show up repeatedly in production integrations.
Truncating a message with substr() instead of mb_substr()
PHP's
substr() counts bytes, not characters. An emoji is a multi-byte UTF-8 sequence, cut it in half and you send a broken character or invalid JSON. Always use the multi-byte functions on user-generated WhatsApp text.$safe = mb_substr($text, 0, 4096, 'UTF-8');cURL error 60 on Windows and XAMPP
"SSL certificate problem: unable to get local issuer certificate." Windows PHP builds and XAMPP often ship without a current CA bundle wired into php.ini, so Guzzle's HTTPS calls fail even though the code is correct.
curl.cainfo = "C:\php\extras\cacert.pem"
openssl.cafile = "C:\php\extras\cacert.pem"max_execution_time killing the webhook before it answers
A default of 30 seconds is plenty until the handler makes a slow outbound call before responding. If Unipile does not get a fast response, it retries, and a slow handler starts processing the same message twice. Respond first, work second, as shown in the webhook section above.
No backoff on 429 or 5xx responses
A tight
while loop that retries immediately on failure turns one rate limit into a self-inflicted outage. Wait, and increase the wait on every consecutive failure.for ($attempt = 1; $attempt <= 5; $attempt++) {
try {
return $client->post($uri, $options);
} catch (ConnectException | ServerException $e) {
usleep((2 ** $attempt) * 100_000); // 0.2s, 0.4s, 0.8s...
}
}Logging the raw response and calling it debugging
If you build a JSON body by hand instead of letting Guzzle's
json option handle it, add JSON_UNESCAPED_UNICODE to json_encode(). Without it, emoji and accented names show up as escaped \u sequences in your logs, which is technically valid JSON but unreadable when you are trying to debug a real conversation.Cost
How much does it cost to send WhatsApp messages from PHP?
Nothing in the PHP code changes the price. Meta bills per message since July 1, 2025, not per conversation. The one thing your code controls is timing: a reply sent inside the 24-hour service window after a user's message is still free, the identical call sent an hour later is billable.
Utility and authentication templates stay free inside an open service window, billed outside it.
Marketing templates are billed regardless of the window.
Per-message rates vary by recipient country and message category, not by the client library you send them with.
Timestamp every inbound message your PHP webhook receives and keep a per-conversation "window open until" value, that is the only input your billing logic actually needs. For current per-message WhatsApp pricing by country and category, see the full pricing breakdown.
WhatsApp API with PHP - FAQ
Straight answers on connecting, sending, Laravel, and the abandoned PHP SDK.
Create a Guzzle client pointed at the Unipile REST API, call
POST /api/v1/accounts with provider WHATSAPP to get a QR code or pairing code, have the user complete it on their phone, then use the same client for GET, POST and PATCH calls to send messages, list chats and manage groups. There is no PHP-specific endpoint, every call is a normal HTTP request with an X-API-KEY header. The connect a WhatsApp account section above walks through the exact code.Yes. A single
POST to /api/v1/chats/{chat_id}/messages with a text field in a multipart body sends the message and returns a message_id, see the send section above for the exact Guzzle call.Only if you go through Meta's Cloud API directly or a Business Solution Provider built on top of it. Connecting a WhatsApp account through Unipile links the user's existing WhatsApp number by QR code or pairing code and does not require Meta Business verification at all.
No actively maintained one. A community package,
unipile/unipile-php-sdk, is on Packagist but marked abandoned, with no commit since October 17, 2023 and 5 stars on GitHub. Every example in this guide calls the REST API directly through Guzzle instead, which is also what the abandoned SDK did internally.You cannot receive webhooks without a publicly reachable HTTPS URL, that is how HTTP callbacks work. During local development, tunnel a local port with a service like ngrok and point the webhook's
request_url at the tunnel's HTTPS address. Polling the messages endpoint is the fallback when a public endpoint genuinely is not possible, at the cost of latency and extra requests.Yes, nothing about it is framework-specific. The Laravel section above wraps the same Guzzle-equivalent calls in the
Http facade, a queued job, and a webhook route and controller, which is the idiomatic way to structure it in a Laravel codebase.Yes, that is the normal SaaS shape. One Unipile API key covers the whole application, while every end user gets their own
account_id after connecting their WhatsApp. Store that id next to your tenant record and pass it on outbound calls to prevent cross-tenant sends, see the multi-tenant section above.PATCH /api/v1/chats/{chat_id} with {"action": "addParticipant", "value": "<phone>@s.whatsapp.net"}. Meta's own Cloud API groups feature has no equivalent endpoint, only an invite link, which is one of the clearer differences between building directly on Meta and going through Unipile. Details in the groups section above.Almost always because a string was truncated with
substr() instead of mb_substr(). PHP's plain string functions count bytes, and an emoji is a multi-byte UTF-8 sequence, cut in the middle it turns into an invalid character or breaks the JSON payload entirely.Guzzle is enough, it is an HTTP client built on top of PHP's cURL extension, not a replacement for it. Every request in this guide uses Guzzle; a raw
curl_init() call works identically if a project cannot add the dependency, the JSON and multipart bodies are the same either way.The same as sending it any other way. Meta bills per message since July 1, 2025, based on category and recipient country, not on the client library used to call the API. See the cost section above and the full pricing guide for current rates.
Yes, it is not going away, it just determines what is free rather than what is technically allowed. A reply inside the 24-hour window after a user's last message ships without being billed as a template message, PHP code that ignores the window simply pays for messages it did not need to.
Still have questions about building this in PHP? Our team is here to help.
WhatsApp API PHP Guide
Ready to build the WhatsApp API into your PHP application?
Every endpoint in this guide, connect, send, receive, groups, runs on the same Unipile account you can create right now. No Meta Business verification, no abandoned SDK, just
Guzzle and a REST API your PHP application already knows how to call.Automating instead of integrating into an existing app? Read the same integration in Python.