What is WhatsApp Cloud API and Why Integrate It with Laravel 12?

             The WhatsApp Cloud API is Meta's official, cloud-hosted solution that lets businesses and developers send and receive WhatsApp messages programmatically — without managing on-premise infrastructure. By leveraging Meta's Graph API endpoints, you get direct access to WhatsApp's 2+ billion users with built-in scalability, security, and compliance.

Pairing the WhatsApp Cloud API with Laravel 12 is a natural fit. Laravel's expressive HTTP client, service container, and queue system make it straightforward to build production-grade messaging features — from transactional alerts and order updates to full customer support chatbots. Here's why this combination works so well:

  • 98%+ open rates — WhatsApp messages vastly outperform email and SMS in engagement.
  • Conversation-based pricing — Pay per conversation (not per message), with free inbound messages in many tiers.
  • Rich media & interactive templates — Send images, videos, documents, buttons, and list menus approved by Meta.
  • End-to-end encryption — Built-in privacy ensures user trust and regulatory compliance.
  • Zero extra packages — Laravel's built-in HTTP client and RESTful patterns handle all API calls cleanly. For heavy workloads, offload processing to Laravel queues and jobs.


WhatsApp Cloud API Integration in Laravel 12: Complete Step-by-Step Guide

Table Of Content

1 Prerequisites for WhatsApp Cloud API Setup

Follow Meta’s “Get started” and “Messaging” docs to:
  • Create/verify a Business Manager and WhatsApp Business Account (WABA).
  • Add a phone number to the WABA and get the Phone Number ID.
  • Get a Cloud API access token (short-lived or long-lived) from the Meta developer dashboard.
  • Configure webhook subscription for your app (you must provide a public HTTPS URL and verify it).

You will need:
  • PHONE_NUMBER_ID (like 123456789012345)
  • WHATSAPP_TOKEN (a bearer token)
  • Your app APP_SECRET (optional for webhook verification)

2 Introduction to WhatsApp Cloud API in Laravel

In this step-by-step tutorial, you'll build a complete Laravel 12 WhatsApp integration from scratch — a reusable service class, controllers for sending messages, secure webhook handling, media uploads, and template messaging. Everything uses Laravel's native HTTP facade (Guzzle under the hood) with no third-party WhatsApp packages.

By the end of this guide, you'll have:

  • A WhatsAppService class that wraps all Cloud API calls (text, templates, media)
  • Controllers and API routes for sending messages and processing webhooks
  • HMAC signature verification for secure webhook ingestion
  • A local testing workflow using ngrok
  • Production deployment tips including queue offloading and token security

If you're building real-time features alongside WhatsApp (like live dashboards or chat UIs), check out our Laravel 12 WebSocket tutorial. For the latest API endpoints and policies, always refer to the official Meta WhatsApp Cloud API documentation.

3 Create / Install a Laravel Project

3.1 Install Laravel Project

First, ensure Composer is installed on your system. Use the following command to install a new Laravel Project:

composer create-project laravel/laravel:^12.0 laravel12-whatsapp-api

Navigate to your project directory:

cd laravel12-whatsapp-api

3.2 Configure MySql Database

Open the .env file and input the necessary database credentials:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel12_whatsapp
DB_USERNAME=root
DB_PASSWORD=

Note: While this basic integration doesn't require database tables, you may want a database later to log messages, store conversation history, or track delivery statuses.

4 Add environment variables

Add the following WhatsApp credentials to your .env file. You'll get these values from the Meta Developer Dashboard after setting up your WhatsApp Business app:
    
        WHATSAPP_PHONE_NUMBER_ID=123456789012345
        WHATSAPP_TOKEN=EAAJ...your_access_token...
        WHATSAPP_API_VERSION=v21.0
        WHATSAPP_API_BASE=https://graph.facebook.com

        # Webhook verification token (you define this — must match Meta dashboard)
        WHATSAPP_WEBHOOK_VERIFY_TOKEN=my_custom_verify_token

        # Optional: App Secret for HMAC signature verification on incoming webhooks
        WHATSAPP_WEBHOOK_SECRET=your_app_secret_here
    

