Send WhatsApp messages from PHP and Laravel: a working example
Send WhatsApp template messages from plain PHP (cURL) or Laravel's HTTP client with a REST API — test keys, safe retries, errors by type and signed webhook verification.
November 3, 2026 · 7 min read
To send a WhatsApp message from PHP you need an approved template, an API key and one POST. This guide shows that call in plain PHP (cURL, no packages) and in Laravel, how to retry it without sending twice, how to handle the errors WhatsApp's rules cause, and how to verify the signed webhooks that tell you what happened next.
SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. It runs on Meta's official WhatsApp Cloud API; you keep your own WhatsApp Business account.
What was tested: nothing in this guide was run. The request and response shapes are taken from our REST API reference, and the signature check follows the same scheme as the Python and Node.js guides. The PHP and Laravel code on this page was not executed, not even against a mock server, and not against a live SendFromChat key. Start with a test key, and read the code before you paste it. It assumes PHP 8.1 or newer with the cURL extension.
The short version
- Get a template approved (usually minutes, sometimes up to 24 hours).
- Create a test key, which runs every check and sends nothing.
POST /messageswith anIdempotency-Key, so a retry never double-sends.- Verify the
SendFromChat-Signatureheader on webhooks before you trust them.
1. A template
A message you start outside a 24-hour conversation window must use a template Meta has approved (how the window works). A shipping update is a Utility template, for example:
Hi {{1}}, your order {{2}} has shipped.
Create it on the dashboard or ask your assistant to draft and submit it. It's checked against Meta's formatting rules first (the rules, explained). Only message people who agreed to WhatsApp updates (how to collect opt-in).
2. A key you can't hurt yourself with
On the dashboard's Developers page, create a test key (sfc_test_…). It checks your plan, opt-outs, the template and the 24-hour window, then stops: nothing is sent, saved or counted. Swap in a live key (sfc_live_…) when you go to production.
Keep the key on your server, in an environment variable or your framework's secrets. Never put it in browser or mobile-app code, and never commit it.
export SENDFROMCHAT_KEY=sfc_test_...
In Laravel, put it in .env as SENDFROMCHAT_KEY= and read it through config/services.php ('sendfromchat' => ['key' => env('SENDFROMCHAT_KEY')]), so it still works after php artisan config:cache.
3. Send a template in plain PHP
<?php
const SFC_API = 'https://sendfromchat.com/api/v1';
final class SendFromChatException extends RuntimeException
{
public function __construct(public readonly int $status, public readonly string $type, string $message)
{
parent::__construct("$type: $message");
}
}
function sendTemplate(string $to, string $name, array $variables, string $idempotencyKey, string $language = 'en_US'): array
{
$ch = curl_init(SFC_API . '/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SENDFROMCHAT_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencyKey,
],
CURLOPT_POSTFIELDS => json_encode([
'to' => $to,
'template' => [
'name' => $name,
'language' => $language,
'variables' => array_values($variables),
],
], JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException('Network error: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = json_decode($raw, true) ?? [];
if ($status >= 400) {
$err = $body['error'] ?? [];
throw new SendFromChatException($status, $err['type'] ?? 'unknown', $err['message'] ?? '');
}
return $body; // ["id" => "wamid…", "status" => "accepted", …]
}
sendTemplate('+491512345678', 'order_shipped', ['Ana', '#4821'], 'order-4821-shipped');
Three details matter:
tois international format with the country code, such as+491512345678. If your numbers come from forms or a spreadsheet, normalise them first (phone number format guide).variablesfill{{1}},{{2}}… in order.array_values()keeps the list a JSON array even if your PHP array has odd keys.- The idempotency key comes from the event, not from the attempt.
order-4821-shippedis the same on every retry, so a timeout or a re-run queue job gets the first response back instead of a second message. Reusing a key with a different body returnsidempotency_mismatch.
With a test key the response is 200 with "status": "test". With a live key it is 202 accepted, which means the request was accepted, not that the customer received or read it. Check GET /messages/{id} or subscribe to webhooks for the status.
4. The same call in Laravel
Laravel's HTTP client does the same job in fewer lines. A small service class keeps it in one place:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class SendFromChat
{
public function sendTemplate(string $to, string $name, array $variables, string $idempotencyKey, string $language = 'en_US'): array
{
$response = Http::baseUrl('https://sendfromchat.com/api/v1')
->withToken(config('services.sendfromchat.key'))
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->timeout(10)
->post('/messages', [
'to' => $to,
'template' => [
'name' => $name,
'language' => $language,
'variables' => array_values($variables),
],
]);
if ($response->failed()) {
throw new SendFromChatException(
$response->status(),
$response->json('error.type', 'unknown'),
$response->json('error.message', ''),
);
}
return $response->json();
}
}
SendFromChatException is the class from step 3; in a Laravel app put it in app/Services/SendFromChatException.php as its own file and add namespace App\Services; under the <?php line, so the class is found next to the service. Call the service from a queued job rather than from the web request, so a slow response never holds up checkout:
$sfc->sendTemplate($order->phone, 'order_shipped', [$order->first_name, "#{$order->number}"], "order-{$order->id}-shipped");
Using the order id in the key means a job that Laravel retries after a failure sends once.
5. Handle errors by type
Errors share one shape, {"error": {"type": …, "message": …}}. Branch on type, not on the message text:
try {
sendTemplate('+491512345678', 'order_shipped', ['Ana', '#4821'], 'order-4821-shipped');
} catch (SendFromChatException $e) {
if (in_array($e->type, ['opted_out', 'window_closed', 'template_not_approved'], true)) {
// WhatsApp's rules, not a bug: log it and move on
} elseif ($e->type === 'warmup_limit') {
// a new number hit today's cap: retry later
} elseif ($e->status >= 500) {
// safe to retry with the same Idempotency-Key
} else {
throw $e;
}
}
opted_out, window_closed, template_not_approved and whatsapp_rejected come back as 422. warmup_limit is a 429: brand-new numbers are capped at 50, 100, then 200 template sends a day for the first three days. The full table is in the REST API reference.
6. Know what happened next
For delivery status and customer replies, add a webhook endpoint on the Developers page and subscribe to message.status and message.received. Each request carries a header like SendFromChat-Signature: t=1790300000,v1=5f2b…, where v1 is the hex HMAC-SHA256 of <t>.<raw request body> keyed with your endpoint secret. Verify it over the raw bytes, compare in constant time and reject timestamps older than five minutes:
function verifySignature(string $rawBody, ?string $header, string $secret): bool
{
if (!$header) {
return false;
}
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$key] = $value;
}
if (!ctype_digit($parts['t'] ?? '')) {
return false;
}
$t = (int) $parts['t'];
if (abs(time() - $t) > 300) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
In plain PHP, read the body with file_get_contents('php://input') and the header from $_SERVER['HTTP_SENDFROMCHAT_SIGNATURE'].
In Laravel, use $request->getContent(), not $request->all() or json(): re-encoded JSON no longer matches the signature. Put the route in routes/api.php (no CSRF check), where Laravel serves it under /api, so the URL to register on the Developers page is https://your-domain.example/api/webhooks/sendfromchat. If you use routes/web.php instead, the URL has no /api and you must exempt the route from CSRF. Put verifySignature() above in its own file, for example a small helper class or a functions.php your app loads, and import or require it where the route uses it:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/sendfromchat', function (Request $request) {
$valid = verifySignature(
$request->getContent(),
$request->header('SendFromChat-Signature'),
(string) config('services.sendfromchat.webhook_secret'),
);
if (!$valid) {
return response('', 400);
}
$event = $request->json()->all();
// deliveries can repeat: skip $event['id'] values you have already handled
return response('', 204);
});
Set webhook_secret in config/services.php from your endpoint's secret; the (string) cast turns a missing secret into an empty string, so the check fails with a 400 instead of a PHP error. Make sure the secret is set: an empty secret makes the signature check meaningless. Answer with a 2xx within 5 seconds, so do the real work in a queued job. Failures are retried with backoff for about a day. The event payloads are in the REST API reference, and the webhooks guide shows the Node version.
Replies
When a customer answers, a 24-hour window opens and you can reply with free-form text, no template:
Http::withToken(config('services.sendfromchat.key'))
->timeout(10)
->post('https://sendfromchat.com/api/v1/messages', [
'to' => '+491512345678',
'text' => "Thanks, we're on it.",
]);
Replies, auto-replies and chatbot messages are unlimited on every plan. Only messages you start count toward the monthly allowance (plans and limits).
Costs, in plain terms
Two separate things: SendFromChat's flat monthly plan (from $19), and Meta's per-message charge, which Meta bills to your own WhatsApp Business account at its published rates, with no markup from us. Rates depend on the message category and the recipient's country; see WhatsApp API pricing.
A note for EU and UK customers
SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. Read it before you decide; our GDPR guide covers consent, opt-out and records on your side.
Try it
Start the 30-day free trial (no card), connect a number, create a test key and run the snippet above. If you build with Claude Code, you can ask it to "add WhatsApp order notifications to my app" and it writes the integration in your language, including PHP (Claude Code guide). Weighing other routes? See SendFromChat vs Twilio and using Meta's Cloud API directly.
Do this from your AI assistant
SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.
Start freeMore guides
WhatsApp for Black Friday: a dated plan to be ready to send on 27 November 2026
Black Friday is Friday 27 November 2026. A step-by-step plan for a first WhatsApp campaign - connect a number, collect opt-in, get templates approved, warm up and send - with the limits that decide how many people you can reach.
Track WhatsApp delivery status in your app: sent, delivered, read and failed
Store the status of every WhatsApp message you send, handle receipts that arrive late or twice, read the failure reason, and fall back to polling when a webhook is missed. TypeScript example with a REST API.
Do you need a Facebook login for the WhatsApp Business API? What 'Continue with Facebook' asks
What the Facebook window asks you to do when you connect a WhatsApp number, which two permissions it gives an app, what you keep, and how to remove the access later. Based on Meta's own pages, read 31 Oct 2026.
WhatsApp number won't connect? What to check when the code doesn't arrive or Facebook's window won't load
A troubleshooting list for connecting a number to the WhatsApp Business API: the verification code that never arrives (SMS or voice), landlines and IVR menus, a number already on WhatsApp, a blocked Facebook sign-in window, and a trial number limit.