WhatsApp API with PHP: The Complete Integration Guide

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
connect-whatsapp.php
usar 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->postagem('/api/v1/accounts', [ 'json' => ['provider' => 'WHATSAPP'], ]);
201 Created, account_id returned
Same pattern for LinkedIn Gmail Perspectivas IMAP
Definição

What is the WhatsApp API in PHP?

O 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
Um 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.
Decisão

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 Conta vinculada 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.
Critérios
Cloud API (direct)
BSP
Linked account
Meta Business verification
Obrigatório
Obrigatório
Não é necessário
Time to first message
Days to a few weeks
Faster onboarding, same requirements underneath
Minutos
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
Configuração

What do you need before writing any PHP code?

Você precisa PHP 8.1 or newer, Composer, o 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.
terminal
composer require guzzlehttp/guzzle:^7.0
Is 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.
Implementação

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.
connect-qr.php
$response = $client->postagem('/api/v1/accounts', [ 'json' => ['provider' => 'WHATSAPP'], ]); $payload = json_decode($response->getBody()->getContents(), true); $accountId = $payload['id_da_conta']; // $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
Passar 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.
connect-pairing.php
$response = $client->postagem('/api/v1/accounts', [ 'json' => [ 'provider' => 'WHATSAPP', 'pairing_phone_number' => '33612345678', ], ]); $payload = json_decode($response->getBody()->getContents(), true); $accountId = $payload['id_da_conta']; // 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 CONEXÃO state until the QR code is scanned or the pairing code is entered. Check sources[0].status on the account.
poll-status.php
$response = $client->obter("/api/v1/accounts/{$accountId}"); $account = json_decode($response->getBody()->getContents(), true); $status = $account['sources'][0]['status'] ?? nulo; se ($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.
send-message.php
$response = $client->postagem("/api/v1/chats/{$chatId}/messages", [ 'multipart' => [ ['nome' => 'texto', 'contents' => 'Hi! Your order has shipped.'], ], ]); $result = json_decode($response->getBody()->getContents(), true); $messageId = $result['message_id'];
O mesmo multipart array also carries anexos, a mensagem_de_voz 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 e um 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.
start-conversation.php
$response = $client->postagem('/api/v1/chats', [ 'multipart' => [ ['nome' => 'id_da_conta', 'contents' => $accountId], ['nome' => 'attendees_ids[]', 'contents' => '33612345678@s.whatsapp.net'], ['nome' => 'texto', '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}/messagese GET /api/v1/chats/{chat_id}/attendees, all paginated with a cursor.
List every WhatsApp chat on an account
list-chats.php
$response = $client->obter('/api/v1/chats', [ 'query' => [ 'account_id' => $accountId, 'account_type' => 'WHATSAPP', 'limit' => 50, ], ]); $data = json_decode($response->getBody()->getContents(), true); $chats = $data['itens']; $nextCursor = $data['cursor'];
Page through one chat's history
list-messages.php
$cursor = nulo; $all = []; do { $res = $client->obter("/api/v1/chats/{$chatId}/messages", [ 'query' => array_filter(['cursor' => $cursor, 'limite' => 100]), ]); $page = json_decode($res->getBody()->getContents(), true); $all = array_merge($all, $page['itens']); $cursor = $page['cursor']; } enquanto ($cursor !== nulo);
Every list endpoint follows the same cursor contract: pass the previous response's cursor back in, stop when it comes back nulo. 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 ou removeParticipant on a group later in this guide.
Produção

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
register-webhook.php
$client->postagem('/api/v1/webhooks', [ 'json' => [ 'source' => 'Mensagens', 'request_url' => 'https://example.com/webhooks/whatsapp', 'events' => ['mensagem_recebida'], 'headers' => [ ['key' => 'X-Webhook-Secret', 'value' => $webhookSecret], ], ], ]);
2. Verify, respond, then process
public/webhooks/whatsapp.php
$rawBody = file_get_contents('php://input'); $provided = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? ''; se (!hash_equals($expectedSecret, $provided)) { código_de_resposta_http(401); exit; } código_de_resposta_http(200); se (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } $payload = json_decode($rawBody, true); se (($payload['evento'] ?? nulo) === 'mensagem_recebida') { // 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 cabeçalhos 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 $cargaútil, account_id, account_type, evento, chat_id, message_id, message e sender.attendee_provider_id, matches the JSON Unipile documents for the messaging webhook.
Produção

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
config/services.php
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
app/Services/WhatsappService.php
namespace App\Services; usar Illuminate\Support\Facades\Http; aula WhatsappService { protected função client() { return Http::baseUrl(config('services.unipile.base_uri')) ->withHeaders(['X-API-KEY' => config('services.unipile.api_key')]); } public função sendMessage(string $chatId, string $text): array { return $this->client() ->asMultipart() ->postagem("/api/v1/chats/{$chatId}/messages", [ ['nome' => 'texto', 'contents' => $text], ]) ->json(); } }
3. Route and webhook controller
routes/api.php
Route::postagem('/webhooks/whatsapp', [WhatsappWebhookController::aula, 'handle']);
app/Http/Controllers/WhatsappWebhookController.php
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
app/Jobs/ProcessWhatsappMessage.php
aula ProcessWhatsappMessage implements ShouldQueue { usar Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public função __construct(public array $payload) {} public função 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 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.
Build it in Laravel
Produção

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, removeParticipantou 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
add-participant.php
$client->patch("/api/v1/chats/{$groupChatId}", [ 'json' => [ 'action' => 'addParticipant', 'value' => '33612345678@s.whatsapp.net', ], ]);
Get the invite link
invite-link.php
$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.
Build group management
Escala

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.
route-by-tenant.php
// 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->executar([$payload['id_da_conta']]); $tenantId = $stmt->fetchColumn(); se ($tenantId === falso) { // unknown account_id, reject rather than guess the tenant código_de_resposta_http(404); exit; } // Outbound: pass account_id so a stale or reused chat_id can // never send through the wrong tenant's WhatsApp number. $client->postagem("/api/v1/chats/{$chatId}/messages", [ 'multipart' => [ ['nome' => 'texto', 'contents' => $text], ['nome' => 'id_da_conta', '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.
Armadilhas

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.
Consertar
$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.
Fix, php.ini
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 enquanto loop that retries immediately on failure turns one rate limit into a self-inflicted outage. Wait, and increase the wait on every consecutive failure.
Consertar
para ($attempt = 1; $attempt <= 5; $attempt++) { tentar { return $client->postagem($uri, $options); } captura (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 para 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.
Custo

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 OBTER, POST e 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 para /api/v1/chats/{chat_id}/mensagens with a text field in a multipart body sends the message and returns a message_id, veja o 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 Acima.
PATCH /api/v1/chats/{chat_id} com {"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 Acima.
Almost always because a string was truncated with substr() em vez de 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.

Fale com um especialista
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.
pt_BRBR