smstunnel / smstunnel-php
PHP client for SMSTunnel: send texts from your own apps through your own Android phone, from your own number.
Requires
- php: ^8.1
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0 || ^13.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Suggests
- ext-curl: Used by the built-in transport (most PHP installs have it).
- psr/http-client-implementation: To send through your own PSR-18 client (Guzzle, Symfony HttpClient, ...) with SmsTunnel\Transport\Psr18Transport.
- psr/http-factory-implementation: The PSR-17 request and stream factories that go with your PSR-18 client.
Provides
None
Conflicts
None
Replaces
None
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:
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
curlextension (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
- 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).
- 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:sendandsms:read). - Reading received texts only works with a key you have explicitly allowed to (Can also read received texts, the
sms:inboxpermission, 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, passdefaultCountryCode: it always takes precedence (+49,49and0049all work). - Offline phone: the text waits on your account (
queuedistrue) 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.