smstunnel

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 16 Imported by: 0

README

SMSTunnel for Go

smstunnel: send texts from your own Go programs through your own Android phone, from your own number, with SMSTunnel.

Your program 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. You can cut a program 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

Go Reference · Docs: smstunnel.io/docs · Keys: dashboard.smstunnel.io/apps

  • Go 1.22 or newer. No dependencies: only the standard library.
  • Every call takes a context.Context; errors keep the HTTP status, SMSTunnel's code and the Retry-After wait.

Install

go get github.com/narcisbodea/smstunnel-go

Get your 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.
  3. Click Add an app and copy its key (sk_live_…). It can send texts and look up whether they went out (sms:send and sms:read). Reading received texts is a separate opt-in for each key: Can also read received texts (sms:inbox).

Keep the key out of your code: in an environment variable such as SMSTUNNEL_API_KEY, or your secret store.

Quick start

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.

Usage

Send a text
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.

Send a sign-in or verification code
_, 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.

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", "starter", "standard", "pro" 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. When it is on, only texts that arrive from then on are synced.

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 address instead: in the dashboard, Apps → the key → Forward received texts.

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, for fields this version doesn't map yet.

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:

Error When
*smstunnel.APIError SMSTunnel answered with an error. StatusCode, Code (such as smstunnel.CodePermissionRequired), Message, Permission, RetryAfter, Header, Body.
errors.Is(err, smstunnel.ErrUnauthorized) 401: the API key is wrong, was deleted or turned off, or its account is suspended.
errors.Is(err, smstunnel.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.
errors.Is(err, smstunnel.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).
errors.Is(err, smstunnel.ErrServer) 5xx: a problem on SMSTunnel's side.
errors.Is(err, smstunnel.ErrNotFound) 404: no such message for this key.
*smstunnel.ConnectionError No answer: SMSTunnel could not be reached, or Timeout() when it didn't answer in time.
errors.Is(err, smstunnel.ErrInvalidInput) An argument was refused before anything was sent: an empty number, two numbers in one, a country code that isn't one.
errors.Is(err, context.Canceled) The context was cancelled.

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

_, 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)
}

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 (otherwise it is marked failed).

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 gets the first answer back (Replayed is true) instead of sending a second text. 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.

Notes and limits

  • Rate limit: 30 sends a minute per key; the keys of one account together get three times that. Over it, an *APIError with status 429 and RetryAfter.
  • Plans: the Free plan has a daily and a monthly allowance; see smstunnel.io/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, 49 and 0049 all 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

Paste this into Claude, ChatGPT, Cursor or Copilot, and replace the part in brackets. Never paste your real API key into a prompt.

I want my Go program to send texts with SMSTunnel. SMSTunnel sends each text from my own Android phone, from my own number: the phone runs the SMSTunnel app and is linked to my SMSTunnel account.

Use the module github.com/narcisbodea/smstunnel-go (go get github.com/narcisbodea/smstunnel-go), package smstunnel: client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY")).
API facts:
- client.SendSMS(ctx, to, message, smstunnel.WithDeviceID(id), smstunnel.WithCountryCode("+40"), smstunnel.WithIdempotencyKey(key)) calls POST https://smstunnel.io/api/v1/sms/send with the header X-API-Key and the JSON body {"to", "message", "deviceId"?, "defaultCountryCode"?}. It returns MessageID and Queued.
- One recipient per call. To text several people, call it once per number.
- client.SendSignInCode (POST /api/v1/sms/send-2fa) is only for sign-in codes: the text must contain a code of 4 to 10 digits standing on its own (e.g. 482913 or 123-456), at most 320 characters; it uses a separate allowance.
- Numbers in international format (+14155550123). A country code (WithCountryCode, or WithDefaultCountryCode on the client) completes numbers written without + or 00.
- Limits: 30 sends a minute per key, 1600 characters a text.
- Errors: *smstunnel.APIError with StatusCode, Code (such as PERMISSION_REQUIRED), Message and RetryAfter; errors.Is with smstunnel.ErrUnauthorized (401, bad key), ErrForbidden (403, missing permission or plan), ErrRateLimited (429, wait RetryAfter), ErrServer (5xx); *smstunnel.ConnectionError when there is no answer; ErrInvalidInput for refused arguments.
- The client never retries. Retry a send only with the same WithIdempotencyKey: a repeat within 24 h returns the first answer instead of sending twice.
- If the phone is offline, the text waits on the account (Queued is true) and goes out when the phone is back.

