# The Sms kit — send a text message, keep a record, pick a carrier

> `Osysharp.Sms` is the contract between an app that sends text messages and whatever carries them, shaped like the Mail kit. `SendSms` writes the message to an outbox in the caller's own commit; a workflow delivers it after the commit through the app's sender, retrying a sender that is down, and HOLDS it — kept, with the reason — when there is no sender. A number is written E.164 or refused where it is written. A sender is an interface: `Osysharp.Sms.FortySixElks` (46elks) is one.

<!-- id: ui-sms-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/sms-kit/ -->

## Summary        {#summary}
`use Osysharp.Sms@0;` gives an app one way to send a text message, whichever carrier it ends up using:

```osy syntax
SendSms(new SmsMessage { To = booking.Mobile, Text = $"See you at {booking.StartsAt:HH:mm}." }, $"reminder-{booking.Id}");
```

The message is a row in the outbox, written in the **same commit** as whatever it is about — so a text about a change
that was rolled back is never sent — and delivered after that commit through the sender `app.Sms` names.

## Signature      {#signature}
```osy syntax
use Osysharp.Sms@0;                                   // with Osysharp.Identity@0 and Osysharp.Workflow

OutgoingSms SendSms(SmsMessage message, string? idempotencyKey = null);
string      NormalisedPhone(string number);           // "+46 70-123 45 67" → "+46701234567"; "" when not E.164
bool        IsPhoneNumber(string number);

app.Sms = new SmsSetup { Sender = <an ISmsSender>, From = "Acme" };

public interface ISmsSender {
  string Key { get; }
  bool IsReady();
  string SendSms(SmsMessage message, string idempotencyKey);
  string NotReadyReason() => $"The text-message sender '{Key}' is not ready — its credentials are not set.";   // a default
}
```

## Description    {#description}

### What does a message carry, and which numbers are accepted?   {#message}
`SmsMessage` has `From` (a number the app owns, or a sender name of up to eleven letters and digits — which cannot be
replied to), `To` and `Text`. `To` must be **E.164** — a `+`, the country code and the number. `SendSms` normalises what
people type (`+46 70-123 45 67`, `0046 70 123 45 67`) and **refuses a local form** (`0701234567`) where the message is
written, naming the missing country code; a carrier's own refusal would arrive later and name a parameter.

### What happens to a message after SendSms?      {#outbox}
`OutgoingSms` is the delivery record: `Status` is `Queued`, then `Sent` (with `SentAt`, `SentWith` — the sender's `Key` —
and `ProviderId`, the carrier's id) or `Held` (with `Problem` saying why). A sender that throws is retried by the delivery
workflow.

- **No sender, or one not ready**: the message is **Held**, with the sender's own `NotReadyReason()`. The app keeps
  working, and the held codes are there to read.
- **`idempotencyKey`**: the same key twice is ONE message, so a handler that runs twice texts once.

### Who may send, and who may read the outbox?     {#security}
The outbox grants only the sign-in flow (`IsAuthenticator`, which texts one-time codes). An app grants its own people
with a partial:

```osy syntax
partial entity OutgoingSms { security { allow create, read when IsAgent; } }
```

Nobody else can put a row there — so nobody can text strangers at the app's expense — and **nobody reads it who was not
granted, not even the recipient**: a one-time code sits in those rows, and reading it out of the outbox would skip the
phone it proves.

### Forgetting a number                          {#erase}
`EraseSmsTo(number)` deletes every text the outbox holds to that number (written any way — it is normalised first) —
for a person exercising their right to erasure. It runs with the caller's rights; the app decides who may ask.
`Osysharp.Conversations` calls it through its text channel when a customer is forgotten.

### Which carriers can send, and how is one chosen?   {#senders}
A sender implements `ISmsSender`: `Key`, `IsReady()` (false leaves messages held) and `SendSms(message, idempotencyKey)`,
answering the carrier's id. `NotReadyReason()` has a **default** ([[class-interfaces#defaults]]); a sender that knows which
credential is missing says so. `osy kits Osysharp.Sms` lists the implementations the project you run it in can see, and
which one this app wired:

Generated from the kits in scope (`osy kits Osysharp.Sms` lists the same):

- `ElksSmsSender` — `Osysharp.Sms.FortySixElks` (`use Osysharp.Sms.FortySixElks@0;`)

**`Osysharp.Sms.FortySixElks`** takes the API username and password from the app's own secrets, and `DryRun = true` prices every
message without sending it — for a staging app that must never text a real person:

```osy syntax
use Osysharp.Sms.FortySixElks@0 { egress "api.46elks.com"; }
use Osysharp.FortySixElks@0 { egress "api.46elks.com"; }

app.Sms = new SmsSetup {
  Sender = new ElksSmsSender {
    Username = Security.IsSecretSet("ElksUsername") ? Secret.ElksUsername : "",
    Password = Security.IsSecretSet("ElksPassword") ? Secret.ElksPassword : "",
  },
  From = "Acme",
};
app.Secrets = [ new Secret("ElksUsername"), new Secret("ElksPassword") ];
```

### Is a texted code a second factor?              {#not-a-factor}
Not one an app can **require**. A code by text proves a number *today*; a SIM swap or a port-out moves the number to
somebody else (NIST 800-63B lists SMS as "restricted"). [The UserAccounts kit — sign-in done properly, once](https://osysharp.com/reference/security/accounts-kit/) uses this kit to prove a number, to
sign in with a texted code, and as a second step an account may CHOOSE — and still asks an account the app requires a
second factor of for an authenticator app.

## Examples       {#examples}

### Text a customer a reminder through 46elks, and keep the record   {#example-reminder}
```osy title="send after the commit, held until the carrier's credentials are set" test app=ui-sms-kit
app Bookings {
  use Osysharp.Workflow;
  use Osysharp.Identity;
  use Osysharp.Sms;
  use Osysharp.Sms.FortySixElks { egress "api.46elks.com"; }
  use Osysharp.FortySixElks { egress "api.46elks.com"; }
  use Osysharp.Http;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Sms;
using Osysharp.Sms.FortySixElks;

[Role] enum AppRole { Authenticator, Clerk }
policy IsClerk => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Clerk);

app.Sms = new SmsSetup {
  Sender = new ElksSmsSender {
    Username = Security.IsSecretSet("ElksUsername") ? Secret.ElksUsername : "",
    Password = Security.IsSecretSet("ElksPassword") ? Secret.ElksPassword : "",
  },
  From = "Acme",
};
app.Secrets = [ new Secret("ElksUsername"), new Secret("ElksPassword") ];

// The app's clerks may send, and read what was sent.
partial entity OutgoingSms { security { allow create, read when IsClerk; } }

entity Booking {
  [Required, MaxLength(20)] string Mobile;
  [MaxLength(40)] string? ReminderStatus;
  security { allow read, create, update when IsClerk; }
}

void Remind(Booking booking) {
  var sms = SendSms(new SmsMessage { To = booking.Mobile, Text = "Your table is ready at seven." }, $"reminder-{booking.Id}");
  booking.ReminderStatus = sms.Status.ToString();
}
```

## See also       {#see-also}
- [The Mail kit — send a message, keep a record, pick a sender](https://osysharp.com/reference/ui/mail-kit/) — the same shape for mail
- [The UserAccounts kit — sign-in done properly, once](https://osysharp.com/reference/security/accounts-kit/) — one-time codes by text: a proven number, a sign-in code, an optional second step
- [Interfaces — a contract several types can satisfy](https://osysharp.com/reference/class/interfaces/) — a contract grows by a member with a default
