WhatsApp Operators DailyThe Blueticks DispatchTuesday, August 11, 2026
Productivity

How to Send WhatsApp Messages from PHP on Your Own Number (2026)

Fifteen lines of cURL, no Composer needed. Then the parts that break: the plus sign in the URL, Guzzle's zero-second timeout, and a retry that sends twice.

DRBy Daniel Roth · August 11, 2026 · 13 min read
How to Send WhatsApp Messages from PHP on Your Own Number (2026)

Your PHP app already knows the invoice is overdue, the table is ready, the shipment moved. Getting that into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for six notifications a day.

Here is the plain version. A php whatsapp api integration means one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own, linked as a device over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so no templates, no Business verification, no per-conversation billing anywhere below. Consent stays yours, and nobody can promise an account will never be actioned.

What do you need before PHP can send a WhatsApp message?

Three things: a PHP install with the cURL extension, a WhatsApp number already linked to your Blueticks account, and a bt_live_ API key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That one detail causes most first-attempt failures in a whatsapp api php setup.

The pre-flight, in order:

  1. Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so they live in .env and never in a repo. The own-number REST API guide covers scopes and the auth model.
  2. Link a number. Blueticks drives your number, not a Meta-provisioned one. Already connected a phone in the app? You are done.
  3. Check your PHP version. The raw REST path runs on any PHP with cURL. The official SDK needs 8.1 or newer, and 8.1 itself reached end of life on 31 December 2025.

Now the trap. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A leading + is not safe there, so PHP must encode it with rawurlencode(), which follows RFC 3986 and turns +15551234567 into %2B15551234567. Not urlencode(): that encodes spaces as +, the form-encoding convention, wrong in a path. You can also target a chat id directly, such as 1234567890@g.us for a group.

How do you send your first WhatsApp message from PHP with zero dependencies?

Build the URL with rawurlencode(), set an Authorization: Bearer header, and POST a flat JSON body of {"type":"text","text":"..."}. Omit sendAt and it goes immediately. Fifteen lines of curl_init, no Composer, no framework. This is the php send whatsapp message baseline that works on shared hosting.

<?php
$apiKey = getenv('BLUETICKS_API_KEY');          // bt_live_YOUR_KEY_HERE
$to     = rawurlencode('+15551234567');         // -> %2B15551234567
$url    = "https://api.blueticks.co/v1/scheduled-messages/{$to}";

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT        => 20,
    CURLOPT_HTTPHEADER     => [
        "Authorization: Bearer {$apiKey}",
        'Content-Type: application/json',
        'Idempotency-Key: invoice-4471-reminder',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'type' => 'text',
        'text' => 'Your invoice #4471 is due tomorrow.',
    ]),
]);