What I want: [describe what should send a text, to whom, and when]

Rules:
- Read the API key from an environment variable or the platform's secret store. Never put it in source code, in the repository or in client-side code.
- Validate phone numbers before sending, and handle every error above: log it clearly, don't retry 401/403, wait RetryAfter on 429.
- Only send texts people expect (alerts to myself, reminders, sign-in codes, order updates).

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

go vet ./...
go test -race ./...

The end-to-end tests (e2e_test.go) send real texts, so they run only against a test server, never smstunnel.io; without one they are skipped. Point them at it with a JSON file {"serverUrl": "http://127.0.0.1:3141", "apiKey": "...", "deviceId": "..."}, or with SMSTUNNEL_E2E_URL and SMSTUNNEL_E2E_API_KEY:

SMSTUNNEL_E2E_KEY_FILE=/path/to/test-key.json go test -run EndToEnd -v ./...

SMSTunnel is not affiliated with, endorsed by, or sponsored by Google or the Go project. Go is a trademark of Google LLC, and so are Android and Google Play.

License

MIT, © NARBOWEB SRL. See LICENSE.

Documentation

Overview

Package smstunnel sends texts (SMS) from your own Android phone, from your own number, with SMSTunnel (https://smstunnel.io).

The phone runs the SMSTunnel app from Google Play and is linked to your SMSTunnel account. Your program hands each text to your account with an API key, and the phone sends it from its own SIM, so recipients see a number they know and can reply to. Create the key in your dashboard: Apps → Add an app (https://dashboard.smstunnel.io/apps).

client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"))
if err != nil {
	log.Fatal(err)
}
res, err := client.SendSMS(ctx, "+14155550123", "Your order #1042 has shipped.")
if err != nil {
	log.Fatal(err)
}
fmt.Println(res.MessageID)

One call sends one text to one number: to text several people, call Client.SendSMS once per number. Sign-in and verification codes go through Client.SendSignInCode. Client.GetMessageStatus, Client.ListPhones, Client.GetUsage and Client.ListReceivedTexts read the rest; ReceivedTextsCursor follows received texts as they arrive.

Errors

Every method returns an error when the call doesn't go through:

The client never retries by itself. After a timeout, a connection error or a 5xx answer a text may already be on its way: retry a send only with the same WithIdempotencyKey, so it can't go out twice.

A phone that is offline is not an error: the text waits on your account (SendResult.Queued) and goes out when the phone is back.

Index

Examples

Constants

View Source
const (
	// CodePermissionRequired (403): the API key lacks a permission; APIError.Permission names it, e.g. "sms:inbox".
	CodePermissionRequired = "PERMISSION_REQUIRED"
	// CodeAPIKeyFromWebPage (401): an API key sent from a web page on another site. Call SMSTunnel from a server.
	CodeAPIKeyFromWebPage = "API_KEY_FROM_WEB_PAGE"
	// CodeDisabledBySupport: SMSTunnel support turned the key or the phone off.
	CodeDisabledBySupport = "DISABLED_BY_SUPPORT"
	// CodeIdempotencyKeyInvalid (400): the idempotency key is not 1 to 255 printable ASCII characters.
	CodeIdempotencyKeyInvalid = "IDEMPOTENCY_KEY_INVALID"
	// CodeIdempotencyKeyReused (422): the idempotency key was used in the last 24 hours for a different text.
	CodeIdempotencyKeyReused = "IDEMPOTENCY_KEY_REUSED"
	// CodeIdempotencyKeyInProgress (409): a call with this idempotency key is still running; retry after RetryAfter.
	CodeIdempotencyKeyInProgress = "IDEMPOTENCY_KEY_IN_PROGRESS"
)

Values of APIError.Code a program can act on.

View Source
const DefaultBaseURL = "https://smstunnel.io"

DefaultBaseURL is the address of SMSTunnel's API.

View Source
const DefaultCursorOverlap = 2 * time.Minute

DefaultCursorOverlap is how far back each poll of a ReceivedTextsCursor looks again, by default.

View Source
const DefaultTimeout = 30 * time.Second

DefaultTimeout is how long a call may take, unless WithTimeout changes it.

View Source
const Version = "1.0.0"

Version is the version of this library. It is sent in the User-Agent header.

Variables

View Source
var (
	// ErrUnauthorized (401): the API key is wrong, was deleted or turned off, or its account is suspended.
	ErrUnauthorized = errors.New("smstunnel: unauthorized")
	// ErrForbidden (403): the key lacks a permission (Code is CodePermissionRequired), or the plan doesn't include
	// the feature.
	ErrForbidden = errors.New("smstunnel: forbidden")
	// ErrNotFound (404): no such message for this key.
	ErrNotFound = errors.New("smstunnel: not found")
	// 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).
	ErrRateLimited = errors.New("smstunnel: rate limited")
	// ErrServer (5xx): a problem on SMSTunnel's side. A send may or may not have been accepted.
	ErrServer = errors.New("smstunnel: server error")
)

Errors that *APIError matches with errors.Is, by HTTP status.

View Source
var ErrInvalidInput = errors.New("smstunnel: invalid input")

ErrInvalidInput is matched (errors.Is) by the errors of arguments refused before anything is sent: an empty number, two numbers in one, a country code that isn't one, a bad option.

Functions

This section is empty.

Types

type APIError

type APIError struct {
	// StatusCode is the HTTP status.
	StatusCode int
	// Code is SMSTunnel's own code for errors a program can act on (see CodePermissionRequired and the others),
	// or "".
	Code string
	// Message is what went wrong, in SMSTunnel's words.
	Message string
	// Permission is, with CodePermissionRequired, the permission the key lacks, such as "sms:inbox".
	Permission string
	// RetryAfter is how long to wait before trying again, from the Retry-After header; 0 when there was none.
	RetryAfter time.Duration
	// Header holds the answer's headers.
	Header http.Header
	// Body is the answer as SMSTunnel sent it (usually JSON).
	Body []byte
}

APIError is returned when SMSTunnel answered with an error status.

Besides the statuses with a sentinel (see ErrUnauthorized and the others), expect 400 (a number SMSTunnel can't read, a sign-in code text without a code, a refused field), 409 and 422 (an idempotency key still in progress, or reused for another text).

Example
package main

import (
	"context"
	"errors"
	"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)
	}

	_, err = client.SendSMS(context.Background(), "+14155550123", "Your table is ready.")

	var apiErr *smstunnel.APIError
	var connErr *smstunnel.ConnectionError
	switch {
	case err == nil:
		fmt.Println("sent")
	case errors.Is(err, smstunnel.ErrRateLimited) && errors.As(err, &apiErr) && apiErr.RetryAfter > 0:
		fmt.Println("too many texts right now; try again in", apiErr.RetryAfter)
	case errors.Is(err, smstunnel.ErrUnauthorized):
		fmt.Println("check the API key in the dashboard: Apps")
	case errors.As(err, &apiErr) && apiErr.Code == smstunnel.CodePermissionRequired:
		fmt.Println("the key lacks", apiErr.Permission)
	case errors.As(err, &connErr):
		fmt.Println("no answer; retry only with the same idempotency key:", connErr.Timeout())
	default:
		fmt.Println(err)
	}
}

func (*APIError) Error

func (e *APIError) Error() string

Error is "smstunnel: <status>[ <code>]: <message>".

func (*APIError) Is

func (e *APIError) Is(target error) bool

Is matches the sentinel of the status: ErrUnauthorized, ErrForbidden, ErrNotFound, ErrRateLimited or ErrServer.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client calls the SMSTunnel API. It is safe for concurrent use: make one and reuse it.

func NewClient

func NewClient(apiKey string, opts ...Option) (*Client, error)

NewClient returns a client that sends with this API key, the key you create in your SMSTunnel dashboard (Apps → Add an app). Read it from an environment variable or a secret store, never from source code.

The error matches ErrInvalidInput when the key is empty or an option is refused.

func (*Client) BaseURL

func (c *Client) BaseURL() string

BaseURL is the address the client calls, ending with "/".

func (*Client) GetMessageStatus

func (c *Client) GetMessageStatus(ctx context.Context, messageID string) (*MessageStatus, error)

GetMessageStatus looks up where a text is now (GET /api/v1/sms/status/{id}). It needs a key with sms:read.

Example
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)
	}

	status, err := client.GetMessageStatus(context.Background(), "6aea38f9b38ed3bed4f8569f")
	if err != nil {
		log.Fatal(err)
	}
	switch status.State {
	case smstunnel.StateSent, smstunnel.StateDelivered:
		fmt.Println("sent to", status.PhoneNumber)
	case smstunnel.StateFailed:
		fmt.Println("failed:", status.ErrorMessage)
	case smstunnel.StateUnconfirmed:
		fmt.Println("most likely sent; don't resend it")
	default:
		fmt.Println("waiting for the phone")
	}
}

