API Reference

The API lets your own software act as your other device: it hands texts to your linked phone, which sends them from your number.

Base URL: https://smstunnel.io/api/v1

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 own app with the SMSTunnel API. My app is written in [language / framework].

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:
- 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.
- Libraries, if they fit: Node.js npm install @narcisbodea/smstunnel-sdk (import { SmsTunnelNodeClient, SmsTunnelApiError } from '@narcisbodea/smstunnel-sdk/node'; new SmsTunnelNodeClient({ apiKey }); sendSms(to, message, deviceId?, { defaultCountryCode }?), send2FaSms(to, message), getMessageStatus(id); errors are SmsTunnelApiError with status, code and permission). PHP: composer require smstunnel/smstunnel-php. Laravel: composer require smstunnel/smstunnel-laravel. Symfony Notifier: composer require smstunnel/smstunnel-notifier, with the DSN smstunnel://API_KEY@default. Python: pip install smstunnel (smstunnel.Client(api_key).send_sms(to, message)). Django: pip install django-smstunnel ("django_smstunnel" in INSTALLED_APPS, SMSTUNNEL_API_KEY in settings, then django_smstunnel.send_sms(to, message)). Ruby and Rails: bundle add smstunnel (SMSTunnel::Client.new(api_key: key).send_sms(to, message); in Rails, bin/rails generate smstunnel:install and SMSTunnel::SendTextJob). Go: go get github.com/narcisbodea/smstunnel-go (smstunnel.NewClient(key), then client.SendSMS(ctx, to, message)). .NET: dotnet add package SmsTunnel (new SmsTunnelClient(apiKey).SendSmsAsync(to, message); in ASP.NET Core, SmsTunnel.Extensions.DependencyInjection with builder.Services.AddSmsTunnel() and ISmsTunnelClient). Java: Maven io.github.narcisbodea:smstunnel-java:1.0.0 (SmsTunnelClient.builder().apiKey(key).build().sendSms(to, message)); Spring Boot: io.github.narcisbodea:smstunnel-spring-boot-starter:1.0.0 with smstunnel.api-key, for a ready SmsTunnelClient bean. Shell scripts: the smstunnel command (smstunnel send <number> <text>; https://smstunnel.io/integrations/cli/). Otherwise, plain HTTPS calls as below.

