The job, in PHP terms
Your PHP billing app already makes the invoice PDF, with dompdf, mPDF or TCPDF. Now the customer wants it to go to their party on WhatsApp the moment the bill is saved, and to see later whether it was delivered and read.
That takes four pieces: a clean phone number, one HTTP call with the PDF, a cron job that retries without ever sending twice, and a webhook that receives the delivery reports. This guide gives each piece in plain PHP and in Laravel, plus the shared-hosting gotchas that cost the most time.
In short: post multipart/form-data with the JSON in a message field and the PDF in a document field, send an Idempotency-Key header that you save with the invoice, retry timeouts from cron with the same key, and check the signature on every webhook.
The examples use the JS1 Talkback Send API. Each message uses a template that Meta has approved; for invoices, a utility template with a document header, such as:
Dear {{1}}, your invoice {{2}} is attached. Thank you for your business.
Step 1: a number the API will take
The API wants the full number with the country code, digits only: 919876543210. A bare 10-digit number is refused. This works on PHP 7.4 and later, which matters on shared hosting:
// India-first: a 10-digit number gets 91 in front. Returns null when it cannot be a valid number.
function to_whatsapp_number(?string $raw): ?string
{
$d = preg_replace('/\D/', '', (string) $raw);
if (substr($d, 0, 2) === '00') $d = substr($d, 2); // 0091 98765 43210
if (strlen($d) === 13 && substr($d, 0, 3) === '910') $d = '91' . substr($d, 3); // +91 098765 43210
if (strlen($d) === 11 && $d[0] === '0') $d = substr($d, 1); // 098765 43210
if (strlen($d) === 10) $d = '91' . $d; // 98765 43210
return (strlen($d) >= 11 && strlen($d) <= 15 && $d[0] !== '0') ? $d : null;
}
// Each value: not empty, one line, no runs of spaces.
function clean_param(?string $s): string
{
$s = trim(preg_replace('/\s+/', ' ', (string) $s));
return $s === '' ? '-' : $s;
}
Watch for one PHP trap here. If your party names come from an old MySQL table in latin1, json_encode() meets bytes that are not valid UTF-8 and returns false, not an error. You then post an empty message and get a confusing 422. Always encode with JSON_THROW_ON_ERROR, and fix the connection's character set (utf8mb4) at the source.
Step 2: save the send before you make it
Make a row for every send before calling the API, with its own key:
CREATE TABLE invoice_message (
idem_key VARCHAR(64) NOT NULL PRIMARY KEY, -- INV-1043-send-1
invoice_no VARCHAR(30) NOT NULL,
message_id VARCHAR(50) NULL,
status VARCHAR(20) NOT NULL, -- queued, retry, submitted, sent, delivered, read, failed, refused
status_rank TINYINT NOT NULL DEFAULT 0, -- 0 queued/retry, 1 submitted, 2 sent, 3 delivered, 4 read, 9 failed/refused
failure_code VARCHAR(40) NULL,
tries INT NOT NULL DEFAULT 0,
next_try_at DATETIME NULL
);
The key is what makes a retry safe. If a request times out, posting again with the same Idempotency-Key returns the first answer and the customer still gets one message. When the user presses "Send again" on purpose, make a new row with INV-1043-send-2.
Step 3a: send the PDF with plain PHP (curl)
Passing an array to CURLOPT_POSTFIELDS makes curl send multipart/form-data. CURLFile attaches the PDF; its third argument is the name the customer sees, so make it INV-1043.pdf:
function talkback_send_pdf(string $apiKey, string $to, string $party, string $invoiceNo,
string $pdfPath, string $idemKey): array
{
$message = json_encode([
'to' => $to,
'template' => 'invoice_pdf',
'params' => [clean_param($party), clean_param($invoiceNo)],
'reference' => $idemKey,
], JSON_THROW_ON_ERROR);
$ch = curl_init('https://talkback.js1software.net/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Idempotency-Key: ' . $idemKey],
CURLOPT_POSTFIELDS => [
'message' => $message,
'document' => new CURLFile($pdfPath, 'application/pdf', pdf_name($invoiceNo)),
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 0 = no answer at all
curl_close($ch);
$json = is_string($body) ? json_decode($body, true) : null; // null on a 401 (no body)
if ($status === 202) return ['ok', $json['id'] ?? null, null];
if ($status === 0 || $status === 429 || $status >= 500) return ['retry', null, $json['code'] ?? null];
return ['refused', null, $json['code'] ?? ($status === 401 ? 'UNAUTHORIZED' : 'HTTP_' . $status)];
}
// GST invoice numbers often contain "/" (INV/1043/26-27), which cannot be in a file name.
function pdf_name(string $invoiceNo): string
{
return preg_replace('/[^A-Za-z0-9._-]+/', '-', $invoiceNo) . '.pdf';
}
If the PDF is only in memory (dompdf's $dompdf->output()), PHP 8.1 and later can skip the temporary file: new CURLStringFile($pdfBytes, pdf_name($invoiceNo), 'application/pdf').
Step 3b: the same send in Laravel
Laravel's HTTP client turns attach() plus the post data into a multipart request:
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
try {
$res = Http::withToken(config('services.talkback.key'))
->withHeaders(['Idempotency-Key' => $send->idem_key])
->timeout(30)
->attach('document', $pdfBytes, pdf_name($invoice->number), ['Content-Type' => 'application/pdf'])
->post('https://talkback.js1software.net/v1/messages', [
'message' => json_encode([
'to' => $to,
'template' => 'invoice_pdf',
'params' => [clean_param($invoice->party_name), clean_param($invoice->number)],
'reference' => $send->idem_key,
], JSON_THROW_ON_ERROR),
]);
} catch (ConnectionException $e) {
$res = null; // timed out or no answer: retry, same key
}
if ($res?->status() === 202) { /* save $res->json('id'), status submitted */ }
elseif (!$res || $res->status() === 429 || $res->serverError()) { /* status retry */ }
else { /* refused: show $res->json('code') and $res->json('message') */ }
Put the key in .env and read it through config/services.php, never straight from env() in your code (it returns null once the config is cached). In Laravel, this send belongs in a queued job with $tries and backoff(), so the "Save invoice" click never waits on the network.
Step 4: retry from cron, safely
On shared hosting there is no queue worker, but there is always cron. Run a small script every five minutes:
*/5 * * * * /usr/local/bin/php /home/youruser/app/talkback-retry.php >/dev/null 2>&1
It picks rows with status = 'retry' AND next_try_at <= NOW(), posts each one again with its own saved key, and pushes next_try_at out a little more each time (5 minutes, 15 minutes, then hourly). After about a day of failures, stop and show the invoice to a person.
Things worth knowing for a month-end run of a few hundred bills:
- The API takes about 120 requests a minute. A
usleep(500000)between sends keeps you well under; on429 RATE_LIMITED, wait the 60 seconds theRetry-Afterheader asks for. - A refusal (
TEMPLATE_NOT_APPROVED,BAD_PARAMS,INSUFFICIENT_BALANCE, …) will be refused again until someone fixes the cause. Mark itrefused; do not put it back in the queue. Nothing is charged for a refusal.
Step 5: the webhook, in plain PHP
The API posts every change (sent, delivered, read, failed) and every customer reply to one https:// address you set. Check the signature over the raw body before anything else:
<?php // talkback-webhook.php
$secret = getenv('TALKBACK_WEBHOOK_SECRET'); // the whole "tbwh_…" string, prefix included
if ($secret === false || $secret === '') { http_response_code(500); exit; } // never check against an empty secret
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_TALKBACK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_TALKBACK_SIGNATURE'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
&& hash_equals('v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secret), $sig);
if (!$ok) { http_response_code(401); exit; }
http_response_code(200);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request(); // answer first, then work
$e = json_decode($raw, true);
if ($e['event'] === 'message.status') { // $pdo = your PDO connection
$rank = ['sent' => 2, 'delivered' => 3, 'read' => 4, 'failed' => 9][$e['status']] ?? 0;
$pdo->prepare('UPDATE invoice_message SET status = ?, status_rank = ?, failure_code = ?
WHERE idem_key = ? AND status_rank < ?')
->execute([$e['status'], $rank, $e['failureCode'], $e['reference'], $rank]);
}
Why it is written this way:
- Answer within 10 seconds.
fastcgi_finish_request()(PHP-FPM) sends the200and lets the database work carry on. Without it, keep the work small. - Events can arrive twice or out of order, so the update only ever moves a row forward, and
failed(rank 9) wins over everything. A repeat or a late event changes nothing. - Match on
reference, not the message id. Thedeliveredevent can arrive before your send code has saved the202answer. Your own key was saved before the send, so it is always there to match. When you save the202answer later, always setmessage_id, and setstatus = 'submitted', status_rank = 1only whenstatus_rank < 1.
For customer replies (message.received), insert the eventId into a table with a unique key (INSERT IGNORE) and skip the reply when no row was added.
The same webhook in Laravel
Read the raw body with $request->getContent() and the headers with $request->header('Talkback-Timestamp'), then use the same check. Laravel's CSRF protection answers 419 to any POST from outside your app, so put the route in routes/api.php, or in Laravel 11 and later exclude it in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['talkback/webhook']);
})
Shared-hosting gotchas
- Redirects are not followed. If your site sends
http://tohttps://, orexample.comtowww.example.com, give the webhook its final address. A301counts as a failed delivery. - The host's firewall may block it. Some shared hosts run mod_security rules that answer
403to JSON posts from unknown senders. If the "Send a test" button on the Developers page gets a403, ask your host to allow that one path. - Check outgoing calls work. Most hosts allow outgoing HTTPS; a few block it on cheap plans. The test key below tells you in one call.
upload_max_filesizedoes not matter here. That limit is for files sent to your site. Sending a PDF out with curl is limited only by the API's 5 MB.
Test without sending to anyone
A test key (tb_test_…) runs every check on the request, but sends nothing and charges nothing. These numbers force each path:
to |
You get |
|---|---|
910000000402 |
402 INSUFFICIENT_BALANCE |
910000000502 |
422 PROVIDER_REFUSED |
910000000999 |
202, then a failed webhook |
| any other valid number | 202, then a delivered webhook |
A test key does not check your template. Before going live, call GET /v1/readiness?template=invoice_pdf with the live key and fix anything it lists.
Common questions
Does this need Composer or a special library?
No. Plain PHP needs only the curl extension, which nearly every host has. Laravel's HTTP client is built in.
Can I send the bill as a normal message without a template?
Not when your software starts the conversation. Business-started messages must use an approved template; free text is only for the 24 hours after the customer writes to you.
My PDF is made on the fly. Must I save it to disk first?
On PHP 8.1 and later, no: use CURLStringFile, or Laravel's attach() with the bytes. On older PHP, write it to sys_get_temp_dir(), send it, then delete it.
What if the number is not on WhatsApp?
The send is taken with 202, and the webhook later says failed with NOT_REACHABLE. List those for the user to call or correct. A message that was sent and then failed is still counted.
Sending through JS1 Talkback
JS1 Talkback is the sending service behind these examples:
- Messages go from your customer's own WhatsApp number, and their staff answer replies in a shared inbox.
- Test keys, idempotent sends, signed webhooks and a readiness check, documented in the API guide.
- No monthly fee. You buy prepaid messages; see the current price per message.
Writing in C# or VB.NET instead? See how to send an invoice PDF on WhatsApp from C# and .NET.
See the developer page and get a test key.
← Back to all posts