func (*Client) GetUsage

func (c *Client) GetUsage(ctx context.Context) (*Usage, error)

GetUsage reads the plan, today's usage and what is left (GET /api/v1/account/usage).

Example
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)
	}

	usage, err := client.GetUsage(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s plan: %d texts today, %s left\n", usage.Plan, usage.Used.SMSClean, usage.Remaining.SMSClean)
}

func (*Client) GoString

func (c *Client) GoString() string

GoString is String, for %#v.

func (*Client) ListPhones

func (c *Client) ListPhones(ctx context.Context) ([]Phone, error)

ListPhones lists the phones of the key's account and whether each is online (GET /api/v1/devices).

Example
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)
	}

	phones, err := client.ListPhones(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	for _, phone := range phones {
		fmt.Println(phone.ID, phone.Name, phone.Online)
	}
}

func (*Client) ListReceivedTexts

func (c *Client) ListReceivedTexts(ctx context.Context, q ReceivedTextsQuery) (*ReceivedTextsPage, error)

ListReceivedTexts lists texts your phones received, newest first (GET /api/v1/sms/received). It needs a key you allowed to read received texts ("Can also read received texts", the sms:inbox permission); other keys get a 403 *APIError with CodePermissionRequired. To follow new texts as they arrive, use Client.NewReceivedTextsCursor.