SMSTunnel API facts:
- Send a text: POST https://smstunnel.io/api/v1/sms/send with the headers "X-API-Key: <key>" and "Content-Type: application/json", and the JSON body {"to": "+14155550123", "message": "..."}. Optional: "deviceId" (which of my phones sends it; default: my main phone) and "defaultCountryCode". Other fields are refused with 400.
- Call it from a server: browsers can't send the X-API-Key header to SMSTunnel (CORS), and the key must stay secret.
- One recipient per call. To text several people, make one call per number.
- Numbers: international format, with + and the country code. For numbers written without + or 00, "defaultCountryCode" ("44", "+44" or "0044") completes them, and it always takes precedence over the country set for the phone.
- A text can be up to 1600 characters (10 joined SMS).
- Sign-in codes: POST https://smstunnel.io/api/v1/sms/send-2fa, same headers and fields. The text must contain the code as 4 to 10 digits standing on their own (like 482913) or as 123-456, and can be up to 320 characters; otherwise the answer is 400 "No valid verification code found". Codes have their own daily allowance. My app creates and checks the code; SMSTunnel only delivers the text.
- Success: HTTP 200 {"success": true, "messageId": "...", "data": {"messageId": "...", "branded": false, ...}}. "queued": true is added when the phone is offline: the text waits and goes out as soon as the phone is back (for up to 3 days). On the Free plan, 25 texts a day (500 a month) go out as written, up to 25 more a day go out with a short "free by smstunnel.io" line added ("branded": true), and past that the answer is 429.
- Errors: 400 bad input (the "message" field says why, sometimes as a list); 401 missing or invalid key; 403 the key lacks a permission ("code": "PERMISSION_REQUIRED", "permission": "...") or my plan does not include the feature; 429 with a Retry-After header: too many requests (30 send requests a minute per API key, 90 for all keys of one account together), so wait that many seconds, then retry; 429 without Retry-After: the plan's limit for the day or month is reached, so don't retry before then; 5xx: retry later, with a limit.
- Safe retries: send the header "Idempotency-Key: <one key per text>" (1 to 255 printable ASCII characters, such as order-1042-shipped or a UUID stored with the text). A repeat with the same key within 24 hours gets the first answer back (or the same 5xx error) instead of a second text; 409 while the first request is still running, 422 if the key was used for a different text.
- Status: GET https://smstunnel.io/api/v1/sms/status/{messageId} answers {"success": true, "data": {"status": ...}}, with pending, sent, delivered or failed. If "unconfirmed" is true, the phone handed the text to its SIM without getting a confirmation: it was probably sent, so never resend it automatically.
- Texts my own phones receive (once "Sync received texts" is on in the app): SMSTunnel can forward each one to my server as a signed HTTPS POST (dashboard: Apps → Forward received texts; details at https://smstunnel.io/api-reference/#forward-received), or my app can list them with GET https://smstunnel.io/api/v1/sms/received (permission sms:inbox).

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.

Authentication

Every request needs one of your personal API keys (your dashboard → Apps → Add an app). Send it in the X-API-Key header, from your server: browsers can't send it to SMSTunnel, and the key must stay secret. Tools that only offer Authorization: Bearer <key>, or HTTP Basic with the key as the password, work too.

curl https://smstunnel.io/api/v1/devices \
  -H "X-API-Key: sk_live_abc123xyz"

Key Permissions

  • sms:send — hand texts to your phone (send and sign-in code endpoints)
  • sms:read — check the status of texts sent from your account (any of your keys, the dashboard or a linked site)
  • sms:inbox — read texts synced from your phone. Off by default; grant it only to your own software. Keys created when you link a website or plugin never get it.

Keys look like sk_live_…. The full key is shown only once, when you create it; deactivate or delete it in the dashboard at any time.

Rate Limits

To protect your SIM and your plan, every plan has the same request limits, counted per API key and endpoint: 30 send requests per minute on each send endpoint and 100 requests per minute on the others. All the keys of one account together can make three times that. Daily allowances depend on your plan:

Plan Texts/day Sign-in codes/day
Free 25 as written (500 a month), then up to 25 more with a short added line 50
Starter 300 No daily cap
Standard 2,000 No daily cap
Professional, Enterprise No daily cap No daily cap

Past a daily allowance a text is refused with HTTP 429 until the next day (UTC); on Free, texts past the 25 as written first go out with the added line. Texts you write yourself in the dashboard don't count. See Pricing for the current plans.

Rate Limit Headers

Responses include the limit of the current one-minute window; over the limit you get HTTP 429 with a Retry-After header:

X-RateLimit-Limit: 30

X-RateLimit-Remaining: 27

X-RateLimit-Reset: 42

Error Handling

The API uses standard HTTP status codes. Error bodies look like this:

{
  "statusCode": 400,
  "message": "Invalid phone number",
  "error": "Bad Request"
}
HTTP Status Meaning
400 Missing or invalid parameters (for example a malformed number, or a field the endpoint doesn't take); message may be a list
401 Missing, invalid or deactivated API key ("Invalid or expired API key")
403 The key lacks a permission: the body has "code": "PERMISSION_REQUIRED" and "permission". Also when your plan doesn't include a feature, such as Maximum Privacy
404 Not found (for example an unknown message id or phone)
429 Too many requests: wait for Retry-After seconds. Without Retry-After, a daily or monthly allowance of your plan is used up (see Rate Limits)
5xx Something went wrong on our side — try again a little later

Retrying Safely

After a timeout or a 5xx, a text may already be on its way. Give each send an Idempotency-Key header, one per text (1 to 255 printable ASCII characters, such as order-1042-shipped or a UUID you store with the text), and send the same key when you retry. It works on Send a Text and Send a Sign-in Code.

curl -X POST https://smstunnel.io/api/v1/sms/send \
  -H "X-API-Key: sk_live_abc123xyz" \
  -H "Idempotency-Key: order-1042-shipped" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155550123", "message": "Your order #1042 has shipped."}'
  • Within 24 hours, the same key with the same request gets the first answer back (the same messageId, with the header Idempotent-Replayed: true) instead of sending a second text. If the first call ended in a 5xx, the repeat gets the same error back.
  • While the first request is still running: 409 IDEMPOTENCY_KEY_IN_PROGRESS, with Retry-After: 1.
  • The same key for a different text, number, phone, country code or endpoint: 422 IDEMPOTENCY_KEY_REUSED. Use a new key for a new text.
  • A send refused with a 4xx (a number SMSTunnel can't read, a code text without a code) frees the key: correct it and send it again with the same key.
  • Keys count per API key. A key that isn't 1 to 255 printable ASCII characters is refused with 400 IDEMPOTENCY_KEY_INVALID.

Send a Text

POST /sms/send permission sms:send

Hands a text to your linked phone, which sends it from your number. If none of your phones is online, the text is queued and goes out when a phone reconnects, within 3 days (otherwise it is marked failed).

Request Body

Parameter Type Required Description
to string Yes Recipient number, preferably international format (+14155550123). Left out only for a sealed text, see Maximum Privacy
message string Yes Text, up to 1600 characters
deviceId string No Which of your phones sends it (default: your default phone)
defaultCountryCode string No Country code for numbers written without + or 00: "49", "+49" and "0049" all work. When you send it, it always wins; a leading 0 is dropped, so 0151 2345678 becomes +491512345678. Without it, the country saved for the connection or the phone is used, then +40. Letters such as "DE" are ignored. See Country codes
encrypted, encryptedPayload boolean, string No Optional end-to-end encryption, see Get Public Key and Maximum Privacy

Country codes

A number with + or 00 is sent as it is. For a number written without them, the country code comes from, in this order:

  1. defaultCountryCode in the request, when you send one;
  2. the country saved for the connection the text goes through, or for the phone;
  3. +40.

The same order applies to sign-in codes and to webhooks. Numbers in international format never depend on it.

Example Request

curl -X POST https://smstunnel.io/api/v1/sms/send \
  -H "X-API-Key: sk_live_abc123" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+40712345678",
    "message": "Your order #1042 is ready for pickup."
  }'

Response

{
  "success": true,
  "messageId": "6720f1c2a9e4b3d5c8a1f001",
  "queued": false,
  "data": {
    "messageId": "6720f1c2a9e4b3d5c8a1f001",
    "branded": false,
    "queued": false,
    "remaining": { "clean": 9, "total": "unlimited" }
  }
}

Send a Sign-in Code

POST /sms/send-2fa permission sms:send

For two-factor sign-in codes from your own website or app; counts against the separate sign-in code allowance. You generate and verify the code yourself; the text must contain it.

What counts as a code

  • A number of 4 to 10 digits standing on its own: 123456, Code: 1234, G-123456, 12-345678.
  • Or two groups of 3 digits joined by a dash: 123-456.
  • Not a code: words, codes with letters (A1B2C3), numbers of 11 digits or more, digits right after a + (a phone number), and digits joined to others by . , : or / (prices, decimals, times and dates such as 3.14 or 01/11/2026).

A text without a code is refused with 400 and the message "Invalid 2FA message: No valid verification code found…". Texts without a code go through Send a Text.

Request Body

Parameter Type Required Description
to string Yes Recipient number
message string Yes Text containing the code, up to 320 characters
deviceId, defaultCountryCode string No As for Send a Text

Example

curl -X POST https://smstunnel.io/api/v1/sms/send-2fa \
  -H "X-API-Key: sk_live_abc123" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+40712345678",
    "message": "Your login code is 12-345678. It expires in 5 minutes."
  }'

Get Status

GET /sms/status/{messageId} permission sms:read

Returns the status of a text you sent.

Response

{
  "success": true,
  "data": {
    "id": "6720f1c2a9e4b3d5c8a1f001",
    "phoneNumber": "+40712345678",
    "status": "sent",
    "deliveryStatus": "sent",
    "sentAt": "2026-10-28T10:30:02Z",
    "deliveredAt": null,
    "errorMessage": null
  }
}

Status Values

  • pending — waiting for your phone, or being sent
  • sent — your phone handed it to the SIM and got the confirmation
  • failed — could not be sent; errorMessage says why. Check unconfirmed first (below)

Unconfirmed Texts

When the phone handed a text to the SIM but never got the confirmation, the text was most likely sent, and it is never resent automatically. For compatibility with existing integrations it is reported as "status": "failed" with "unconfirmed": true; deliveryStatus is "unconfirmed" and errorMessage starts with "Not confirmed by the phone". Don't resend these texts automatically — the recipient could get them twice. deliveryStatus always holds the real state: pending, sent, failed or unconfirmed.

{
  "success": true,
  "data": {
    "id": "6720f1c2a9e4b3d5c8a1f002",
    "phoneNumber": "+40712345678",
    "status": "failed",
    "unconfirmed": true,
    "deliveryStatus": "unconfirmed",
    "sentAt": null,
    "deliveredAt": null,
    "errorMessage": "Not confirmed by the phone - the SMS may have been sent; …"
  }
}

Received Texts

GET /sms/received permission sms:inbox

Lists texts your phone received after you turned on "Sync received texts" in the app (it is off by default; the phone's existing inbox is never read). Only keys you explicitly allowed to read received messages can call it. To get each text pushed to your server instead of asking for it, see Forward Received Texts.

Query Parameters

  • limit — up to 100 (default 50); page — page number
  • since — ISO date, only texts after it
  • phone — filter by sender; deviceId — filter by phone

Response

{
  "success": true,
  "data": {
    "messages": [
      {
        "id": "6720f3a1b2c4d5e6f7a8b9c0",
        "sender": "+40712345678",
        "message": "No problem, see you!",
        "isEncrypted": false,
        "receivedAt": "2026-10-28T10:31:15Z",
        "deviceId": "671fe0d9c3b2a1f0e9d8c7b6",
        "deviceName": "My phone"
      }
    ],
    "total": 1, "page": 1, "limit": 50, "hasMore": false
  }
}

Forward Received Texts

SMSTunnel can push each text your own phones receive to your server, right away, as a signed HTTPS request. Your app doesn't have to keep calling GET /sms/received. Available on every plan, Free included.

Set it up in the dashboard, under Apps → Forward received texts:

  1. Enter your server's address and save it. It must start with https:// and point to a server on the internet: addresses on a local or private network, localhost and IPv6 literals are refused.
  2. Copy the signing secret (whsec_…). It is shown once. New secret makes another one at any time, and the old one stops working at once.
  3. Click Send a test, then turn forwarding on.

What is forwarded

  • Texts that your own phones receive after you turn on "Sync received texts" in the SMSTunnel app. Sync is off by default, and the phone's existing inbox is never read.
  • Only phones that belong to your account. Texts on phones linked to you by someone else, such as a client's phone connected to a company account, are never forwarded, to anyone.
  • A text encrypted end to end is forwarded as the phone sent it. SMSTunnel can't read it, so message is null and encryptedPayload carries it unchanged.

The request

POST /sms/received HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: SMSTunnel-Forwarding/1.0
X-SMSTunnel-Event: sms.received
X-SMSTunnel-Signature: sha256=<64 hex characters>

{
  "event": "sms.received",
  "id": "6745e3c2a1b2c3d4e5f60718",
  "from": "+14155550100",
  "message": "Is my table ready?",
  "encrypted": false,
  "receivedAt": "2026-11-03T08:15:30.000Z",
  "phone": { "deviceId": "6745e3c2a1b2c3d4e5f60001", "name": "Pixel 8" },
  "simSlot": 0
}
Field What it is
event Always sms.received (also in the X-SMSTunnel-Event header)
id The text's id, the same as in GET /sms/received. It stays the same on every retry: use it to skip a text you already have
from The sender as the phone received it: a phone number, or a name such as a bank's
message The wording. null when the text is encrypted end to end
encrypted true when the text is encrypted end to end
encryptedPayload Only when encrypted is true: what the phone sent, unchanged
receivedAt When the phone received the text (ISO 8601, UTC)
phone.deviceId, phone.name Which of your phones received it (deviceId as in GET /devices)
simSlot The SIM slot that received it, counting from 0, or null when the phone didn't say
test Only in the dashboard's Send a test: true. The test's id starts with test_

Fields may be added later: ignore the ones you don't know.

Check the signature

X-SMSTunnel-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, made with your signing secret (the whole whsec_… string) as the key. Compute it over the exact bytes you received, before parsing the JSON, compare in constant time, and refuse the request when the two differ.

Node.js, with nothing to install:

import crypto from "node:crypto";
import http from "node:http";

const SECRET = process.env.SMSTUNNEL_SIGNING_SECRET; // whsec_...

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    // Check exactly the bytes received, before parsing them.
    // With Express: express.raw({ type: "application/json" }) on this route, then req.body.
    const body = Buffer.concat(chunks);
    const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", SECRET).update(body).digest("hex"));
    const given = Buffer.from(req.headers["x-smstunnel-signature"] ?? "");
    if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
      res.writeHead(401).end();
      return;
    }

    const text = JSON.parse(body);
    if (text.event === "sms.received" && !text.test) {
      console.log(`From ${text.from}: ${text.message}`);
    }
    res.writeHead(200).end();
  });
}).listen(3000);

