# Send alerts and on-call pages by SMS

Source: https://joinsimplesms.com/use-cases/alerts
Index: https://joinsimplesms.com/llms.txt

You build the last hop of an alerting pipeline: a monitor fires, your router picks who is on call, and a text reaches their phone. The API gives you a deduplicated send, a delivery receipt to drive escalation, a reply webhook for acknowledgements, and a monthly cap so a flapping monitor cannot run up a bill.

## What you build

- **A pager function** that takes an incident and a recipient and calls `POST /v1/messages` with an idempotency key built from the incident id, the recipient and the escalation level.
- **An escalation timer** in your own system. It starts when the page is sent and is cleared by a `message.delivered` webhook plus an acknowledgement, or fires and pages the next person.
- **Acknowledge by reply.** The engineer texts back `ACK 4812`; a `message.received` webhook resolves the incident number and stops the escalation.
- **A storm guard.** A monthly spend limit, with `spend.threshold_reached` webhooks at the percentages you choose, routed to a channel a human reads.

The same pieces serve customer-facing alerts: a suspicious sign-in, a payment that failed, an outage notice to affected accounts.

## How it works

1. Buy a number. For customer-facing alerts, register the use case that matches: `account_notification`, `security_alert` or `fraud_alert`.
2. Call `POST /v1/messages` with an `Idempotency-Key`. A monitor that fires the same incident five times in a minute produces five calls and one text, because the key and payload match and the later calls replay the first response.
3. Branch on the status code. `201`: accepted by the carrier. `202`: the carrier had a temporary problem, the message is `queued`, and `next_attempt_at` says when the retry runs (retries follow at 30 seconds, 2 minutes and 10 minutes). For a page, treat `202` as "not delivered yet" and start escalating in parallel. `429 rate_limited`: wait for `Retry-After`. `502 carrier_error`: permanent, escalate now.
4. Wait for the receipt. `message.delivered` means the carrier confirmed delivery. `message.failed` carries `failure.code`; for a pager the one to plan for is `unreachable`, a phone that is off or out of coverage.
5. If you would rather poll, `GET /v1/messages/{id}` returns the message with a `timeline` of every step and its timestamp.
6. On `message.received`, parse the acknowledgement and close the loop.

## Code

Page one person, deduplicated by incident and escalation level:

```bash
curl -X POST https://api.joinsimplesms.com/v1/messages \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: inc-4812-oncall-primary-l1" \
  -d '{
    "from": "+15005550100",
    "to": "+15005550006",
    "body": "Acme Ops: [SEV1] checkout-api error rate 14% for 5 min. Incident 4812. Reply ACK 4812 to acknowledge."
  }'
```

Cap the month and choose the alert thresholds:

```bash
curl -X PATCH https://api.joinsimplesms.com/v1/spend-limit \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monthly_limit_usd": 50, "alert_thresholds": [50, 80, 100]}'
```

Node.js. A shorter request timeout suits paging; the SDK retries transient failures with the same idempotency key, so a retry cannot double-page:

```js
import { SimpleSMS, SimpleSMSError } from 'joinsimplesms';

const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY, { timeout: 5000, maxRetries: 2 });

export async function page(incident, responder, level) {
  try {
    const message = await sms.messages.send(
      {
        from: '+15005550100',
        to: responder.phone,
        body: 'Acme Ops: [' + incident.severity + '] ' + incident.summary +
          '. Incident ' + incident.id + '. Reply ACK ' + incident.id + ' to acknowledge.',
      },
      { idempotencyKey: 'inc-' + incident.id + '-' + responder.id + '-l' + level }
    );
    // "queued" means the carrier is being retried: keep the escalation timer short.
    return { messageId: message.id, accepted: message.status !== 'queued' };
  } catch (err) {
    if (err instanceof SimpleSMSError) {
      // recipient_opted_out, spend_limit_reached, carrier_error, rate_limited, ...
      return { failed: err.code, retryable: err.retryable };
    }
    throw err;
  }
}
```

Python:

```python
from joinsimplesms import SimpleSMS, SimpleSMSError

client = SimpleSMS(timeout=5.0, max_retries=2)  # reads SIMPLESMS_API_KEY

def page(incident, responder, level: int) -> dict:
    try:
        message = client.messages.send(
            responder.phone,
            f"Acme Ops: [{incident.severity}] {incident.summary}. "
            f"Incident {incident.id}. Reply ACK {incident.id} to acknowledge.",
            from_="+15005550100",
            idempotency_key=f"inc-{incident.id}-{responder.id}-l{level}",
        )
        return {"message_id": message["id"], "accepted": message["status"] != "queued"}
    except SimpleSMSError as err:
        return {"failed": err.code, "retryable": err.retryable}
```