Example
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/narcisbodea/smstunnel-go"
)

func main() {
	client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"))
	if err != nil {
		log.Fatal(err)
	}

	page, err := client.ListReceivedTexts(context.Background(), smstunnel.ReceivedTextsQuery{
		Since: time.Now().Add(-24 * time.Hour),
		Limit: 20,
	})
	if err != nil {
		log.Fatal(err)
	}
	for _, text := range page.Texts {
		fmt.Println(text.ReceivedAt.Format(time.Kitchen), text.Sender, text.Message)
	}
}

func (*Client) NewReceivedTextsCursor

func (c *Client) NewReceivedTextsCursor(opts ReceivedTextsCursorOptions) (*ReceivedTextsCursor, error)

NewReceivedTextsCursor returns a cursor over the texts your phones receive. See ReceivedTextsCursor.

func (*Client) SendSMS

func (c *Client) SendSMS(ctx context.Context, to, message string, opts ...SendOption) (*SendResult, error)

SendSMS sends one text to one number (POST /api/v1/sms/send). To text several people, call it once per number.

to is the recipient in international format (+14155550123); a local number needs a country code. message is the text, up to 1600 characters.

Example
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.",
		smstunnel.WithIdempotencyKey("order-1042-shipped"))
	if err != nil {
		log.Fatal(err)
	}
	if res.Queued {
		fmt.Println("Your phone is offline: the text goes out when it is back.")
	}
	fmt.Println("message", res.MessageID)
}

func (*Client) SendSignInCode