PHP:

<?php
$secret = getenv('SMSTUNNEL_SIGNING_SECRET'); // whsec_...

// Check exactly the bytes received, before decoding them.
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
$given = $_SERVER['HTTP_X_SMSTUNNEL_SIGNATURE'] ?? '';

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$text = json_decode($body, true);
if ($text['event'] === 'sms.received' && empty($text['test'])) {
    error_log("From {$text['from']}: {$text['message']}");
}
http_response_code(200);

To check your own code, in any language:

  • secret: whsec_test_secret_for_docs
  • body, exactly these bytes, without a newline at the end: {"event":"sms.received","id":"6745e3c2a1b2c3d4e5f60718","from":"+14155550100","message":"Hello"}
  • signature: sha256=523c53167f5741fcbde6a13211ecefd5605ee8c76365c8c61bbef7e136be9e13

Answer and retries

  • Answer with any 2xx status within 10 seconds. The body of your answer is ignored, so do the slow work after answering.
  • Anything else counts as a failed try: another status, no answer within 10 seconds, a connection error, or a certificate that can't be verified (a self-signed one, for example). SMSTunnel then tries again after 10 seconds, 1 minute and 5 minutes: four tries in all, over about 6 minutes. After the last one the text is not forwarded again; it stays in your account and in GET /sms/received.
  • Redirects aren't followed: use the final address.
  • A text can arrive twice, for example when your answer came after the 10 seconds. Skip an id you already have.
  • Your texts are forwarded one at a time, in the order they arrived. A text waiting for a retry doesn't hold up the next ones, so after a failure the order can change: sort by receivedAt if it matters.
  • The address is checked again before every request, and it must still point to a public server.
  • Every try is signed with your current secret, so after New secret the retries still waiting use the new one.
  • The dashboard shows the last delivery, why the last try failed and how many tries failed in a row. Turning forwarding off cancels the texts still waiting for a retry.

