.NET

SMSTunnel for .NET

Send texts from your own .NET apps, from your own number

NuGet packages · .NET 8, 9 and 10 · MIT
dotnet add package SmsTunnel

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. 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.
ASP.NET Core
AddSmsTunnel() reads the settings from configuration; inject ISmsTunnelClient where a text goes out.
ASP.NET Core Identity
Phone confirmation and two-factor sign-in codes, texted from your own phone.
Safe retries
An idempotency key per text: a retry gets the first answer back instead of sending a second text.
Requirements
.NET 8 or newer (.NET 9 and 10 too). No dependencies; trimming- and Native AOT-safe.

1. Install

dotnet add package SmsTunnel
# in ASP.NET Core, worker services and other apps with dependency injection:
dotnet add package SmsTunnel.Extensions.DependencyInjection

The client is SmsTunnel; SmsTunnel.Extensions.DependencyInjection adds services.AddSmsTunnel(), with the settings read from configuration and the client's HttpClient from IHttpClientFactory. Both need .NET 8 or newer.

2. Get your key

  1. 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
  2. 2. In your dashboard, open Apps → Add an app, pick .NET, name it (for example "My .NET app") and click Create the key.
  3. 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My .NET app can't send texts anymore.

Get the steps with your key in them

It opens Add an app with .NET 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: in user secrets, an environment variable or your secret store.

3. Send a text

using SmsTunnel;

using var sms = new SmsTunnelClient(Environment.GetEnvironmentVariable("SMSTUNNEL_API_KEY")!);

SendResult result = await sms.SendSmsAsync("+14155550123", "Your order #1042 has shipped.");

Console.WriteLine(result.MessageId); // keep it to look up the status later

Make one client and reuse it: it is thread-safe, and every client you create without your own HttpClient shares one connection pool.

All the options, and what comes back:

SendResult result = await sms.SendSmsAsync(
    "+14155550123",                    // international format; one number per call
    "See you at 10:30.",               // up to 1600 characters
    new SendOptions
    {
        DeviceId = "6aea3248fedd7cccac9abf42",       // the phone that sends it (Phones → Copy); leave it out to let SMSTunnel pick one of yours
        DefaultCountryCode = "+40",                  // completes numbers written without + or 00, like 0721 123 456
        IdempotencyKey = "appointment-981-reminder", // makes a retry safe, see below
    },
    cancellationToken);

result.MessageId; // string
result.Queued;    // true when no phone of yours was online: the text waits and goes out when one is back
result.Branded;   // true when the Free plan added its short line to the text
result.Replayed;  // true when this is the stored answer to an earlier call with the same IdempotencyKey

One call sends one text to one number. To text several people, call SendSmsAsync once for each.

Sign-in and verification codes