func (c *Client) SendSignInCode(ctx context.Context, to, message string, opts ...SendOption) (*SendResult, error)

SendSignInCode sends a sign-in or verification code (POST /api/v1/sms/send-2fa). The text must contain the code as a number of its own, 4 to 10 digits ("Your code is 482913" or "123-456"), and be at most 320 characters; otherwise SMSTunnel answers 400. Codes have their own daily allowance. Use it for codes only.

Example
package main

import (
	"context"
	"log"
	"os"

	"github.com/narcisbodea/smstunnel-go"
)

func main() {
	client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"))
	if err != nil {
		log.Fatal(err)
	}

	// The text must contain the code as a number of its own, 4 to 10 digits.
	if _, err := client.SendSignInCode(context.Background(), "+14155550123", "Your sign-in code is 482913"); err != nil {
		log.Fatal(err)
	}
}

func (*Client) String

func (c *Client) String() string

String describes the client without its API key, so printing it (%v, %+v) never shows the key.

func (*Client) UserAgent

func (c *Client) UserAgent() string

UserAgent is the User-Agent header the client sends.

type ConnectionError

type ConnectionError struct {
	// Err is the error of the HTTP client.
	Err error
}

ConnectionError is returned when no answer came back: SMSTunnel could not be reached (DNS, TLS, a refused or broken connection), or didn't answer in time. There is no HTTP status. For a send, the text may or may not have been accepted: retry it only with the same idempotency key.

func (*ConnectionError) Error

func (e *ConnectionError) Error() string

func (*ConnectionError) Timeout

func (e *ConnectionError) Timeout() bool

Timeout reports whether the call ran out of time (the client's timeout or the context's deadline).

func (*ConnectionError) Unwrap

func (e *ConnectionError) Unwrap() error

type MessageStatus

type MessageStatus struct {
	// ID is the text's ID.
	ID string `json:"id"`
	// PhoneNumber is the number the text went to, in international format.
	PhoneNumber string `json:"phoneNumber"`
	// State is where the text is now.
	State State `json:"state"`
	// SentAt is when the phone sent it, or nil.
	SentAt *time.Time `json:"sentAt,omitempty"`
	// DeliveredAt is when delivery was confirmed, or nil.
	DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
	// ErrorMessage says why it failed, or why it is unconfirmed.
	ErrorMessage string `json:"errorMessage,omitempty"`
	// Raw is SMSTunnel's whole answer, for fields this version doesn't map.
	Raw json.RawMessage `json:"-"`
}

MessageStatus is where one text is now.

type Option

type Option func(*Client) error

An Option changes a setting of NewClient.

func WithBaseURL

func WithBaseURL(rawURL string) Option

WithBaseURL points the client at another address than DefaultBaseURL, such as a test server ("http://127.0.0.1:3141"). A path is kept: "https://example.com/smstunnel" calls "https://example.com/smstunnel/api/v1/...".

func WithDefaultCountryCode

func WithDefaultCountryCode(code string) Option

WithDefaultCountryCode sets the country code ("+40", "40" or "0040") that completes numbers written without + or 00, for every text the client sends. A text's own WithCountryCode wins over it; when neither is set, SMSTunnel uses the phone's country. Numbers in international format (+14155550123) never need it.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient sends through your own http.Client, for its transport, proxy or TLS settings. The client keeps a copy of it: unless yours has its own CheckRedirect, the copy doesn't follow redirects, so the API key is never sent to another address. Its Timeout applies on top of WithTimeout.

Example
package main

import (
	"log"
	"net/http"
	"os"
	"time"

	"github.com/narcisbodea/smstunnel-go"
)

func main() {
	client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"),
		smstunnel.WithHTTPClient(&http.Client{Transport: http.DefaultTransport}),
		smstunnel.WithDefaultCountryCode("+40"),
		smstunnel.WithTimeout(15*time.Second),
		smstunnel.WithUserAgentSuffix("my-tool/2.1"),
	)
	if err != nil {
		log.Fatal(err)
	}
	_ = client
}

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets how long one call may take (DefaultTimeout otherwise). 0 turns the client's own timeout off; the context's deadline still applies.