Read the setting with an API key

GET /received-forwarding permission sms:inbox

For an integration that wants to know whether texts are pushed to it, or whether it should poll GET /sms/received. Read only: changing the setting is done in the dashboard. The secret is never returned, only its last 4 characters. A key without sms:inbox gets 403 with the code PERMISSION_REQUIRED.

{
  "success": true,
  "data": {
    "enabled": true,
    "url": "https://example.com/sms/received",
    "hasSecret": true,
    "secretHint": "Q7Yo",
    "secretCreatedAt": "2026-11-03T07:56:12.000Z",
    "lastDeliveryAt": "2026-11-03T08:15:31.000Z",
    "lastStatus": "delivered",
    "lastStatusCode": 200,
    "lastError": null,
    "consecutiveFailures": 0,
    "pending": 0
  }
}

lastStatus is delivered or failed (null before the first text), lastError says why the last try failed, consecutiveFailures counts the failed tries in a row and pending the texts waiting for a retry.

List Phones

GET /devices

Lists the phones linked to your account, so you can pick a deviceId.

Response

{
  "success": true,
  "data": {
    "devices": [
      {
        "id": "671fe0d9c3b2a1f0e9d8c7b6",
        "name": "My phone",
        "isOnline": true,
        "lastSeenAt": "2026-10-28T10:30:00Z"
      }
    ],
    "online": 1,
    "total": 1
  }
}

