Nuevo Safe Mode calienta los números nuevos para reducir el riesgo de bloqueo en WhatsApp. Más información →

Maytapi/Blog/Tutorials

Build a WhatsApp Bot in PHP: cURL, Webhooks and a List Menu

Build a WhatsApp bot with plain PHP 8 and cURL: read webhooks from php://input, route commands with a list menu and reply with images and PDFs.

Tutorials Updated Oct 27, 2026·7 min read

Build a WhatsApp Bot in PHP: cURL, Webhooks and a List Menu

PHP still runs a large share of the web, and a WhatsApp bot fits it well. Each incoming message is one HTTP request to a script, and each reply is one cURL call. This tutorial builds a WhatsApp bot in PHP with the Maytapi API, using plain PHP 8 and the cURL extension, with no framework and no Composer packages. You'll send a first message, read webhook events from php://input, route commands through a list menu, and reply with images and documents. A short Laravel version comes at the end.

Maytapi is an unofficial WhatsApp API. You link your own WhatsApp number as a device by scanning a QR code, the same way WhatsApp Web works, and then control it over REST and webhooks. There are no message templates and no per-message fees. The other side of that is that your bot speaks from a normal WhatsApp account, so WhatsApp's anti-spam rules apply to it. The production section links to the details, and our WhatsApp Business API alternative page compares this approach with Meta's official Cloud API.

What you need

  • PHP 8.0 or newer with the curl and json extensions. Most hosting plans and Docker images have both.
  • A Maytapi account with a connected phone. The free trial runs for three days and doesn't ask for a card. Scan the QR code in the console with the number the bot should use.
  • Your product ID, token and phone ID. The product ID and token are on the console's Product ID & Token page. The phone ID is on the Phones page, or in the id field returned by GET /listPhones.

Keep the credentials in environment variables so the same code runs locally and on the server:

Terminal
export PRODUCT_ID="your-product-id"
export PHONE_ID="your-phone-id"
export TOKEN="your-token"

On PHP-FPM, set them in the pool config (env[TOKEN] = ...) or your hosting panel instead, because FPM workers don't inherit your shell's variables by default.

Send a WhatsApp message with PHP and cURL

Every request goes to https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/ with the token in the x-maytapi-key header. Put one small helper in maytapi.php and reuse it everywhere:

PHP
<?php
declare(strict_types=1);

function maytapi_post(string $url, array $body): array
{
    // webhooks are unauthenticated: only send the token to your own Maytapi product
    $prefix = 'https://api.maytapi.com/api/' . getenv('PRODUCT_ID') . '/';
    if (!str_starts_with($url, $prefix)) {
        return ['success' => false, 'message' => 'unexpected URL'];
    }
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'x-maytapi-key: ' . getenv('TOKEN'),
        ],
        CURLOPT_POSTFIELDS     => json_encode($body),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);
    $raw = curl_exec($ch);
    if ($raw === false) {
        return ['success' => false, 'message' => curl_error($ch)];
    }
    return json_decode($raw, true) ?? ['success' => false, 'message' => 'Invalid JSON'];
}

Now check that the number can send. Create send.php:

PHP
<?php
require __DIR__ . '/maytapi.php';

$PRODUCT_ID = getenv('PRODUCT_ID');
$PHONE_ID   = getenv('PHONE_ID');

$res = maytapi_post(
    "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage",
    ['to_number' => '3461xxxxx67', 'type' => 'text', 'message' => 'Hello from PHP!']
);
print_r($res);

Run php send.php with your own number in to_number: country code first, no + and no spaces. A successful call returns success: true and a data object with the chatId and msgId. POST /sendMessage adds the message to the phone's sending queue, so if nothing arrives, check the phone's status in the console first. The PHP quick reference has more one-off request examples.

Register your webhook URL

Incoming messages, delivery receipts and phone status changes all go to one URL, set at product level with POST /setWebhook:

Terminal
curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \
  -H "x-maytapi-key: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"webhook": "https://bot.example.com/webhook.php"}'

Before saving the URL, Maytapi checks it by posting a sample message event from a test number. Your endpoint therefore has to be reachable when you call setWebhook. The sample has no reply field, and the handler below uses that to skip it. If one product runs several numbers and each should reach a different server, POST /config sets a webhook for a single phone. The webhook events guide covers every event type.

Read incoming messages from php://input

Maytapi sends each event as a JSON POST, so $_POST stays empty. Read the raw body from php://input instead. An incoming text message looks like this:

JSON
{
  "type": "message",
  "product_id": "your-product-id",
  "phone_id": "12345",
  "user": { "id": "3461xxxxx67@c.us", "name": "Customer", "phone": "3461xxxxx67" },
  "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "menu", "fromMe": false },
  "conversation": "3461xxxxx67@c.us",
  "receiver": "3461xxxxx21",
  "timestamp": 1574081887,
  "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage"
}

