WhatsApp API & Developers

WhatsApp API in PHP: cURL Send, Webhook and Laravel

A lot of the businesses that need WhatsApp run on PHP: a billing system that emails invoices, a booking plugin, a Laravel app behind a sales team. Adding WhatsApp looks like one cURL call, and sending really is. Receiving is where PHP has a couple of habits that silently break Meta's webhook, starting with what it does to a query parameter called hub.mode.

This WhatsApp API PHP tutorial gives you working code for both halves: plain PHP 8 with the cURL extension, then the same thing as Laravel routes. You will send text, templates and a PDF invoice, verify the webhook signature, and answer Meta fast enough that it never retries.

At the end, the Tenashi API version for sending from a number your team already uses.

What you need, and where PHP-FPM hides it

You need four values: a system user access token, the phone number ID of the sending number, your app secret, and a verify token you make up. If any of those are unfamiliar, start with the WhatsApp Cloud API setup guide, which covers the app, the test number and the token.

The code reads them with getenv(). One PHP-specific catch: PHP-FPM clears environment variables for its workers by default (the clear_env pool setting), so values you exported in a shell or set in systemd may not reach the script. Either declare them in the pool config, as below, or load them from a config file outside the web root.

; /etc/php/8.3/fpm/pool.d/www.conf
env[WHATSAPP_TOKEN] = "your-system-user-token"
env[PHONE_NUMBER_ID] = "PHONE_NUMBER_ID"
env[APP_SECRET] = "your-app-secret"
env[VERIFY_TOKEN] = "any-string-you-choose"

The examples use Graph API v23.0. Use the current version from Meta's changelog when you build.

Send messages with plain cURL

One function does the HTTP work, and small wrappers build each message type.

<?php
// whatsapp.php: Cloud API helpers with plain cURL (PHP 8+)

const GRAPH = 'https://graph.facebook.com/v23.0';

function wa_post(array $payload): array
{
    $ch = curl_init(GRAPH . '/' . getenv('PHONE_NUMBER_ID') . '/messages');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('WHATSAPP_TOKEN'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode(
            ['messaging_product' => 'whatsapp'] + $payload,
            JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
        ),
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('cURL: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $data = json_decode($body, true) ?? [];
    if ($status >= 400) {
        $code = $data['error']['code'] ?? $status;
        throw new RuntimeException($code . ': ' . ($data['error']['message'] ?? 'request failed'), (int) $code);
    }
    return $data;
}

/** $to is a phone number with country code. Returns the wamid. */
function wa_send_text(string $to, string $body): string
{
    $data = wa_post([
        'recipient_type' => 'individual',
        'to' => $to,
        'type' => 'text',
        'text' => ['body' => $body],
    ]);
    return $data['messages'][0]['id'];
}

function wa_send_template(string $to, string $name, string $lang, array $params = []): string
{
    $data = wa_post([
        'recipient_type' => 'individual',
        'to' => $to,
        'type' => 'template',
        'template' => [
            'name' => $name,
            'language' => ['code' => $lang],
            'components' => [[
                'type' => 'body',
                'parameters' => array_map(fn ($p) => ['type' => 'text', 'text' => (string) $p], $params),
            ]],
        ],
    ]);
    return $data['messages'][0]['id'];
}

JSON_THROW_ON_ERROR is there for a reason older PHP code hits often. If a customer name comes out of a legacy database in Latin-1, json_encode() returns false on invalid UTF-8, cURL posts an empty body and Meta answers with a confusing parameter error. With the flag, you get a JsonException pointing at the real problem. JSON_UNESCAPED_UNICODE is optional; it just keeps Hindi, Arabic or emoji readable in your logs.

Note there is no curl_close(). It has done nothing since PHP 8.0, when cURL handles became objects freed automatically, and PHP 8.5 deprecates it.

The $code passed to the exception is Meta's numeric error code, which is what you should branch on. 190 means the token expired, 132001 means the template name or language does not match an approved template, and 131047 means the 24-hour window is closed. That last one may also arrive later as a failed status webhook rather than here. The WhatsApp API error codes reference lists the rest with fixes.

Sending a PDF invoice

PHP apps send documents more than anything else. A document message looks like this:

<?php
wa_post([
    'recipient_type' => 'individual',
    'to' => '+919811122233',
    'type' => 'document',
    'document' => [
        'link' => 'https://example.com/invoices/INV-1042.pdf',
        'filename' => 'INV-1042.pdf',
        'caption' => 'Your invoice for October',
    ],
]);

The link must be a public HTTPS URL that Meta can fetch. Meta's docs mark hosted links as not recommended and prefer uploading the file first and sending its media id. Like any free-form message, this only works inside the customer service window. To send an invoice cold, use an approved template with a document header. Sending media through the WhatsApp API covers uploads, IDs and size limits.

A webhook in plain PHP, and the hub_mode trap

Meta sends a GET to verify your URL with parameters named hub.mode, hub.verify_token and hub.challenge. PHP converts dots in incoming variable names to underscores, so those arrive in $_GET as hub_mode, hub_verify_token and hub_challenge. Code ported from a Node or Python example that reads $_GET['hub.mode'] gets null, verification fails, and nothing in the logs says why.

<?php
// webhook.php: Meta calls this URL for verification (GET) and events (POST)
require __DIR__ . '/whatsapp.php';

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    // PHP rewrites dots in query names: hub.verify_token arrives as hub_verify_token.
    $mode = $_GET['hub_mode'] ?? '';
    $token = $_GET['hub_verify_token'] ?? '';
    if ($mode === 'subscribe' && hash_equals((string) getenv('VERIFY_TOKEN'), $token)) {
        header('Content-Type: text/plain');
        echo $_GET['hub_challenge'] ?? '';
        exit;
    }
    http_response_code(403);
    exit;
}

$raw = file_get_contents('php://input'); // exact bytes, before json_decode
$expected = 'sha256=' . hash_hmac('sha256', $raw, (string) getenv('APP_SECRET'));
$received = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request(); // PHP-FPM: Meta gets its 200 now, the script keeps running
}

