Your Laravel app already knows the invoice is overdue, the order shipped, the appointment is tomorrow. Getting that fact onto WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.
Here is the plain version. A laravel whatsapp api integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.
Can you send WhatsApp from Laravel on your own number, no Cloud API?
Yes. You POST to a REST endpoint with Laravel's Http facade, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the whatsapp own number api model, not the Cloud API, so no templates, no Business verification, and no per-message fees apply.
The distinction decides your whole integration. On the Cloud API path you register a number with Meta, get it approved, submit message templates for review, and pay per message: Meta deprecated conversation-based pricing on 1 July 2025, so it now bills per message sent. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key.
This is the Laravel-framework sibling of the published PHP guide. That piece covers raw cURL, Guzzle, and the official PHP SDK from scratch, so this one does not re-teach them. Here you get the idiomatic Laravel surface instead: the Http facade, a service provider with config publishing, a custom Notification channel, and queued Jobs with backoff. The wire shape is identical across languages, so the Elixir guide makes the same REST call from the BEAM if you want to compare.
What you need before your first send from Laravel
Three things: a supported Laravel version (Laravel 11 or 12 on PHP 8.2+ is a safe floor), a WhatsApp number already linked to your Blueticks account, and a bt_live_ key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That single detail causes most first-attempt failures in a laravel whatsapp integration.
The pre-flight, in order:
- Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Put it in
.envasBLUETICKS_API_KEY, then read it through config, never with a bareenv()call inside a job (more on why below). The auth model and scopes live in the own-number REST API guide. - Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
- Check what you do not need. No Meta Business verification, no message templates, no per-message billing.
And the trap that catches everyone. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B with PHP's rawurlencode(). Not urlencode(): that encodes a space as +, which is wrong in a path. You can also target a chat id directly, such as 120363...@g.us for a group.
What is the exact Blueticks /v1 send contract?
A single flat JSON body with a required type, the recipient in the path, bearer auth, and a success envelope. The full contract, so nothing below surprises you: type is one of text, media, or poll and is required even though it is validation-only, so a bare {"text":"hi"} is rejected with a 400. Text tops out at 4,096 characters.
- URL:
POST https://api.blueticks.co/v1/scheduled-messages/{chatId}, recipient in the path. - Auth:
Authorization: Bearer bt_live_..., which Laravel sets with->withToken(...). - Body: flat JSON,
{"type":"text","text":"...","sendAt":"..."}. No nestedmedia{}orpoll{}. sendAt: optional, camelCase, an RFC 3339 timestamp with an explicit offset. Omit it to send now. The accepted window is roughly 10 seconds to 365 days ahead; outside that is a400.- Response: a
2xxreturns{"success":true,"data":{...}}, so the id lives atdata.id, a 24-character hex queue id, never at the top level. status:pending, thenconfirmed,received,read,played, orfailed.waMessageKey: an object, ornulluntil WhatsApp dispatches the message.
Those details come from the live /v1 contract, cross-checked at dev.blueticks.co/docs. Hold that shape in mind and the Laravel below reads as plain facade calls.
How do you send your first WhatsApp message from Laravel with the Http facade?
Set a bearer token with withToken(), give it real timeouts, and POST the escaped path with a flat array body. Laravel's HTTP client is an expressive, minimal API around the Guzzle HTTP client, so you get sane defaults and a readable response object. Omit sendAt and it sends immediately. This is the send whatsapp message laravel baseline, no vendor SDK required.
use Illuminate\Support\Facades\Http;
$chatId = rawurlencode('+15551234567'); // -> %2B15551234567
$response = Http::withToken(config('services.blueticks.key'))
->withHeader('Idempotency-Key', 'invoice-4471-reminder')
->connectTimeout(5)
->timeout(20)
->acceptJson()
->post("https://api.blueticks.co/v1/scheduled-messages/{$chatId}", [
'type' => 'text',
'text' => 'Your invoice #4471 is due tomorrow.',
]);
$data = $response->json('data'); // id, status, waMessageKey live under data
Two Laravel specifics matter here. First, timeouts. Laravel's defaults already beat raw Guzzle, with the request timeout defaulting to 30 seconds and connectTimeout to 10 seconds, where Guzzle on its own has no timeout at all. Set them tighter anyway so a slow dependency never pins a PHP-FPM worker. Second, read the envelope: a 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id is $response->json('data.id'), never $response->json('id'). Try it from php artisan tinker before you wire it into anything. The same first send in shell, Python, and Node lives in the language-agnostic walkthrough.
How do you wrap the API in a service class and register it with a provider?
Put the base URL, the key, and the path escaping behind one service class, bind it as a singleton in a service provider, and publish a config file so deployments stay twelve-factor. Your controllers and jobs then resolve Blueticks from the container and never touch the wire format. This is the laravel whatsapp integration shape that survives refactors.
Start with a published config file and read everything through it. Laravel's docs are explicit that once config:cache has run, the env function will only return external, system level environment variables, so an env() call inside a job silently becomes null on your first cached production deploy.
// config/blueticks.php
return [
'key' => env('BLUETICKS_API_KEY'),
'base_uri' => env('BLUETICKS_BASE_URI', 'https://api.blueticks.co/v1'),
];
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class Blueticks
{
public function __construct(
private string $key,
private string $baseUri,
) {}
private function client(): PendingRequest
{
return Http::baseUrl($this->baseUri)
->withToken($this->key)
->connectTimeout(5)
->timeout(20)
->acceptJson();
}
public function sendText(string $chatId, string $text, ?string $idem = null): array
{
$req = $this->client();
if ($idem) {
$req = $req->withHeader('Idempotency-Key', $idem);
}
return $req
->post('/scheduled-messages/' . rawurlencode($chatId), [
'type' => 'text',
'text' => $text,
])
->throw() // 4xx/5xx -> RequestException, let the queue catch it
->json('data') ?? [];
}
}
The provider binds it once and exposes the config for publishing:
namespace App\Providers;
use App\Services\Blueticks;
use Illuminate\Support\ServiceProvider;
class BlueticksServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/blueticks.php', 'blueticks');
$this->app->singleton(Blueticks::class, fn () => new Blueticks(
config('blueticks.key'),
config('blueticks.base_uri'),
));
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../../config/blueticks.php' => config_path('blueticks.php'),
], 'blueticks-config');
}
}
Register the provider in bootstrap/providers.php (Laravel 11+) and run php artisan vendor:publish --tag=blueticks-config to copy the file. Now type-hint Blueticks anywhere and the container hands you a configured client.
How do you send WhatsApp as a Laravel Notification channel?
Define a channel class with a send($notifiable, $notification) method, return that class from the notification's via(), and route the recipient with a routeNotificationForWhatsApp method on the notifiable. Laravel's docs confirm you return the class name from the via method and that a custom channel's send method receives the notifiable and the notification. This is the most idiomatic path for transactional alerts.
namespace App\Notifications\Channels;
use App\Services\Blueticks;
use Illuminate\Notifications\Notification;
class WhatsAppChannel
{
public function __construct(private Blueticks $blueticks) {}
public function send(object $notifiable, Notification $notification): void
{
$chatId = $notifiable->routeNotificationFor('whatsapp', $notification);
if (! $chatId) {
return;
}
$payload = $notification->toWhatsApp($notifiable);
$this->blueticks->sendText(
$chatId,
$payload['text'],
$payload['idempotency_key'] ?? null,
);
}
}
The notification names the channel and builds the body. Add ShouldQueue and Laravel automatically queues the delivery, so the send never blocks the web request:
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function via(object $notifiable): array
{
return [\App\Notifications\Channels\WhatsAppChannel::class];
}
public function toWhatsApp(object $notifiable): array
{
return [
'text' => "Order #{$this->order->id} shipped.",
'idempotency_key' => "order-{$this->order->id}-shipped",
];
}
}
On the notifiable (usually your User model), the routing method returns the recipient. Laravel looks it up by the routeNotificationFor{Driver} convention:
public function routeNotificationForWhatsApp($notification): string
{
return $this->phone; // '+15551234567'
}
Now $user->notify(new OrderShipped($order)) sends over WhatsApp, queued, with an idempotency key already attached. Prefer talking to the Blueticks agent instead of writing code? You can drive the same sends by connecting the hosted MCP at https://api.blueticks.co/mcp and asking in plain language, covered in the Claude MCP guide.

