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|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.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.
Publish the AI agent skill
The package ships the SDK documentation as an Agent Skill (open SKILL.md standard), so AI coding assistants (Claude Code, opencode, Codex, ChatGPT, Cursor, Gemini CLI, Antigravity, etc.) can implement the SDK correctly without browsing the source.
Install it for your provider:
# Pick the tag that matches your AI coding assistant php artisan vendor:publish --tag="cloud-api-whatsapp-agents-claude" php artisan vendor:publish --tag="cloud-api-whatsapp-agents-opencode" php artisan vendor:publish --tag="cloud-api-whatsapp-agents-codex" php artisan vendor:publish --tag="cloud-api-whatsapp-agents-chatgpt" php artisan vendor:publish --tag="cloud-api-whatsapp-agents-cursor" php artisan vendor:publish --tag="cloud-api-whatsapp-agents-gemini" php artisan vendor:publish --tag="cloud-api-whatsapp-agents-antigravity"
Each tag copies the skill into the provider's skill directory in your project:
| Tag | Installed to | Providers that read it |
|---|---|---|
...-claude |
.claude/skills/cloud-api-whatsapp/ |
Claude Code |
...-opencode |
.opencode/skills/cloud-api-whatsapp/ |
opencode |
...-codex |
.agents/skills/cloud-api-whatsapp/ |
OpenAI Codex |
...-chatgpt |
.agents/skills/cloud-api-whatsapp/ |
ChatGPT |
...-cursor |
.cursor/skills/cloud-api-whatsapp/ |
Cursor |
...-gemini |
.gemini/skills/cloud-api-whatsapp/ |
Gemini CLI |
...-antigravity |
.agents/skills/cloud-api-whatsapp/ |
Google Antigravity |
cloud-api-whatsapp-agents (combined) |
.agents/skills/cloud-api-whatsapp/ |
opencode, Codex, ChatGPT, Cursor, Gemini CLI, Antigravity |
The combined tag installs to the shared .agents/skills/ location that most tools read. Claude Code does not read .agents/skills โ use cloud-api-whatsapp-agents-claude if you use Claude Code.
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.