func WithUserAgentSuffix

func WithUserAgentSuffix(suffix string) Option

WithUserAgentSuffix adds your program's name and version to the User-Agent header, after this library's: "smstunnel-go/1.0.0 (+https://smstunnel.io) my-tool/2.1".

type Phone

type Phone struct {
	// ID identifies the phone: pass it to WithDeviceID to send from it.
	ID string `json:"id"`
	// Name is the phone's name, as shown under Phones in the dashboard.
	Name string `json:"name"`
	// Online is whether the SMSTunnel app on the phone is connected now. Texts for an offline phone wait until it is back.
	Online bool `json:"isOnline"`
	// LastSeenAt is when the phone was last connected, or nil.
	LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
}

Phone is one of your phones.

type Quota

type Quota int64

Quota is a limit, or what is left of one. Unlimited means there is no limit. In JSON it is a number, or the string "unlimited".

const Unlimited Quota = -1

Unlimited is the Quota of something without a limit.

func (Quota) IsUnlimited

func (q Quota) IsUnlimited() bool

IsUnlimited reports whether there is no limit.

func (Quota) MarshalJSON

func (q Quota) MarshalJSON() ([]byte, error)

MarshalJSON writes the number, or "unlimited".

func (Quota) String

func (q Quota) String() string

String is the number, or "unlimited".

func (*Quota) UnmarshalJSON

func (q *Quota) UnmarshalJSON(data []byte) error

UnmarshalJSON reads a number (a negative one meaning no limit), "unlimited", or null (left as it is).

type ReceivedText

type ReceivedText struct {
	// ID is the text's ID.
	ID string `json:"id"`
	// Sender is the number that sent it.
	Sender string `json:"sender"`
	// Message is the words; "" when the phone synced the text encrypted (see EncryptedPayload).
	Message string `json:"message"`
	// Encrypted is whether the phone synced the text encrypted.
	Encrypted bool `json:"isEncrypted"`
	// EncryptedPayload is the encrypted text, for software that holds the key.
	EncryptedPayload string `json:"encryptedPayload,omitempty"`
	// ReceivedAt is when the phone received it (the phone's clock).
	ReceivedAt time.Time `json:"receivedAt"`
	// SIMSlot is the SIM slot it arrived on, when the phone says.
	SIMSlot *int `json:"simSlot,omitempty"`
	// SubscriptionID is Android's subscription ID of that SIM, when the phone says.
	SubscriptionID *int `json:"subscriptionId,omitempty"`
	// DeviceID is the ID of the phone that received it.
	DeviceID string `json:"deviceId,omitempty"`
	// DeviceName is the name of that phone.
	DeviceName string `json:"deviceName,omitempty"`
}

ReceivedText is a text one of your phones received and synced to your account.

type ReceivedTextsCursor

type ReceivedTextsCursor struct {
	// contains filtered or unexported fields
}

ReceivedTextsCursor follows received texts as they arrive, for a watch loop: each call to Next returns the texts that reached SMSTunnel since the previous call, oldest first, each one once. It is not safe for concurrent use.

SMSTunnel filters received texts by the time it got them from the phone, so a text the phone syncs late is still found. The cursor asks again for the last Overlap of every poll and remembers the IDs it returned in that window.

It needs a key allowed to read received texts (sms:inbox).

Example
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/narcisbodea/smstunnel-go"
)

func main() {
	client, err := smstunnel.NewClient(os.Getenv("SMSTUNNEL_API_KEY"))
	if err != nil {
		log.Fatal(err)
	}

	cursor, err := client.NewReceivedTextsCursor(smstunnel.ReceivedTextsCursorOptions{Since: time.Now()})
	if err != nil {
		log.Fatal(err)
	}
	for {
		texts, err := cursor.Next(context.Background())
		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)
	}
}

func (*ReceivedTextsCursor) Next

Next returns the texts that arrived since the previous call (on the first call, since Options.Since), oldest first; none is an empty slice. After an error nothing is lost: the next call asks for the same texts again.

