textbee Logotextbee.dev
Plans from $9.99/mo.View Plans

Send and receive SMS in PHP with a REST API

Published and updated

You need an API key and one Android phone with a SIM registered as a device. From PHP you POST to /gateway/send-sms with the x-api-key header, receive replies through a signed webhook or by polling /gateway/messages with a cursor, and the phone sends from its own number. The Pro plan, up to 5,000 messages for $9.99 a month, with no per message fee.

Prerequisites

  • A textbee account with an API key from the dashboard, kept in the TEXTBEE_API_KEY environment variable and never in source control.
  • An Android phone with a SIM, registered as a device on that account. The API picks your default device, so the code below names none.
  • PHP 8 with the curl extension, which most distributions enable by default. No Composer package is required; in a framework you can swap the curl calls for its HTTP client.

Send an SMS

One POST to the account-level endpoint. The phone registered on your account sends the message over its SIM and the response carries the batch id to follow.

send.php
<?php
$baseUrl = getenv('TEXTBEE_BASE_URL') ?: 'https://api.textbee.dev/api/v1';
$apiKey = getenv('TEXTBEE_API_KEY');

function sendSms(string $baseUrl, string $apiKey, array $recipients, string $message): array
{
    $ch = curl_init("$baseUrl/gateway/send-sms");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode(['recipients' => $recipients, 'message' => $message]),
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status !== 200) {
        throw new RuntimeException("HTTP $status");
    }
    return json_decode($body, true)['data'];
}

$result = sendSms($baseUrl, $apiKey, ['+12015550123'], 'Hello from textbee');
echo $result['smsBatchId'], ' ', $result['recipientCount'], PHP_EOL;

Receive SMS with a webhook

textbee POSTs each event to your URL and signs the raw body with HMAC-SHA256 using the secret you set, sent in the X-Signature header. Verify over the exact bytes, deduplicate on idempotencyKey, and answer 200 quickly.

webhook.php
<?php
// Run with: php -S 0.0.0.0:3000 webhook.php
$secret = getenv('TEXTBEE_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$expected = hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
$seenMarker = sys_get_temp_dir() . '/textbee-' . md5($event['idempotencyKey']); // use your database in production
if (!file_exists($seenMarker)) {
    touch($seenMarker);
    if ($event['webhookEvent'] === 'MESSAGE_RECEIVED') {
        error_log("{$event['sender']}: {$event['message']}");
    }
}

http_response_code(200);

Poll for new messages with a cursor

The pull option. Ask for received messages in ascending order from a start time, then follow meta.nextCursor until meta.hasMore is false. Store the last cursor and resume from it on the next poll, so nothing is missed or read twice.

poll.php
<?php
$baseUrl = getenv('TEXTBEE_BASE_URL') ?: 'https://api.textbee.dev/api/v1';
$apiKey = getenv('TEXTBEE_API_KEY');

function fetchPage(string $baseUrl, string $apiKey, array $params): array
{
    $ch = curl_init("$baseUrl/gateway/messages?" . http_build_query($params));
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["x-api-key: $apiKey"],
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status !== 200) {
        throw new RuntimeException("HTTP $status");
    }
    return json_decode($body, true);
}

$params = ['direction' => 'received', 'order' => 'asc', 'limit' => 50, 'from' => '2026-09-01T00:00:00Z'];
do {
    $page = fetchPage($baseUrl, $apiKey, $params);
    foreach ($page['data'] as $message) {
        echo $message['_id'], ' ', $message['sender'], ' ', $message['message'], PHP_EOL;
    }
    $params['cursor'] = $page['meta']['nextCursor']; // store this to resume the next poll
} while ($page['meta']['hasMore']);

Handle errors

A 401 means the key is missing or revoked, a 400 means the request or the device state was rejected, and a 429 means a plan or batch limit was hit. The body carries a message field that says which. Retry only the 429, once, after a pause.

errors.php
<?php
$baseUrl = getenv('TEXTBEE_BASE_URL') ?: 'https://api.textbee.dev/api/v1';
$apiKey = getenv('TEXTBEE_API_KEY');

function sendSms(string $baseUrl, string $apiKey, array $recipients, string $message, bool $retried = false): array
{
    $ch = curl_init("$baseUrl/gateway/send-sms");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode(['recipients' => $recipients, 'message' => $message]),
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status === 200) {
        return json_decode($body, true)['data'];
    }
    $detail = json_decode($body, true)['message'] ?? 'no detail';
    if ($status === 429 && !$retried) {
        sleep(2); // a plan limit or a burst; one retry after a pause
        return sendSms($baseUrl, $apiKey, $recipients, $message, true);
    }
    if ($status === 401) {
        throw new RuntimeException("API key rejected: $detail");
    }
    if ($status === 400) {
        throw new RuntimeException("Request rejected: $detail");
    }
    throw new RuntimeException("HTTP $status: $detail");
}

print_r(sendSms($baseUrl, $apiKey, ['+12015550123'], 'Hello from textbee'));

Using it in Laravel

Use Http::withHeaders(["x-api-key" => config("services.textbee.key")])->post(...) from a job or a notification channel, with the key in config/services.php and .env. For the webhook, exclude the route from CSRF verification, read $request->getContent() for the raw body, verify the signature, and return response()->noContent(200).

Honest limits

One phone sends roughly 10 to 15 messages a minute, so a large blast takes time to drain. Carriers can filter bulk patterns on consumer SIMs. Marketing messages still need consent from the recipient under the local rules.

Frequently asked questions

Is there a PHP package for textbee?

No official one. The API is a handful of HTTP calls, so the built in curl extension or your framework HTTP client is enough.

Why does the webhook read php://input instead of $_POST?

The signature is computed over the raw JSON body. $_POST only parses form encoded data, and re-encoding the parsed array would not reproduce the original bytes.

Can I send from a shared host?

Yes, as long as outbound HTTPS is allowed. The phone does the sending, so the host needs no special ports or extensions beyond curl.

How do I test without sending a real message?

Point TEXTBEE_BASE_URL at a local stub that returns the documented response shape. That is how these samples are checked before publication.

Read next