Important notes:

  • WHATSAPP_API_VERSION — Always use the latest stable Graph API version. Check Meta's changelog for the current version (v21.0+ as of 2026).
  • WHATSAPP_WEBHOOK_VERIFY_TOKEN — This is a string you create. It must match exactly what you enter in the Meta App Dashboard when subscribing to webhooks.
  • WHATSAPP_WEBHOOK_SECRET — This is your App Secret from the Meta dashboard, used to verify that incoming webhook requests genuinely come from Meta via HMAC-SHA256 signature.

5 Create a Reusable WhatsApp Service Class

The service class encapsulates all WhatsApp Cloud API interactions in one reusable place. This follows Laravel's service layer pattern — keeping your controllers thin and your API logic testable.

Create app/Services/WhatsAppService.php:

    
        <?php

        namespace App\Services;

        use Illuminate\Support\Facades\Http;
        use Illuminate\Http\Client\Response;
        use Illuminate\Support\Facades\Log;

        class WhatsAppService
        {
            protected string $base;
            protected string $version;
            protected string $phoneNumberId;
            protected string $token;

            public function __construct()
            {
                $this->base = config('services.whatsapp.base', env('WHATSAPP_API_BASE', 'https://graph.facebook.com'));
                $this->version = env('WHATSAPP_API_VERSION', 'v21.0');
                $this->phoneNumberId = env('WHATSAPP_PHONE_NUMBER_ID');
                $this->token = env('WHATSAPP_TOKEN');
            }

            protected function endpoint(string $path = ''): string
            {
                return "{$this->base}/{$this->version}/{$this->phoneNumberId}{$path}";
            }

            protected function withAuth()
            {
                return Http::withToken($this->token)
                        ->acceptJson();
            }

            /**
            * Send a text message
            * $to = recipient in international format (e.g., "9199xxxxxxx")
            * $text = message string
            */
            public function sendText(string $to, string $text): Response
            {
                $payload = [
                    'messaging_product' => 'whatsapp',
                    'to' => $to,
                    'type' => 'text',
                    'text' => ['preview_url' => false, 'body' => $text],
                ];

                return $this->withAuth()->post($this->endpoint('/messages'), $payload);
            }

            /**
            * Send a template message (pre-approved template)
            * $templateName (string), $language (e.g., "en_US"), $components (array)
            */
            public function sendTemplate(string $to, string $templateName, string $language = 'en_US', array $components = []): Response
            {
                $payload = [
                    'messaging_product' => 'whatsapp',
                    'to' => $to,
                    'type' => 'template',
                    'template' => [
                        'name' => $templateName,
                        'language' => ['code' => $language],
                        'components' => $components,
                    ],
                ];

                return $this->withAuth()->post($this->endpoint('/messages'), $payload);
            }

            /**
            * Upload media (multipart/form-data). $filePath = local path
            * Returns JSON with "id" (media id) on success.
            */
            public function uploadMedia(string $filePath, string $type = null): Response
            {
                $url = $this->endpoint('/media');

                $request = Http::withToken($this->token)
                    ->asMultipart()
                    ->attach('file', fopen($filePath, 'r'));

                if ($type) {
                    $request = $request->withBody(null, 'multipart/form-data');
                }

                return $request->post($url, [
                    'messaging_product' => 'whatsapp',
                ]);
            }

            /**
            * Send an image/video/audio/document message using media_id
            */
            public function sendMediaMessage(string $to, string $mediaType, string $mediaId, string $caption = null): Response
            {
                $payload = [
                    'messaging_product' => 'whatsapp',
                    'to' => $to,
                    'type' => $mediaType,
                    $mediaType => [
                        'id' => $mediaId,
                    ],
                ];

                if ($caption && in_array($mediaType, ['image', 'video', 'document'])) {
                    $payload[$mediaType]['caption'] = $caption;
                }

                return $this->withAuth()->post($this->endpoint('/messages'), $payload);
            }
        }