await sms.SendSignInCodeAsync("+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

MessageStatus status = await sms.GetMessageStatusAsync(result.MessageId); // needs sms:read

status.State;        // Pending, Sent, Delivered, Failed or Unconfirmed
status.IsFinal;      // true once it won't change: Delivered, Failed or Unconfirmed
status.SentAt;       // DateTimeOffset?
status.ErrorMessage; // why it failed

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

foreach (Phone phone in await sms.GetPhonesAsync())
{
    Console.WriteLine($"{phone.Name}: {(phone.IsOnline ? "online" : "offline")} ({phone.Id})");
}

Usage usage = await sms.GetUsageAsync();
usage.Plan;                 // "free", "twofa" (Starter), "standard", "pro" (Professional) or "enterprise"
usage.Used.SmsClean;        // texts sent today
usage.Remaining.SmsClean;   // texts left today; null means 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.

ReceivedTextPage page = await sms.GetReceivedTextsAsync(new ReceivedTextsQuery
{
    Since = DateTimeOffset.UtcNow.AddDays(-1),
    Phone = "+14155550123", // only from this sender
    Limit = 20,             // 1 to 100
    Page = 1,
});

foreach (ReceivedText text in page.Texts) // newest first
{
    Console.WriteLine($"{text.ReceivedAt:t} {text.Sender}: {text.Message}");
}

page.HasMore; // ask for the next page

SMSTunnel can also post each received text to your own server instead: in your dashboard, Apps → Forward received texts (how it works). Results are records; SendResult, MessageStatus, Usage, Phone and ReceivedText also have a Raw property with SMSTunnel's whole answer as a JsonElement.

In ASP.NET Core

Register the client once; it reads the SmsTunnel configuration section:

builder.Services.AddSmsTunnel();
// appsettings.json: everything except the key, which stays out of the repository
{
  "SmsTunnel": {
    "DefaultCountryCode": "+40", // optional
    "Timeout": "30"              // optional: seconds, or a time span such as 00:00:30
  }
}

The key goes in user secrets while you develop, and in the environment or your host's secret store on a server:

dotnet user-secrets set "SmsTunnel:ApiKey" "<your key>"   # while developing
export SmsTunnel__ApiKey="<your key>"                      # on a server, or your host's secret store

Then inject ISmsTunnelClient wherever a text should go out:

app.MapPost("/orders/{id:int}/shipped", async (int id, ShopDb db, ISmsTunnelClient sms, ILogger<Program> log, CancellationToken ct) =>
{
    Order order = await db.Orders.FindAsync([id], ct) ?? throw new BadHttpRequestException("No such order.");
    order.Status = OrderStatus.Shipped;
    await db.SaveChangesAsync(ct);

    try
    {
        await sms.SendSmsAsync(order.Phone, $"Your order #{order.Id} has shipped.",
            new SendOptions { IdempotencyKey = $"order-{order.Id}-shipped" }, ct);
    }
    catch (SmsTunnelException error)
    {
        log.LogWarning(error, "The shipping text for order {OrderId} did not go out", order.Id);
    }

    return Results.NoContent();
});

AddSmsTunnel also takes a callback (services.AddSmsTunnel(o => o.DefaultCountryCode = "+49")) or another section (services.AddSmsTunnel(builder.Configuration.GetSection("Sms"))). It returns the IHttpClientBuilder of the client's HttpClient, for your own handlers; that HttpClient doesn't follow redirects, so the key is never sent to another address. The same works in a worker service or a console app on the generic host: inject ISmsTunnelClient into the background job, or create a scope from IServiceScopeFactory in a singleton.

ASP.NET Core Identity: phone confirmation and two-factor codes

ASP.NET Core Identity makes the phone codes and leaves the sending to your app. Give it an SMS sender that uses SMSTunnel:

public interface ISmsSender
{
    Task SendSmsAsync(string phoneNumber, string message, CancellationToken cancellationToken = default);
}

public sealed class SmsTunnelSmsSender(ISmsTunnelClient smsTunnel) : ISmsSender
{
    // Identity's phone codes are 6 digits on their own: they go out as sign-in codes, with their own allowance.
    public Task SendSmsAsync(string phoneNumber, string message, CancellationToken cancellationToken = default) =>
        smsTunnel.SendSignInCodeAsync(phoneNumber, message, cancellationToken: cancellationToken);
}
builder.Services.AddSmsTunnel();
builder.Services.AddTransient<ISmsSender, SmsTunnelSmsSender>();

Confirm a phone number:

string code = await userManager.GenerateChangePhoneNumberTokenAsync(user, phoneNumber);
await smsSender.SendSmsAsync(phoneNumber, $"Your verification code is {code}");
// later, with the code the user typed:
IdentityResult result = await userManager.ChangePhoneNumberAsync(user, phoneNumber, typedCode);

Sign in with a texted code, once the phone is confirmed and two-factor sign-in is on (userManager.SetTwoFactorEnabledAsync(user, true)):

SignInResult result = await signInManager.PasswordSignInAsync(email, password, isPersistent: false, lockoutOnFailure: true);
if (result.RequiresTwoFactor)
{
    IdentityUser user = (await userManager.FindByNameAsync(email))!;
    string code = await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultPhoneProvider);
    await smsSender.SendSmsAsync(user.PhoneNumber!, $"Your sign-in code is {code}");
}
// next request, with the code the user typed:
SignInResult signedIn = await signInManager.TwoFactorSignInAsync(TokenOptions.DefaultPhoneProvider, typedCode, isPersistent: false, rememberClient: false);

The IdentitySms sample is a small ASP.NET Core app with these steps: register, sign in, add and confirm a phone number, turn on two-factor sign-in, and sign in with the texted code.

Errors

Every call throws an SmsTunnelException when it doesn't go through:

