Developers

How to send a WhatsApp message from PHP and Laravel

· 6 min read · By JPL365

Sending a WhatsApp message from PHP does not need an SDK. With Qwela it is one authenticated HTTP request, and replies come back to your app as signed webhooks. This guide covers plain PHP first, then a Laravel setup with a service class, a queued job with sensible retries, and a webhook controller.

Before you start

  1. Sign up at qwela.jpl365.com and scan the QR code to link a WhatsApp number.
  2. Create an API key on the API keys page. It starts with gw_. Keep it in an environment variable, never in code.
  3. Use phone numbers with the country code, such as 254712345678. A leading 0 is read as a Kenyan number.

Plain PHP

<?php
$ch = curl_init('https://qwela.jpl365.com/v1/messages');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 15,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('QWELA_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'to'   => '254712345678',
        'text' => 'Your order #1042 is ready for pickup.',
    ]),
]);
$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status === 202) {
    $message = json_decode($body, true); // ['id' => '...', 'status' => 'queued']
} else {
    error_log("Qwela error $status: $body");
}

A 202 means the message is queued. It is sent a few seconds later, paced to protect your number. Use the returned id to check its status with GET /v1/messages/{id}.

Laravel: configuration

Add the settings to config/services.php and the values to your .env:

'qwela' => [
    'url'            => env('QWELA_URL', 'https://qwela.jpl365.com'),
    'key'            => env('QWELA_KEY'),
    'webhook_secret' => env('QWELA_WEBHOOK_SECRET'),
],

Laravel: a small service class

namespace App\Services;

use Illuminate\Support\Facades\Http;

class Qwela
{
    public function send(string $to, string $text): array
    {
        $response = Http::withToken(config('services.qwela.key'))
            ->acceptJson()
            ->timeout(15)
            ->post(config('services.qwela.url') . '/v1/messages', [
                'to'   => $to,
                'text' => $text,
            ]);

        $response->throw();       // throws on 4xx and 5xx
        return $response->json(); // ['id' => '...', 'status' => 'queued']
    }
}

Laravel: send from a queued job

Never call an external API inside a web request if you can avoid it. A queued job keeps pages fast and retries when something is temporarily wrong. The important detail is which errors to retry: a 429 or a 5xx may succeed later, but a 401, 402 or 422 will fail every time.

namespace App\Jobs;

use App\Services\Qwela;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SendWhatsAppMessage implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;

    public function backoff(): array
    {
        return [30, 120, 600];
    }

    public function __construct(public string $to, public string $text) {}

    public function handle(Qwela $qwela): void
    {
        try {
            $qwela->send($this->to, $this->text);
        } catch (RequestException $e) {
            $status = $e->response->status();
            if ($status === 429 || $status >= 500) {
                throw $e;        // retry later using the backoff above
            }
            $this->fail($e);     // retrying will not help
        }
    }
}

// Usage, for example in an order listener:
SendWhatsAppMessage::dispatch($order->phone, "Your order #{$order->id} is confirmed.");

Laravel: receive replies

Save your webhook URL on the Webhook page of the dashboard and copy the signing secret into QWELA_WEBHOOK_SECRET. Then add a route in routes/api.php, which is not subject to CSRF checks (if you use web.php, exclude the route from CSRF verification):

Route::post('/webhooks/qwela', [QwelaWebhookController::class, 'handle']);
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class QwelaWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $raw = $request->getContent();                 // raw body, exactly as received
        $ts  = $request->header('X-Qwela-Timestamp', '');
        $sig = $request->header('X-Qwela-Signature', '');

        $expected = 'sha256=' . hash_hmac(
            'sha256', $ts . '.' . $raw, config('services.qwela.webhook_secret')
        );

        if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($expected, $sig)) {
            return response('invalid signature', 401);
        }

        $event = json_decode($raw, true);

        if ($event['event'] === 'message.received') {
            // $event['data']['from'] is the sender's number
            // $event['data']['text'] is the message
            // Dedupe on $event['id']: retries can deliver the same event twice.
        }

        return response('', 200);
    }
}

The signature check is explained step by step in our guide to verifying Qwela webhook signatures.

Handling errors

StatusMeaningRetry?
401Missing or invalid API keyNo
402Subscription inactiveNo
422Invalid number or textNo
429Daily limit reached, or too many requestsLater
5xxSomething went wrong on our sideYes

A note on sending responsibly

Only message people who expect to hear from you, such as customers with an order or appointment. WhatsApp can restrict numbers that send unwanted messages. Read how to avoid a number ban before you automate anything.

Want this wired into your Laravel app by someone else? Talk to JPL365.