How this service works:

  • Constructor — Reads credentials from .env and builds the base URL. All four methods share the same auth header via withAuth().
  • sendText() — Posts a simple text message to the /messages endpoint. The to field must be the recipient's full international number (no + prefix).
  • sendTemplate() — Sends a pre-approved template with optional dynamic parameters (body, header, buttons). Templates are required for initiating conversations outside the 24-hour reply window.
  • uploadMedia() — Uploads a file via multipart/form-data to the /media endpoint. The API returns a media_id you'll use when sending the media message.
  • sendMediaMessage() — Sends an image, video, audio, or document using the media_id from the upload step. Captions are supported for images, videos, and documents.

6 Build Controller for Sending Messages

Create app/Http/Controllers/WhatsAppController.php:
    
        <?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\WhatsAppService;
use Illuminate\Http\JsonResponse;

class WhatsAppController extends Controller
{
    protected WhatsAppService $wa;

    public function __construct(WhatsAppService $wa)
    {
        $this->wa = $wa;
    }

    // POST /api/whatsapp/send-text
    public function sendText(Request $request): JsonResponse
    {
        $data = $request->validate([
            'to' => 'required|string',
            'message' => 'required|string',
        ]);

        $resp = $this->wa->sendText($data['to'], $data['message']);

        return response()->json($resp->json(), $resp->status());
    }

    // POST /api/whatsapp/send-template
    public function sendTemplate(Request $request): JsonResponse
    {
        $data = $request->validate([
            'to' => 'required|string',
            'template' => 'required|string',
        ]);

        // Example: pass components as JSON body (buttons/inputs)
        $components = $request->input('components', []);

        $resp = $this->wa->sendTemplate($data['to'], $data['template'], 'en_US', $components);

        return response()->json($resp->json(), $resp->status());
    }

    // POST /api/whatsapp/upload-media (file)
    public function uploadMedia(Request $request): JsonResponse
    {
        $request->validate([
            'file' => 'required|file|max:10240', // 10MB example
        ]);

        $path = $request->file('file')->getPathname();

        $resp = $this->wa->uploadMedia($path);

        return response()->json($resp->json(), $resp->status());
    }

    // POST /api/whatsapp/send-media
    public function sendMedia(Request $request): JsonResponse
    {
        $data = $request->validate([
            'to' => 'required|string',
            'media_id' => 'required|string',
            'media_type' => 'required|string|in:image,video,audio,document',
            'caption' => 'nullable|string',
        ]);

        $resp = $this->wa->sendMediaMessage($data['to'], $data['media_type'], $data['media_id'], $data['caption'] ?? null);

        return response()->json($resp->json(), $resp->status());
    }
}

7 Implement Webhook Verification & Receiving Messages

Meta webhooks require a GET verification challenge (when you add the webhook in the Meta app dashboard) and later POSTs with JSON events. Create app/Http/Controllers/WebhookController.php:
    
        <?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class WebhookController extends Controller
{
    // GET verification (setup in Meta app dashboard)
    public function verify(Request $request)
    {
        $mode = $request->get('hub_mode') ?? $request->get('hub.mode') ?? $request->get('hub_mode');
        $token = $request->get('hub_verify_token') ?? $request->get('hub.verify_token') ?? $request->get('hub_verify_token');
        $challenge = $request->get('hub_challenge') ?? $request->get('hub.challenge');

        // When registering webhook you must configure the verify token in Meta dashboard and here.
        $expected = env('WHATSAPP_WEBHOOK_VERIFY_TOKEN', 'your_verify_token_here');

        if ($mode === 'subscribe' && $token === $expected) {
            return response($challenge, Response::HTTP_OK);
        }

        return response('Forbidden', Response::HTTP_FORBIDDEN);
    }

    // POST receiver for webhook events
    public function receive(Request $request)
    {
        $payload = $request->all();

        // Optional: verify HMAC signature if App Secret is configured
        // Meta includes "X-Hub-Signature-256" header when you configure App Secret
        $signature = $request->header('X-Hub-Signature-256'); // value: sha256=...
        if ($signature && env('WHATSAPP_WEBHOOK_SECRET')) {
            $computed = 'sha256='.hash_hmac('sha256', $request->getContent(), env('WHATSAPP_WEBHOOK_SECRET'));
            if (!hash_equals($computed, $signature)) {
                Log::warning('Invalid webhook signature', ['signature' => $signature]);
                return response('Invalid signature', 403);
            }
        }

        // Handle the message: check object, entry[0], changes, value.messages
        if (isset($payload['object']) && $payload['object'] === 'whatsapp_business_account') {
            foreach ($payload['entry'] ?? [] as $entry) {
                foreach ($entry['changes'] ?? [] as $change) {
                    $value = $change['value'] ?? [];
                    // Check for messages
                    if (!empty($value['messages'])) {
                        foreach ($value['messages'] as $message) {
                            // Example: log incoming message
                            Log::info('Incoming WhatsApp message', $message);

                            // You may queue a job: HandleIncomingWhatsAppMessage::dispatch($message);
                        }
                    }
                    // Check for statuses (message status updates)
                    if (!empty($value['statuses'])) {
                        foreach ($value['statuses'] as $status) {
                            Log::info('WhatsApp message status', $status);
                        }
                    }
                }
            }
        }

        // Return 200 OK quickly to Meta
        return response('EVENT_RECEIVED', 200);
    }
}