func (*ReceivedTextsCursor) Seen

func (rc *ReceivedTextsCursor) Seen() []string

Seen lists, sorted, the IDs the cursor remembers, to pass as Options.Seen after a restart.

func (*ReceivedTextsCursor) Since

func (rc *ReceivedTextsCursor) Since() time.Time

Since is the moment the next poll asks from. Save it with Seen to go on after a restart.

type ReceivedTextsCursorOptions

type ReceivedTextsCursorOptions struct {
	// Since is where to start: texts that reached SMSTunnel at or after this moment, by SMSTunnel's clock. Use
	// time.Now() to follow only new texts; the zero time starts with every text the account has.
	Since time.Time
	// Phone keeps only the texts from this sender (part of the number is enough).
	Phone string
	// DeviceID keeps only the texts received by this phone.
	DeviceID string
	// PageSize is how many texts each request asks for, 1 to 100; 0 means 100.
	PageSize int
	// Overlap is how far back each poll looks again, so that no text is missed because of a clock difference
	// between this computer and SMSTunnel. Texts seen in the overlap are not returned twice. 0 means
	// DefaultCursorOverlap.
	Overlap time.Duration
	// Seen are the IDs of texts already handled, for example Seen() saved by an earlier run along with Since():
	// they are not returned again.
	Seen []string
}

ReceivedTextsCursorOptions sets up a ReceivedTextsCursor.

type ReceivedTextsPage

type ReceivedTextsPage struct {
	// Texts are the texts on this page, newest first.
	Texts []ReceivedText `json:"messages"`
	// Total is how many texts match, on every page.
	Total int `json:"total"`
	// Page is this page's number, starting at 1.
	Page int `json:"page"`
	// Limit is the number of texts per page.
	Limit int `json:"limit"`
	// HasMore is whether there is a next page.
	HasMore bool `json:"hasMore"`
}

ReceivedTextsPage is one page of received texts, newest first.

type ReceivedTextsQuery

type ReceivedTextsQuery struct {
	// Limit is how many texts per page, 1 to 100; 0 means SMSTunnel's default (50).
	Limit int
	// Page is the page, starting at 1; 0 means the first.
	Page int
	// Since keeps only the texts that reached SMSTunnel at or after this moment (by SMSTunnel's clock).
	Since time.Time
	// Phone keeps only the texts from this sender (part of the number is enough).
	Phone string
	// DeviceID keeps only the texts received by this phone.
	DeviceID string
}

ReceivedTextsQuery chooses which received texts to list. Every field is optional.

type Remaining

type Remaining struct {
	// Clean counts the texts (or, after SendSignInCode, the sign-in codes) that can still go out as written.
	Clean Quota `json:"clean"`
	// Total counts all that can still go out, including the ones with the Free plan's added line.
	Total Quota `json:"total"`
}

Remaining is today's allowance left after a text.

type SendOption

type SendOption func(*sendSettings)

A SendOption changes one text: the phone it goes out from, its country code, its idempotency key.

func WithCountryCode

func WithCountryCode(code string) SendOption

WithCountryCode sets the country code ("+49", "49" or "0049") that completes this text's number when it is written without + or 00. It wins over WithDefaultCountryCode and over the phone's own country.

func WithDeviceID

func WithDeviceID(id string) SendOption

WithDeviceID sends the text from this phone: its ID from the dashboard (Phones, the Copy button) or Phone.ID. Without it, SMSTunnel picks one of your phones.

func WithIdempotencyKey

func WithIdempotencyKey(key string) SendOption

WithIdempotencyKey makes a retry safe: a second call with the same key within 24 hours gets the first call's answer back (SendResult.Replayed) instead of sending a second text. Use a value that names this one text, such as "order-1042-shipped" or a UUID you store with it (1 to 255 printable ASCII characters). Reusing a key for a different text fails with status 422 and CodeIdempotencyKeyReused.

type SendResult

