Send a WhatsApp message from Laravel
This page sends a WhatsApp message from a Laravel application with Laravel's own HTTP client, then receives the reply. The first message goes to your own phone, and a queued job follows.
What you need
- An account on Threadwire.
- A linked number. In your panel, click "Add instance" and scan the QR code with WhatsApp on the phone (Settings → Linked devices). The Instance ID is on the instance's page.
- An API key. Make it under Developers → API keys → New key (admins only).
- A Laravel application. The
Httpfacade comes with it, so there is nothing to install.
Put the key and the Instance ID in .env, and read them through config/services.php:
// config/services.php
'threadwire' => [
'key' => env('THREADWIRE_KEY'),
'instance_id' => env('THREADWIRE_INSTANCE_ID'),
],
An instance is one linked WhatsApp number. Threadwire links it as a device, like WhatsApp Web, so this is not Meta's official API. WhatsApp API without Meta approval says what that means.
Send the message
Send the first message to yourself. Write "hi" to your linked number from your own WhatsApp on another phone, then send to that phone. This Artisan command does it:
// routes/console.php
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
Artisan::command('whatsapp:hello', function () {
$message = Http::withToken(config('services.threadwire.key'))
->acceptJson()
->post('https://threadwire.net/api/v1/messages', [
'instance_id' => config('services.threadwire.instance_id'),
'to' => 'YOUR_PHONE',
'text' => 'Hello from Threadwire',
])
->throw()
->json('data');
$this->info($message['id'].' '.$message['status']);
});
Run it with php artisan whatsapp:hello.
withToken(): sends your API key as a bearer token, as every request needs.instance_id: the Instance ID from your config.to: that phone's number in international format, like201012345678: country code, no+needed, no leading 0.text: the message, up to 4,096 characters.
In a queued job
In your application, send from a queued job, and add an Idempotency-Key header:
// app/Jobs/SendOrderShipped.php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
class SendOrderShipped implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function handle(): void
{
Http::withToken(config('services.threadwire.key'))
->acceptJson()
->withHeaders(['Idempotency-Key' => "order-{$this->order->id}-shipped"])
->post('https://threadwire.net/api/v1/messages', [
'instance_id' => config('services.threadwire.instance_id'),
'to' => $this->order->phone,
'text' => "Hi {$this->order->name}, your order {$this->order->id} is on its way.",
])
->throw();
}
}
The key is any unique string of yours, such as an order id. When the queue runs the job again, the same key returns the first message instead of sending a second.
Read the answer
The answer comes at once, and json('data') reads the message from it:
202 {"data": {"id": "01K…", "status": "queued", "phone": "201012345678", "body": "Hello from Threadwire", "…": "…"}}
id: store it with your order.GET /v1/messages/{id}returns the message with its status at that moment.status:queuedmeans the message was accepted and waits its turn. It then goes out the way a person would send it.
The status then moves to sent, delivered and read, or to failed with the reason in error.
Your test arrives in seconds: you wrote first, so it is a reply. An order update to a customer who never wrote to your number is a first message. First messages are spaced out, capped per day and not sent at night. How sending protects your number has the figures.
Receive the reply
Under Developers → Webhooks (admins only), set your endpoint's URL: a public https:// address. Every reply and status change is posted there as JSON:
{"id": "evt_01K…", "type": "message.received", "created_at": "2026-10-01T10:00:00+00:00",
"data": {"id": "01K…", "instance_id": "01K…", "phone": "201012345678", "body": "Hi!", "…": "…"}}
A reply arrives as message.received, and a delivery update as message.status. Every event is signed per the Standard Webhooks spec. Our package receives your webhooks as Laravel events, with the signature checked:
composer require tritech/threadwire-laravel
Tools and code samples says what else it does, and links to its source.
To check the signature in your own controller, copy the PHP function from Webhooks and events. Give it the webhook-id, webhook-timestamp and webhook-signature headers, and the raw body from $request->getContent(), never the parsed JSON. The signing secret starts with whsec_, and is shown when you save the URL.
Answer with any 2xx within 15 seconds, and leave slow work to a queued job.
Good to know
- A failed message is not a failed job. A message that cannot go is still answered
202. It later becomesfailed, with the reason inerror. throw()raises on a refused request. The answer has amessagein plain words and a stablecode, such asvalidation_failed. Read it with$e->response->json('code').- Retry only what can pass. Release the job for
429,502and503, afterRetry-Afterwhen present.401,403,404and422need a fix first: see what to retry. - Do not dispatch a job per person on a list. A number sends one message at a time, so a burst only makes a queue: see the pace.
Next
- Sending messages: files, voice notes, polls and the rest.
- Webhooks and events: every event, retries, and polling.
- The API reference: every endpoint, and the errors.
- Tools and code samples: the Laravel package and the Postman collection.