The scenario: an old system on simple hosting
Plenty of back-office systems, stores and customer portals still run on procedural PHP, on shared hosting with cPanel and no root access. Rewriting them to add WhatsApp is not an option. The good news is that the D-API WhatsApp API is REST: it needs no library, no background process and no persistent WhatsApp connection on your server. D-API keeps the connection to the number; your PHP only makes HTTPS requests and receives HTTPS requests.
What the server needs:
- the cURL extension enabled, which practically every host has;
- outbound access to
https://api.d-api.cloudon port 443; - a public HTTPS address for the webhook file;
- access to the control panel's cron, if you want to process events in batches.
Send a message with plain cURL
The function below fits in a dapi.php file and can be included from any screen in the system. It sets a maximum connect and response time, handles network failures and turns the API error response, which arrives as {"success": false, "error": "...", "statusCode": 400}, into an exception.
<?php
// dapi.php
define('DAPI_API_KEY', getenv('DAPI_API_KEY'));
function dapi_post(string $path, array $payload): array
{
$ch = curl_init('https://api.d-api.cloud' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3, // seconds to open the connection
CURLOPT_TIMEOUT => 10, // seconds for the whole request
CURLOPT_HTTPHEADER => [
'Authorization: ' . DAPI_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Network failure talking to D-API: ' . $error);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$json = json_decode($body, true) ?: [];
if ($status >= 400) {
throw new RuntimeException($json['error'] ?? 'HTTP ' . $status, $status);
}
return $json;
}
// usage
dapi_post('/api/v1/messages/send/text', [
'sessionId' => 'store',
'to' => '14155550123',
'text' => 'We received your order 4812. Thank you!',
]);By changing the path, the same function sends an image with a caption: use /api/v1/messages/send/image with the image field (public URL or base64) and caption. The timeout is what keeps the system's page from spinning forever if the network wobbles. On shared hosting PHP usually has a low execution limit, and a call without a timeout can take down the user's whole request.
The same call with Guzzle
If the project already uses Composer, Guzzle makes the code more readable and clearly separates network errors from errors returned by the API.
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\ConnectException;
$dapi = new Client([
'base_uri' => 'https://api.d-api.cloud',
'timeout' => 10,
'connect_timeout' => 3,
'headers' => ['Authorization' => getenv('DAPI_API_KEY')],
]);
try {
$dapi->post('/api/v1/messages/send/text', [
'json' => ['sessionId' => 'store', 'to' => '14155550123', 'text' => 'Your order has shipped.'],
]);
} catch (BadResponseException $e) {
$error = json_decode((string) $e->getResponse()->getBody(), true);
error_log('D-API rejected: ' . ($error['error'] ?? $e->getMessage()));
} catch (ConnectException $e) {
error_log('D-API unreachable or timed out: ' . $e->getMessage());
}BadResponseException covers 4xx and 5xx responses. ConnectException covers DNS, refused connections and timeouts. For the first, review the payload; for the second, it is worth retrying later.
Receive the webhook with php://input
The webhook arrives as a POST with a JSON body. In PHP that means reading php://input: $_POST is empty because it is not a form. On shared hosting, the safest way to handle events is to store first and process later, because the file has to respond fast and the provider's PHP-FPM usually has few processes available.
<?php
// whatsapp-webhook.php (registered URL: https://yoursite.com/whatsapp-webhook.php?token=SECRET)
require __DIR__ . '/config.php';
if (!hash_equals(WEBHOOK_TOKEN, $_GET['token'] ?? '')) {
http_response_code(401);
exit;
}
$raw = file_get_contents('php://input');
$event = json_decode($raw, true);
if (!is_array($event) || !isset($event['event'])) {
http_response_code(400);
exit;
}
// a unique key avoids storing the same retried event twice
$key = $event['data']['id'] ?? $event['traceId'];
$pdo->prepare(
'INSERT IGNORE INTO whatsapp_events (event_key, event, session, payload, created_at)
VALUES (?, ?, ?, ?, NOW())'
)->execute([$key, $event['event'], $event['sessionId'], $raw]);
http_response_code(200);
echo 'ok';The whatsapp_events table needs a unique index on event_key; that is what makes INSERT IGNORE drop repeats. The webhook has no signature, so the token in the query string is the endpoint's protection. A cron script running every minute reads pending events and does the slower work: replying to the customer, updating the order, notifying the finance team.
In that script, mark each row as processed only after the work is done, and cap how many rows it takes per run, for example 100. That way a backlog does not blow past the maximum execution time your provider sets for PHP.
Common pitfalls on shared servers
| Symptom | Likely cause | What to do |
|---|---|---|
| Events stop arriving after a site change | An .htaccess rule redirecting the file and returning 404 | Exclude the webhook path from rewrite rules. 404 and 410 responses stop the retries |
| Webhook gets a 403 for no apparent reason | The provider's web application firewall blocking POST with JSON | Ask support for an exception for that file |
| Same message processed twice | A slow response caused the delivery to be repeated | Store and respond, process in cron, unique index |
| Sending fails with a certificate error | Outdated CA bundle on the server | Update PHP or the certificate bundle; never turn off SSL verification |
Where this fits in your system
The most common entry points in older PHP systems are order checkout, invoice issuance and scheduling. A call to dapi_post right after the INSERT that already exists covers most of it. For a payment reminder flow, see the payment reminders guide. If your project runs on Laravel, the Laravel guide shows the version with queues and notifications. Details of each incoming event are in WhatsApp webhooks.
The connection used in these examples is the unofficial one, paired by QR code, which does not require template approval to send the first message. The rules and precautions for that model are in unofficial WhatsApp API, and the plans, billed per connection, on the pricing page.