Phone E2E Status

GET /devices/{deviceId}/e2e-status

Tells your software whether one of your phones (yours, or one you linked to this key by QR code) can receive end-to-end encrypted texts.

Response

{
  "success": true,
  "data": {
    "encryptionEnabled": true,
    "hasPublicKey": true,
    "publicKeyFingerprint": "a1b2c3d4e5f67890",
    "keyCreatedAt": "2026-03-10T14:30:00Z"
  }
}

Response Fields

  • encryptionEnabled: Whether E2E is enabled on the phone
  • hasPublicKey: Whether a public RSA key is stored
  • publicKeyFingerprint: SHA-256 fingerprint (first 16 hex chars) — use it to detect key changes after the app is reinstalled
  • keyCreatedAt: When the key was created or rotated

Get Public Key

GET /devices/{deviceId}/public-key

Returns the phone's RSA public key. Encrypt the JSON { "to": "+14155550123", "message": "…" } with it and send the result to /sms/send as encryptedPayload (base64) with encrypted: true and the deviceId. Send to as well, so your Messages page can show the number; leaving it out is part of Maximum Privacy. We can't read the encrypted text, and we erase it once it's sent. An encrypted text counts toward your daily limit and is refused past it, because it can't carry the free line. Your dashboard has ready code for PHP, Node.js, Python and C#.