conversation is the chat to answer in. reply is the sendMessage URL for the phone that received the message, so your code doesn't have to build it, and a bot serving several numbers always answers from the right one.

Check the reply URL before you send your token. Anyone who learns your webhook address can post a fake event to it, so never send x-maytapi-key to a URL taken from the request without checking it. The send helper only accepts a reply that starts with https://api.maytapi.com/api/ followed by your own product ID. For extra protection, register the webhook with a hard-to-guess path or query parameter (for example /maytapi/webhook?key=…) and reject requests that don't carry it. If your firewall supports allowlists, the static IP option described in the webhook events guide lets you accept deliveries from one address only.

Workflow of the PHP webhook: decode php://input, return 200 early, skip non-message events, fromMe and group chats, then route the text and post the answer to the reply URL.

Create webhook.php:

PHP
<?php
declare(strict_types=1);
require __DIR__ . '/maytapi.php';
require __DIR__ . '/router.php';

$event = json_decode(file_get_contents('php://input'), true);

// Answer Maytapi right away, then keep working.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

if (!is_array($event) || ($event['type'] ?? '') !== 'message') exit;

$msg  = $event['message'] ?? [];
$chat = $event['conversation'] ?? '';

if (!empty($msg['fromMe'])) exit;          // our own messages, including bot replies
if (str_ends_with($chat, '@g.us')) exit;   // one-to-one chats only
if (empty($event['reply'])) exit;          // e.g. the setWebhook check

handle($event);

Each check has a job. fastcgi_finish_request() closes the response under PHP-FPM, so Maytapi gets its 200 while your script keeps running. The built-in server doesn't have it, which is why the call is wrapped in function_exists. fromMe is true for anything your own number sends, including the bot's replies, so skipping it prevents the bot from answering itself in a loop. Group chat IDs end in @g.us, and a command bot that replies to every message in a busy group annoys people quickly. Remove that line once you've decided how the bot should behave in groups.

Route commands with a list menu

Customers don't remember typed commands, so give them a menu. A list message shows one button that opens titled sections, and each row has a rowId. When someone picks a row, the webhook receives an ordinary text message with the row title in message.text and the rowId in message.payload.

Create router.php. The trick here is to use the digits 1, 2 and 3 as row IDs, so a tap and a typed "2" end up in the same branch:

PHP
<?php
declare(strict_types=1);

function menu(): array
{
    return [
        'type'        => 'list',
        'title'       => 'Acme Store',
        'message'     => "Hi! How can we help? Tap the menu, or reply 1, 2 or 3.",
        'footer'      => 'Type menu anytime',
        'button_text' => 'Open menu',
        'sections'    => [[
            'title' => 'Shop',
            'rows'  => [
                ['rowId' => '1', 'title' => 'Price list', 'description' => 'PDF with current prices'],
                ['rowId' => '2', 'title' => 'Opening hours', 'description' => 'Store photo and hours'],
                ['rowId' => '3', 'title' => 'Talk to a person', 'description' => 'A team member replies here'],
            ],
        ]],
        'skip_filter' => true,
    ];
}

function handle(array $event): void
{
    $msg = $event['message'];

    // reactions, poll votes, edits, pins, calls and group notices need no answer
    if (in_array($msg['type'] ?? '', ['reaction', 'pollVoteUpdate', 'message_edit', 'pinMessage', 'info'], true)) {
        return;
    }
    if (($msg['type'] ?? '') !== 'text') {
        $body = ['type' => 'text', 'message' => 'I can read text for now. Type *menu* to start.'];
    } else {
        $key  = strtolower(trim((string) ($msg['payload'] ?? $msg['text'] ?? '')));
        $body = match ($key) {
            '1', 'prices' => price_list(),
            '2', 'hours'  => opening_hours(),
            '3', 'agent'  => ['type' => 'text', 'message' => 'Thanks! A team member will reply in this chat.'],
            default       => menu(),
        };
    }

    $res = maytapi_post($event['reply'], ['to_number' => $event['conversation']] + $body);
    if (empty($res['success'])) {
        error_log('sendMessage failed: ' . json_encode($res));
    }
}

Reactions, poll votes, edits, pins and info notices (calls, group changes) also arrive as message events, so the router skips them instead of answering. Anything the router doesn't recognize, including "hi", gets the menu, so a first-time customer always has a next step. Reading payload first and falling back to text keeps the flow working whether people tap or type.

The menu sets skip_filter. sendMessage has a duplicate filter that drops the same content sent to the same chat several times in a short window. That protects you from accidental loops, but a menu is meant to repeat, so it opts out. Leave the filter on for everything else.

List rows are the safest way to collect a choice. Reply-button taps don't reach the webhook as a regular message on every connection, which is why this bot uses a list and accepts typed numbers too. The buttons, lists and carousels guide compares the formats.