type SendResult struct {
	// MessageID identifies the text: keep it to look up the status with GetMessageStatus.
	MessageID string `json:"messageId"`
	// Queued is true when no phone of yours was online: the text waits on your account and goes out as soon as a
	// phone is back (within 3 days, otherwise it is marked failed). Nothing to resend.
	Queued bool `json:"queued"`
	// Branded is true when the Free plan added its short SMSTunnel line to the text.
	Branded bool `json:"branded"`
	// Remaining is what is left today after this text, when SMSTunnel says.
	Remaining *Remaining `json:"remaining,omitempty"`
	// Replayed is true when this is the stored answer to an earlier call with the same idempotency key: no second
	// text was sent.
	Replayed bool `json:"replayed"`
	// Raw is SMSTunnel's whole answer, for fields this version doesn't map.
	Raw json.RawMessage `json:"-"`
}

SendResult is SMSTunnel's answer to a text it accepted.

type State

type State string

State is where a text is now.

const (
	// StatePending: waiting for the phone, for example while it is offline.
	StatePending State = "pending"
	// StateSent: the phone sent it.
	StateSent State = "sent"
	// StateDelivered: the recipient's network confirmed delivery.
	StateDelivered State = "delivered"
	// StateFailed: it could not be sent; MessageStatus.ErrorMessage says why.
	StateFailed State = "failed"
	// StateUnconfirmed: the phone handed it to the SIM but never got the confirmation. It most likely went out and
	// is never resent automatically. Don't resend it yourself: the recipient could get it twice.
	StateUnconfirmed State = "unconfirmed"
)

The states of a text. A state this version doesn't know is kept as SMSTunnel sent it.

func (State) Final

func (s State) Final() bool

Final reports whether the state won't change any more: delivered, failed or unconfirmed.

type Usage

type Usage struct {
	// Plan is the plan, for example "free", "starter", "standard", "pro" or "enterprise".
	Plan string `json:"plan"`
	// AccountType is the kind of account, for example "standard".
	AccountType string `json:"accountType"`
	// Used is what was used today, and this month.
	Used UsageCounts `json:"usage"`
	// Limits are the plan's limits.
	Limits UsageLimits `json:"limits"`
	// Remaining is what is left.
	Remaining UsageRemaining `json:"remaining"`
	// Enterprise is the company account whose allowance this account shares, or nil.
	Enterprise *UsageEnterprise `json:"enterprise,omitempty"`
	// Raw is SMSTunnel's whole answer, for fields this version doesn't map.
	Raw json.RawMessage `json:"-"`
}

Usage is the plan, today's usage and what is left. Daily counts restart at 00:00 UTC, the month's count on the 1st. A client of a company account shares the company's allowance and sees it here. The field names are the API's.

type UsageCounts

type UsageCounts struct {
	SMSClean      int64 `json:"smsClean"`      // texts sent today as written
	SMSBranded    int64 `json:"smsBranded"`    // texts sent today with the Free plan's added line
	TwoFAClean    int64 `json:"twoFaClean"`    // sign-in codes sent today
	SMSCleanMonth int64 `json:"smsCleanMonth"` // texts sent this month as written
}

UsageCounts is what was used.

type UsageEnterprise

type UsageEnterprise struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

UsageEnterprise is the company account a client account belongs to.

type UsageLimits

type UsageLimits struct {
	SMSClean         Quota `json:"smsClean"`         // texts as written, per day
	TwoFAClean       Quota `json:"twoFaClean"`       // sign-in codes, per day
	SMSCleanPerMonth Quota `json:"smsCleanPerMonth"` // texts as written, per month
	SMSBrandedPerDay Quota `json:"smsBrandedPerDay"` // texts with the added line, per day (Free plan; 0 on paid plans)
}

UsageLimits are the plan's limits.

type UsageRemaining

type UsageRemaining struct {
	SMSClean      Quota `json:"smsClean"`      // texts that can still go out today as written
	TwoFAClean    Quota `json:"twoFaClean"`    // sign-in codes that can still go out today
	SMSCleanMonth Quota `json:"smsCleanMonth"` // texts as written left this month
	SMSBranded    Quota `json:"smsBranded"`    // texts with the added line left today
}

UsageRemaining is what is left.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL