Send alerts and on-call pages by SMS

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

More in use cases