$payload = json_decode($raw, true) ?? [];
foreach ($payload['entry'] ?? [] as $entry) {
    foreach ($entry['changes'] ?? [] as $change) {
        $value = $change['value'] ?? [];
        foreach ($value['messages'] ?? [] as $msg) {
            if (($msg['type'] ?? '') !== 'text' || empty($msg['from'])) {
                continue;
            }
            try {
                wa_send_text($msg['from'], 'Thanks, we got your message. A teammate will reply here.');
            } catch (RuntimeException $e) {
                error_log('WhatsApp reply failed: ' . $e->getMessage());
            }
        }
        foreach ($value['statuses'] ?? [] as $status) {
            error_log("status {$status['id']} {$status['status']}");
        }
    }
}

The rest of the file is about two more PHP habits.

Read php://input, not $_POST. Meta posts JSON, so $_POST is empty, and the HMAC must be computed over the exact bytes received. hash_hmac() returns lowercase hex, which matches the sha256= value Meta sends. hash_equals() compares in constant time.

Answer before you work. Meta retries any delivery that does not get a 200, with decreasing frequency for up to 7 days, which produces duplicates. Under PHP-FPM, fastcgi_finish_request() sends the response and lets the script carry on with the reply. On Apache with mod_php it does not exist, so write the payload to a queue table and process it from a cron or worker instead.

The empty($msg['from']) check is for 2026: users with a WhatsApp username can arrive without a phone number. Store from_user_id, the business-scoped user ID, alongside the phone so you can still reach them.

If verification passes but messages never show up, the usual cause is that the app is not subscribed to your WhatsApp Business Account, or the messages field is not ticked. WhatsApp webhook not working goes through the checks in order.

The same thing in Laravel

Laravel removes the cURL boilerplate but adds one thing to switch off: CSRF protection on POST routes.

<?php
// routes/web.php
use App\Jobs\HandleWhatsAppWebhook;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/webhooks/whatsapp', function (Request $request) {
    // PHP already renamed hub.mode to hub_mode, so query('hub.mode') returns null.
    if ($request->query('hub_mode') === 'subscribe'
        && hash_equals((string) config('services.whatsapp.verify_token'), (string) $request->query('hub_verify_token'))) {
        return response((string) $request->query('hub_challenge'), 200)
            ->header('Content-Type', 'text/plain');
    }
    abort(403);
});

Route::post('/webhooks/whatsapp', function (Request $request) {
    $expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), (string) config('services.whatsapp.app_secret'));
    abort_unless(hash_equals($expected, (string) $request->header('X-Hub-Signature-256')), 401);

    HandleWhatsAppWebhook::dispatch($request->json()->all()); // queue it, answer now
    return response('', 200);
});

Routes in routes/web.php run through the web middleware group, which checks CSRF tokens Meta will never send. On Laravel 11 and later, exclude the path in bootstrap/app.php:

<?php
// bootstrap/app.php (Laravel 11 and later): add the validateCsrfTokens call
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(web: __DIR__ . '/../routes/web.php')
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->validateCsrfTokens(except: ['webhooks/whatsapp']);
    })
    ->create();

Alternatively put the routes in routes/api.php, which skips the session and CSRF middleware; on Laravel 11 and later that file only exists after php artisan install:api, and the URL gains an /api prefix.

$request->getContent() is the raw body, and dispatching a queued job keeps the response fast. Sending is just as short with the HTTP client, which encodes arrays as JSON by default:

<?php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.whatsapp.token'))
    ->timeout(10)
    ->post('https://graph.facebook.com/v23.0/' . config('services.whatsapp.phone_number_id') . '/messages', [
        'messaging_product' => 'whatsapp',
        'to' => $customer->phone,
        'type' => 'document',
        'document' => [
            'link' => $invoice->pdf_url,
            'filename' => "INV-{$invoice->number}.pdf",
        ],
    ])
    ->throw(); // throws on 4xx/5xx; Meta's error JSON is in the exception's response