$body   = curl_exec($ch);
$errno  = curl_errno($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

if ($errno !== 0) {                              // transport failed, no HTTP status exists
    throw new RuntimeException('curl: ' . curl_strerror($errno));
}

$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$message = $payload['data'];                     // id, status, waMessageKey live under data

Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected. Text tops out at 4,096 characters. A 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id is $payload['data']['id'], never $payload['id']. Do not assume data is always present, though — a 2xx with nothing to return sends {"success":true} on its own, so read data defensively rather than indexing straight into it. The language-agnostic API walkthrough shows the same call in shell, Python and Node.

Do not build this with file_get_contents() and a stream context. It looks tidier and hides the one thing you need most: the HTTP stream wrapper's ignore_errors option defaults to false, so a 400 or 429 gives you false and a warning instead of the JSON error explaining what was wrong. Turn it on and you are then parsing $http_response_header by hand to recover the status code, which is a worse cURL wrapper. Plenty of shared hosts disable allow_url_fopen anyway.

Hands beside a closed laptop and a blank sheet, working through the raw cURL send path in PHP

Guzzle or raw cURL: which belongs in a whatsapp php integration?

Use Guzzle if you already have Composer: PSR-18 compatibility, a separate connect timeout, a typed exception hierarchy, and a retry middleware you do not have to write. Use raw cURL when Composer is not available, when the app is legacy, or when you want one file with no dependency graph. Both hit the identical endpoint.

One thing to set on day one. Guzzle's request options document timeout as "Default 0" and connect_timeout as "Default 0", meaning no timeout at all unless you say otherwise. A PHP-FPM worker blocked forever on a socket is how one slow dependency takes down a pool.

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;

$http = new Client([
    'base_uri'        => 'https://api.blueticks.co',
    'connect_timeout' => 5,
    'timeout'         => 20,
    'http_errors'     => false,   // read the body yourself instead of catching
    'headers'         => ['Authorization' => 'Bearer ' . getenv('BLUETICKS_API_KEY')],
]);

try {
    $res = $http->post('/v1/scheduled-messages/' . rawurlencode($chatId), [
        'headers' => ['Idempotency-Key' => $idempotencyKey],
        'json'    => ['type' => 'text', 'text' => $text],
    ]);
} catch (ConnectException $e) {
    // No response ever arrived. Retryable, but ONLY because the key above is stable.
    throw $e;
}

$data = json_decode((string) $res->getBody(), true, 512, JSON_THROW_ON_ERROR);

The exception split is the part worth internalising. Guzzle's http_errors defaults to true, so a 400 throws and your error body ends up buried in an exception instead of in front of you. Setting it to false inverts that: branch on $res->getStatusCode() and read the JSON error every time. ConnectException still throws, and that is the useful distinction. It means no response ever existed, so you cannot know whether the request landed. A response, any response, means the server answered and told you something specific.

Whether you may safely repeat either is a real question and not a PHP one; the production hardening guide covers it. The PHP mechanic is just this: attach a stable Idempotency-Key, and put GuzzleHttp\Middleware::retry on the handler stack instead of a for loop with sleep().

One more reason the choice matters later: Guzzle is a PSR-18 client and raw cURL is not, which is what lets the official SDK below discover it automatically.

Should you install the official PHP SDK instead of calling REST directly?

Install it on a modern Composer project if you want typed responses and automatic path encoding. Skip it on shared hosting, below PHP 8.1, or on a stack the package has not been tested against. It is a convenience layer over the same REST calls, not a different capability.

composer require blueticks/blueticks guzzlehttp/guzzle

The second package is not optional. The SDK ships no HTTP client of its own: it requires psr/http-client, psr/http-factory and php-http/discovery, then finds whatever concrete client you installed at runtime. With none present, discovery throws Http\Discovery\Exception\NotFoundException, which reads as a crash rather than a missing dependency. Guzzle is the usual choice; symfony/http-client works too.

use Blueticks\Blueticks;

$client = new Blueticks(['apiKey' => getenv('BLUETICKS_API_KEY')]);

$message = $client->scheduled_messages->create('+15551234567', [
    'type'            => 'text',
    'text'            => 'Your table is ready.',
    'idempotency_key' => 'booking-8812-ready',
]);

echo $message->id, ' ', $message->status;

The chat id is the first positional argument and the SDK rawurlencodes it into the path for you, which removes the most common first-call mistake. The magic idempotency_key entry is lifted out of the array and sent as a header. Media works the same way with 'type' => 'media' and a mediaUrl, covered in the image and document guide.

Two honest caveats, because the package page will not tell you either.

It is new. blueticks/blueticks on Packagist is MIT, currently v5.0.0, published 21 July 2026, and lightly used so far. The source is small and readable, so if a response shape surprises you, read the resource class. Our API quickstart carries the same install line, worth checking because the repository sits under a serenix-com GitHub org and looks third-party at a glance.

Its tested ceiling is PHP 8.3. The package declares php: ^8.1, and Composer's caret operator means >=8.1 <9.0, checked against the PHP actually running Composer. Its CI matrix and README list 8.1, 8.2 and 8.3 only. So it installs cleanly on 8.4 and 8.5, and you are then running untested-against code. Not hypothetical: Laravel 13 requires PHP ^8.3, so a shop that stays current sits at the top of the tested range or above it. The raw REST path has no such ceiling.

How do you schedule a message for later and get sendAt right in PHP?

Add a sendAt field holding an RFC 3339 timestamp with an explicit UTC offset. Build it with DateTimeImmutable plus an explicit DateTimeZone, then format with the DateTimeInterface::RFC3339 constant. The window is roughly 10 seconds to 365 days ahead. Anything outside that is rejected at validation.

$sendAt = (new DateTimeImmutable('2026-09-01 09:00', new DateTimeZone('Asia/Jakarta')))
    ->format(DateTimeInterface::RFC3339);       // 2026-09-01T09:00:00+07:00

$body = ['type' => 'text', 'text' => 'Reminder', 'sendAt' => $sendAt];

Three PHP-specific ways this goes wrong.

date('c') looks correct and is not. It emits a valid offset, so it passes validation, but the offset comes from date_default_timezone_get(). Your laptop says +03:00, the container says +00:00, and the same code schedules two different moments.

DateTime mutates. The manual states that DateTimeImmutable "behaves the same as DateTime except new objects are returned when modification methods such as DateTime::modify() are called" (PHP manual). Loop over 200 reminders calling ->modify('+1 day') on a shared DateTime and every send drifts a day further than the last.

Off by ten seconds. Compute sendAt when the job is created, sit in a queue for nine seconds, and dispatch arrives with a timestamp already inside the floor: 400 sendAt must be at least 10 seconds in the future. Compute it at send time or leave a minute of headroom. Recurring cadences are a different mechanism, covered in the recurring messages guide.

How do you send from Laravel without blocking the HTTP request?

Never call the API inline in a controller. Dispatch a queued job, bind configuration through config/services.php, and let the queue own retries. Laravel's HTTP client has sane timeouts where raw Guzzle has none, and the job's tries() and backoff() give you the retry curve for free.

Put the key in config/services.php as 'blueticks' => ['key' => env('BLUETICKS_API_KEY')] and read it with config('services.blueticks.key'). Not style: Laravel's docs are explicit that once config:cache has run, "the env function will only return external, system level environment variables" (configuration docs), so an env() call inside a job silently becomes null on your first cached production deploy.

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;

class SendWhatsAppMessage implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public string $chatId,
        public string $text,
        public string $idempotencyKey,   // derived from the ORDER, not from time()
    ) {}

    public function tries(): int { return 5; }

    public function backoff(): array { return [10, 60, 300, 900]; }

    public function handle(): void
    {
        $res = Http::withToken(config('services.blueticks.key'))
            ->withHeader('Idempotency-Key', $this->idempotencyKey)
            ->connectTimeout(5)
            ->timeout(20)
            ->post(
                'https://api.blueticks.co/v1/scheduled-messages/' . rawurlencode($this->chatId),
                ['type' => 'text', 'text' => $this->text],
            );

        if ($res->status() === 429 || $res->serverError()) {
            $res->throw();                       // let the queue back off and retry
        }

        if ($res->failed()) {
            $this->fail(new \RuntimeException('permanent: ' . $res->body()));
        }
    }
}

The tries() and backoff() methods are documented on both Laravel 12 and Laravel 13; on 13 the same delays can be written as a #[Backoff([10, 60, 300, 900])] attribute. 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. Compute it inside handle() from a timestamp and every retry becomes a fresh message.

Http::retry() is the tempting shortcut and it has a sharp edge. The docs describe it as retrying "if a client or server error occurs" (HTTP client docs), which includes 400s that will never succeed and, without a stable key, turns one timeout into two delivered messages. Restrict it: Http::retry(3, 200, fn ($e) => $e instanceof ConnectionException). Laravel's defaults do beat raw Guzzle, with timeout at 30 seconds and connectTimeout at 10.

One pacing warning, because a queue makes this easy to get wrong. Fan 500 jobs onto Horizon with ten workers and you fire 500 sends as fast as the workers drain. That burst is the pattern that gets numbers flagged, whatever sent it, and WhatsApp's policy on automated or bulk messaging applies either way. Throttle the queue and spread a batch over hours. Which events deserve a message at all is covered in the event-triggered automation guide.

Ready to wire this into your app? Get a bt_live_ key and paste the job above into your existing Horizon setup. Your own number, your own contacts, no Meta Business verification and no per-message template fees.

Server rack status lights at night, the queue workers behind a Laravel WhatsApp job

What errors does a PHP client actually hit, and what should it do with each?

Four of them carry PHP-relevant surprises. Below those sit three failure modes that are not HTTP errors at all: a silent json_decode, a transport error with no status code, and a missing CA bundle. Which codes are permanently fatal and which are worth a retry is covered in the production hardening guide.

StatusMeaningWhat your client should do
400Body or sendAt failed validationFix, never retry. Read error.message
409Same Idempotency-Key, different bodyBug in your key derivation
429Rate limitedBack off, retry with the same key
5xxServer sideRetry with backoff and the same key

Two limits shape that. The free plan allows 5 requests per 6-hour slot, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second; any subscription removes the ceiling. And Idempotency-Key is capped at 64 characters and scoped per workspace: reuse it with an identical body and the original response replays, returning the same 201 and the same message id, so detect a replay by the id, not the status. The body secret field is not a dedup key. Send the same secret twice and you sent two messages.

json_decode returns null twice over. The manual says null "is returned if the json cannot be decoded or if the encoded data is deeper than the nesting limit" (json_decode), and null is also what valid JSON null decodes to. Pass JSON_THROW_ON_ERROR, available since PHP 7.3, and get a JsonException instead of a mystery empty value three frames later.

curl_errno and HTTP status are different universes. A 500 is a successful transfer: curl_exec returns the body, curl_errno is 0, CURLINFO_RESPONSE_CODE is 500. A DNS failure or timeout returns false with a non-zero curl_errno and no status code at all. Check curl_errno() first. Code that only reads the status treats a timeout as a success with an empty body.

Error 60 on Windows and XAMPP. "SSL certificate problem: unable to get local issuer certificate" is not an API problem, it is a local PHP with no CA bundle. Download the Mozilla CA extract and point curl.cainfo and openssl.cafile in php.ini at the cacert.pem. What not to do is disable verification. curl's documentation is blunt: "We strongly recommend this is avoided and that even if you end up doing this for experimentation or development, never skip verification in production" (curl SSL certificates).

How do you confirm the message actually arrived?

Not from the 201. Take the id from data.id and GET /v1/scheduled-messages/{id}, then read the status ladder: pending, confirmed, received, read, played, or failed. In a whatsapp api php client, a 201 only means the queue accepted it.

confirmed means WhatsApp itself took the message, received is the double grey tick, read the double blue tick, and failed carries a failureReason.

The field to watch is waMessageKey, and two things about it catch people out. It is an object (fromMe, remote, id, _serialized), 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. Your 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 changes instead. Registration, payload verification and duplicate handling all live in the webhooks and auto-reply guide, deliberately not summarised here.

A notebook checklist beside a phone on a wooden table, confirming WhatsApp message delivery

Where Blueticks fits for a PHP team, and where a BSP is the better call

A hosted REST engine fits PHP shops for one structural reason: the maintained WhatsApp Web clients are Node projects. Running your own means supervising a Node process and a browser profile next to your PHP app forever. For template broadcast at scale or a multi-agent shared inbox, a Business Solution Provider is genuinely the better product.

That is the argument for whatsapp api integration in php specifically. In Python or Node you can at least weigh a library against a hosted API. As of August 2026 a PHP team has no equivalent choice: whatsapp-web.js and Baileys are both Node, so "build it yourself" means a second runtime, a session store, a reconnect supervisor and a pager rotation for something that is not your product. The Node bot guide shows what that maintenance looks like.

Where a BSP wins is not close. Approved marketing templates to 50,000 people, an Official Business Account badge, Meta's throughput tiers, ten agents on one inbox: that is what the Cloud API and its partners are built for, and pricing is per delivered template message. Our read on when the Business API is worth it says the same. The trade is the number itself: one registered on the Cloud API stops being a normal WhatsApp number.

The laravel whatsapp api case is the middle ground. Transactional volume, real conversations, replies that go to a human, a number your customers already have saved. One Http::post in a queued job.

FAQ

Can I send a WhatsApp message from PHP without Composer?

Yes. The curl_init example above needs nothing beyond the cURL extension and runs on shared hosting. Composer only matters if you want Guzzle or the SDK, and neither adds capability, only ergonomics.

Which PHP versions does the Blueticks PHP SDK support?

Its composer.json declares php: ^8.1; its CI matrix and README list 8.1, 8.2 and 8.3. It installs on 8.4 and 8.5 because a caret constraint permits any 8.x, but those are not covered by its tests. On 8.4 or newer, accept that or call REST directly.

How do I stop a retry from sending the same WhatsApp message twice?

Send an Idempotency-Key header, computed once from the business object (order-4471-shipped) and stored with the job so every retry reuses it. A replay returns the original 201 and the same message id instead of sending again. Not the body secret field: that is a correlation tag, and sending the same one twice sends two messages.

Will sending from PHP 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 loop firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, spread batches over hours, and stop when someone asks you to.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.