Industries
Restaurants & Food
Take restaurant orders, table bookings and payments in one WhatsApp chat. Auto-reply to common questions and cut support costs with ChatMitra.
Real Estate
Use WhatsApp automation to capture property leads, qualify them automatically, and follow up fast. See how real estate agents close more with ChatMitra.
Travel & Tourism
Use the WhatsApp Business API to capture travel leads, send itineraries, confirm bookings, and keep travellers updated. See how agencies grow with ChatMitra.
Banking & Finance
Run secure KYC, send account alerts and payment notifications on WhatsApp. Cut support load and stay within Meta's rules with ChatMitra.
Beauty & Cosmetics
Sell your catalog, recover carts and win repeat orders on WhatsApp. Auto-reply to product questions and send launches to opted-in customers with ChatMitra.
Education
Answer admission enquiries, fee and timetable questions, and keep parents updated — all on WhatsApp. Cut admin load and costs with ChatMitra.
EduTech
Capture course leads, qualify them and onboard learners on WhatsApp. Automate replies and cut lead-qualification cost with ChatMitra's no-code chatbot.
Events & Webinars
Handle event registrations, reminders and RSVPs in one WhatsApp chat. Cut no-shows, automate FAQs and keep attendees updated with ChatMitra.
Freelancers & Consultants
Capture leads, send proposals and chase invoices in one chat. WhatsApp automation for freelancers and consultants with ChatMitra — from ₹0.20 a chat.
Health & Wellness
Book appointments, send reminders and answer client questions in one WhatsApp chat. Auto-reply to routine queries and cut support costs with ChatMitra.
Home Decor
Share your home decor catalog, take orders and send delivery updates on WhatsApp. Automate FAQs and cut support costs with ChatMitra.
IT Services
Use the WhatsApp Business API to qualify leads, run support over chat, and wire WhatsApp into your stack with webhooks. How IT companies scale with ChatMitra.
Marketing Agencies
Use WhatsApp automation to capture leads, run campaigns and manage multiple client brands from one account. How agencies scale on WhatsApp with ChatMitra.
Offline & Retail
Turn shop footfall into a WhatsApp list. Share catalogs, add a QR-to-chat at the counter and bring buyers back — with ChatMitra from ₹0.20 per chat.
Spas & Salons
Use the WhatsApp Business API to take spa and salon bookings, cut no-shows with automated reminders, and rebook regulars. How salons grow with ChatMitra.
Automotive
Use the WhatsApp Business API for automotive dealerships to qualify buyers, book test drives and send service reminders — automated, on one chat, with ChatMitra.
All Industries
Browse every industry guide
Setup & API Guides

WhatsApp API in PHP: cURL, Laravel and Signed Webhooks

Send WhatsApp messages from PHP on Meta's Cloud API with cURL or Laravel: templates, retries, a signed webhook, queues on shared hosting, and ChatMitra's API.

Nikunj Gohil
Written byNikunj Gohil
Read time 18 min
Posted on
WhatsApp API in PHP: cURL, Laravel and Signed Webhooks
Summarise this post with:

Short on time? Read this first

  • PHP talks to Meta's WhatsApp Cloud API with the cURL extension that ships with most hosts: a JSON POST to /<PHONE_NUMBER_ID>/messages on the current Graph API version (v26.0 when we checked), with a system user token in the Authorization header. No SDK is required.
  • Three PHP habits break WhatsApp integrations: passing an array to CURLOPT_POSTFIELDS (it goes out as multipart, not JSON), reading $_GET['hub.mode'] (PHP renames it hub_mode), and hashing a re-encoded body instead of the raw bytes from php://input.
  • In Laravel 13, put the send in a service class on the HTTP client, the retries in a queued job with #[Tries] and #[Backoff], and the signature check in middleware. Shared hosting can run that queue from a one-minute cron.
  • ChatMitra's REST API takes the same kind of template payload from PHP with an API key, and signs its own webhooks with a plain hex HMAC. It needs a paid plan with API access (Pro or Enterprise).
