SMSTunnel for PHP

Send texts from your own PHP apps, from your own number

Composer package · PHP 8.1+ · MIT
smstunnel/smstunnel-php

Your app writes, your phone sends

Your app hands the text to your SMSTunnel account and your own Android phone sends it from its own SIM, so people see a number they know and can reply. You can stop an app, or delete its key, in your dashboard at any time.

1. Install

composer require smstunnel/smstunnel-php

PHP 8.1 or newer, with the curl extension (most PHP installs have it) or any PSR-18 HTTP client. No other dependencies.

2. Get your key

  1. 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
  2. 2. In your dashboard, open Apps → Add an app, pick PHP or Laravel, name it (for example "My PHP app") and click Create the key.
  3. 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My PHP app can't send texts anymore.

Get the steps with your key in them

It opens Add an app with PHP or Laravel picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.

The key can send texts and look up whether they went out. Only if your app reads the texts your phone receives: also tick Can also read received texts, and turn on Sync received texts in the app. Syncing is off by default, and only texts that arrive after you turn it on are synced. Keep the key out of your code: put it in an environment variable such as SMSTUNNEL_API_KEY.

3. Send a text

use SmsTunnel\Client;

$client = new Client((string) getenv('SMSTUNNEL_API_KEY')); // sk_live_...

$result = $client->sendSms('+14155550123', 'Your order #1042 has shipped.');

echo $result->messageId; // keep it to look up the status later

All the options, and what comes back:

$result = $client->sendSms(
    to: '+14155550123',            // international format
    message: 'See you at 10:30.',  // up to 1600 characters
    deviceId: null,                // a phone id from getDevices(), or null to let SMSTunnel pick one of yours
    defaultCountryCode: null,      // e.g. '+44': completes numbers given without + or 00
);

$result->messageId; // string
$result->queued;    // true when the phone was offline: it sends the text as soon as it is back
$result->branded;   // true when the text carries a short SMSTunnel signature (plans with a free quota)
$result->remaining; // today's quota left, e.g. ['clean' => 95, 'total' => 'unlimited'] (-1 means no limit)

One call sends one text to one number. To text several people, call sendSms() once for each.

Sign-in and verification codes

$client->sendVerificationCode('+14155550123', 'Your sign-in code is 482913');

The text must contain the code: 4 to 10 digits standing on their own, like 482913, or 123-456. SMSTunnel refuses it otherwise. Codes count against a separate allowance, and the text can be up to 320 characters. What counts as a code

Look up a status

use SmsTunnel\Response\MessageStatus;

$status = $client->getMessageStatus($result->messageId);

$status->status;         // 'pending', 'sent', 'delivered' or 'failed'
$status->deliveryStatus; // the same, or 'unconfirmed': handed to the SIM but the phone got no confirmation
$status->sentAt;         // ISO 8601, or null
$status->errorMessage;   // why it failed, or null
$status->isFinal();      // true once it will not change any more

if ($status->status === MessageStatus::FAILED) {
    // ...
}

Read received texts

Needs a key allowed to read received texts, and syncing turned on in the app.

$page = $client->getReceivedMessages([
    'limit' => 20,                                     // at most 100
    'page' => 1,
    'since' => new DateTimeImmutable('-1 day'),        // or an ISO 8601 string
    'phone' => '+14155550123',                         // only from this sender
    'deviceId' => null,                                // only this phone
]);

foreach ($page as $text) {
    echo $text->sender, ': ', $text->message, PHP_EOL; // $text->receivedAt, $text->deviceName, ...
}

$page->total;   // all texts that match, on every page
$page->hasMore; // ask for the next page

Your phones and your usage

foreach ($client->getDevices() as $phone) {
    echo $phone->name, $phone->isOnline ? ' (online)' : ' (offline)', PHP_EOL; // $phone->id, $phone->lastSeenAt
}

$usage = $client->getUsage();
$usage->plan;      // e.g. 'standard'
$usage->usage;     // ['smsClean' => 15, 'smsBranded' => 0, 'twoFaClean' => 4]
$usage->remaining; // ['smsClean' => 1985, 'twoFaClean' => 'unlimited']

Every response object also has $raw, the whole answer, for fields this version does not map yet.

Errors

Every call throws a SmsTunnel\Exception\SmsTunnelException when it does not go through:

Exception When
AuthenticationException (401) The API key is wrong, was deleted, or its account is suspended.
PermissionException (403) The key lacks a permission (getErrorCode() is PERMISSION_REQUIRED, getPermission() names it, e.g. sms:inbox), or the plan does not include the feature.
RateLimitException (429) Too many requests: getRetryAfter() gives the seconds to wait. Without it, the plan's limit for the day or month is reached: don't retry until then.
ApiException Any other error answer: 400 (a number it cannot read, a verification text without a code), 404 (no such message or phone), 5xx. The three above extend it.
TransportException SMSTunnel could not be reached (DNS, TLS, timeout). After a timeout the text may still go out: check your dashboard's Messages before you send it again.
use SmsTunnel\Exception\ApiException;
use SmsTunnel\Exception\RateLimitException;
use SmsTunnel\Exception\TransportException;