Send images and documents

Images, videos, audio and documents all use type: "media". Put a public URL in message, an optional caption in text, and use filename to control the name a document shows in the chat. Add the two remaining functions to router.php:

PHP
function price_list(): array
{
    return [
        'type'     => 'media',
        'message'  => 'https://example.com/files/price-list.pdf',
        'text'     => 'Our current price list',
        'filename' => 'acme-price-list.pdf',
    ];
}

function opening_hours(): array
{
    return [
        'type'    => 'media',
        'message' => 'https://example.com/images/store-front.jpg',
        'text'    => "Open Mon-Sat, 09:00-19:00.\nReply menu to go back.",
    ];
}

If the file isn't public, send it inline as a base64 data URI instead of a URL:

PHP
$data = 'data:application/pdf;base64,' . base64_encode(file_get_contents('/srv/files/invoice-10482.pdf'));
$body = ['type' => 'media', 'message' => $data, 'filename' => 'invoice-10482.pdf'];

Customers can send files too. Incoming media arrives with a message.type such as image or document, plus url, mime, filename and an optional caption. To keep a copy, download it with file_get_contents($msg['url']) and save it. The handler above answers media with a short note, which is the place to hook that in.

Test the PHP bot locally

Run PHP's built-in server and expose it with a tunnel in a second terminal:

Terminal
php -S localhost:8000
# second terminal, pick one:
cloudflared tunnel --url http://localhost:8000
ngrok http 8000

Register the HTTPS URL the tunnel prints, with /webhook.php added, using setWebhook. Then message the bot's number from another phone: "hi" should bring up the menu, and "1" should send the PDF. Messages you type on the bot's own phone arrive with fromMe: true and get ignored. Free tunnel URLs usually change when they restart, so if the bot goes quiet, check the webhook URL first.

Production checklist for a PHP WhatsApp bot

Run the bot behind nginx or Apache with PHP-FPM and HTTPS, and register the server's stable URL. Then work through these points before real customers arrive.

Production checklist for a PHP WhatsApp bot: answer 200 early, skip repeated message ids, send slow work to a queue, alert on status events, keep the token out of code and only message people who wrote first.

Answer first, work after. fastcgi_finish_request() handles quick replies. For AI calls, order lookups or retries, write the event to a job table or a Redis list and process it with a worker, so the webhook script only parses, stores and returns.

Skip repeats. Use message.id as a key and handle each ID once, so a redeploy or a duplicate delivery never sends the same PDF twice. PHP keeps no memory between requests, so store the IDs. SQLite is enough for one server:

PHP
function first_time(string $id): bool
{
    $db = new PDO('sqlite:' . __DIR__ . '/seen.sqlite');
    $db->exec('CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY)');
    $stmt = $db->prepare('INSERT OR IGNORE INTO seen (id) VALUES (?)');
    $stmt->execute([$id]);
    return $stmt->rowCount() === 1;
}
// in webhook.php: if (!first_time($msg['id'] ?? '')) exit;

Watch the phone. status events tell you when the number disconnects or returns to the QR screen. Alert someone when that happens. ack events report whether replies were delivered and seen.

Send like a person would. A bot that answers people who wrote first is the safest kind of automation on an unofficial API. Large proactive sends from a new number are what get accounts restricted. Read how Safe Mode prevents WhatsApp bans before you add broadcasts.

Note: Never commit TOKEN to your repository or leave it in a public web root. Load it from the environment or a config file outside the document root.

The same webhook in Laravel

In a Laravel app, the webhook is a route in routes/api.php. API routes skip CSRF checks, so Maytapi can post to them. On Laravel 11 or newer, create the file with php artisan install:api if it doesn't exist yet. Store the token in config/services.php as services.maytapi.token:

PHP
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;

Route::post('/maytapi/webhook', function (Request $request) {
    $event = $request->json()->all();
    $msg   = $event['message'] ?? [];

    if (($event['type'] ?? '') === 'message' && empty($msg['fromMe'])
        && !str_ends_with($event['conversation'] ?? '', '@g.us') && !empty($event['reply'])) {
        Http::withHeaders(['x-maytapi-key' => config('services.maytapi.token')])
            ->timeout(10)
            ->post($event['reply'], ['to_number' => $event['conversation'], 'type' => 'text', 'message' => 'Got it!']);
    }

    return response('', 200);
});

The URL becomes https://your-app.com/api/maytapi/webhook. When the handler grows, move the send into a queued job so the route returns immediately.

Next steps

Your PHP bot now receives messages, shows a list menu and sends images and documents. To go further:

Published Mar 24, 2026 · Updated Oct 27, 2026 ← All posts
Empieza a usar la API de WhatsApp

Consigue tu clave y envía tu primer mensaje en diez minutos

Sin aprobación, sin plantillas, sin tarjeta. Todos los endpoints abiertos durante tres días.