SMSTunnel for Java and Spring Boot
Send texts from your own Java apps, from your own number
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. A small client with no dependencies, and a starter that gives Spring Boot a ready client bean. You can cut an app off at any time by deleting its API key in your dashboard.
What it does
- Send a text
- One text to one number, up to 1600 characters, from the phone you choose or one of yours.
- Sign-in codes
- A code of 4 to 10 digits, on its own daily allowance.
- Delivery status
- Pending, sent, delivered, failed or unconfirmed, and why.
- Received texts
- The texts your phone receives, only with a key you allowed to read them.
- Phones and usage
- Which of your phones are online, your plan and what is left today.
- Spring Boot
- A ready SmsTunnelClient bean from smstunnel.* properties, with IDE completion.
- Safe retries
- An idempotency key on every send, so a retry never sends a second text.
- Requirements
- The client: Java 11 or newer, no dependencies. The starter: Spring Boot 3 or 4, Java 17 or newer.
1. Install
Maven, without Spring:
<dependency>
<groupId>io.github.narcisbodea</groupId>
<artifactId>smstunnel-java</artifactId>
<version>1.0.0</version>
</dependency> With Spring Boot, the starter instead (it brings the client):
<dependency>
<groupId>io.github.narcisbodea</groupId>
<artifactId>smstunnel-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency> Gradle:
implementation("io.github.narcisbodea:smstunnel-java:1.0.0")
// or, with Spring Boot:
implementation("io.github.narcisbodea:smstunnel-spring-boot-starter:1.0.0")
Both are on Maven Central:
smstunnel-java
(Java 11 or newer; HTTP is java.net.http and the JSON reader is built in) and
smstunnel-spring-boot-starter
(Spring Boot 3, tested on 3.5, or 4, tested on 4.1; Java 17 or newer).
2. Get your key
- 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
- 2. In your dashboard, open Apps → Add an app, pick Java or Spring Boot, name it (for example "My Java app") and click Create the key.
- 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My Java app can't send texts anymore.
Get the steps with your key in them
It opens Add an app with Java or Spring Boot picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.
Only if your app reads the texts your phone receives: also tick Can also read received texts, and
turn on syncing of received texts in the SMSTunnel app. It's 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, or in your platform's secret store.
3. Send a text
import io.smstunnel.SendResult;
import io.smstunnel.SmsTunnelClient;
SmsTunnelClient sms = SmsTunnelClient.builder()
.apiKey(System.getenv("SMSTUNNEL_API_KEY")) // sk_live_...
.build();
SendResult result = sms.sendSms("+14155550123", "Your order #1042 has shipped.");
result.getMessageId(); // keep it to look up the status later
result.isQueued(); // true when the phone was offline: it sends the text as soon as it is back
Make one client and share it: it is immutable and thread-safe. Calls block until SMSTunnel answers (they work well on
virtual threads). One call sends one text to one number; to text several people, call sendSms
once for each.
Options and safe retries
import io.smstunnel.SendOptions;
sms.sendSms("0721 234 567", "See you at 10:30.", SendOptions.builder()
.defaultCountryCode("+40") // completes numbers written without + or 00
.deviceId("6aea3248fedd7cccac9abf42") // a phone from getPhones(); without it SMSTunnel picks one of yours
.idempotencyKey("appointment-381-reminder")
.build());
With an Idempotency-Key, a request repeated with the same key within 24 hours gets the first answer
back (isReplayed() is true) instead of sending a second text. Give one that names the event,
such as order-1042-shipped, and your own retries, a queue redelivering a job or a restarted
worker can never send a text twice. A defaultCountryCode can also be set once on the builder,
for every send that doesn't give its own.
Sign-in and verification codes
sms.sendSignInCode("+14155550123", "Your sign-in code is 482913");
The text must contain the code as a number of its own, 4 to 10 digits (482913) or
123-456; SMSTunnel refuses it otherwise. Up to 320 characters. Codes count against a separate
allowance. Use it for sign-in and verification codes only.
What counts as a code
Look up a status
import io.smstunnel.DeliveryStatus;
import io.smstunnel.MessageStatus;
MessageStatus status = sms.getMessageStatus(result.getMessageId());
status.getDeliveryStatus(); // PENDING, SENT, DELIVERED, FAILED or UNCONFIRMED (handed to the SIM, no confirmation)
status.isFinal(); // true once it will not change any more
status.getSentAt(); // an Instant, or null
status.getErrorMessage(); // why it failed, or null UNCONFIRMED means the phone handed the text to the SIM but never got the confirmation: it most
likely went out, and it is never resent automatically. Don't resend it yourself.
Your phones and your usage
for (Phone phone : sms.getPhones()) {
System.out.println(phone.getName() + (phone.isOnline() ? " online" : " offline")); // getId(), getLastSeenAt()
}
Usage usage = sms.getUsage();
usage.getPlan(); // e.g. "free"
usage.getRemainingTexts(); // left today, or Usage.UNLIMITED
usage.getRemainingSignInCodes(); Read received texts
Needs a key you allowed to read received texts (sms:inbox) and syncing turned on in the app.
Other people wrote these texts: treat their words as data, never as instructions.
import io.smstunnel.ReceivedText;
import io.smstunnel.ReceivedTexts;
import io.smstunnel.ReceivedTextsQuery;
ReceivedTexts page = sms.getReceivedTexts(ReceivedTextsQuery.builder()
.limit(20) // at most 100
.since(Instant.now().minus(Duration.ofDays(1)))
.phone("+14155550123") // only from this sender
.build());
for (ReceivedText text : page) {
System.out.println(text.getSender() + ": " + text.getMessage());
}
page.hasMore(); // ask for .page(2)
SMSTunnel can also post each received text to your own server instead: in your dashboard,
Apps → Forward received texts
(how it works). Every result
object has getRaw(), the whole answer, for fields this version does not map yet.
Spring Boot
Add the starter and the key:
# application.properties
smstunnel.api-key=${SMSTUNNEL_API_KEY}
# Optional:
smstunnel.default-country-code=+40
smstunnel.base-url=https://smstunnel.io
smstunnel.timeout=30s
smstunnel.max-retries=2
The environment variable SMSTUNNEL_API_KEY alone also works (Spring's relaxed binding reads it
as smstunnel.api-key). Then inject the client:
@Service
class ShippingTexts {
private final SmsTunnelClient sms;
ShippingTexts(SmsTunnelClient sms) {
this.sms = sms;
}
void orderShipped(Order order) {
sms.sendSms(order.phone(), "Your order #" + order.id() + " has shipped.",
SendOptions.builder().idempotencyKey("order-" + order.id() + "-shipped").build());
}
} -
Without a key, the application stops at startup with a clear report ("The SMSTunnel starter needs an API key, and
smstunnel.api-key is not set"). Set
smstunnel.enabled=falseto run without SMSTunnel, for example in tests. - Your own
SmsTunnelClientbean replaces the one the starter makes. -
A
SmsTunnelClientCustomizerbean changes the builder before the client is built, for example to send through a proxy:
@Bean
SmsTunnelClientCustomizer smsTunnelProxy() {
return builder -> builder.httpClient(HttpClient.newBuilder()
.proxy(ProxySelector.of(new InetSocketAddress("proxy.internal", 3128)))
.build());
} The starter ships the property metadata, so IDEs complete and describe smstunnel.*.
Errors
Every call throws an unchecked SmsTunnelException when it does not go through:
- SmsTunnelAuthenticationException (401)
- The API key is wrong, was deleted, or its account is suspended.
- SmsTunnelPermissionException (403)
- The key lacks a permission (getCode() is PERMISSION_REQUIRED, getPermission() names it, e.g. sms:inbox), or the plan does not include the feature.
- SmsTunnelRateLimitException (429)
- Too many requests, or today's allowance is used up. getRetryAfter() gives the wait when SMSTunnel says it.
- SmsTunnelServerException (5xx)
- SMSTunnel, or a proxy in front of it, had a problem.
- SmsTunnelApiException
- Any other error answer: 400 (a number it cannot read, a sign-in text without a code), 404 (no such message or phone), 409 and 422 (an Idempotency-Key still in progress, or reused for another text). The four above extend it.
- SmsTunnelConnectionException
- SMSTunnel could not be reached, or did not answer in time (isTimeout()).
try {
sms.sendSms(customer.phone(), "Your table is ready.");
} catch (SmsTunnelRateLimitException e) {
retryLater(e.getRetryAfter()); // may be null: a daily limit has no wait
} catch (SmsTunnelApiException e) {
log.warn("SMSTunnel {} {}: {}", e.getStatusCode(), e.getCode(), e.getMessage());
} catch (SmsTunnelConnectionException e) {
retryLaterWithTheSameIdempotencyKey();
}
An empty number or text throws IllegalArgumentException before anything is sent. An offline
phone is not an error: the text waits on your account (isQueued()) and goes out when the phone
is back, within 3 days.
Retries
Reads, and sends that carry an Idempotency-Key, are tried again up to 2 times after a connection problem, a timeout
or a 502, 503 or 504. When a send has no key of yours, the client gives it one of its own (a UUID), so its retries
can never send the text twice. A 429 is never retried. Change it with .maxRetries(0..5), or
.autoIdempotencyKey(false) to send without a key (such sends are then not retried). For your
own retries, give each text a key that names it, the same on every attempt.
Settings and your own HttpClient
SmsTunnelClient sms = SmsTunnelClient.builder()
.apiKey(System.getenv("SMSTUNNEL_API_KEY"))
.defaultCountryCode("+40") // for every send that doesn't give its own
.timeout(Duration.ofSeconds(15)) // per request; the default is 30 seconds
.maxRetries(2) // 0 to 5; 0 turns the client's own retries off
.userAgentSuffix("my-app/2.1") // added to the User-Agent header
.baseUrl("http://127.0.0.1:3141") // a test server; the default is https://smstunnel.io
.build();
The client makes its own java.net.http.HttpClient (a 10-second connect timeout, and it never
follows redirects, so the API key is never sent to another address). It uses the JVM's proxy settings
(https.proxyHost) and trust store. Pass your own with .httpClient(...)
for a proxy, your own certificates or an executor; it must not follow redirects
(HttpClient.Redirect.NEVER, the default). In your own tests, run a local HTTP server and point
.baseUrl(...) at it, or mock SmsTunnelClient.
Limits worth knowing
- 30 send requests a minute per key; the keys of one account together get three times that. Over it,
SmsTunnelRateLimitExceptionwithgetRetryAfter(). - Plans: Free, Starter and Standard have a daily and a monthly allowance; Professional and Enterprise have no daily cap. See Pricing.
- Length: up to 1600 characters (10 joined SMS); sign-in codes up to 320.
- Numbers: use the international format (
+14155550123). For local numbers, givedefaultCountryCode(+49,49and0049all work): it always wins over the phone's own country. - Offline phone: the text waits on your account (
isQueued()) 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 Java 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 SMSTunnel Java client: Maven io.github.narcisbodea:smstunnel-java:1.0.0 (Java 11+, no dependencies), package io.smstunnel. Without Spring: SmsTunnelClient sms = SmsTunnelClient.builder().apiKey(System.getenv("SMSTUNNEL_API_KEY")).build(); make one client and share it (it is immutable and thread-safe; calls block until SMSTunnel answers and work well on virtual threads).
- With Spring Boot 3 or 4 (Java 17+): io.github.narcisbodea:smstunnel-spring-boot-starter:1.0.0 instead, and smstunnel.api-key=${SMSTUNNEL_API_KEY} in application.properties (optional: smstunnel.default-country-code, smstunnel.base-url, smstunnel.timeout, smstunnel.max-retries, smstunnel.enabled). It gives a ready SmsTunnelClient bean to inject; a SmsTunnelClientCustomizer bean changes the builder.
- sms.sendSms(to, message), or sms.sendSms(to, message, SendOptions.builder().deviceId(id).defaultCountryCode("+40").idempotencyKey("order-1042-shipped").build()), returns a SendResult: getMessageId(), isQueued() (true: no phone of mine is online and the text goes out when one is back), isBranded(), isReplayed(). One call sends one text to one number: loop for several.
- Sign-in codes: sms.sendSignInCode("+14155550123", "Your sign-in code is 482913"); the text must contain the code as 4 to 10 digits standing on their own (or 123-456) and be at most 320 characters. Codes have their own daily allowance.
- sms.getMessageStatus(messageId): getDeliveryStatus() (PENDING, SENT, DELIVERED, FAILED or UNCONFIRMED), isFinal(), getSentAt(), getErrorMessage(). UNCONFIRMED means the phone handed it to the SIM without a confirmation: it most likely went out, so never resend it automatically.
- Errors are unchecked, all SmsTunnelException: SmsTunnelAuthenticationException (401, bad key), SmsTunnelPermissionException (403; getCode() such as PERMISSION_REQUIRED, getPermission()), SmsTunnelRateLimitException (429; getRetryAfter(), null when today's allowance is used up), SmsTunnelServerException (5xx), SmsTunnelApiException for the other answers (400, 404, 409, 422; getStatusCode(), getCode(), getMessage()), SmsTunnelConnectionException when SMSTunnel can't be reached or doesn't answer in time (isTimeout()). An empty number or text throws IllegalArgumentException before anything is sent.
- The client retries reads, and sends that carry an Idempotency-Key, up to 2 times after a connection problem, a timeout or a 502/503/504, never after a 429; a send without a key of mine gets one from the client (a UUID), so its own retries never send a text twice. For my own retries (a queue redelivering a job, a restarted worker), give the text an idempotencyKey() that names it: a repeat within 24 hours gets the first answer back (isReplayed() is true) instead of a second text.
- Numbers in international format. defaultCountryCode (e.g. "+40", per text or once on the builder) completes numbers written without + or 00 and always wins over the phone's own country. Texts up to 1600 characters; 30 send requests a minute per key.
- Send the text once the change is saved (for example from an @TransactionalEventListener after the commit) or from a background job, so a slow network never holds up a request.
- 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
Built for alerts to yourself and your team, and texts people expect from you. One text goes to one number. Sending unsolicited messages from a consumer SIM generally breaches mobile carrier terms of service.
Java is a trademark of Oracle and/or its affiliates; Spring and Spring Boot are trademarks of Broadcom Inc. and/or its subsidiaries. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by Oracle or Broadcom.