How do you schedule, queue, and retry sends with Laravel Queues and Jobs?
Dispatch a job, let the queue own retries, and carry a stable idempotency key so a retry never double-sends. A queued Job gives you $tries for the attempt cap, a backoff() curve between attempts, and the failed_jobs table for anything that exhausts them. Laravel defaults a job to a single attempt unless you raise tries, so set it explicitly.
namespace App\Jobs;
use App\Services\Blueticks;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendWhatsAppMessage implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function __construct(
public string $chatId,
public string $text,
public string $idempotencyKey, // derived from the ORDER, not from time()
) {}
public function backoff(): array
{
return [10, 60, 300, 900]; // seconds between attempts
}
public function handle(Blueticks $blueticks): void
{
$blueticks->sendText($this->chatId, $this->text, $this->idempotencyKey);
}
}
Dispatch it immediately or push it into the future with delay():
SendWhatsAppMessage::dispatch($user->phone, 'Your table is ready.', "booking-{$id}-ready")
->delay(now()->addMinutes(30));
Three things keep this safe. The idempotency key is constructor state on purpose: compute it once from the business object at dispatch so all five attempts carry the identical value, and the API treats the Idempotency-Key header as at-most-once. A replay returns the original 201 and the same 24-hex id instead of sending again, and a different body under the same key returns 409 Conflict. The key is capped at 64 characters and scoped per workspace. Second, let real failures land in failed_jobs: ->throw() in the service turns a 4xx/5xx into an exception the queue records, so you can php artisan queue:retry later. Third, do not reach for Http::retry() as a shortcut; Laravel retries on any client or server error, which includes 400s that will never succeed. Let the Job's backoff() own the retry curve instead. Deeper timezone and idempotency handling lives in the production scheduling guide.
Ready to wire this into your app? Grab a
bt_live_key and paste theHttp::postintophp artisan tinkerto watch the first send hit your own WhatsApp number. Then move it behind the queued Job and the Notification channel above for scheduled, retried delivery. Your own number, no Meta Business verification, no per-message template fees.

How do you track delivery status back into your Laravel app?
Take the id from data.id and GET /v1/scheduled-messages/{id}, then read the status ladder: pending, confirmed, received, read, played, or failed. A 201 only means the queue accepted the message, not that WhatsApp sent it. confirmed means WhatsApp itself took it, received is the double grey tick, read the double blue tick, and failed carries a reason.
$data = Http::withToken(config('blueticks.key'))
->acceptJson()
->get("https://api.blueticks.co/v1/scheduled-messages/{$id}")
->json('data');
$status = $data['status'] ?? null; // pending|confirmed|received|read|played|failed
$key = $data['waMessageKey'] ?? null; // null until dispatch, then an object
The field that trips people up is waMessageKey. It is an object (fromMe, remote, id, _serialized, participant), not a string, and it is null on the response to a scheduled send because the engine has not dispatched yet. It fills in when the message actually goes out, which is why confirmed and a non-null waMessageKey arrive together. Use the null coalescing operator everywhere you read it. Your durable handle in the meantime is id, a 24-character hex queue id you can GET, PATCH before dispatch, or DELETE to cancel.
Polling is fine for one message and stops being fine at volume. Past that, register a webhook and receive status transitions as pushes. Registration, signature verification, and duplicate handling live in the delivery-status webhooks guide, and the two-way inbound side is in the auto-reply webhooks guide.
How do you stay compliant and reduce ban risk from your own number?
Treat consent as the gate, not the API. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and those rules apply whatever code is behind the send. There is no guaranteed no-ban, and anyone who promises one is selling you something. Consent to message is the sender's responsibility.
The Laravel-specific failure mode is how easy the queue makes a burst. Fan 500 notifications onto Horizon with ten workers and you fire 500 sends as fast as the workers drain, which is the pattern most likely to get a number flagged. Pace it: throttle the queue, spread a batch over hours with delay(), warm a new number slowly, and message only people who opted in. On nightly shipping notifications, a stable Idempotency-Key is what stands between one "your order shipped" and three of them at 2am after a deploy replays the queue. Which events deserve a message at all is the subject of the event-triggered automation guide.

When should you use the own-number /v1 API versus a Meta Cloud API package?
Use the own-number /v1 API for transactional volume, real two-way conversations, and a number your customers already have saved. Reach for a Laravel package wrapping Meta's Cloud API when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade runs both ways.
Here is the honest split for a Laravel shop. A Cloud API package means registering a number with Meta, Business verification, template review, and per-message pricing, and the number itself stops being a normal WhatsApp number once it is on the Cloud API. The own-number path skips all of that: no verification, no templates, your real number, one Http::post from a queued Job. For a few hundred transactional notifications a day from an app you already run, that is the lighter fit, the argument the no-Meta-verification guide makes in full. For 50,000 approved marketing templates and ten agents on one inbox, the Cloud API is genuinely the better product. Pick by the job, not the brand.
FAQ
Do I need a package or SDK to send WhatsApp from Laravel?
No. The own-number API is plain REST, so Laravel's built-in Http facade makes the whole call: Http::withToken($key)->post($url, ['type' => 'text', 'text' => 'hi']). There is no dependency to add beyond what ships with the framework. A dedicated service class and provider are for ergonomics, not capability.
Why does waMessageKey come back null in the response?
On a scheduled send the engine has not dispatched the message yet, so waMessageKey is null. It fills in later as an object with fromMe, remote, id, _serialized, and participant, never a bare string. Read it with the null coalescing operator and use the 24-hex id as your handle meanwhile.
How do I stop a queued Job retry from sending the same message twice?
Send an Idempotency-Key header derived once from the business object (order-4471-shipped) and stored as constructor state so every one of the Job's $tries reuses it. A replay returns the original 201 and the same message id instead of sending again. The key is capped at 64 characters and scoped per workspace. Do not generate a fresh key inside handle().
How do I schedule a message for later from Laravel?
Either pass a camelCase sendAt holding an RFC 3339 timestamp with an explicit offset in the API body, or dispatch the Job with Laravel's ->delay(now()->addMinutes(30)). The API's accepted sendAt window is roughly 10 seconds to 365 days ahead; outside that is a 400. Omit sendAt entirely to send immediately.
Will sending from Laravel get my WhatsApp number banned?
Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a queue firing a wide unsolicited burst is the pattern most likely to get a number flagged. Message people who asked to hear from you, throttle the queue, spread batches over hours, and stop when someone asks. Consent is yours to collect.



