laravel-whatsapp-cloud-api maintained by blissjaspis
Laravel Whatsapp Cloud API
Laravel package to interact with the WhatsApp Cloud API.
- Outbound — send messages, media, templates, mark as read, and structured Graph API errors (
sendOrFail()). - Inbound — webhook verification, payload parsing, inbound media helpers, delivery failure details, optional idempotency (you provide routes and business logic).
Requirements
- PHP 8.2 or higher
- Laravel 11, 12 or 13
Installation
composer require blissjaspis/laravel-whatsapp-cloud-api:^1.2
Publish the configuration file:
php artisan vendor:publish --provider="BlissJaspis\\WhatsappCloudApi\\WhatsappServiceProvider" --tag="config"
Use Outbound to send messages, Inbound to receive webhooks, or both. Each section below includes its own configuration and examples.
Outbound
Send messages and call the Graph API.
Configuration
Each connection is one Meta app. Credentials and Graph API version sit on the connection; phone numbers are nested under phones.
WHATSAPP_DEFAULT=default
WHATSAPP_VERSION_SDK=v25.0
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_WEBHOOK_APP_SECRET=
WHATSAPP_WEBHOOK_VERIFY_TOKEN=
| Variable | Description |
|---|---|
WHATSAPP_DEFAULT |
Default target: connection name or connection.phone (e.g. default.sales) |
WHATSAPP_VERSION_SDK |
Graph API version for the default connection |
WHATSAPP_PHONE_NUMBER_ID |
Phone number ID for the default connection (phones.main) |
WHATSAPP_ACCESS_TOKEN |
System user access token for the default connection |
Define connections in config/whatsapp-cloud-api.php:
'default' => env('WHATSAPP_DEFAULT', 'default'),
'connections' => [
'default' => [
'version_sdk' => env('WHATSAPP_VERSION_SDK', 'v25.0'),
'access_token' => env('WHATSAPP_ACCESS_TOKEN'),
'webhook_app_secret' => env('WHATSAPP_WEBHOOK_APP_SECRET'),
'webhook_verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN'),
'default_phone' => 'main',
'phones' => [
'main' => ['phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID')],
'sales' => ['phone_number_id' => env('WHATSAPP_SALES_PHONE_NUMBER_ID')],
],
],
'brand_b' => [
'version_sdk' => env('WHATSAPP_BRAND_B_VERSION_SDK', 'v25.0'),
'access_token' => env('WHATSAPP_BRAND_B_ACCESS_TOKEN'),
'webhook_app_secret' => env('WHATSAPP_BRAND_B_WEBHOOK_APP_SECRET'),
'phones' => [
'support' => ['phone_number_id' => env('WHATSAPP_BRAND_B_PHONE_NUMBER_ID')],
],
],
],
Send from a connection or a specific phone:
Whatsapp::connection('default.sales')
->message()
->to('6281234567890')
->body(Text::message('Hello from sales')->build())
->send();
Whatsapp::connection('brand_b')->media()->upload($path, Media::ImageJPEG);
Whatsapp::connection('default.sales')->readMessage('wamid.xxx');
Whatsapp::message() without connection() uses the target named in WHATSAPP_DEFAULT (default: default).
Validate configuration
List connections and spot missing tokens or phone number IDs:
php artisan whatsapp:connections
php artisan whatsapp:connections --validate # exit code 1 when invalid
Optional boot-time checks (reports to logs, does not stop the app):
WHATSAPP_VALIDATE_ON_BOOT=true
Core pattern
All outbound messages follow the same pattern:
Whatsapp::message()
->to($phoneNumber)
->body($messageBuilder->build())
->send(); // Illuminate\Http\Client\Response
Use ->sendOrFail() instead of ->send() when you want a WhatsappApiException on Graph API errors (see Graph API errors).
Use the Whatsapp facade or resolve BlissJaspis\WhatsappCloudApi\Whatsapp from the container.
Graph API errors
Failed requests return a normal Response from send(). For structured Meta error fields, use sendOrFail() or WhatsappApiException::throwIfFailed():
use BlissJaspis\WhatsappCloudApi\Exceptions\WhatsappApiException;
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Support\Text;
try {
Whatsapp::connection('default')
->message()
->to('6281234567890')
->body(Text::message('Hello')->build())
->sendOrFail();
} catch (WhatsappApiException $e) {
// $e->getMessage()
// $e->getCode() — HTTP status (400, 401, …)
// $e->apiCode, $e->errorSubcode, $e->type, $e->fbtraceId, $e->error
}
$response = Whatsapp::message()->to('628...')->body(...)->send();
WhatsappApiException::throwIfFailed($response);
Media and read receipts use the same *OrFail() helpers:
use BlissJaspis\WhatsappCloudApi\Enums\Media;
use BlissJaspis\WhatsappCloudApi\WhatsappRead;
$upload = Whatsapp::connection('default')->media()->uploadOrFail('/path/to/file.jpg', Media::ImageJPEG);
(new WhatsappRead('default.sales'))->sendOrFail('wamid.xxx');
Phone numbers
The package passes the recipient number to the API as-is. Format the number in your application before calling to(). WhatsApp expects digits only with the country code included and no + prefix (e.g. 6281234567890).
Text messages
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Support\Text;
$response = Whatsapp::message()
->to('6281234567890')
->body(Text::message('Hello from Laravel!')->build())
->sendOrFail();
$messageId = $response->json('messages.0.id');
Disable link preview:
Text::message('Visit https://example.com')->disableLinkPreview()->build();
Replying to a message
Whatsapp::message()
->to('6281234567890')
->replyTo('wamid.xxx')
->body(Text::message('Thanks for your message!')->build())
->send();
Media messages
Media builders accept an uploaded media ID (asset) or a public HTTPS URL (url).
Image
use BlissJaspis\WhatsappCloudApi\Support\Image;
// By URL
Whatsapp::message()
->to('6281234567890')
->body(Image::media('https://example.com/image.jpg', 'url')->build())
->send();
// By media ID with caption
Whatsapp::message()
->to('6281234567890')
->body(Image::media('your-media-id')->caption('Your caption')->build())
->send();
Document
use BlissJaspis\WhatsappCloudApi\Support\Document;
Whatsapp::message()
->to('6281234567890')
->body(
Document::media('https://example.com/document.pdf', 'url')
->filename('invoice.pdf')
->caption('Your invoice')
->build()
)
->send();
Audio
use BlissJaspis\WhatsappCloudApi\Support\Audio;
Whatsapp::message()
->to('6281234567890')
->body(Audio::media('your-media-id')->build())
->send();
Video
use BlissJaspis\WhatsappCloudApi\Support\Video;
Whatsapp::message()
->to('6281234567890')
->body(Video::media('your-media-id')->caption('Optional caption')->build())
->send();
Sticker
use BlissJaspis\WhatsappCloudApi\Support\Sticker;
Whatsapp::message()
->to('6281234567890')
->body(Sticker::media('your-media-id')->build())
->send();
Template messages
use BlissJaspis\WhatsappCloudApi\Support\Template;
// Static template (no variables)
Whatsapp::message()
->to('6281234567890')
->body(Template::name('hello_world')->lang('en')->build())
->send();
// Template with variables (fluent components)
use BlissJaspis\WhatsappCloudApi\Support\Template\BodyComponent;
use BlissJaspis\WhatsappCloudApi\Support\Template\ButtonComponent;
use BlissJaspis\WhatsappCloudApi\Support\Template\HeaderComponent;
Whatsapp::message()
->to('6281234567890')
->body(
Template::name('order_update')
->lang('en')
->components([
HeaderComponent::text('Your order'),
BodyComponent::make()->text('John')->text('#12345'),
ButtonComponent::url(0, 'https://example.com/orders/12345'),
])
->build()
)
->send();
BodyComponent also supports currency() and dateTime() for non-text variables. Use raw components([...]) arrays for uncommon template layouts.
Optional category when your WhatsApp Business setup requires it:
Template::name('promo_offer')->lang('en')->category('marketing')->build();
Location
use BlissJaspis\WhatsappCloudApi\Support\Location;
Whatsapp::message()
->to('6281234567890')
->body(
Location::mark()
->latitude(-6.2088)
->longitude(106.8456)
->name('Jakarta')
->address('Indonesia')
->build()
)
->send();
Location request
use BlissJaspis\WhatsappCloudApi\Support\LocationRequest;
Whatsapp::message()
->to('6281234567890')
->body(LocationRequest::message('Please share your location.')->build())
->send();
Interactive messages
List message with fluent action helpers:
use BlissJaspis\WhatsappCloudApi\Support\Interactive;
use BlissJaspis\WhatsappCloudApi\Support\Interactive\ListAction;
use BlissJaspis\WhatsappCloudApi\Support\Interactive\ReplyButtonAction;
Whatsapp::message()
->to('6281234567890')
->body(
Interactive::list()
->body('Choose an option')
->header('Menu')
->action(
ListAction::make('View options')
->section('Section 1')
->row('row_1', 'Option 1', 'First option')
)
->build()
)
->send();
// Reply buttons (up to 3)
Whatsapp::message()
->to('6281234567890')
->body(
Interactive::button()
->body('Do you agree?')
->action(
ReplyButtonAction::make()
->reply('yes', 'Yes')
->reply('no', 'No')
)
->build()
)
->send();
Fluent entry points for other interactive types (pass a raw action array for the payload):
Interactive::flow()->body('Complete the form')->action([/* flow action */])->build();
Interactive::productList()->body('Browse')->action([/* catalog */])->build();
Interactive::catalog()->body('View catalog')->action([/* catalog_message */])->build();
Contacts and reactions
use BlissJaspis\WhatsappCloudApi\Support\Contacts;
use BlissJaspis\WhatsappCloudApi\Support\Reaction;
Whatsapp::message()
->to('6281234567890')
->body(Contacts::data([/* WhatsApp contacts array */])->build())
->send();
Whatsapp::message()
->to('6281234567890')
->body(Reaction::messageId('wamid.xxx')->emoji('👍')->build())
->send();
Media upload, retrieve, and delete
use BlissJaspis\WhatsappCloudApi\Enums\Media;
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Support\Image;
$upload = Whatsapp::media()->upload('/path/to/image.jpg', Media::ImageJPEG);
$mediaId = $upload->json('id');
Whatsapp::message()
->to('6281234567890')
->body(Image::media($mediaId)->build())
->send();
$metadata = Whatsapp::media()->retrieve($mediaId);
$binary = Whatsapp::media()->download($metadata->json('url'));
Whatsapp::media()->delete($mediaId);
// Or throw WhatsappApiException on failure:
$metadata = Whatsapp::media()->retrieveOrFail($mediaId);
$binary = Whatsapp::media()->downloadOrFail($metadata->json('url'));
Mark a message as read
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
// Uses WHATSAPP_DEFAULT connection
Whatsapp::readMessage('wamid.xxx');
Whatsapp::readMessage('wamid.xxx', withTypingIndicator: true);
// Named connection with sendOrFail()
use BlissJaspis\WhatsappCloudApi\WhatsappRead;
(new WhatsappRead('default.sales'))->sendOrFail('wamid.xxx');
Inbound
Receive WhatsApp webhooks in your Laravel app. You provide routes, controllers, jobs, and business logic; this package supplies verification and optional idempotency helpers.
Configuration
Webhook credentials are set per Meta app under connections (see Outbound configuration):
'connections' => [
'default' => [
'webhook_app_secret' => env('WHATSAPP_WEBHOOK_APP_SECRET'),
'webhook_verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN'),
// ...
],
],
| Config key | Description |
|---|---|
connections.*.webhook_app_secret |
App Secret (Meta Developer Console → App settings → Basic) |
connections.*.webhook_verify_token |
Same token as in your Meta webhook subscription |
webhook_idempotency |
Optional: prefix, ttl, cache_store (see below) |
Subscription challenge (GET)
When you configure the webhook URL in Meta, respond to the verification request:
use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookChallenge;
use Illuminate\Http\Request;
Route::get('/webhook/whatsapp', fn (Request $request) => WebhookChallenge::toResponse($request));
Signature verification (POST)
Verify X-Hub-Signature-256 using the raw request body and your app secret:
use BlissJaspis\WhatsappCloudApi\Http\Middleware\VerifyWhatsappWebhookSignature;
Route::post('/webhook/whatsapp', function (Request $request) {
// Handle payload in your app
})->middleware(VerifyWhatsappWebhookSignature::class);
When no middleware parameter is passed, the app secret is read from the default connection (WHATSAPP_DEFAULT, usually default).
Pass a connection name (from connections) or a literal app secret after ::
Route::post('/webhook/whatsapp', function (Request $request) {
// Handle payload in your app
})->middleware(VerifyWhatsappWebhookSignature::class . ':your-meta-app-secret');
// Connection name (per Meta app — not connection.phone)
Route::post('/webhook/whatsapp/support', $handler)
->middleware(VerifyWhatsappWebhookSignature::class . ':support');
Or verify manually:
use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookSignature;
WebhookSignature::assertValidRequest($request); // uses config; throws InvalidWebhookSignature
WebhookSignature::assertValidRequest($request, 'custom-app-secret');
Note: Signature verification uses the raw request body. Do not parse the JSON before the middleware runs. If your app secret contains
:, Laravel splits middleware parameters on that character — use config only orWebhookSignature::verifyRequest($request, $secret)instead.
Idempotency (optional)
Meta may retry webhooks. Use WebhookPayload to extract message/status IDs and skip duplicates:
use BlissJaspis\WhatsappCloudApi\Contracts\WebhookIdempotencyStore;
use BlissJaspis\WhatsappCloudApi\Http\Middleware\EnsureWhatsappWebhookIdempotency;
use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookPayload;
Route::post('/webhook/whatsapp', function (Request $request) {
// ...
})->middleware([
VerifyWhatsappWebhookSignature::class,
EnsureWhatsappWebhookIdempotency::class,
]);
Configure webhook_idempotency in config/whatsapp-cloud-api.php (default TTL: 24 hours). Bind your own WebhookIdempotencyStore for database-backed deduplication. For production, use Redis via WHATSAPP_WEBHOOK_IDEMPOTENCY_CACHE_STORE.
Payload parsing
Parse nested Meta payloads into typed events instead of drilling into entry[0]['changes'][0] manually:
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\IncomingMessage;
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\MessageStatus;
use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookPayload;
Route::post('/webhook/whatsapp', function (Request $request) {
foreach (WebhookPayload::events($request->all()) as $event) {
if ($event instanceof MessageStatus) {
// delivery/read receipts
continue;
}
if (! $event instanceof IncomingMessage || ! $event->isMessagesField()) {
continue;
}
if ($reply = $event->interactiveReply()) {
ProcessInteractiveResponseJob::dispatch($event->raw(), $reply->id);
continue;
}
if ($text = $event->textBody()) {
// handle plain text
}
if ($mediaId = $event->mediaId()) {
// image, video, audio, document, or sticker — see inbound media below
}
}
})->middleware(VerifyWhatsappWebhookSignature::class);
Helpers:
| Method | Returns |
|---|---|
WebhookPayload::events() |
All parsed events (IncomingMessage, MessageStatus, MessageTemplateStatusUpdate, UnknownWebhookEvent) |
WebhookPayload::incomingMessages() |
Incoming messages only |
WebhookPayload::messageStatuses() |
Delivery/read/failed statuses only |
WebhookPayload::messageTemplateStatusUpdates() |
Template approval/rejection updates from Meta |
IncomingMessage
| Method | Description |
|---|---|
textBody(), hasTextBody() |
Plain text messages |
interactiveReply(), buttonReplyId(), listReplyId() |
Button/list/NFM replies |
mediaId(), mimeType(), sha256(), mediaCaption(), documentFilename() |
Inbound image/video/audio/document/sticker |
location(), isLocation() |
Shared location coordinates and label |
from(), phoneNumberId(), messageType(), raw() |
Routing and debugging |
Unknown webhook fields are returned as UnknownWebhookEvent so you can still read value() without the parser failing.
MessageStatus and delivery failures
foreach (WebhookPayload::messageStatuses($request->all()) as $status) {
if ($status->isFailed() && ($error = $status->firstError())) {
// $error->code, $error->title, $error->message, $error->details
}
if ($status->isDelivered()) {
// ...
}
}
errors() returns a list of WhatsappDeliveryError value objects parsed from Meta's statuses[].errors payload.
Template status updates
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\MessageTemplateStatusUpdate;
foreach (WebhookPayload::messageTemplateStatusUpdates($request->all()) as $event) {
if ($event->isApproved()) {
// $event->templateName(), $event->templateLanguage()
}
}
Download inbound media
When a user sends media, use mediaId() with the same connection that received the message:
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\IncomingMessage;
if ($event instanceof IncomingMessage && ($mediaId = $event->mediaId())) {
$meta = Whatsapp::connection($connection->name)->media()->retrieveOrFail($mediaId);
$file = Whatsapp::connection($connection->name)->media()->downloadOrFail($meta->json('url'));
// $event->mimeType(), $event->sha256(), $event->mediaCaption()
}
Route inbound messages to a connection (multiple numbers)
One webhook URL can receive events from every number in the same Meta app. Use phoneNumberId() to pick the connection, then reply from that number:
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\IncomingMessage;
use BlissJaspis\WhatsappCloudApi\WhatsappConnectionManager;
foreach (WebhookPayload::events($request->all()) as $event) {
if (! $event instanceof IncomingMessage) {
continue;
}
$connection = app(WhatsappConnectionManager::class)
->findByPhoneNumberId($event->phoneNumberId() ?? '');
if ($connection === null) {
continue;
}
Whatsapp::connection($connection->name)
->message()
->to($event->from() ?? '')
->body(Text::message('Thanks!')->build())
->send();
}
HTTP observability
Before each Graph API request, the package dispatches BlissJaspis\WhatsappCloudApi\Events\HttpSending with the pending HTTP client and resolved WhatsappConnection. Listen in your app to log URLs, connection names, or add headers:
use BlissJaspis\WhatsappCloudApi\Events\HttpSending;
Event::listen(HttpSending::class, function (HttpSending $event) {
logger()->info('WhatsApp API request', [
'connection' => $event->connection->name,
'phone_number_id' => $event->connection->phoneNumberId,
]);
});
Queue outbound messages
The package does not queue messages automatically. Dispatch a job from your app:
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
SendWhatsappTextJob::dispatch('6281234567890', 'Hello');
// Job example:
final class SendWhatsappTextJob implements ShouldQueue
{
public function __construct(
private string $to,
private string $body,
private ?string $connection = null,
) {}
public function handle(): void
{
$whatsapp = $this->connection
? Whatsapp::connection($this->connection)
: Whatsapp::message();
$whatsapp->to($this->to)
->body(['type' => 'text', 'text' => ['body' => $this->body]])
->sendOrFail();
}
}
Testing
Fake outbound calls without hitting Meta (requires phpunit/phpunit in your app for assertion helpers):
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
Whatsapp::fake();
Whatsapp::message()
->to('6281234567890')
->body(['type' => 'text', 'text' => ['body' => 'Hello']])
->send();
Whatsapp::fake()->assertSentTo('6281234567890');
composer lint
Laravel Boost
This package includes a Laravel Boost agent skill at resources/boost/skills/laravel-whatsapp-cloud-api/SKILL.md to help AI agents generate correct integration code.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.