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.

Table Of Content
1 Prerequisites for WhatsApp Cloud API Setup
- 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
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
composer create-project laravel/laravel:^12.0 laravel12-whatsapp-api
Navigate to your project directory:
cd laravel12-whatsapp-api
3.2 Configure MySql Database
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
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
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
<?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
<?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
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
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
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
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
- 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
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
- 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.
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.