Response

{
  "success": true,
  "data": {
    "deviceId": "671fe0d9c3b2a1f0e9d8c7b6",
    "deviceName": "My phone",
    "publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
  }
}

Maximum Privacy

POST /sms/send

On the Professional and Enterprise plans, the number can be encrypted too, so we can't see who a text is for. You make a private certificate with a password only you know, and put it in your software and on your phone (app 1.0.9 or newer: Settings, then Private certificate). The key never passes through our servers, and your phone refuses any text not sealed with it.

Make the certificate in your dashboard (Apps, Add an app, Encrypt end to end, Maximum privacy), which also gives you ready code for PHP, Node.js, Python and C#. Or make it on your own computer with npx -p @narcisbodea/smstunnel-sdk smstunnel-make-certificate and send with sendSealedSms from the SDK. Send the sealed packet as encryptedPayload, without to:

{
  "deviceId": "671fe0d9c3b2a1f0e9d8c7b6",
  "encrypted": true,
  "encryptedPayload": "eyJ2IjozLCJjIjoiYTFiMmMzZDRlNWY2MDcxOCIs..."
}
  • We still see your account, your phone and the time of each text. After your phone sends it, the text travels over the mobile network like any other SMS.
  • If you sync received texts to your account, a reply shows the number it came from.
  • We can't recover a lost certificate or password. Make a new certificate and put it on your phone and in your software again.
  • Your software needs the certificate and its password to send. Keep the password out of your code.
  • On other plans a text without to, or sealed with a certificate, is refused with status 403.

E2E Pairing

Lets your own site (for example the WordPress plugin) exchange encryption keys with your phone: the site shows a QR code and you scan it with the app.

Create Pairing Request

POST /e2e/pair
{
  "siteName": "My WordPress Site",
  "siteUrl": "https://example.com"
}

Poll Status

GET /e2e/pair/{token}

Returns pending, completed (with the public key and fingerprint), or expired.

Complete Pairing (called by the app)

POST /e2e/pair/{token}/complete

No API key — the one-time token validates the request. The phone sends only its public key; the private key never leaves the phone.

Usage

GET /account/usage

Your plan, today's usage and what is left.

Response

{
  "success": true,
  "data": {
    "plan": "standard",
    "accountType": "standard",
    "usage": { "smsClean": 12, "smsBranded": 0, "twoFaClean": 3 },
    "limits": { "smsClean": 500, "twoFaClean": "unlimited" },
    "remaining": { "smsClean": 488, "twoFaClean": "unlimited" }
  }
}

Webhooks

SMSTunnel webhooks are secret URLs that your tools call to text you: each webhook has recipients fixed when you create it, and the request only sets the wording. You make them in the dashboard (Apps → Webhooks); a script can also manage them with your account's sign-in token, not an API key. A webhook's defaultCountryCode completes its recipients written without + or 00, the same way as on Send a Text.

See Webhooks for the endpoints and examples. To follow a text you sent, poll Get Status.

SDKs & Libraries

Node.js / TypeScript: the official SDK is on npm.

npm install @narcisbodea/smstunnel-sdk

It includes a Node.js client that uses your API key (@narcisbodea/smstunnel-sdk/node) and helpers for NestJS, React, Vue and Angular (guide).

  • PHP: composer require smstunnel/smstunnel-php (guide)
  • Laravel notifications: composer require smstunnel/smstunnel-laravel (guide)
  • Symfony Notifier: composer require smstunnel/smstunnel-notifier (guide)
  • Python: pip install smstunnel (guide)
  • Django: pip install django-smstunnel (guide)
  • Ruby and Rails: bundle add smstunnel (guide)
  • Go: go get github.com/narcisbodea/smstunnel-go (guide)
  • .NET: dotnet add package SmsTunnel (guide)
  • Java and Spring Boot: io.github.narcisbodea:smstunnel-java:1.0.0 (guide)
  • Command line, for Windows, macOS and Linux: smstunnel send <number> <text> (guide)

Sign-in codes for Supabase Auth, Auth0, Clerk, Better Auth and Ory: @narcisbodea/smstunnel-otp (guides).

For other languages, use the REST API directly or check the integrations.