Search by

smstunnel / smstunnel-php

narcisbodea

PHP client for SMSTunnel: send texts from your own apps through your own Android phone, from your own number.

v1.0.0 2026-11-01 15:44 UTC

This package is auto-updated.

Last update: 2026-11-01 21:37:51 UTC


README

smstunnel/smstunnel-php: a small PHP client for SMSTunnel. Send texts from your own PHP apps through your own Android phone, from your own number.

Your app hands the text to your SMSTunnel account and your phone sends it from its own SIM, so recipients see a number they know and can reply to. The phone is linked to your account when you sign in to the SMSTunnel app (or scan the QR code from your dashboard), and you can cut an app off at any time by deleting its API key in your dashboard.

Your phone needs the SMSTunnel app from Google Play:

Get it on Google Play

Using Laravel? smstunnel/smstunnel-laravel builds on this package, so $user->notify(new OrderShipped) sends the text from your phone.

Requirements

  • PHP 8.1 or newer
  • the curl extension (most PHP installs have it), or any PSR-18 HTTP client (see Your own HTTP client)

No other dependencies.

Installation

composer require smstunnel/smstunnel-php

Get an API key

  1. Install the SMSTunnel app from Google Play on your Android phone and sign in. Sign in at smstunnel.io on your computer with the same account: the phone appears under Phones (you can also link it with the QR code from Phones → Add phone).
  2. In your SMSTunnel dashboard, open Apps → Add an app and create its key. The key can send texts and look up whether they went out (sms:send and sms:read).
  3. Reading received texts only works with a key you have explicitly allowed to (Can also read received texts, the sms:inbox permission, a separate opt-in per key). Syncing received texts is off by default in the app; when you turn it on, only texts that arrive from then on are synced, and the phone's existing messages are never read.

Keep the key out of your code: put it in an environment variable such as SMSTUNNEL_API_KEY.

Usage

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

Send a text

$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. '+40': 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.

Send a sign-in or verification code

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

The text must contain the code, 4 to 10 digits (for example 482913 or 123-456); SMSTunnel refuses it otherwise. Codes count against a separate quota, and the text can be up to 320 characters.

Look up a status

Needs a key with sms:read.

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 you allowed to read received texts (sms:inbox) 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. 'pro'
$usage->usage;     // ['smsClean' => 15, 'smsBranded' => 0, 'twoFaClean' => 4]
$usage->remaining; // ['smsClean' => 'unlimited', 'twoFaClean' => 96]

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, when SMSTunnel says.
ApiException Any other error answer: 400 (a number it cannot read, a verification text without a code), 404 (no such message), 5xx. The three above extend it.
TransportException SMSTunnel could not be reached (DNS, TLS, timeout).
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
} catch (ApiException $e) {
    error_log(sprintf('SMSTunnel %d %s: %s', $e->getStatusCode(), $e->getErrorCode() ?? '-', $e->getMessage()));
} catch (TransportException $e) {
    // network problem: try again later
}

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.

new Client($apiKey, 'https://your-own-host') points the client at a self-hosted SMSTunnel; the default is https://smstunnel.io.

Notes and limits

  • Rate limit: sending allows 30 requests a minute.
  • Length: up to 1600 characters (10 joined SMS); verification codes up to 320.
  • Numbers: use the international format (+14155550123). For local numbers, pass defaultCountryCode: it always takes precedence (+49, 49 and 0049 all work).
  • 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

Paste this 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).

Development

composer install
composer test      # PHPUnit
composer analyse   # PHPStan, level max
composer cs        # PSR-12 check (composer cs:fix to fix)

The end-to-end tests run only against a test server, never production:

SMSTUNNEL_E2E=1 SMSTUNNEL_BASE_URL=http://127.0.0.1:3101 SMSTUNNEL_API_KEY=... composer test:e2e

Changelog

See CHANGELOG.md.

License

MIT, © NARBOWEB SRL. See LICENSE.