SMSTunnel for Go
Send texts from your own Go programs, from your own number
Your program writes, your phone sends
Your program 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 cut a program 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, and a cursor that follows new ones.
- Phones and usage
- Which of your phones are online, your plan and what is left today.
- Safe retries
- An idempotency key per text: a retry gets the first answer back instead of sending a second text.
- No dependencies
- Go 1.22 or newer and the standard library. Every call takes a context.Context.
1. Install
go get github.com/narcisbodea/smstunnel-go Go 1.22 or newer. No dependencies outside the standard library. The reference is on pkg.go.dev.
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 Go, name it (for example "My Go 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 Go app can't send texts anymore.
Get the steps with your key in them
It opens Add an app with Go 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 program 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.
3. Send a text
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/narcisbodea/smstunnel-go"
)
func main() {
client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"))
if err != nil {
log.Fatal(err)
}
res, err := client.SendSMS(context.Background(), "+14155550123", "Your order #1042 has shipped.")
if err != nil {
log.Fatal(err)
}
fmt.Println(res.MessageID) // keep it to look up the status later
} Make one Client and reuse it: it is safe for concurrent use.
All the options, and what comes back:
res, err := client.SendSMS(ctx,
"+14155550123", // international format; one number per call
"See you at 10:30.", // up to 1600 characters
smstunnel.WithDeviceID("6aea3248fedd7cccac9abf42"), // the phone that sends it (Phones → Copy); leave it out to let SMSTunnel pick one of yours
smstunnel.WithCountryCode("+40"), // completes numbers written without + or 00, like 0721 123 456
smstunnel.WithIdempotencyKey("appointment-981-reminder"), // makes a retry safe, see below
)
res.MessageID // string
res.Queued // true when no phone of yours was online: the text waits and goes out when one is back
res.Branded // true when the Free plan added its short line to the text
res.Replayed // true when this is the stored answer to an earlier call with the same idempotency key
One call sends one text to one number. To text several people, call SendSMS once for each.
Sign-in and verification codes
_, err := client.SendSignInCode(ctx, "+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), and be at most 320 characters; otherwise SMSTunnel refuses it (400). Codes have
their own daily allowance. Use it for codes only.
What counts as a code
Look up a status
status, err := client.GetMessageStatus(ctx, res.MessageID) // needs sms:read
status.State // smstunnel.StatePending, StateSent, StateDelivered, StateFailed or StateUnconfirmed
status.State.Final() // true once it won't change: delivered, failed or unconfirmed
status.SentAt // *time.Time
status.ErrorMessage // why it failed StateUnconfirmed 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
phones, err := client.ListPhones(ctx)
for _, phone := range phones {
fmt.Println(phone.ID, phone.Name, phone.Online)
}
usage, err := client.GetUsage(ctx)
usage.Plan // "free", "twofa" (Starter), "standard", "pro" (Professional) or "enterprise"
usage.Used.SMSClean // texts sent today
usage.Remaining.SMSClean // texts left today: a Quota, smstunnel.Unlimited when there is no limit
usage.Remaining.TwoFAClean // sign-in codes left today Read received texts
Needs a key you allowed to read received texts (Can also read received texts) and syncing turned on in the app.
page, err := client.ListReceivedTexts(ctx, smstunnel.ReceivedTextsQuery{
Since: time.Now().Add(-24 * time.Hour),
Phone: "+14155550123", // only from this sender
Limit: 20, // 1 to 100
Page: 1,
})
for _, text := range page.Texts { // newest first
fmt.Println(text.ReceivedAt.Format(time.Kitchen), text.Sender, text.Message)
}
page.HasMore // ask for the next page
To follow texts as they arrive, use a cursor. Each Next returns the texts that came in since
the previous call, oldest first, each one once, reading as many pages as it needs:
cursor, err := client.NewReceivedTextsCursor(smstunnel.ReceivedTextsCursorOptions{Since: time.Now()})
for {
texts, err := cursor.Next(ctx)
if err != nil {
log.Print(err) // nothing is lost: the next call asks again
}
for _, text := range texts {
fmt.Println(text.Sender, text.Message)
}
time.Sleep(10 * time.Second)
}
To go on after a restart, save cursor.Since() and cursor.Seen() and
pass them back as Since and Seen. SMSTunnel can also post each received
text to your own server instead: in your dashboard, Apps → Forward received texts
(how it works). Every result
struct has JSON tags with the API's field names, and the send, status and usage results have a
Raw field with SMSTunnel's whole answer.
In a web server
sms, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"), smstunnel.WithDefaultCountryCode("+40"))
if err != nil {
log.Fatal(err)
}
http.HandleFunc("POST /orders/{id}/shipped", func(w http.ResponseWriter, r *http.Request) {
order, err := orders.MarkShipped(r.Context(), r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
_, err = sms.SendSMS(r.Context(), order.Phone, "Your order #"+order.ID+" has shipped.",
smstunnel.WithIdempotencyKey("order-"+order.ID+"-shipped"))
if err != nil {
log.Printf("the shipping text for order %s did not go out: %v", order.ID, err)
}
w.WriteHeader(http.StatusNoContent)
})
To keep requests fast, you can send from a goroutine or a job queue instead, with a context of its own
(context.WithoutCancel(r.Context()) on Go 1.21+).
Errors
Every method returns an error when the call doesn't go through:
- *smstunnel.APIError
- SMSTunnel answered with an error: StatusCode, Code (such as smstunnel.CodePermissionRequired), Message, Permission, RetryAfter, Header and Body.
- ErrUnauthorized (401)
- The API key is wrong, was deleted or turned off, or its account is suspended.
- ErrForbidden (403)
- The key lacks a permission (Code is PERMISSION_REQUIRED and Permission names it, e.g. sms:inbox), or the plan doesn't include the feature.
- ErrRateLimited (429)
- Too many requests (RetryAfter says how long to wait), or the plan's limit for the day or month is reached (no RetryAfter; the message says when texts can go out again).
- ErrServer (5xx)
- A problem on SMSTunnel's side.
- ErrNotFound (404)
- No such message, or no such phone, for this key.
- *smstunnel.ConnectionError
- No answer: SMSTunnel could not be reached, or Timeout() when it didn't answer in time.
- ErrInvalidInput
- An argument was refused before anything was sent: an empty number, two numbers in one, a country code that isn't one.
Match them with errors.Is and errors.As. A cancelled context gives
context.Canceled.
_, err := client.SendSMS(ctx, number, text, smstunnel.WithIdempotencyKey(textID))
var apiErr *smstunnel.APIError
var connErr *smstunnel.ConnectionError
switch {
case err == nil:
case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests && apiErr.RetryAfter > 0:
time.Sleep(apiErr.RetryAfter) // then try again, with the same idempotency key
case errors.As(err, &apiErr) && apiErr.Code == smstunnel.CodePermissionRequired:
log.Printf("the SMSTunnel key lacks %s", apiErr.Permission)
case errors.As(err, &connErr):
log.Printf("no answer (timeout: %v): retry only with the same idempotency key", connErr.Timeout())
default:
log.Print(err)
}
Other statuses come as an *APIError too: 400 (a number SMSTunnel can't read, a code text
without a code), 409 and 422 (an idempotency key still in progress, or reused for another text). A phone that is
offline is not an error: the text waits on your account (Queued is true) and goes out when the
phone is back, within 3 days.
Retrying a send safely
The client never retries on its own. After a timeout, a connection error or a 5xx answer, the text may already be
on its way, so retry a send only with WithIdempotencyKey: a second call with the same key
within 24 hours never sends a second text: it gets the first answer back (Replayed is true),
or the same error if the first call ended in a 5xx (look the text up in your dashboard before you send it again
with a new key). Use a key that names the text ("order-" + id + "-shipped"), or a UUID you
store with it.
Settings
client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"),
smstunnel.WithDefaultCountryCode("+40"), // for numbers written without + or 00; a text's WithCountryCode wins
smstunnel.WithTimeout(15*time.Second), // per call; the default is 30 seconds, 0 turns it off
smstunnel.WithHTTPClient(myHTTPClient), // your transport, proxy or TLS settings
smstunnel.WithUserAgentSuffix("my-tool/2.1"), // added to the User-Agent header
smstunnel.WithBaseURL("http://127.0.0.1:3141"), // a test server; the default is https://smstunnel.io
)
The client doesn't follow redirects (unless your http.Client has its own
CheckRedirect), so the API key is never sent to another address. The default transport reads
the proxy from HTTPS_PROXY. In your own tests, point WithBaseURL at an
httptest.Server.
Limits worth knowing
- 30 sends a minute per key; the keys of one account together get three times that. Over it, an
*APIErrorwith status 429 andRetryAfter. - 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, set a country code (+49,49and0049all work): the text's own wins over the client's, and either wins over the phone's country. - Offline phone: the text waits (
Queued) 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 Go program 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 module: go get github.com/narcisbodea/smstunnel-go (Go 1.22+, standard library only), package smstunnel. client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY")); make one client and reuse it (it is safe for concurrent use). Every method takes a context.Context.
- client.SendSMS(ctx, to, message, smstunnel.WithDeviceID(id), smstunnel.WithCountryCode("+40"), smstunnel.WithIdempotencyKey(key)) returns MessageID, Queued (true: no phone of mine is online and the text goes out when one is back), Branded and Replayed. One call sends one text to one number: loop for several.
- Sign-in codes: client.SendSignInCode(ctx, "+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.
- client.GetMessageStatus(ctx, messageID): State (smstunnel.StatePending, StateSent, StateDelivered, StateFailed or StateUnconfirmed; State.Final() is true once it won't change), SentAt, ErrorMessage. StateUnconfirmed means the phone handed it to the SIM without a confirmation: it most likely went out, so never resend it automatically.
- Errors: *smstunnel.APIError (StatusCode, Code such as smstunnel.CodePermissionRequired, Message, Permission, RetryAfter); errors.Is with smstunnel.ErrUnauthorized (401, bad key), ErrForbidden (403, missing permission or plan), ErrRateLimited (429: wait RetryAfter; without it, the plan's daily or monthly limit is reached), ErrServer (5xx) and ErrNotFound (404); *smstunnel.ConnectionError when there is no answer (Timeout()); ErrInvalidInput for arguments refused before anything is sent.
- The client never retries by itself. Retry a send only with the same WithIdempotencyKey (for example "order-" + id + "-shipped"): a repeat within 24 hours gets the first answer back (Replayed is true) instead of sending a second text; if the first call ended in a 5xx, the repeat gets the same error back, and still no second text.
- Numbers in international format. WithCountryCode on a text, or WithDefaultCountryCode on the client (e.g. "+40"), completes numbers written without + or 00; the text's own wins, and either wins over the phone's own country. Texts up to 1600 characters; 30 sends a minute per key.
- In a web server: one client for the whole program, the key from the environment, and the text sent once the change is saved, or from a goroutine or a job queue with a context of its own (context.WithoutCancel).
- 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.
Go is a trademark of Google LLC. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by Google or the Go project.