cd /blog
GET/blog/send-whatsapp-messages-php-laravel-2026—200OK
phplaraveltutorial

Send WhatsApp Messages with PHP and Laravel

September 21, 2026|4 min read

There is no PHP SDK for WSAPI, and you do not need one. The API is plain REST over JSON, so curl, Guzzle, or Laravel's HTTP client all work with no wrapper in between. That is usually an advantage in PHP projects: one less dependency to keep current, and nothing hiding the actual request from you.

Here is the whole thing, in three parts.

Sending a message in plain PHP

send-message.php
<?php
$ch = curl_init('https://api.wsapi.chat/messages/text');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'X-Api-Key: ' . getenv('WSAPI_API_KEY'),
        'X-Instance-Id: ' . getenv('WSAPI_INSTANCE_ID'),
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'to'   => '1234567890@s.whatsapp.net',
        'text' => 'Hello from PHP',
    ]),
]);

$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 201) {
    $error = json_decode($body, true);
    throw new RuntimeException("WSAPI {$status}: " . ($error['detail'] ?? 'unknown error'));
}

$messageId = json_decode($body, true)['id'];

Three things worth knowing before you copy it. Authentication is two headers, not a bearer token: X-Api-Key plus X-Instance-Id, which together say who you are and which connected number to send from. A successful send returns 201, not 200, and the body is {"id": "..."}, so a check for 200 will treat every success as a failure. And the recipient is a JID, number@s.whatsapp.net, not a bare phone number.

The errors you will actually hit are worth handling by code rather than by message: 401 means the key or instance is wrong, 409 means the instance exists but no phone is paired to it yet, and 503 means the instance is momentarily unavailable. Only the last one is worth retrying.

The same thing in Laravel

app/Services/WhatsApp.php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class WhatsApp
{
    public function sendText(string $to, string $text): string
    {
        $response = Http::withHeaders([
            'X-Api-Key'     => config('services.wsapi.key'),
            'X-Instance-Id' => config('services.wsapi.instance'),
        ])->post('https://api.wsapi.chat/messages/text', [
            'to'   => $to,
            'text' => $text,
        ]);

        if ($response->status() !== 201) {
            throw new RuntimeException(
                'WSAPI ' . $response->status() . ': ' . $response->json('detail', 'unknown error')
            );
        }

        return $response->json('id');
    }
}

Put the credentials in config/services.php and inject the class wherever you need it. Because sends are ordinary HTTP calls, queueing them is just a job, and Http::fake() covers it in tests without a wrapper library pretending to be the API.

Receiving messages

Incoming messages arrive as webhooks, and this is the part most integrations get wrong.

app/Http/Controllers/WebhookController.php
public function __invoke(Request $request)
{
    $raw      = $request->getContent();
    $expected = 'sha256=' . hash_hmac('sha256', $raw, config('services.wsapi.signing_secret'));

    if (! hash_equals($expected, (string) $request->header('X-Webhook-Signature'))) {
        abort(401);
    }

    $event = $request->json();

    if ($event->get('eventType') === 'message') {
        $data = $event->get('eventData');
        logger()->info('incoming', [
            'from' => $data['sender']['id'] ?? null,
            'text' => $data['text'] ?? null,
        ]);
    }

    return response()->noContent();
}

The signature covers the raw bytes that arrived, so compute the HMAC over $request->getContent() and never over a re-encoded array. The header carries the sha256= prefix, so it has to be in your expected value too. And hash_equals compares in constant time and returns false on a length mismatch rather than throwing, which is exactly what you want when the forged signature is the wrong length.

A receiver that skips verification when the header is missing is not verifying anything. Fail closed. There is more on payload shapes and on the two ways events can reach you in the webhooks and SSE reference.

What you skip by not self-hosting

Everything above is identical whether you point it at your own deployment or at managed instances. The code does not change. What changes is who restarts the session at 3am. If that is not a job you want, hosted instances start at $5.00 per month with a 14 day trial and no card.

Want to send the first message before writing any of this? The quickstart does it with one curl command.

FAQ

Do I need a PHP SDK?

No. It is a REST API, and any HTTP client works. There are official SDKs for Node, Python, and .NET if you use those languages.

Why does a successful send return 201?

Because it creates a message resource. The body contains the new message ID, which you use later for replies, edits, reactions, and deletes.

Can my WhatsApp number get blocked?

Any unofficial API carries that risk. Send to people who expect to hear from you and keep volume sane.

Does this work on shared hosting?

Sending does, since it is an outbound HTTPS call. Receiving webhooks needs a publicly reachable URL, which is where SSE is an alternative if you do not have one.