On this page 0

    To use the WhatsApp API with PHP, send a JSON POST to Meta’s Cloud API at /<PHONE_NUMBER_ID>/messages with the cURL extension or Laravel’s HTTP client, with a system user token as the Bearer header. Approved templates reach anyone who opted in. Free-form replies work only for 24 hours after the customer last wrote to you. A signed webhook receives replies.

    That’s the whole contract. The rest of this guide is the PHP around it: a cURL version with no dependencies for plain PHP and shared hosting, then a Laravel 13 version with a service class, a queued job and webhook middleware. It also covers the three PHP quirks that break most first attempts, cron-driven queues, and ChatMitra’s REST API.

    Key takeaways

    • Send JSON as a string. An array in CURLOPT_POSTFIELDS is posted as multipart form data.
    • Retry only what Meta says is temporary, from a queue or cron, never with sleep() inside a page request.
    • Check the webhook's signature against the untouched request body before parsing anything, then answer 200 fast.

    Every snippet was run on PHP 8.5.8 and Laravel 13.33.0 against a local stand-in for Meta's API; no real message was sent. Facts checked against Meta for Developers, php.net and the Laravel documentation on 26 September 2026. Rupee figures are Meta's India rates and exclude tax.

    What do you need before the first PHP request?

    Five values, all of which belong in environment variables rather than in code:

    ValueWhere it comes fromUsed for
    Phone number IDApp Dashboard, WhatsApp API Setup (not the phone number itself)The path of every send request
    System user access tokenMeta Business settings: create a system user, then generate a token for itThe Authorization: Bearer header
    App secretYour Meta app's basic settingsChecking webhook signatures
    Verify tokenA string you make upThe one-time webhook handshake
    Approved template name and languageWhatsApp Manager, or your provider's template screenMessaging anyone outside the 24-hour window

    Meta’s get-started guide creates the app and hands you a test number. The temporary token it generates “expires quickly and is not suitable for development purposes”, so switch early to a system user token. Meta’s access token guide has you choose an expiration preference and grant three permissions: business_management, whatsapp_business_management and whatsapp_business_messaging. Our Python tutorial walks through the dashboard screens in more detail, and none of that part depends on the language.

    One recent change matters if you read older tutorials. Meta’s account model update splits the old WhatsApp Business Account into a WhatsApp account and a Messaging account. It became generally available on 23 September 2026, with every business due to move by mid-October 2026. For your PHP code, the send endpoint “continues to work the same way”. An optional messaging_account_id matters only when a single token can send through several Messaging accounts attached to one number. Meta’s timeline also says that from the first half of 2028, URL paths take a new WhatsApp account ID instead of the phone number ID, so keep that ID in configuration.

    If you’re still deciding whether to go to Meta directly or through a provider, read how to get the WhatsApp API first. For what the Cloud API is and isn’t, see the WhatsApp Cloud API guide.

    Which PHP setup fits your hosting?

    Most PHP tutorials assume a VPS. Plenty of PHP runs on cPanel-style shared hosting, where nothing runs in the background, and that changes how you retry sends and process webhooks.

    Your setupSend withRetries and heavy workReceive with
    Plain PHP on shared hostingcURL function belowA database outbox and a cron scriptwebhook.php that stores the payload
    Laravel on shared hostingService class on Laravel's HTTP clientQueued jobs, worked by schedule:run from cronController plus signature middleware
    Laravel on a VPS or containerSame service classQueued jobs with a permanent worker under SupervisorSame controller
    Any PHP, provider handles MetaThe provider's REST APIThe provider's queue, plus your idempotency keyThe provider's webhooks

    A word on the other kind of “WhatsApp PHP library”. Packages that log in to WhatsApp Web as a personal account are not the Business Platform. WhatsApp’s help centre says linking an account to unofficial versions breaks its Terms of Service, and the account “might also be temporarily or permanently banned”. Community Composer packages that wrap the official Cloud API are a different thing, since they never touch WhatsApp Web, but they are still third-party code. This guide uses none, so you can see every byte that goes to Meta. The official vs unofficial sender comparison covers the business side.

    How do you send a WhatsApp message with plain PHP and cURL?

    The cURL extension is enough, with no Composer and no framework. Keep the credentials in the server’s environment (your host’s panel, or SetEnv in Apache), or in a config file outside the public web folder. If the project already uses Composer, vlucas/phpdotenv can load a .env file. Note that it fills $_ENV and $_SERVER, and getenv() only if you ask it to, so switch the getenv() calls below to $_ENV if you use it. Wherever the file lives, keep it out of public_html.

    A send function you can reuse

    <?php
    // whatsapp.php: Cloud API helpers for plain PHP (no Composer needed)
    
    final class WhatsAppException extends RuntimeException
    {
        public function __construct(
            public readonly int $status,       // HTTP status, 0 if Meta was never reached
            public readonly ?int $metaCode,    // Meta's numeric error code
            string $details,
            public readonly int $curlErrno = 0,
        ) {
            parent::__construct("HTTP $status, code " . ($metaCode ?? 'none') . ": $details");
        }
    }
    
    function wa_send(array $message): array
    {
        $graph = getenv('WA_GRAPH_BASE') ?: 'https://graph.facebook.com/v26.0';
        $body  = ['messaging_product' => 'whatsapp', 'recipient_type' => 'individual'] + $message;
    
        $ch = curl_init($graph . '/' . getenv('WA_PHONE_NUMBER_ID') . '/messages');
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($body, JSON_THROW_ON_ERROR), // a string, never an array
            CURLOPT_HTTPHEADER     => [
                'Authorization: Bearer ' . getenv('WA_TOKEN'),
                'Content-Type: application/json',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT        => 20,
        ]);
        $raw    = curl_exec($ch);
        $errno  = curl_errno($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    
        if ($errno !== 0) {
            throw new WhatsAppException(0, null, curl_strerror($errno), $errno);
        }
        $data = json_decode((string) $raw, true) ?? [];
        if ($status >= 400) {
            $error = $data['error'] ?? [];
            throw new WhatsAppException(
                $status,
                $error['code'] ?? null,
                $error['error_data']['details'] ?? $error['message'] ?? 'no error body',
            );
        }
        return $data;
    }

    It needs PHP 8.1 or later for the readonly properties. Four details are deliberate:

    • The body is a JSON string. The curl_setopt manual says passing an array to CURLOPT_POSTFIELDS “will encode the data as multipart/form-data”. In our test, the array version reached the server as multipart, not the JSON body Meta’s reference describes.
    • The token travels in a header. A token in a URL query string can end up in proxy and server logs.
    • The Graph version lives in one place. Meta’s changelog named v26.0 the latest version; it arrived on 29 July 2026. v20.0 was available only until 24 September 2026, so hard-coded versions scattered across files do age out.
    • There’s no curl_close(). Many older snippets end with it. The PHP manual says it has had no effect since PHP 8.0 and is deprecated as of PHP 8.5.

    Send an approved template

    A template is the only way to message someone who hasn’t written to you in the last 24 hours. This one uses named variables ({{customer_name}}, {{tracking_id}}), so each value carries a parameter_name, as Meta’s template overview shows:

    $result = wa_send([
        'to'       => '+919812345678', // plus sign and country code
        'type'     => 'template',
        'template' => [
            'name'       => 'order_shipped',
            'language'   => ['code' => 'en'],
            'components' => [[
                'type'       => 'body',
                'parameters' => [
                    ['type' => 'text', 'parameter_name' => 'customer_name', 'text' => 'Asha'],
                    ['type' => 'text', 'parameter_name' => 'tracking_id', 'text' => 'DL48213'],
                ],
            ]],
        ],
    ]);
    $wamid = $result['messages'][0]['id']; // save it: status webhooks quote this ID

    Keep the plus sign. Meta’s send-messages guide warns that without it, “your business phone number’s country calling code is prepended”, which can misdeliver the message. Use the exact language code the template was approved under, or you get error 132001. What the template should say is covered in the order confirmation message guide.

    A 200 at this point means Meta accepted the request, not that the customer has it. Delivery news arrives later on your webhook.

    Free-form replies while the window is open

    Once a customer writes to you, a 24-hour customer service window starts, and every further message from them restarts the clock. While it’s open, text, media and interactive buttons are all allowed:

    wa_send(['to' => $customerNumber, 'type' => 'text', 'text' => ['body' => 'Got it, your photo is with our team.']]);

    A text body tops out at 4,096 characters. Once the window has closed, the same call fails with error 131047, and only a template will get through. For buttons and lists, see the guide to WhatsApp interactive messages.

    How should PHP code retry failed sends?

    Meta’s error codes page says to build error handling “around error codes instead of subcodes or HTTP response status codes”. In practice the codes fall into three buckets:

    BucketCodesWhat your code does
    Temporary: wait and retry4 and 80007 (app or account rate limit), 130429 (throughput reached), 131056 (too many messages to one user), 131000 (unknown error), HTTP 5xxBack off with jitter, then retry
    Fix the request: never retry131047 (window closed), 132000 (parameter count mismatch), 132001 (template missing or unapproved in that language), 131026 (undeliverable)Log it, change the message or the template, flag the number
    Stop and alert190 (token expired), 131048 (spam-rate restriction on the number)Alert someone: renew the token, or look at the number's quality rating in WhatsApp Manager

    Two more are worth knowing. Error 131049 means Meta held back a marketing template “to maintain healthy ecosystem engagement”; Meta says to wait at least 24 hours before resending. And statuses can fail later, on the webhook, even after a successful API call.

    The table becomes a short wrapper. Call it wherever you called wa_send():

    const WA_RETRY_CODES = [4, 80007, 130429, 131000, 131056];
    // Failures before any byte reached Meta, so a resend can't duplicate a message
    const WA_SAFE_CURL_ERRORS = [CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT];
    
    function wa_send_with_retry(array $message, int $attempts = 4): array
    {
        for ($n = 1; ; $n++) {
            try {
                return wa_send($message);
            } catch (WhatsAppException $e) {
                $retry = in_array($e->metaCode, WA_RETRY_CODES, true)
                    || $e->status >= 500
                    || in_array($e->curlErrno, WA_SAFE_CURL_ERRORS, true);
                if (!$retry || $n === $attempts) {
                    throw $e;
                }
                usleep(2 ** ($n - 1) * 1_000_000 + random_int(0, 500_000)); // 1 s, 2 s, 4 s + jitter
            }
        }
    }

    It deliberately doesn’t retry a cURL timeout. A timeout can happen after Meta has accepted the message, and a blind resend can deliver an OTP twice. Check for a status webhook on the first attempt instead.

    Our tests confirmed the behaviour. Two 130429 responses were followed by a success after 3 to 4 seconds. An HTML 502 from a proxy was retried. A 131047, a 132001 and a 190 each stopped after one request. A refused connection was tried four times over about 8 seconds.

    That’s also the wrapper’s limit: it sleeps, and on a web request your visitor waits for every retry. Whether that waiting counts toward PHP’s max_execution_time (30 seconds by default) depends on the platform, so don’t design around it either way. Call wa_send_with_retry() from a cron script or a queue, and have page requests only record what should be sent.

    How do you receive WhatsApp webhooks in plain PHP?

    Meta’s webhook endpoint guide asks for a public URL served over HTTPS with a proper certificate; self-signed ones are rejected. It must do two things: answer a GET handshake, and accept signed POSTs.

    <?php
    // webhook.php: the callback URL you save in the App Dashboard
    
    if ($_SERVER['REQUEST_METHOD'] === 'GET') {
        // PHP turns "hub.mode" into "hub_mode" before your code sees it
        $ok = ($_GET['hub_mode'] ?? '') === 'subscribe'
            && hash_equals((string) getenv('WA_VERIFY_TOKEN'), (string) ($_GET['hub_verify_token'] ?? ''));
        http_response_code($ok ? 200 : 403);
        header('Content-Type: text/plain');
        echo $ok ? ($_GET['hub_challenge'] ?? '') : '';
        exit;
    }
    
    $raw      = file_get_contents('php://input');   // the exact bytes Meta signed
    $expected = 'sha256=' . hash_hmac('sha256', $raw, (string) getenv('META_APP_SECRET'));
    
    if (!hash_equals($expected, $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '')) {
        http_response_code(401);
        exit;
    }
    
    // Store now, process from cron: Meta wants a fast 200 and resends failures
    $pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER') ?: null, getenv('DB_PASS') ?: null);
    $pdo->prepare('INSERT INTO wa_inbox (payload) VALUES (?)')->execute([$raw]);
    http_response_code(200);

    The three PHP traps are all in this file:

    1. The dots disappear. Meta sends hub.mode, hub.verify_token and hub.challenge. PHP’s manual on variables from external sources is blunt: “Dots and spaces in variable names are converted to underscores.” Reading $_GET[‘hub.mode’] returns nothing, the handshake fails, and Meta won’t send webhooks.
    2. Re-encoded JSON has different bytes. Meta computes the HMAC-SHA256 “using the post body payload and your app secret”. If you json_decode() the body and json_encode() it again, PHP escapes slashes and non-ASCII characters by default. In our test a message containing “नमस्ते” and a URL came back as \u0928… and https:\/\/, and the signature no longer matched. Hash what php://input gives you.
    3. == leaks timing. hash_equals() is PHP’s “timing attack safe string comparison”. Cast both sides to strings, because it throws a TypeError on null, and a missing header would then return a 500 instead of a 401.

    Why store the payload and stop? Meta retries failed deliveries “over the next 7 days”, batches up to 1,000 updates in one POST, and its throughput page asks webhook servers for a “median latency not to exceed 250ms”. Replying to the customer inside this request would blow that budget. On PHP-FPM, fastcgi_finish_request() can flush the 200 and keep working, but a table plus cron works on every host. Meta also notes there are “no APIs for fetching historical webhook data”, so the stored row is your only copy.

    The processor runs from cron:

    <?php
    // process_inbox.php: cron runs it every minute
    $lock = fopen(__FILE__, 'r');
    if (!flock($lock, LOCK_EX | LOCK_NB)) {
        exit; // the previous run is still working
    }
    $pdo  = new PDO(getenv('DB_DSN'), getenv('DB_USER') ?: null, getenv('DB_PASS') ?: null);
    $rows = $pdo->query('SELECT id, payload FROM wa_inbox WHERE processed_at IS NULL ORDER BY id LIMIT 200');
    
    foreach ($rows->fetchAll(PDO::FETCH_ASSOC) as $row) {
        foreach (json_decode($row['payload'], true)['entry'] ?? [] as $entry) {
            foreach ($entry['changes'] ?? [] as $change) {
                foreach ($change['value']['messages'] ?? [] as $msg) {
                    handle_message($pdo, $msg);     // skip IDs you've already stored
                }
                foreach ($change['value']['statuses'] ?? [] as $status) {
                    handle_status($pdo, $status);   // sent, delivered, read, failed
                }
            }
        }
        $pdo->prepare('UPDATE wa_inbox SET processed_at = CURRENT_TIMESTAMP WHERE id = ?')->execute([$row['id']]);
    }

    The flock() guard matters on shared hosting: if one run is slow, the next minute’s run exits instead of processing the same rows twice. Deduplicate messages on their id, because retried webhooks repeat them. The webhooks and message status explainer covers what each status means for non-developers.

    How do you build it in Laravel?

    Laravel 13 requires PHP 8.3 or later, per its release notes, and it spreads the same logic across four small files.

    Config first

    Add an entry to config/services.php and keep the values in .env:

    'whatsapp' => [
        'graph'           => env('WA_GRAPH_BASE', 'https://graph.facebook.com/v26.0'),
        'phone_number_id' => env('WA_PHONE_NUMBER_ID'),
        'token'           => env('WA_TOKEN'),
        'app_secret'      => env('META_APP_SECRET'),
        'verify_token'    => env('WA_VERIFY_TOKEN'),
    ],

    Call env() only in config files. Laravel’s configuration docs explain that once you run config:cache in production, “.env file will not be loaded”, so an env() call elsewhere usually returns null and every send fails with an empty token.

    A service class on the HTTP client

    namespace App\Services;
    
    use App\Exceptions\WhatsAppException;
    use Illuminate\Support\Facades\Http;
    
    class WhatsApp
    {
        public function send(array $message): string
        {
            $cfg = config('services.whatsapp');
    
            $response = Http::withToken($cfg['token'])
                ->acceptJson()
                ->connectTimeout(5)
                ->timeout(20)
                ->post("{$cfg['graph']}/{$cfg['phone_number_id']}/messages",
                    ['messaging_product' => 'whatsapp', 'recipient_type' => 'individual'] + $message);
    
            if ($response->failed()) {
                throw new WhatsAppException(
                    $response->status(),
                    $response->json('error.code'),
                    $response->json('error.error_data.details') ?? $response->json('error.message') ?? 'no error body',
                );
            }
    
            return $response->json('messages.0.id'); // the wamid your status webhooks will quote
        }
    }

    WhatsAppException is a trimmed copy of the plain-PHP class in App\Exceptions, plus a retryable() method that returns true for HTTP 5xx and for the five temporary codes in the table. The service doesn’t retry: Http::retry() would block the caller, and the queue does it better.

    A queued job with tries and backoff

    namespace App\Jobs;
    
    use App\Exceptions\WhatsAppException;
    use App\Services\WhatsApp;
    use Illuminate\Contracts\Queue\ShouldQueue;
    use Illuminate\Foundation\Queue\Queueable;
    use Illuminate\Queue\Attributes\Backoff;
    use Illuminate\Queue\Attributes\Tries;
    
    #[Tries(5)]
    #[Backoff([10, 30, 90, 300])]
    class SendWhatsAppMessage implements ShouldQueue
    {
        use Queueable;
    
        public function __construct(public array $message) {}
    
        public function handle(WhatsApp $whatsapp): void
        {
            try {
                $wamid = $whatsapp->send($this->message);
                // save $wamid against the order so status webhooks can be matched
            } catch (WhatsAppException $e) {
                if (! $e->retryable()) {
                    $this->fail($e); // 131047, 132001, 190: a retry can't fix these
                    return;
                }
                throw $e; // released back to the queue, retried after the backoff
            }
        }
    }

    Dispatch it with SendWhatsAppMessage::dispatch($message). #[Tries] and #[Backoff] are Laravel 13’s queue attributes; an array of backoff values gives the increasing delays. A connection error from the HTTP client isn’t caught here, so Laravel retries it too. That can duplicate a send if the connection dropped after Meta accepted the message, so make order-critical messages check your stored wamid first.

    In our run on the database queue driver, a 132001 went straight to failed_jobs after one attempt. A 130429 was released with a delay and sent on the next pass.

    Webhook middleware and controller

    The signature check lives in middleware, so no controller code runs for an unsigned request:

    namespace App\Http\Middleware;
    
    use Closure;
    use Illuminate\Http\Request;
    
    class VerifyMetaSignature
    {
        public function handle(Request $request, Closure $next)
        {
            $expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.whatsapp.app_secret'));
    
            abort_unless(hash_equals($expected, (string) $request->header('X-Hub-Signature-256')), 401);
    
            return $next($request);
        }
    }

    $request->getContent() returns the raw body, so the re-encoding trap doesn’t apply. The controller handles the handshake and hands the payload to a job:

    namespace App\Http\Controllers;
    
    use App\Jobs\ProcessWhatsAppWebhook;
    use Illuminate\Http\Request;
    
    class WhatsAppWebhookController
    {
        public function verify(Request $request)
        {
            // Laravel reads PHP's $_GET, so the dots arrive as underscores here too
            abort_unless($request->query('hub_mode') === 'subscribe' && hash_equals(
                (string) config('services.whatsapp.verify_token'),
                (string) $request->query('hub_verify_token'),
            ), 403);
    
            return response($request->query('hub_challenge'), 200, ['Content-Type' => 'text/plain']);
        }
    
        public function receive(Request $request)
        {
            ProcessWhatsAppWebhook::dispatch($request->json()->all());
    
            return response('', 200);
        }
    }

    Register a GET and a POST route for /webhooks/whatsapp, with VerifyMetaSignature on the POST. Then exclude the path from CSRF protection in bootstrap/app.php, because Meta can’t send a CSRF token. Laravel 13’s CSRF docs use preventRequestForgery for that:

    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->preventRequestForgery(except: ['webhooks/whatsapp']);
    })

    Without it, Meta’s POSTs get HTTP 419 and never reach your code. Inside ProcessWhatsAppWebhook, Cache::add(“wa:msg:{$id}”, true, now()->addDays(8)) is a compact way to deduplicate: it returns false when the key already exists, and eight days outlasts Meta’s seven-day retry period.

    Test it without Meta

    public function test_signed_webhook_is_accepted_and_tampered_one_is_not(): void
    {
        Queue::fake();
        config(['services.whatsapp.app_secret' => 'test-secret']);
        $body = '{"object":"whatsapp_business_account","entry":[]}';
        $server = ['HTTP_X_HUB_SIGNATURE_256' => 'sha256=' . hash_hmac('sha256', $body, 'test-secret'),
                   'CONTENT_TYPE' => 'application/json'];
    
        $this->call('POST', '/webhooks/whatsapp', [], [], [], $server, $body)->assertOk();
        $this->call('POST', '/webhooks/whatsapp', [], [], [], $server, $body . ' ')->assertUnauthorized();
        Queue::assertPushed(ProcessWhatsAppWebhook::class, 1);
    }

    Pair it with Http::fake() and Http::preventStrayRequests() for the send side, and your test suite can never message a real customer.

    How do you run queues and retries on shared hosting?

    Laravel’s queue documentation keeps workers alive with Supervisor, which shared hosting doesn’t offer. What it does offer is cron. Laravel’s scheduler needs a single entry:

    * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

    Then, in routes/console.php, have the scheduler drain the queue each minute:

    Schedule::command('queue:work --stop-when-empty --max-time=50')
        ->everyMinute()
        ->withoutOverlapping();

    —stop-when-empty processes the waiting jobs and exits, and —max-time=50 ends a busy run after about 50 seconds. withoutOverlapping() stops two workers starting on top of each other. Use the database queue driver, which is the default in a new Laravel 13 project, so you don’t need Redis.

    Laravel queue workers compared: on shared hosting a cron wakes a worker each minute to process a batch and stop; under Supervisor one worker runs continuously and processes jobs as they arrive
    Same jobs, different engine: cron works the queue in bursts, while a supervised worker never stops. With the every-minute schedule above, a queued send waits up to a minute.

    The trade-offs:

    • Latency. A queued send waits up to a minute for the next run. That’s fine for shipping updates, but too slow for an OTP, so send OTPs directly with the retry wrapper and a short attempt count.
    • Backoff stretches. In our test, a job released with a 10-second backoff made the worker exit because nothing was ready yet. It was sent on the next run, so real delays round up to your cron interval.
    • Throughput. Meta’s default ceiling is 80 messages per second for each number, and “Throughput is inclusive of inbound and outbound messages”. A one-minute burst on shared hosting is unlikely to reach that, but a campaign can. Pace large sends and expect 130429 when you don’t.

    On a VPS, run php artisan queue:work under Supervisor as the Laravel docs describe, and the latency disappears.

    How do you call ChatMitra’s REST API from PHP?

    Going direct means you own the token, templates, the webhook server and a place for your team to read replies. If you’d rather keep PHP for the triggers and handle the rest in a dashboard, ChatMitra exposes one send endpoint. The shapes below were checked against ChatMitra’s source code, and this payload passed its request validator.

    $response = Http::withToken(config('services.chatmitra.key'))
        ->withHeaders(['Idempotency-Key' => "order-{$orderId}-shipped"])
        ->timeout(40)
        ->post('https://backend.chatmitra.com/developer/api/send_message', [
            'recipient_mobile_number' => '919812345678',
            'messages' => [[
                'kind'     => 'template',
                'template' => [
                    'name'       => 'order_shipped',
                    'language'   => 'en', // a plain string here, not ['code' => 'en']
                    'components' => [[
                        'type'       => 'body',
                        'parameters' => [
                            ['type' => 'text', 'parameter_name' => 'customer_name', 'text' => 'Asha'],
                            ['type' => 'text', 'parameter_name' => 'tracking_id', 'text' => 'DL48213'],
                        ],
                    ]],
                ],
            ]],
        ]);
    
    $status = $response->json('send_status'); // completed, partial, failed, pending or scheduled
    Diagram of a PHP application sending through ChatMitra: the app calls ChatMitra, ChatMitra forwards the message to Meta's WhatsApp Cloud API, Meta sends the reply webhook to ChatMitra, and ChatMitra posts it back to the app
    With ChatMitra in the middle, your PHP code calls one endpoint and receives ChatMitra's signed webhooks; Meta's token and webhook stay on ChatMitra's side. The calls come from your own code: ChatMitra has no WooCommerce plugin.

    What differs from calling Meta:

    • Auth and plan. Create a key in the ChatMitra app under Settings, API Key; a project can hold up to 10. Only paid plans with API access can call it (Pro or Enterprise, per ChatMitra’s pricing page); Starter-plan keys are refused with HTTP 403 and PLAN_UPGRADE_REQUIRED.
    • Responses. Valid requests are answered with HTTP 202. If the send hasn’t finished within roughly 30 seconds, send_status comes back as pending, hence the 40-second timeout. A full per-number backlog returns 429 with RATE_LIMITED.
    • Retries. Repeating a request with the same Idempotency-Key and identical body replays the first result for a short period (or reports processing if the original hasn’t finished) instead of sending again.
    • Text rules. The validator refuses Markdown-style characters in text values, so a tracking ID written “#DL48213” is rejected while “DL48213” passes.
    • Cost. On Pro, each 24-hour conversation with a contact costs one ₹0.20 credit on top of Meta’s charge.

    ChatMitra also POSTs message.received, message.sent and message.status.updated events to your server. Its signature differs from Meta’s: X-Webhook-Signature carries the HMAC-SHA256 of the body in plain hex, using your webhook secret as the key, and has no sha256= prefix.

    $raw      = file_get_contents('php://input');
    $expected = hash_hmac('sha256', $raw, (string) getenv('CHATMITRA_WEBHOOK_SECRET')); // plain hex, no "sha256=" prefix
    
    if (!hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
        http_response_code(401);
        exit;
    }

    We checked it against a body signed by the same Node.js HMAC call ChatMitra’s server uses, including Hindi text and an emoji. ChatMitra waits up to 8 seconds for a 2xx and makes up to 3 attempts, so the store-then-process pattern above applies here too. Field-by-field details are in ChatMitra’s API reference.

    On WordPress, the same calls work through wp_remote_post(), WordPress’s own HTTP function, instead of raw cURL. ChatMitra has no WooCommerce integration of its own, so any WooCommerce trigger is code you write.

    Common mistakes in PHP WhatsApp integrations

    1. Passing an array to CURLOPT_POSTFIELDS. It posts multipart form data. Send json_encode() output.
    2. Reading $_GET[‘hub.mode’]. PHP renamed it hub_mode.
    3. Hashing a re-encoded body. Use php://input or $request->getContent().
    4. Comparing signatures with ==. Use hash_equals(), with both sides cast to strings.
    5. Sending from the page request. Queue it; retries with sleep() hold up the visitor and a PHP worker.
    6. Forgetting the CSRF exception in Laravel. Meta’s POSTs get 419.
    7. Calling env() outside config files. It returns null after config:cache.
    8. Retrying 131047 or 132001. They fail every time. Fix the message or send a template.
    9. Putting the token in the URL. Keep it in the Authorization header.

    Where ChatMitra fits

    ChatMitra suits PHP teams that want their code to trigger messages but don’t want to host the rest. Templates are built in the app, replies land in a shared inbox, broadcasts run from the dashboard, and webhooks bring events back to your systems. Meta’s rules still bind: templates need approval, the 24-hour window holds, and Meta bills its own rates. If you’re comfortable running your own webhook server and queue, going straight to Meta costs less per conversation. If you’d rather not, see ChatMitra’s plans; Pro includes API access and webhooks.

    Sources: Meta for Developers pages on the Graph API changelog, getting started, access tokens, the account model update, sending messages, text messages, templates, error codes, throughput, pricing (with the INR rate card effective 1 October 2026) and webhook endpoints; WhatsApp Help Center; php.net (curl_setopt, curl_close, hash_equals, variables from external sources, fastcgi_finish_request, runtime configuration); Laravel 13 documentation (release notes, configuration, HTTP client, queues, CSRF, scheduling); WordPress developer reference (wp_remote_post). ChatMitra behaviour verified in ChatMitra’s code on 26 September 2026.

    Facts checked against Meta for Developers, php.net and the Laravel documentation on 26 September 2026.

    Clear answers

    Frequently Asked Questions

    Meta's WhatsApp documentation offers no PHP SDK. The official route is the WhatsApp Cloud API, a plain HTTPS and JSON API on graph.facebook.com, so PHP's built-in cURL extension or Laravel's HTTP client can call it directly. Community Composer packages wrap the same API. They are third-party code, so read what they send before you depend on one.

    Yes, for sending and receiving. You need PHP with cURL, HTTPS on your domain for the webhook, and cron. What shared hosting usually lacks is a process that runs all the time, so store incoming webhooks in a database table and process them, plus any queued sends, from a cron job every minute. A Laravel app can run php artisan schedule:run from that cron.

    The usual causes are hashing json_encode(json_decode($body)) instead of the raw body, using the access token or verify token instead of the app secret, and comparing against the header without its sha256= prefix. PHP's json_encode escapes slashes and non-ASCII characters by default, so a re-encoded body has different bytes. Read the body with file_get_contents('php://input') and compare with hash_equals.

    PHP converts dots in incoming variable names to underscores, so Meta's hub.mode, hub.verify_token and hub.challenge arrive as $_GET['hub_mode'], $_GET['hub_verify_token'] and $_GET['hub_challenge']. Laravel's $request->query() reads the same array, so use the underscore names there too. Reply with HTTP 200 and the challenge value to verify the endpoint.

    No. Meta charges per delivered message by category and the recipient's country. Since 1 October 2026 in India, a marketing template costs ₹0.8631 and a utility or authentication template ₹0.1150, excluding tax. Service replies inside the 24-hour window are now charged too: each business phone number gets 1,000 free a month, then pays the service rate (₹0.1150 in India). Providers may add their own fee.

    Put the credentials in config/services.php, read from .env. Write a small service class that posts to the Cloud API with Http::withToken(), and call it from a queued job so retries and backoff are handled by Laravel's queue. Verify incoming webhooks in middleware with hash_hmac over $request->getContent(), and exclude the webhook route from CSRF protection.

    Yes, with approved templates to people who opted in. Queue one job per recipient rather than looping inside a web request. Meta allows up to 80 messages per second per number by default, counting inbound and outbound, and per-user marketing limits can still hold a message back with error 131049. Scripts that automate WhatsApp Web instead break WhatsApp's Terms of Service.

    Yes. POST JSON to https://backend.chatmitra.com/developer/api/send_message with your API key as a Bearer token, using cURL or Laravel's HTTP client. A request that passes validation returns HTTP 202 with a send_status. API access needs a paid plan that includes it, which is Pro or Enterprise on ChatMitra's pricing page. Each 24-hour conversation costs one ₹0.20 credit on Pro, plus Meta's charge.

    Nikunj Gohil

    ChatMitra

    Nikunj Gohil writes for the ChatMitra blog about the WhatsApp Business API.

    Ready to try WhatsApp Business API?

    Start free on the Starter plan — free subscription. Pro is ₹999/month with a 15-day trial when you need automation.