telmodev / cloud-api-whatsapp
Laravel SDK for the Meta WhatsApp Cloud API โ send messages, manage templates, handle webhooks and more
Requires
- php: ^8.2|^8.3
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
README
A simple, clean, and elegant Laravel package to interact with the Meta WhatsApp Cloud API.
Features
- โก๏ธ Seamless integration with Laravel's HTTP Client (
Http::) and facades - ๐ฑ Dynamic configuration โ swap token or phone number ID on the fly (multi-tenant support)
- โ๏ธ Text messages, template messages, replies, and emoji reactions
- ๐ Interactive messages โ reply buttons and list menus
- ๐ผ๏ธ Media โ images, videos, audio, documents, stickers (upload, send, delete)
- ๐ Location sharing and contact cards
- ๐ข Business Profile management (read and update)
- ๐ Template management โ list, create, and delete message templates
- ๐ Webhook handling โ challenge verification and HMAC-SHA256 signature validation
- ๐งช Fully testable with Laravel's HTTP mocking
Requirements
- PHP 8.2 or higher
- Laravel 10, 11, or 12
Installation
Install the package via Composer:
composer require telmodev/cloud-api-whatsapp
The Service Provider and Facade are registered automatically via Laravel's package auto-discovery. No manual changes to config/app.php are needed.
Publish the configuration file
php artisan vendor:publish --tag="cloud-api-whatsapp-config"
This creates config/cloud-api-whatsapp.php in your project with all available options.
Configuration
Add these variables to your .env file:
WHATSAPP_TOKEN="your-meta-system-user-access-token" WHATSAPP_PHONE_NUMBER_ID="your-phone-number-id" WHATSAPP_BUSINESS_ACCOUNT_ID="your-whatsapp-business-account-id" WHATSAPP_API_VERSION="v20.0" WHATSAPP_TIMEOUT=30
WHATSAPP_BUSINESS_ACCOUNT_ID is required for template management endpoints. All other template and messaging endpoints only need WHATSAPP_PHONE_NUMBER_ID.
Usage
All examples use the CloudApiWhatsapp facade. You can also resolve the class via dependency injection.
use Telmo\CloudApiWhatsapp\Facades\CloudApiWhatsapp;
Text Messages
// Simple text CloudApiWhatsapp::sendMessage('+1234567890', 'Hello from Laravel!'); // With link preview CloudApiWhatsapp::sendMessage('+1234567890', 'Check this: https://laravel.com', [ 'preview_url' => true, ]);
Reply to a Message
Quote a previous message in the conversation thread.
CloudApiWhatsapp::replyToMessage( to: '+1234567890', body: 'Got your message, we will look into it!', replyMessageId: 'wamid.HBgLMTIzNDU2Nzg5MA==' );
Emoji Reactions
React to a received message. Pass an empty string to remove an existing reaction.
// Add a reaction CloudApiWhatsapp::sendReaction('wamid.HBgLMTIzNDU2Nzg5MA==', '๐'); // Remove a reaction CloudApiWhatsapp::sendReaction('wamid.HBgLMTIzNDU2Nzg5MA==', '');
Template Messages
Required for business-initiated conversations (outside the 24-hour customer window).
CloudApiWhatsapp::sendTemplate( to: '+1234567890', templateName: 'order_confirmation', languageCode: 'en_US', components: [ [ 'type' => 'body', 'parameters' => [ ['type' => 'text', 'text' => 'ORD-98765'], ['type' => 'text', 'text' => '$49.99'], ], ], ] );
Interactive Messages
Reply Buttons (up to 3)
CloudApiWhatsapp::sendButtons( to: '+1234567890', body: 'Would you like to confirm your appointment?', buttons: [ ['id' => 'confirm', 'title' => 'Yes, confirm'], ['id' => 'cancel', 'title' => 'No, cancel'], ], header: 'Appointment Reminder', // optional footer: 'Reply anytime' // optional );
List Menu
CloudApiWhatsapp::sendList( to: '+1234567890', body: 'Please select a support category', buttonLabel: 'View categories', sections: [ [ 'title' => 'Technical', 'rows' => [ ['id' => 'cat_billing', 'title' => 'Billing', 'description' => 'Invoices and payments'], ['id' => 'cat_account', 'title' => 'My Account', 'description' => 'Login, password, profile'], ], ], [ 'title' => 'General', 'rows' => [ ['id' => 'cat_other', 'title' => 'Other', 'description' => 'Anything else'], ], ], ], header: 'Support', // optional footer: 'We\'re here to help' // optional );
Media
You can send media using a public URL or a Meta Media ID obtained after uploading.
Images
// By URL CloudApiWhatsapp::sendImage('+1234567890', 'https://example.com/banner.png', 'Summer sale!'); // By Media ID CloudApiWhatsapp::sendImage('+1234567890', 'your-media-id');
Documents
CloudApiWhatsapp::sendDocument( to: '+1234567890', documentUrlOrId: 'https://example.com/invoice.pdf', filename: 'Invoice-July.pdf', // optional, only applied for URL-based documents caption: 'Your July invoice' // optional );
Video
CloudApiWhatsapp::sendVideo('+1234567890', 'https://example.com/intro.mp4', 'Intro video');
Audio
CloudApiWhatsapp::sendAudio('+1234567890', 'https://example.com/voice.ogg');
Stickers
Stickers must be in .webp format.
CloudApiWhatsapp::sendSticker('+1234567890', 'https://example.com/sticker.webp'); // or by Media ID CloudApiWhatsapp::sendSticker('+1234567890', 'your-sticker-media-id');
Upload, retrieve, and delete media
// Upload a local file and get back a Media ID $response = CloudApiWhatsapp::uploadMedia( filePath: storage_path('app/invoice.pdf'), mimeType: 'application/pdf' ); $mediaId = $response->json('id'); // Get metadata (includes temporary download URL) $response = CloudApiWhatsapp::getMedia($mediaId); $downloadUrl = $response->json('url'); // Delete CloudApiWhatsapp::deleteMedia($mediaId);
Location
CloudApiWhatsapp::sendLocation( to: '+1234567890', latitude: 37.7749, longitude: -122.4194, name: 'Salesforce Tower', // optional address: 'San Francisco, CA' // optional );
Contacts
CloudApiWhatsapp::sendContact('+1234567890', [ [ 'name' => [ 'first_name' => 'Jane', 'last_name' => 'Doe', 'formatted_name' => 'Jane Doe', ], 'phones' => [ ['phone' => '+1987654321', 'type' => 'MOBILE'], ], 'emails' => [ ['email' => 'jane@example.com', 'type' => 'WORK'], ], ], ]);
Mark as Read
CloudApiWhatsapp::markAsRead('wamid.HBgLMTIzNDU2Nzg5MA==');
Raw Payload
For advanced use cases not covered by a dedicated method:
CloudApiWhatsapp::sendRaw([ 'messaging_product' => 'whatsapp', 'to' => '1234567890', 'type' => 'text', 'text' => ['body' => 'Custom payload'], ]);
Business Profile
// Read profile (returns about, address, description, email, websites, vertical, profile_picture_url) $response = CloudApiWhatsapp::getBusinessProfile(); // Read specific fields only $response = CloudApiWhatsapp::getBusinessProfile(['about', 'email']); // Update profile CloudApiWhatsapp::updateBusinessProfile([ 'about' => 'We ship in 24 hours.', 'email' => 'support@yourcompany.com', 'websites' => ['https://yourcompany.com'], 'vertical' => 'RETAIL', ]);
Template Management
Requires WHATSAPP_BUSINESS_ACCOUNT_ID to be set.
// List all approved templates $response = CloudApiWhatsapp::getTemplates(['status' => 'APPROVED']); // List by name $response = CloudApiWhatsapp::getTemplates(['name' => 'order_confirmation']); // Create a new template CloudApiWhatsapp::createTemplate([ 'name' => 'order_shipped', 'language' => 'en_US', 'category' => 'UTILITY', 'components' => [ ['type' => 'BODY', 'text' => 'Your order {{1}} has shipped and will arrive by {{2}}.'], ], ]); // Delete a template by name CloudApiWhatsapp::deleteTemplate('old_promo_template');
Webhooks
1. Verify the webhook subscription (GET endpoint)
Meta sends a GET request to your webhook URL to verify ownership. Return the challenge value as a plain text response.
// routes/web.php or routes/api.php Route::get('/webhook/whatsapp', function (Request $request) { try { $challenge = CloudApiWhatsapp::verifyWebhook( queryParams: $request->query(), verifyToken: config('services.whatsapp.verify_token') ); return response($challenge, 200)->header('Content-Type', 'text/plain'); } catch (\InvalidArgumentException $e) { abort(403, $e->getMessage()); } });
2. Process incoming events (POST endpoint)
Meta sends a POST request with an HMAC-SHA256 signature in the X-Hub-Signature-256 header. Always verify it before processing.
Route::post('/webhook/whatsapp', function (Request $request) { try { $entries = CloudApiWhatsapp::parseWebhook( rawBody: $request->getContent(), signature: $request->header('X-Hub-Signature-256'), appSecret: config('services.whatsapp.app_secret') ); } catch (\InvalidArgumentException $e) { abort(403, $e->getMessage()); } foreach ($entries as $entry) { foreach ($entry['changes'] as $change) { $messages = $change['value']['messages'] ?? []; foreach ($messages as $message) { // Handle $message['type'], $message['text']['body'], etc. } } } return response('EVENT_RECEIVED', 200); });
Error Handling
Every API method returns an Illuminate\Http\Client\Response object. The SDK does not throw exceptions on 4xx/5xx responses from Meta โ you decide how to handle them.
Checking the response
$response = CloudApiWhatsapp::sendMessage('+1234567890', 'Hello!'); if ($response->failed()) { $error = $response->json('error'); // $error['code'] โ numeric Meta error code // $error['message'] โ human-readable description // $error['type'] โ e.g. OAuthException, GraphMethodException }
Common Meta error codes
| Code | Meaning | Action |
|---|---|---|
| 0 | Unknown / generic error | Check message for details |
| 10 | App does not have permission | Review app permissions in Meta dashboard |
| 100 | Invalid parameter | Check the request payload |
| 130429 | Rate limit hit | Back off and retry after a delay |
| 131030 | Recipient phone number not on WhatsApp | Verify the number before sending |
| 131047 | Re-engagement message not allowed | Send a template to re-open the window |
| 131051 | Message type unsupported for this recipient | Try a different message type |
| 190 | Access token expired or invalid | Refresh or regenerate the token |
Throwing on failure
If you prefer exceptions over manual checks, chain ->throw():
// Throws Illuminate\Http\Client\RequestException on any 4xx/5xx $response = CloudApiWhatsapp::sendMessage('+1234567890', 'Hello!')->throw(); // Or handle specific status codes $response = CloudApiWhatsapp::sendMessage('+1234567890', 'Hello!') ->throwIf(fn($r) => $r->status() === 401, new \RuntimeException('Token expired'));
Network errors
Timeouts and connection failures throw Illuminate\Http\Client\ConnectionException regardless of the ->throw() chain. Catch it at the boundary where you call the SDK:
use Illuminate\Http\Client\ConnectionException; try { $response = CloudApiWhatsapp::sendMessage('+1234567890', 'Hello!'); } catch (ConnectionException $e) { // Log and retry, or alert your team }
SDK-level exceptions
InvalidArgumentException is thrown before any HTTP request is made in these cases:
- Missing or empty
WHATSAPP_TOKENorWHATSAPP_PHONE_NUMBER_ID - Missing
WHATSAPP_BUSINESS_ACCOUNT_IDwhen calling template management methods - Phone number contains fewer than 7 digits after stripping non-numeric characters
- File path passed to
uploadMedia()does not exist - Webhook verification token mismatch or invalid
hub.mode - Webhook payload signature does not match the expected HMAC-SHA256 hash
These are programmer-configuration errors. They should surface during development, not be silently swallowed in production.
Dynamic Configuration (Multi-tenant)
Override the default token or phone number ID per request. The facade returns a cloned instance โ the singleton is never mutated.
$client = CloudApiWhatsapp::withToken('tenant-access-token') ->withPhoneNumberId('tenant-phone-number-id'); $client->sendMessage('+1234567890', 'Message from tenant account.');
Testing
Since the package uses Laravel's native HTTP client, testing requires no real API connections:
use Illuminate\Support\Facades\Http; use Telmo\CloudApiWhatsapp\Facades\CloudApiWhatsapp; public function test_sends_message(): void { Http::fake([ 'graph.facebook.com/*' => Http::response([ 'messages' => [['id' => 'wamid.mock123']], ], 200), ]); $response = CloudApiWhatsapp::sendMessage('+1234567890', 'Hello!'); $this->assertTrue($response->successful()); Http::assertSent(function ($request) { return $request['to'] === '1234567890' && $request['text']['body'] === 'Hello!'; }); }
Run the package's own test suite:
composer test
License
The MIT License (MIT). Please see the License File for more information.