$wamid = $response->json('messages.0.id');

Sending from your own number with Tenashi's API

If the invoices and reminders should come from the WhatsApp number your customers already have saved, Tenashi's API takes the same cURL pattern with a different body and no templates.

<?php
// tenashi.php: send through Tenashi and verify its webhooks

function tenashi_send(array $message, ?string $idempotencyKey = null): array
{
    $headers = [
        'Authorization: Bearer ' . getenv('TENASHI_KEY'),
        'Content-Type: application/json',
    ];
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }
    $ch = curl_init('https://usetenashi.com/api/v1/messages');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POSTFIELDS => json_encode($message, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('cURL: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $data = json_decode($body, true) ?? [];
    if ($status !== 202) {
        throw new RuntimeException($status . ' ' . ($data['error']['code'] ?? 'error') . ': ' . ($data['error']['message'] ?? ''));
    }
    return $data['message'];
}

function tenashi_verify(string $raw, string $secret): bool
{
    $timestamp = $_SERVER['HTTP_X_TENASHI_TIMESTAMP'] ?? '';
    $signature = preg_replace('/^v1=/', '', $_SERVER['HTTP_X_TENASHI_SIGNATURE'] ?? '');
    $expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
    $fresh = ctype_digit($timestamp) && abs(time() - (int) $timestamp) < 300;
    return $fresh && hash_equals($expected, $signature);
}

// Send an invoice PDF, safe to retry:
tenashi_send([
    'to' => '+919811122233',
    'text' => 'Hi Priya, here is your invoice for October.',
    'media' => ['url' => 'https://example.com/invoices/INV-1042.pdf', 'filename' => 'INV-1042.pdf'],
], 'invoice-1042');

The send returns 202 with the queued message, and the PDF goes out from your number a few seconds later at a human pace. PDFs can be up to 25 MB. The invoice-1042 key means a cron job that runs twice still sends one message, provided the second run is within 24 hours. The signature covers the timestamp and the raw body, and ctype_digit stops a missing or malformed timestamp from slipping through. Selling through WooCommerce? The WooCommerce WhatsApp notifications guide builds on these same functions with order hooks.

Where Tenashi fits

The plain-PHP and Laravel code above is the right path for OTPs, verified business messaging and high-volume transactional sends on Meta's platform. Many PHP shops want something smaller: invoices, payment nudges and booking confirmations from the number their customers already chat with, and a team that can see and answer the replies.

Tenashi links that existing WhatsApp or WhatsApp Business number with a QR scan. Messages your PHP app sends through the API appear in a shared inbox with contacts, deals and follow-ups under every chat, and replies, from DMs or groups, come back as signed webhooks. It is not Meta's API, so there are no templates, buttons or green tick, and it is paced for conversations rather than bulk alerts. WhatsApp can still ban a number that spams; ban protection reduces that risk without removing it. Plans start from $18/mo.

Read the API docs · Get started

FAQ

Why is $_GET['hub.mode'] empty in my PHP webhook?

PHP replaces dots in incoming variable names with underscores, so Meta's hub.mode arrives as $_GET['hub_mode'], and likewise hub_verify_token and hub_challenge. Laravel and Symfony read the same array, so use the underscore names there too. Return the challenge as plain text with status 200 and verification passes.

Can I use Guzzle instead of cURL for the WhatsApp API?

Yes. Guzzle and Laravel's HTTP client both sit on cURL and send the same JSON. Set the bearer token, a timeout and a JSON body, and read Meta's numeric error code from the response on failure. Plain cURL avoids a dependency, which helps in WordPress plugins and older apps.

Does the webhook need to be on HTTPS?

Yes. Meta only delivers to an endpoint with a valid TLS certificate, and self-signed certificates are rejected. For local development, run php -S localhost:8000 and expose it through a tunnel such as ngrok. In production, any normal certificate from Let's Encrypt or your host works.

How do I respond quickly if I'm not on PHP-FPM?

Without fastcgi_finish_request(), the response waits until the script ends. Keep the webhook script tiny: verify the signature, insert the raw payload into a queue table, return 200. A cron job or a long-running worker then processes rows and sends replies, which also gives you a natural place to deduplicate by message ID.

Can I use this code inside WordPress?

Yes, but swap raw cURL for wp_remote_post(), which respects WordPress proxy and SSL settings, keep tokens in wp-config.php constants rather than the options table, and send from a background job so page loads never wait on WhatsApp. The WooCommerce WhatsApp notifications guide shows that pattern with Action Scheduler.


That is a complete send-and-receive loop in PHP. For pricing, limits and templates in one place, see the complete guide to the WhatsApp API, and when your app should message from your team's own number, read the Tenashi API docs.