SmsTunnelAuthenticationException (401)
The API key is wrong, was deleted or turned off, or its account is suspended.
SmsTunnelPermissionException (403)
The key lacks a permission (ErrorCode is PERMISSION_REQUIRED and Permission names it, e.g. sms:inbox), or the plan doesn't include the feature.
SmsTunnelRateLimitException (429)
Too many requests: RetryAfter says how long to wait. Without RetryAfter, the plan's limit for the day or month is reached and the message says when texts can go out again.
SmsTunnelServerException (5xx)
A problem on SMSTunnel's side.
SmsTunnelApiException
The base of the four above, and every other error answer: 400 (a number it can't read, a code text without a code), 404 (no such message or phone), 409 and 422 (an IdempotencyKey still in progress, or reused for another text).
SmsTunnelConnectionException
No answer: DNS, TLS, a refused or broken connection. SmsTunnelTimeoutException when none came within the timeout.

Each SmsTunnelApiException has the HTTP StatusCode, SMSTunnel's ErrorCode (see SmsTunnelErrorCodes), Permission, RetryAfter, ResponseBody, and SMSTunnel's own explanation as Message. Bad arguments (an empty number, two numbers in one, a country code that isn't one) throw ArgumentException before anything is sent; a cancelled token throws OperationCanceledException.

try
{
    await sms.SendSmsAsync(phoneNumber, text, new SendOptions { IdempotencyKey = textId }, ct);
}
catch (SmsTunnelRateLimitException error) when (error.RetryAfter is TimeSpan wait)
{
    await Task.Delay(wait, ct); // then try again, with the same IdempotencyKey
}
catch (SmsTunnelPermissionException error) when (error.ErrorCode == SmsTunnelErrorCodes.PermissionRequired)
{
    log.LogError("The SMSTunnel key lacks {Permission}", error.Permission);
}
catch (SmsTunnelException error)
{
    log.LogWarning(error, "The text did not go out");
}

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 an IdempotencyKey: 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 GUID you store with it. If you add a retry handler to the HttpClient (Polly, Microsoft.Extensions.Http.Resilience), give every send an IdempotencyKey.

Your own HttpClient, and tests

var handler = new SocketsHttpHandler { AllowAutoRedirect = false }; // the key is never sent to another address
var http = new HttpClient(handler);

var sms = new SmsTunnelClient(http, new SmsTunnelClientOptions
{
    ApiKey = Environment.GetEnvironmentVariable("SMSTUNNEL_API_KEY")!,
    DefaultCountryCode = "+40",            // for numbers written without + or 00; a text's own wins
    Timeout = TimeSpan.FromSeconds(15),    // per call; the default is 30 seconds
    // BaseUrl = new Uri("http://127.0.0.1:3141"), // a test server; the default is https://smstunnel.io
});

new SmsTunnelClient(httpClient, options) sends through your HttpClient and doesn't dispose it. Build its handler with AllowAutoRedirect = false. In your own tests, mock ISmsTunnelClient, or give the client an HttpClient with a fake HttpMessageHandler. Results are records with init properties, so tests can build them: new SendResult { MessageId = "test-1" }.

Limits worth knowing

  • 30 sends a minute per key; the keys of one account together get three times that. Over it, SmsTunnelRateLimitException with RetryAfter.
  • 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 DefaultCountryCode (+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

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 .NET app with SMSTunnel.

About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.

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

How it works:
- Use the official NuGet package: dotnet add package SmsTunnel (.NET 8+, no dependencies), namespace SmsTunnel. Without dependency injection: using var sms = new SmsTunnelClient(apiKey); make one client and reuse it (it is thread-safe). Every method is async and takes a CancellationToken.
- In ASP.NET Core, a worker service or any app on the generic host: also dotnet add package SmsTunnel.Extensions.DependencyInjection, call builder.Services.AddSmsTunnel() (it reads the SmsTunnel configuration section: ApiKey, DefaultCountryCode, Timeout, BaseUrl) and inject ISmsTunnelClient. The key goes in user secrets (dotnet user-secrets set "SmsTunnel:ApiKey" ...) or the environment variable SmsTunnel__ApiKey, never in appsettings.json.
- sms.SendSmsAsync(phoneNumber, message, new SendOptions { DeviceId, DefaultCountryCode, IdempotencyKey }, cancellationToken) returns a SendResult with 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: sms.SendSignInCodeAsync("+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. For ASP.NET Core Identity's phone confirmation and two-factor codes, register an ISmsSender that calls SendSignInCodeAsync.
- sms.GetMessageStatusAsync(messageId): State (Pending, Sent, Delivered, Failed or Unconfirmed), IsFinal, SentAt, ErrorMessage. Unconfirmed means the phone handed it to the SIM without a confirmation: it most likely went out, so never resend it automatically.
- Errors, all SmsTunnelException: SmsTunnelAuthenticationException (401, bad key), SmsTunnelPermissionException (403; ErrorCode such as SmsTunnelErrorCodes.PermissionRequired, and Permission), SmsTunnelRateLimitException (429; RetryAfter; without it, the plan's daily or monthly limit is reached), SmsTunnelServerException (5xx), SmsTunnelApiException for the other answers (400, 404, 409, 422), SmsTunnelConnectionException and SmsTunnelTimeoutException when no answer comes. Bad arguments throw ArgumentException before anything is sent.
- The client never retries by itself. Retry a send only with the same IdempotencyKey (for example $"order-{id}-shipped"): a repeat within 24 hours gets the first answer back (Replayed is true) instead of a second text; if the first call ended in a 5xx, the repeat gets the same error back, and still no second text. If you add a retry handler to the HttpClient (Polly, Microsoft.Extensions.Http.Resilience), give every send an IdempotencyKey.
- Numbers in international format. DefaultCountryCode on a text, or 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.
- Send the text once the change is saved (after SaveChangesAsync), 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.

.NET, ASP.NET Core and NuGet are trademarks of Microsoft Corporation. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by Microsoft.

No phone paired yet?

Install the Android app and sign in with your account, and you're ready in about a minute.

Get it on Google Play