The webhook that drives escalation and acknowledgement:

```js
import express from 'express';
import { verifyWebhook } from 'joinsimplesms';

const app = express();

app.post('/webhooks/simplesms', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook({
      payload: req.body, // raw Buffer
      headers: req.headers,
      secret: process.env.SIMPLESMS_WEBHOOK_SECRET,
    });
  } catch {
    return res.status(400).end();
  }
  res.status(200).end(); // acknowledge first, then work

  const { type, data } = event;
  if (type === 'message.delivered') await markPageDelivered(data.message_id);
  if (type === 'message.failed') await escalateNow(data.message_id, data.failure.code);
  if (type === 'message.received') {
    const match = /^ACK\s+(\d+)/i.exec(String(data.body).trim());
    if (match) await acknowledge(match[1], data.from);
  }
  if (type === 'spend.threshold_reached') await warnHumans(data.threshold_percent, data.limit_usd);
});
```

Sandbox numbers let you test each branch: `+15005550014` fails as `unreachable`, `+15005550013` delivers after a delay of at least 5 seconds, `+15005550001` stays `queued` forever, and `+15005550012` answers `429` with `Retry-After`.

## Compliance notes

- **On-call staff are recipients too.** Get their agreement to be paged by text, including at night. SimpleSMS does not apply quiet hours and does not hold messages; for paging that is the point, and the consent should say so.
- **STOP silences pages.** If an engineer replies STOP, every later page to them answers `403 recipient_opted_out`, because the suppression list is account-wide. Subscribe to `message.opted_out` and `message.blocked` so the rotation finds out immediately. Texting START to your number opts them back in.
- **Verified numbers work before approval.** A live US local number that is not yet linked to an approved registration can still text your verified numbers, up to 3 of them. That covers a small rotation while the carriers review. Everyone else gets `403 sender_not_registered` until the number is `active`.
- **Match the registered use case.** Alerts to customers go under the alert use case you registered. A "we are back up, here is 20% off" message is marketing and does not belong on that sender.
- **Keep secrets out of the body.** Request logs redact API keys and verification codes, not the incident text you write, and the message sits on a phone lock screen.

## What it costs

- **$0.009 per page**, carrier fees included. A page that fails before the carrier accepts it is not billed.
- **$0.95 per number, per month.**
- **Acknowledgement replies:** inbound is listed at $0.004 and is not metered yet.
- **Worked example:** 50 pages a day is 1,500 a month: $14.45 with one number.
- **A storm:** a monitor that pages 10,000 times in a day costs $90.00. Each message counts against the spend limit before it is sent, so a cap bounds this.

See [pricing](/pricing) for the full table.

## Limits and caveats

- SMS delivery is carrier-dependent. The docs promise no delivery time, and some carriers never return a receipt. Do not make SMS the only channel for a page that must be seen.
- A spend limit that is reached stops pages too: sends answer `403 spend_limit_reached` until the 1st (UTC) or until you raise the cap. Set the cap with headroom and alert on the 50 and 80 percent thresholds.
- The carrier retry schedule is fixed. A page that lands in it is late by up to the retry delays, and you cannot shorten them.
- A send that times out at the carrier after handoff fails with `carrier_timeout` and is not retried automatically.
- The default rate limit is 60 requests a minute per key, and paid accounts have an abuse ceiling of 10,000 messages a day, raised on request. The free tier allows 10 texts a day.
- SMS only: no voice calls and no push. Recipients must have US or Canadian numbers, so staff elsewhere cannot be paged this way.
- Idempotency keys last 24 hours.
- Live keys need live-access review; the sandbox simulates every outcome above.

## Related docs

- [Messages](/docs/messages): idempotency, automatic retries, the timeline
- [Spend limits](/docs/spend-limits): caps, thresholds and the `spend-limit` API
- [Webhooks](/docs/webhooks): receipts, signatures, retry schedule
- [Errors](/docs/errors): status codes and delivery failure codes
- [Deliverability](/docs/deliverability): `deliverability.degraded` alerts on your own traffic
- [Sandbox and test numbers](/docs/sandbox): simulate failures, delays and rate limits
- [Numbers](/docs/numbers): sender status and verified numbers