Important: Meta expects fast 200 OK responses for webhook events; do heavy processing asynchronously (jobs/queues) to avoid timeouts. Webhook verification (GET) uses the hub.challenge pattern.

8 Define Routes

Add routes in routes/api.php (or routes/web.php — but api is better for token-protected endpoints). For a deeper understanding of Laravel routing patterns, see our Laravel 12 Routing and Middleware Guide.
    
       use App\Http\Controllers\WhatsAppController;
       use App\Http\Controllers\WebhookController;

       Route::prefix('whatsapp')->group(function () {
            Route::post('send-text', [WhatsAppController::class, 'sendText']);
            Route::post('send-template', [WhatsAppController::class, 'sendTemplate']);
            Route::post('upload-media', [WhatsAppController::class, 'uploadMedia']);
            Route::post('send-media', [WhatsAppController::class, 'sendMedia']);
        });

        // Webhook endpoints (public — no auth middleware)
        Route::get('/whatsapp/webhook', [WebhookController::class, 'verify']);
        Route::post('/whatsapp/webhook', [WebhookController::class, 'receive']);

⚠️ Important — CSRF Protection: If you place webhook routes in routes/web.php instead of routes/api.php, Laravel's CSRF middleware will block Meta's POST requests with a 419 error. You must exclude the webhook URI from CSRF verification.

In Laravel 12, open bootstrap/app.php and add:

    
        ->withMiddleware(function (Middleware $middleware) {
            $middleware->validateCsrfTokens(except: [
                'whatsapp/webhook',
            ]);
        })

When using routes/api.php (recommended), CSRF is not applied by default, so no exemption is needed.

9 Example: Sending Template Messages in Laravel

Templates must be created and approved in the Meta Business Manager before you can use them via the API. Template messages are the only way to initiate conversations with users outside the 24-hour reply window — making them essential for order updates, appointment reminders, shipping notifications, and marketing campaigns.

Example payload (the sendTemplate method in our service class sends this structure):

    
     {
        "messaging_product": "whatsapp",
        "to": "9199XXXXXXXX",
        "type": "template",
        "template": {
                "name": "order_update",
                "language": { "code": "en_US" },
                "components": [
                {
                    "type": "body",
                    "parameters": [
                    { "type": "text", "text": "John Doe" },
                    { "type": "text", "text": "#ABC123" }
                    ]
                }
                ]
            }
        }

Key points about template messages:

  • Templates go through Meta's review process (typically approved within minutes to hours).
  • The components array lets you pass dynamic values (customer name, order ID, dates, etc.) into placeholder slots defined in the template.
  • You can include header media (image, video, document), body text with variables, footer text, and quick-reply or call-to-action buttons.
  • Misusing templates (spam, misleading content) can get your WABA flagged or banned — always follow WhatsApp Business Policy.

10 Example: Uploading & Sending Media Messages

The WhatsApp Cloud API supports sending images, videos, audio files, documents, and stickers. The recommended workflow is: upload first, get a media_id, then send the message referencing that ID. This is faster and more reliable than passing a URL directly.

