By language

WhatsApp API in PHP: cURL, Guzzle and a webhook on any host

To use the WhatsApp API in PHP you don't need a framework or a dedicated server: one cURL function sends the message and a file that reads php://input receives the webhook. It is the path for legacy systems and shared hosting, where you can't install a worker or open a port.

By D-API engineering team6 min read

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.cloud on 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

SymptomLikely causeWhat to do
Events stop arriving after a site changeAn .htaccess rule redirecting the file and returning 404Exclude the webhook path from rewrite rules. 404 and 410 responses stop the retries
Webhook gets a 403 for no apparent reasonThe provider's web application firewall blocking POST with JSONAsk support for an exception for that file
Same message processed twiceA slow response caused the delivery to be repeatedStore and respond, process in cron, unique index
Sending fails with a certificate errorOutdated CA bundle on the serverUpdate 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.

Frequently asked questions

Does it work on shared hosting?
In most cases, yes. Sending only needs the cURL extension and outbound HTTPS. The webhook is a regular PHP file reachable from the internet. Check with your provider that outbound requests are not blocked.
Do I need Composer?
No. The cURL example uses only native PHP functions and fits in a single file included with require. Composer only comes in if you want Guzzle or already use it elsewhere in the project.
What is the minimum PHP version?
The examples on this page run on PHP 7.4 or later. If the system is still on an older version, the logic is the same, but you will need to remove the type declarations from the functions.
My webhook receives the POST but $_POST is empty. Why?
Because the body arrives as JSON, and PHP only fills $_POST for forms. Read the raw body with file_get_contents on php://input and decode it with json_decode.
Can I use the same code in Laravel?
You can, but Laravel already has the Http client, queues and notifications that make the integration cleaner. There is a dedicated Laravel guide for that case.

Try D-API's WhatsApp API

3-day trial with full access. No credit card, no lock-in.