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/messageswith 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.deliveredwebhook plus an acknowledgement, or fires and pages the next person. - Acknowledge by reply. The engineer texts back
ACK 4812; amessage.receivedwebhook resolves the incident number and stops the escalation. - A storm guard. A monthly spend limit, with
spend.threshold_reachedwebhooks 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
- Buy a number. For customer-facing alerts, register the use case that matches:
account_notification,security_alertorfraud_alert. - Call
POST /v1/messageswith anIdempotency-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. - Branch on the status code.
201: accepted by the carrier.202: the carrier had a temporary problem, the message isqueued, andnext_attempt_atsays when the retry runs (retries follow at 30 seconds, 2 minutes and 10 minutes). For a page, treat202as "not delivered yet" and start escalating in parallel.429 rate_limited: wait forRetry-After.502 carrier_error: permanent, escalate now. - Wait for the receipt.
message.deliveredmeans the carrier confirmed delivery.message.failedcarriesfailure.code; for a pager the one to plan for isunreachable, a phone that is off or out of coverage. - If you would rather poll,
GET /v1/messages/{id}returns the message with atimelineof every step and its timestamp. - On
message.received, parse the acknowledgement and close the loop.
Code
Page one person, deduplicated by incident and escalation level:
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:
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:
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:
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:
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 tomessage.opted_outandmessage.blockedso 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_registereduntil the number isactive. - 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_reacheduntil 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_timeoutand 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: idempotency, automatic retries, the timeline
- Spend limits: caps, thresholds and the
spend-limitAPI - Webhooks: receipts, signatures, retry schedule
- Errors: status codes and delivery failure codes
- Deliverability:
deliverability.degradedalerts on your own traffic - Sandbox and test numbers: simulate failures, delays and rate limits
- Numbers: sender status and verified numbers