Step 1 — Upload media (curl example):

    
     curl -X POST "https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/media" \
        -H "Authorization: Bearer {WHATSAPP_TOKEN}" \
        -F "file=@/path/to/image.jpg" \
        -F "messaging_product=whatsapp"

The response returns a JSON object with an id field — this is the media ID you'll use in the next step.

Step 2 — Send image using media_id (JSON body):

    
    {
        "messaging_product": "whatsapp",
        "to": "9199XXXXXXXX",
        "type": "image",
        "image": {
            "id": "MEDIA_ID_FROM_UPLOAD",
            "caption": "Here is your invoice"
        }
    }

Supported media types and size limits:

  • Images — JPEG, PNG (max 5MB)
  • Videos — MP4, 3GPP (max 16MB)
  • Audio — AAC, MP3, OGG, AMR (max 16MB)
  • Documents — PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT (max 100MB)
  • Stickers — WebP (max 100KB static, 500KB animated)

Check the official Media reference for the complete list of supported MIME types.

11 Local Development: Testing Webhooks with ngrok

During local development, Meta cannot reach your localhost server. You need a tunneling tool like ngrok to expose your local Laravel app over a public HTTPS URL.

Step-by-step setup:

  • Start your Laravel app locally with php artisan serve (default port 8000) or use Laravel Valet/Herd.
  • Start ngrok in a separate terminal: ngrok http 8000. Copy the generated https://xxxx.ngrok-free.app URL.
  • Register the webhook in the Meta App Dashboard → WhatsApp → Configuration → Callback URL. Paste the ngrok URL followed by your webhook path, e.g., https://xxxx.ngrok-free.app/api/whatsapp/webhook.
  • Set the Verify Token — Enter the exact same string you configured as WHATSAPP_WEBHOOK_VERIFY_TOKEN in your .env file.
  • Click Verify and Save — Meta will send a GET request with hub.challenge. If your WebhookController::verify method returns the challenge correctly, the webhook is registered.
  • Subscribe to events — After verification, subscribe to the "messages" webhook field to start receiving incoming message notifications.

Tip: The free ngrok tier generates a new URL each time you restart it. Use ngrok http 8000 --domain=your-name.ngrok-free.app (with a free static domain) to avoid re-registering the webhook URL repeatedly.

12 Production Best Practices & Security Tips

Before deploying your WhatsApp integration to production, follow these essential practices to ensure reliability, security, and compliance:
  • Enforce HTTPS — Meta requires a public HTTPS endpoint for webhooks. Use a valid SSL certificate (Let's Encrypt works well). Self-signed certificates will be rejected.
  • Verify webhook signatures — Always validate the X-Hub-Signature-256 HMAC header using your App Secret. This ensures incoming requests genuinely originate from Meta, preventing spoofed payloads. (See our WebhookController code above.)
  • Respond fast, process later — Return 200 OK immediately in your webhook handler, then dispatch a Laravel queue job for heavy processing. Meta will retry (and eventually disable) webhooks that respond slowly.
  • Secure and rotate tokens — Store your WhatsApp access token in environment variables or a secrets manager (e.g., AWS Secrets Manager, HashiCorp Vault). Never commit tokens to version control. Rotate tokens periodically.
  • Follow the 24-hour messaging window — You can only reply to user-initiated conversations within 24 hours of their last message. To proactively message users outside this window, you must use pre-approved message templates. See Meta's template policy.
  • Upload media first, then reference by ID — Pre-uploading media and using media_id is faster and more reliable than sending media URLs directly, especially under high throughput.
  • Monitor and optimize — Use logging and monitoring to track API failures, webhook delivery, and message statuses. For performance tuning, see our Laravel 12 Performance Optimization Guide.

13 Project Folder Structure Overview

14 Quick Testing with Postman/cURL

Send text (curl):
    
        curl -X POST "https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages" \
            -H "Authorization: Bearer ${WHATSAPP_TOKEN}" \
            -H "Content-Type: application/json" \
            -d '{
            "messaging_product": "whatsapp",
            "to": "9199XXXXXXXX",
            "type": "text",
            "text": { "body": "Hello from Laravel!" }
            }'

