Laravel Webhook Integration

Build a Laravel webhook controller for WhatsApp messages routed through Dualhook.

Prerequisites

  • Laravel 10+ with PHP 8.1+
  • A WhatsApp Business Account connected through Dualhook
  • Webhook Override configured so Meta delivers messages directly to your server
  • Your webhook verify token (for Meta's GET handshake — set during connection setup in Dualhook)
  • A connection-scoped Dualhook API key for outbound sends

Add these to your .env:

WEBHOOK_VERIFY_TOKEN=your_verify_token
DUALHOOK_API_KEY=dh_live_your_key
WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id
WHATSAPP_WABA_ID=your_waba_id

No META_APP_SECRET? In the Embedded Signup flow X-Hub-Signature-256 is signed by Dualhook's Meta app and isn't customer-verifiable — validate inbound webhook payload shape instead. See Messaging Webhook → Inbound Webhook POST Validation.

Create a Webhook Controller

Create a controller that handles both the GET verification challenge and POST webhook events. Routes in routes/api.php do not have CSRF protection, so no additional exclusion is needed.

Meta requires a quick 200 response (within ~10 seconds) on POST webhooks and will retry on non-2xx responses. For production workloads, dispatch the processing to a queue job and return 200 immediately.

// routes/api.php
Route::get('/webhook', [WebhookController::class, 'verify']);
Route::post('/webhook', [WebhookController::class, 'handle']);
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function verify(Request $request)
    {
        // PHP converts dots in query params to underscores in $_GET,
        // so hub.mode becomes hub_mode automatically.
        $mode = $request->query('hub_mode');
        $token = $request->query('hub_verify_token');
        $challenge = $request->query('hub_challenge');

        if ($mode === 'subscribe' && $token === config('services.whatsapp.verify_token')) {
            return response($challenge, 200);
        }

        return response('Forbidden', 403);
    }

    public function handle(Request $request)
    {
        if (! $this->isExpectedPayload($request->all())) {
            return response('Unauthorized', 401);
        }

        // Respond 200 immediately to prevent Meta retries.
        // For production, dispatch to a queue job instead of inline processing.
        $this->processWebhook($request->all());

        return response('OK', 200);
    }
}

Add the config reference:

// config/services.php
'whatsapp' => [
    'verify_token' => env('WEBHOOK_VERIFY_TOKEN'),
    'api_key' => env('DUALHOOK_API_KEY'),
    'phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID'),
    'waba_id' => env('WHATSAPP_WABA_ID'),
],

Validate the Webhook Payload

Validate the envelope, WABA ID, and phone_number_id against the values you stored for this connection, and serve the endpoint on a private, high-entropy URL path.

// In WebhookController
private function isExpectedPayload(array $payload): bool
{
    if (($payload['object'] ?? '') !== 'whatsapp_business_account') {
        return false;
    }

    $expectedWaba = config('services.whatsapp.waba_id');
    $expectedPhone = config('services.whatsapp.phone_number_id');

    foreach ($payload['entry'] ?? [] as $entry) {
        if (($entry['id'] ?? '') !== $expectedWaba) {
            return false;
        }
        if (empty($entry['changes']) || ! is_array($entry['changes'])) {
            return false;
        }
        foreach ($entry['changes'] as $change) {
            if (($change['field'] ?? '') !== 'messages') {
                return false;
            }
            $phoneNumberId = $change['value']['metadata']['phone_number_id'] ?? null;
            if ($phoneNumberId !== $expectedPhone) {
                return false;
            }
        }
    }

    return true;
}

Handle Incoming Messages

Parse the webhook payload to extract messages and statuses. Meta sends changes grouped by phone number.

// In WebhookController
private function processWebhook(array $payload): void
{
    if (($payload['object'] ?? '') !== 'whatsapp_business_account') {
        return;
    }

    foreach ($payload['entry'] ?? [] as $entry) {
        foreach ($entry['changes'] ?? [] as $change) {
            if ($change['field'] !== 'messages') {
                continue;
            }

            $value = $change['value'];
            $phoneNumberId = $value['metadata']['phone_number_id'] ?? null;

            // Handle incoming messages
            foreach ($value['messages'] ?? [] as $message) {
                Log::info('WhatsApp message received', [
                    'from' => $message['from'],
                    'type' => $message['type'],
                    'text' => $message['text']['body'] ?? null,
                ]);
                // Dispatch to your application logic
            }

            // Handle delivery statuses
            foreach ($value['statuses'] ?? [] as $status) {
                Log::info('WhatsApp status update', [
                    'message_id' => $status['id'],
                    'status' => $status['status'],
                ]);
            }
        }
    }
}

For production, dispatch webhook processing to a Laravel queue job rather than handling it inline. This keeps response times fast and prevents timeouts.

Send a Template Message

Use Laravel's HTTP client to send template messages through Dualhook's allowlisted runtime. Inbound webhooks still arrive directly from Meta.

The to field must be in E.164 format (e.g. +14155552671). WhatsApp rejects numbers that are not E.164-formatted.

// app/Services/WhatsAppService.php
namespace App\Services;

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

class WhatsAppService
{
    public function sendTemplate(
        string $to,
        string $templateName,
        string $languageCode = 'en'
    ): array {
        $config = config('services.whatsapp');

        $response = Http::withToken($config['api_key'])
            ->post(
                "https://api.dualhook.com/v25.0/{$config['phone_number_id']}/messages",
                [
                    'messaging_product' => 'whatsapp',
                    'to' => $to,
                    'type' => 'template',
                    'template' => [
                        'name' => $templateName,
                        'language' => ['code' => $languageCode],
                    ],
                ]
            );

        if ($response->failed()) {
            Log::error('WhatsApp send failed', [
                'error' => $response->json('error'),
            ]);
        }

        return $response->json();
    }
}

For templates with variables and media headers, see the Sending Template Messages documentation.

Test with Dualhook

  1. Connect your number through Embedded Signup — Dualhook configures the webhook URL via Meta's Webhook Override automatically.
  2. Set your server's URL as the webhook endpoint in the Dualhook dashboard. For local development, use a tunnel tool like ngrok.
  3. Send a test message to your WhatsApp number. The message webhook arrives directly at your Laravel endpoint from Meta.
  4. Check Dualhook's webhook activity logs to verify management events are being received for operational monitoring.

Inbound messages flow from Meta directly to your server. Outbound payloads transit Dualhook's runtime without content persistence or caching.

Related

  • WhatsApp Webhook OverrideHow Dualhook uses WhatsApp Webhook Override to route supported customer-path webhooks directly from Meta to your server.
  • Messaging WebhookReal-time webhook events for inbound messages, delivery statuses, and errors.
  • The Basics OverviewBase URL, authentication, message send payloads, and webhook subscription basics.
Browse more docsStart Free Trial