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 flowX-Hub-Signature-256is 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
- Connect your number through Embedded Signup — Dualhook configures the webhook URL via Meta's Webhook Override automatically.
- Set your server's URL as the webhook endpoint in the Dualhook dashboard. For local development, use a tunnel tool like ngrok.
- Send a test message to your WhatsApp number. The message webhook arrives directly at your Laravel endpoint from Meta.
- 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.