try {
    $client->sendSms('+14155550123', 'Your table is ready.');
} catch (RateLimitException $e) {
    // wait $e->getRetryAfter() seconds and try again; without it, the plan's limit for the day or month is reached
} catch (ApiException $e) {
    error_log(sprintf('SMSTunnel %d %s: %s', $e->getStatusCode(), $e->getErrorCode() ?? '-', $e->getMessage()));
} catch (TransportException $e) {
    // no answer: after a timeout the text may still go out, so check before you send it again
}

getMessage() is SMSTunnel's own explanation, getStatusCode() (also getCode()) the HTTP status, getBody() the decoded answer. An empty number or text throws \InvalidArgumentException before anything is sent.

Your own HTTP client

The built-in transport uses curl with a 30-second timeout and never follows redirects. Change the timeouts, or add curl options such as a proxy:

use SmsTunnel\Transport\CurlTransport;

$client = new Client($apiKey, transport: new CurlTransport(timeout: 10, connectTimeout: 5, curlOptions: [
    CURLOPT_PROXY => 'http://proxy.internal:3128',
]));

Or send through any PSR-18 client (Guzzle, Symfony HttpClient…), so SMSTunnel calls share your app's HTTP setup:

use SmsTunnel\Transport\Psr18Transport;

$factory = new GuzzleHttp\Psr7\HttpFactory(); // a PSR-17 request and stream factory
$client = new Client($apiKey, transport: new Psr18Transport(new GuzzleHttp\Client(), $factory, $factory));

In your own tests, implement SmsTunnel\Transport\TransportInterface (one method) to answer without a network, or mock SmsTunnel\Client.

Limits worth knowing

  • 30 send requests a minute. Past that, you get a RateLimitException.
  • Length: up to 1600 characters (10 joined SMS); verification code texts up to 320.
  • Numbers: use the international format (+14155550123). For local numbers, pass defaultCountryCode: it completes numbers written without + or 00, and it takes precedence over the phone's own country setting.
  • Offline phone: the text waits on your account (queued is true) and goes out when the phone is back online.

Build it with an AI assistant

Copy this prompt into Claude, ChatGPT, Cursor or Copilot and replace the part in [brackets]. Don't paste your API key into the chat: the prompt doesn't need it.

Help me send text messages from my PHP app with SMSTunnel.

About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.

What I want: [describe what should send a text, to whom, and when]

How it works:
- Use the official client: composer require smstunnel/smstunnel-php (PHP 8.1+, the curl extension or any PSR-18 client; no other dependencies).
- $client = new \SmsTunnel\Client((string) getenv('SMSTUNNEL_API_KEY'));
- $result = $client->sendSms(to: '+14155550123', message: '...', deviceId: null, defaultCountryCode: null); returns ->messageId, ->queued (true: the phone is offline and the text goes out when it is back), ->branded, ->remaining. One call sends one text to one number: loop for several.
- Sign-in codes: $client->sendVerificationCode('+14155550123', 'Your sign-in code is 482913'); the text must contain the code as 4 to 10 digits (or 123-456) and can be up to 320 characters. Codes have their own daily allowance.
- $client->getMessageStatus($messageId): ->status ('pending', 'sent', 'delivered' or 'failed'), ->deliveryStatus ('unconfirmed' = handed to the SIM without a confirmation, probably sent: never resend it automatically), ->errorMessage, ->isFinal().
- Exceptions, all extending SmsTunnel\Exception\SmsTunnelException: AuthenticationException (401), PermissionException (403; getErrorCode() is PERMISSION_REQUIRED, getPermission()), RateLimitException (429; getRetryAfter()), ApiException (other errors: 400 bad number or code text without a code, 404, 5xx; getStatusCode(), getErrorCode(), getMessage()), TransportException (network). An empty number or text throws \InvalidArgumentException.
- Numbers in international format. defaultCountryCode (e.g. '+44') completes numbers written without + or 00 and always takes precedence over the phone's own country setting. Texts up to 1600 characters; 30 send requests a minute.
- The API key comes from my SMSTunnel dashboard (https://dashboard.smstunnel.io): Apps → Add an app → Create the key. It is shown only once and can send texts and check whether they went out.

Rules for your answer:
- Keep the API key in an environment variable or the platform's secret store (for example SMSTUNNEL_API_KEY). Never put it in source code, in a repository, or in code that runs in a browser or a mobile app: call SMSTunnel from a server. I will not paste my key in this chat; use a placeholder.
- Validate phone numbers before sending (international format, like +14155550123) and skip or report the invalid ones.
- Handle errors: log or show what went wrong, retry only after 429, 5xx or network errors and only a few times, and never send the same text twice.
- Only send texts people expect from me: alerts to myself, reminders, sign-in codes, updates they asked for. No unsolicited texts.

Responsible use

Send only texts that people expect from you: alerts to yourself, appointment reminders, sign-in codes, order updates. Unsolicited messages from a personal SIM break most carriers' terms of service. Please make sure your usage complies with your carrier's terms and with local regulations (in the EU, that includes GDPR and ePrivacy rules on electronic communications).

No phone paired yet?

Install the Android app and sign in with your account, and you're ready in about a minute.

Get it on Google Play