Send via your Laravel API (curl):

    
        curl -X POST "http://localhost:8000/api/whatsapp/send-text" \
            -H "Content-Type: application/json" \
            -d '{
            "to": "9199XXXXXXXX",
            "message": "Hello from my Laravel app!"
            }'

Webhook verification test: When you register the webhook in the Meta dashboard, Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge parameters. Your WebhookController::verify method must echo back the challenge value with a 200 status to complete verification.

Tip: Use Postman for a friendlier UI when testing your API endpoints — it handles file uploads, headers, and JSON bodies more conveniently than cURL.

15 Conclusion

You now have a complete, production-ready Laravel 12 integration for the WhatsApp Cloud API. Here's a quick recap of what we built:
  • WhatsAppService — A reusable service class wrapping text, template, media upload, and media send API calls using Laravel's HTTP facade.
  • WhatsAppController — Clean controller endpoints for sending different message types via your own API.
  • WebhookController — Secure webhook verification (GET challenge) and incoming message/status processing (POST) with optional HMAC signature validation.
  • Routes — Properly organized API routes with CSRF exemption guidance for webhook endpoints.
  • Production readiness — HTTPS enforcement, token security, queue offloading, and Meta policy compliance.

Next steps to extend this integration:

  • Store incoming messages in a database for conversation history and analytics.
  • Build an auto-reply chatbot by dispatching queued jobs from the webhook handler.
  • Add retry logic and circuit breakers for API resilience at scale.
  • Implement rate limiting on your send endpoints to stay within Meta's throughput limits.

For related Laravel 12 tutorials, check out our guides on building REST APIs, queue and job processing, and routing with middleware. If you're containerizing your setup, our Docker Compose for Laravel + MySQL guide covers the full deployment workflow.

Revathi M - PHP and CodeIgniter Developer

Written by Revathi M

PHP Developer & Technical Writer · 10+ years building web applications with CodeIgniter and Laravel

Revathi specializes in PHP backend development, authentication systems, and REST API design. She writes practical, production-tested tutorials at Get Sample Code to help developers build secure applications faster.

Frequently Asked Questions

You need a Meta Business Manager account, a WhatsApp Business Account, a verified phone number, the Phone Number ID, a permanent access token, and webhook subscription configured in the Meta dashboard.

Add WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_TOKEN, WHATSAPP_API_VERSION (e.g., v21.0 or latest stable), WHATSAPP_WEBHOOK_VERIFY_TOKEN for webhook registration, and optionally WHATSAPP_WEBHOOK_SECRET (your App Secret) for HMAC signature verification on incoming webhooks.

Use Laravel's Http facade in a service class to POST to the /PHONE_NUMBER_ID/messages endpoint with a JSON payload containing messaging_product, to (recipient number), type: text, and the message body. The service class wraps authentication via withToken() for clean, reusable code.

In a WebhookController, handle GET requests: check hub.mode=subscribe and hub.verify_token matches your token, then respond with hub.challenge. For POST, optionally verify HMAC signature.

First, upload media via multipart/form-data to /media endpoint to get a media ID. Then, send a message with type 'image/document/audio/video' and the media ID.

Yes, but templates must be pre-approved in the Meta dashboard. Use the sendTemplate method with template name, language code, and optional components/parameters.

Use ngrok to expose your local server via HTTPS, set the ngrok URL as the webhook in Meta dashboard, and configure a verify token.

Require HTTPS, verify signatures, respond quickly with 200 OK to webhooks, secure tokens, use queues for processing, and follow Meta's template and messaging policies.

This happens because Laravel's CSRF middleware blocks external POST requests. If your webhook route is in routes/web.php, you need to exclude it from CSRF verification in bootstrap/app.php. The recommended approach is to define webhook routes in routes/api.php, which does not apply CSRF middleware by default.

Use the latest stable Graph API version (v21.0 or newer as of 2026). Meta regularly deprecates older versions, so check the official Graph API changelog and update WHATSAPP_API_VERSION in your .env file accordingly. Using a deprecated version can cause unexpected 400 errors.