# SimpleSMS · SMS for Developers Programmable SMS and phone numbers. Base URL: https://api.joinsimplesms.com/v1 Authentication: Authorization: Bearer ssms_sk_test_... (or ssms_sk_live_...) Console (free sandbox keys): https://joinsimplesms.com/console OpenAPI: https://joinsimplesms.com/api/v1/openapi.yaml (also .json) --- # Quickstart Send your first SMS in under five minutes. No card, no sales call: a test key works instantly against the sandbox. ## Choose your path The console asks what you are here to do and shows only the steps that goal needs. The same four paths, in the docs: - **Send SMS from your app.** Stay on this page: get a key, send a test, then [buy a number](/docs/numbers), [register your use case](/docs/compliance) and go live. You never need to import contacts. - **Send a broadcast to customers.** Upload your list to [Contacts](/docs/contacts), then compose, preview, check and send a [broadcast](/docs/broadcasts). - **Build an automated SMS flow.** Emit events from your code and let a flow send the messages, with waits and conditions, under [Automations in the console](/console/automations). - **Move from Twilio.** Bring numbers, sender pools and webhooks for app messaging, or numbers, contacts and templates for marketing: [Migrate from Twilio](/docs/migrate-from-twilio). A marketing send is a *broadcast*. "Campaign" here only ever means the use case you register with carriers, which lives under [Compliance](/docs/compliance). ## 1. Get a key Create a free account at [the console](/console). A sandbox tenant is provisioned automatically with a test key (`ssms_sk_test_...`) and a sandbox number. The key is shown once, so copy it. ## 2. Send a message ```bash curl -X POST https://api.joinsimplesms.com/v1/messages \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+15005550100", "to": "+15005550006", "body": "Hello from SimpleSMS" }' ``` Replace `from` with your sandbox number (shown in the console). `+15005550006` is a magic sandbox number that simulates successful delivery. Put the phone you verified in the console in `to` instead and the text really arrives. ## 3. Read the response ```json { "id": "msg_a1B2c3D4e5F6g7H8", "object": "message", "to": "+15005550006", "from": "+15005550100", "body": "Hello from SimpleSMS", "direction": "outbound", "status": "delivered", "test": true, "created_at": "2026-08-06T16:20:00.000Z" } ``` Fetch it back anytime with `GET /v1/messages/{id}`, and watch the delivery lifecycle in `GET /v1/events` (`message.sent`, then `message.delivered`). The message's `timeline` shows each step with its timestamp. ## 4. Simulate a reply ```bash curl -X POST https://api.joinsimplesms.com/v1/test/inbound \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+15005550100", "from": "+14155550132", "body": "Hey, got your message!" }' ``` The inbound message lands in `GET /v1/messages` and emits a `message.received` event, exactly what a real inbound SMS will do in live mode. ## 5. Receive events (webhooks) Instead of polling, add an endpoint URL under [Webhooks in the console](/console/webhooks) and every event is POSTed to you, signed, with automatic retries. Details in the [webhooks docs](/docs/webhooks). ## 6. Go live When you're ready to send real SMS from real numbers, request live access from the [console](/console): one sentence about what you're building, and we usually flip the switch same day. Live access is free during early access. --- # Authentication Every request is authenticated with an API key in the `Authorization` header: ```bash Authorization: Bearer ssms_sk_test_... ``` ## Test and live keys | Prefix | Mode | Behavior | | --- | --- | --- | | `ssms_sk_test_` | Sandbox | Instant, free, simulated delivery; no real SMS ever leaves the sandbox. | | `ssms_sk_live_` | Live | Real numbers and real delivery. Mintable once your account has live access. | Keys issued with an older prefix (`dsms_sk_*`, `resms_sk_*`) are still accepted and will never be revoked for their prefix; new keys mint as `ssms_sk_`. See [what else kept working](/docs/changelog#2026-10-05-delivered-is-now-simplesms) when Delivered became SimpleSMS. Keys never expire, but you can roll or revoke them anytime from the [console](/console/keys). Rolling revokes the old key immediately and mints a replacement. ## Storage Keys are stored hashed (SHA-256), so we can never display a key again after minting it. If you lose one, roll it. ## Scopes A key has full access unless you restrict it. In [Console → API keys](/console/keys), choose **Restricted** and tick the scopes the key needs; a key that leaks can then only do what its job required. Rolling a key keeps its scopes. | Scope | Allows | | --- | --- | | `messages:send` | `POST /v1/messages`, `POST /v1/test/inbound` | | `messages:read` | `GET /v1/messages`, `GET /v1/messages/{id}`, `GET /v1/deliverability` | | `numbers:read` | `GET /v1/numbers`, `GET /v1/numbers/available` | | `numbers:write` | `POST /v1/numbers`, `PATCH /v1/numbers/{id}`, `DELETE /v1/numbers/{id}` | | `verify` | `/v1/verify`, `/v1/verify/check`, `/v1/verify/{id}` | | `consent` | `/v1/consent` and everything under it | | `lookup` | `GET /v1/lookup/{phone}` | | `webhooks` | `GET /v1/events`, `POST /v1/events/{id}/replay`, `GET /v1/webhooks`, `GET /v1/webhooks/deliveries` | | `customers` | `/v1/customers` and everything under it | | `contacts` | `/v1/contacts`, `/v1/segments`, `/v1/topics` and everything under them | | `registrations` | `/v1/registrations` and everything under it | | `billing` | `GET`/`PATCH /v1/spend-limit` | | `automations` | `POST /v1/track`, `/v1/automations` and everything under it | | `audit_logs:read` | `GET /v1/audit-logs`, `GET /v1/exports/audit_log` | A restricted key calling outside its scopes gets: ```json { "error": { "code": "insufficient_scope", "message": "This API key is missing the `messages:send` scope. Use a key with that scope, or mint one in the console (API keys).", "required_scope": "messages:send" } } ``` Keys minted before scopes existed, and keys minted without choosing any, keep full access. ## Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Missing, malformed, revoked, or unknown key. | | 403 | `live_access_required` | Live key used before live access was granted, or a live-only endpoint hit with a test key. | | 403 | `insufficient_scope` | A restricted key called an endpoint outside its scopes; `required_scope` names the one it needs. | | 403 | `tenant_suspended` | The account is suspended. | --- # Sandbox & test numbers Test keys (`ssms_sk_test_`) run against a fully simulated environment: no carrier traffic, no charges, and nothing real ever sent. Every endpoint works, so you can build your whole integration, including webhooks, before going live. ## Your sandbox number Signup provisions a sandbox number in the reserved `+1 500-555-XXXX` range. It's the `from` for outbound tests and the `to` for simulated inbound. You can "purchase" more from `GET /v1/numbers/available` + `POST /v1/numbers`; the whole numbers API works in the sandbox. ## Your own phone The console asks for your cell once, right after signup, and texts you a code. Once it's verified, a test key delivers to **that one number for real**, so your first API call makes your phone buzz. Every other destination stays simulated. Real sandbox texts are capped at 10 a day and end with `- via SimpleSMS sandbox`. The response is a normal message with `"test": true` and an `X-Sandbox-Real-Delivery: true` header. Your phone also counts as your first verified recipient when you go live on the free tier. ## Magic destination numbers Send **to** these numbers to trigger fixed behaviors: | Number | Behavior | | --- | --- | | `+15005550006` | Delivered: `message.sent` then `message.delivered`; the message is `delivered` by the time the send returns. Any other number behaves the same. | | `+15005550013` | Delayed delivery: status `sent`, then really delivered at least 5 seconds later. Poll `GET /v1/messages/{id}` (it settles as soon as the delay has passed) or wait for the `message.delivered` webhook (within about a minute). Use it to test code that waits on delivery. | | `+15005550001` | Stuck: status stays `queued` forever, no delivery event. | | `+15005550002` | Failed, no reason given: `failure.code` `unknown`. | | `+15005550007` | Failed: filtered as spam, `failure.code` `carrier_filtered`. | | `+15005550008` | Failed: refused by the carrier, `failure.code` `carrier_rejected`. | | `+15005550009` | Failed: number not in service, `failure.code` `invalid_number`. | | `+15005550010` | Failed: a landline, `failure.code` `landline` (and `GET /v1/lookup` says `landline`). | | `+15005550014` | Failed: phone unreachable, `failure.code` `unreachable`. | | `+15005550011` | Opted out: `403 forbidden`, exactly as for a recipient who replied STOP. Nothing is written to your opt-out list. | | `+15005550012` | Rate limited: `429 rate_limited` with a `Retry-After` header. Nothing is stored. | Failed numbers return `201` with status `failed` and emit `message.sent` then `message.failed`; the `failure` object and the event payload are the same ones a real carrier failure produces (see [Errors](/docs/errors#delivery-failures)). Every sandbox message has a full `timeline`, `segments`, `encoding`, a `price` of 0, and `destination_carrier` from the sandbox lookup. ## Simulated inbound `POST /v1/test/inbound` delivers a fake inbound SMS to one of your sandbox numbers through the real pipeline: it appears in `GET /v1/messages` and emits a `message.received` event. (Test keys only; live keys get a 403 `test_mode_only`.) ## Sandbox limits A ceiling of 1,000 messages/day per account keeps the sandbox healthy. It's not a product quota. If you legitimately hit it, tell us. --- # Messages ## Send a message `POST /v1/messages` ```bash curl -X POST https://api.joinsimplesms.com/v1/messages \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+15005550100", "to": "+15005550006", "body": "Hello" }' ``` | Parameter | Type | Notes | | --- | --- | --- | | `from` | string | A number you own (E.164), or a sender pool id (`pool_...`): SimpleSMS picks a member, the same one per recipient. See [Numbers](/docs/numbers). | | `to` | string | Destination number (E.164, US/Canada). | | `body` | string | Up to 1600 characters. | | `customer_id` | string | Optional. Attribute the message to one of your [customers](/docs/customers). Defaults to the customer `from` is assigned to. | Returns `201` with a Message object. `status` starts at `sent` (or `queued`) and progresses via events. ### Scheduling Add `scheduled_at` (a future ISO timestamp or epoch ms, up to 30 days out) to queue the send. The response is a `scheduled_message` object; see [Scheduled](/docs/scheduled). ### Automatic retries If the carrier has a temporary problem (it is overloaded, throttling, or unreachable), we do not fail your send. The response is **`202`** with the message `queued`, and we retry it for you after 30 seconds, 2 minutes and 10 minutes. `attempts` counts the tries so far and `next_attempt_at` says when the next one runs, and the message's `timeline` records every attempt (`sent_to_carrier` and `retry_scheduled` with an `attempt` number). You get `message.sent` when the carrier accepts it, or `message.failed` if every attempt fails. You pay only for messages the carrier accepts. A message is never sent twice. We only retry when we know the carrier did not take the message. If the carrier stops answering after we handed it the message, we cannot know, so the send fails with `failure_reason: "carrier_timeout"` and you decide whether to send again. A permanent rejection (a bad number, say) fails straight away with `502 carrier_error`, as before. ### Idempotency Pass an `Idempotency-Key` header to make retries safe: the same key + same payload replays the original response (with an `Idempotent-Replayed: true` header); the same key with a different payload returns `409 idempotency_conflict`. Keys last 24 hours. Only successful responses are kept: if a request fails, the key is released, so retrying with the same key runs it again. `POST /v1/verify` and `POST /v1/numbers` accept the header too. With `scheduled_at`, the schedule time is part of the payload, so a retried scheduling request never queues a second send. ### With the SDKs Both SDKs add an idempotency key to every send automatically, so their built-in retries can never double-send. ```js import { SimpleSMS } from 'joinsimplesms'; const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY); const message = await sms.messages.send({ from: '+15005550100', to: '+15005550006', body: 'Hello', }); ``` ```python from joinsimplesms import SimpleSMS client = SimpleSMS() # reads SIMPLESMS_API_KEY message = client.messages.send(to="+15005550006", text="Hello") # from_ is optional while your account has one number ``` ## Retrieve a message `GET /v1/messages/{id}`; the id is the `msg_...` from the send response. ## List messages `GET /v1/messages?limit=25&cursor=...&status=failed&created_after=2026-10-01T00:00:00Z` Newest first. Responses are `{ "data": [...], "has_more": bool, "next_cursor": "..." }`; pass `next_cursor` back as `cursor` with the same filters for the next page. | Filter | Notes | | --- | --- | | `status` | `queued`, `sent`, `delivered`, `failed`, or `received`. | | `direction` | `outbound` or `inbound`. | | `to` / `from` | Exact number (E.164). | | `number` | Either side: messages to or from this number. | | `created_after` | Inclusive. ISO timestamp or epoch ms. | | `created_before` | Exclusive. ISO timestamp or epoch ms. | | `customer_id` | One [customer](/docs/customers)'s messages. | Filters combine. Pages come back full: a page shorter than `limit` with `has_more: false` is the end. A very sparse filter over a long history can return a short page with `has_more: true` (one request reads at most 2,000 messages); keep following `next_cursor`. Narrow with a date range to make those requests fast. The SDKs page for you: `sms.messages.listAll()` (Node, an async iterator) and `client.messages.list_all()` (Python, a generator). ## The Message object ```json { "id": "msg_a1B2c3D4e5F6g7H8", "object": "message", "to": "+15005550007", "from": "+15005550100", "body": "Your order shipped", "direction": "outbound", "status": "failed", "test": true, "created_at": "2026-10-01T17:02:11.204Z", "timeline": [ { "status": "accepted", "at": "2026-10-01T17:02:11.204Z" }, { "status": "validated", "at": "2026-10-01T17:02:11.231Z" }, { "status": "queued", "at": "2026-10-01T17:02:11.248Z" }, { "status": "sent_to_carrier", "at": "2026-10-01T17:02:11.249Z" }, { "status": "carrier_accepted", "at": "2026-10-01T17:02:11.412Z" }, { "status": "failed", "at": "2026-10-01T17:02:13.020Z" } ], "segments": 1, "encoding": "gsm7", "price": { "total": 0, "currency": "usd", "breakdown": [{ "label": "Sandbox message (test mode is never billed)", "amount": 0 }] }, "destination_carrier": "Sandbox Wireless", "failure": { "code": "carrier_filtered", "title": "Filtered as spam", "explanation": "The recipient's mobile carrier filtered this message as spam or unwanted traffic, so it never reached the phone.", "action": "Recommended: identify your business in the first words, ...", "carrier_code": null }, "failure_reason": "stat:UNDELIV err:000 (sandbox) message filtered as spam by the destination carrier" } ``` | Field | Notes | | --- | --- | | `timeline` | Every step, oldest first: `accepted` (the API got the request; for a scheduled send, when you scheduled it), `validated` (every check passed), `queued`, `sent_to_carrier`, `carrier_accepted`, then `delivered` or `failed` from the delivery receipt. Steps from `sent_to_carrier` on carry the carrier `attempt` they belong to; an attempt that failed temporarily adds `retry_scheduled` and the next attempt follows (see [automatic retries](#automatic-retries)). Inbound messages have one step, `received`. The gaps are real latencies. | | `segments` | How many parts the body is split into on the handset. | | `encoding` | `gsm7` (160 characters in one segment, 153 per segment when split) or `ucs2` (70, then 67). One character outside the GSM alphabet (an emoji, a curly quote) switches the whole message to `ucs2`. Some symbols (`€ [ ] { } ~ ^`) count as two characters in `gsm7`. | | `price` | What this message costs, in USD. Outbound SMS is $0.009 per message whatever its length, with carrier fees included (shown as a 0 line). Messages that cost nothing say why: sandbox, free-tier allowance, failed before the carrier accepted it (including a retried send whose every attempt failed; any spend reserved for it is released), inbound, automatic replies. `null` on messages sent before prices were recorded. | | `destination_carrier` | The recipient's mobile carrier when a recent [lookup](/docs/lookup) of the number is cached; otherwise `null`. Sending never runs a paid lookup. | | `failure` | `null` unless `status` is `failed`. A stable `code`, a `title`, a plain `explanation`, a recommended `action`, and the carrier's raw `carrier_code` when it sent one. Codes are listed in [Errors](/docs/errors#delivery-failures). | | `failure_reason` | The carrier's raw text for a failed send. Kept for compatibility; `failure` is the readable version. | ## Statuses `queued` → `sent` → `delivered` (or `failed`). Inbound messages have direction `inbound` and status `received`. Carriers report delivery for most messages within minutes. Some never report at all. If a sent message has no delivery report 72 hours after the carrier accepted it, it gets `receipt_status: "missing"`. Its `status` stays `sent`: we know the carrier accepted it, and we will not guess whether it arrived. No event fires. A late report still updates the message and clears the flag. --- # Verify Phone verification in two calls. SimpleSMS generates the code, sends it, enforces expiry and attempt limits, and defends against SMS pumping. You never store a code, and you don't need to own a phone number. ## The whole integration Two calls, no dependency required: ```js const send = (to) => fetch('https://api.joinsimplesms.com/v1/verify', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIMPLESMS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to }), }).then((r) => r.json()); const check = (to, code) => fetch('https://api.joinsimplesms.com/v1/verify/check', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIMPLESMS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to, code }), }).then((r) => r.json()); ``` Or from the terminal: the CLI ships inside the SDK package, so `npx` needs no install at all: ```bash SIMPLESMS_API_KEY=ssms_sk_test_... npx joinsimplesms verify +14155550132 npx joinsimplesms verify +14155550132 482193 # check the code ``` Or with the official SDK, which adds retries, typed errors and automatic idempotency keys: ```bash npm install joinsimplesms ``` ```js import { SimpleSMS } from 'joinsimplesms'; const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY); await sms.verify.send({ to: '+14155550132' }); const { verified } = await sms.verify.check({ to: '+14155550132', code }); ``` In Python (`pip install joinsimplesms`, no dependencies): ```python from joinsimplesms import SimpleSMS client = SimpleSMS() # reads SIMPLESMS_API_KEY client.verify.send(to="+14155550132") verified = client.verify.check(to="+14155550132", code=code)["verified"] ``` ## Send a code ```bash curl -X POST https://api.joinsimplesms.com/v1/verify \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155550132" }' ``` ```json { "id": "ver_a1B2c3D4e5F6g7H8", "object": "verification", "phone": "+14155550132", "status": "pending", "attempts": 0, "charged": false, "expires_at": "2026-08-07T12:10:00.000Z" } ``` Send an `Idempotency-Key` header so that a retried request, after a timeout for example, returns the first verification instead of texting a second code. It works the same way as [on messages](/docs/messages#idempotency). Optional `app_name` (24 chars max) puts your product's name in the message. The body is a fixed SimpleSMS template; you can't set the text, which is what keeps verification traffic out of spam filtering. ## You don't need a phone number Verify sends from SimpleSMS's own verification numbers, registered under our 10DLC campaign. You don't buy a number, you don't provision anything, and you pay no monthly number fee; verification is the whole product. The same recipient always gets codes from the same sender, so a second code lands in the thread they already have. If you'd rather codes came from a number you own, pass it as `from`: ```json { "phone": "+14155550132", "from": "+16155550184" } ``` Platforms verifying for their own customers can pass `customer_id`; it is echoed on the verification and its events and counted in that [customer's usage](/docs/customers). ## Check the code ```bash curl -X POST https://api.joinsimplesms.com/v1/verify/check \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155550132", "code": "482193" }' ``` ```json { "verified": true, "status": "approved", "attempts": 1, "charged": true } ``` That's the whole integration. ## You only pay when it works A verification is billed **only** when a code is actually verified. Wrong codes, expiries, abandoned flows and anything Shield blocks are free; every response tells you plainly with `charged`. ## Rules SimpleSMS enforces for you | | | | --- | --- | | Code lifetime | 10 minutes | | Check attempts | 5, then the code is dead | | Resend cooldown | 60 seconds per number | | Per number | 5 an hour, 10 a day | Statuses: `pending`, `approved`, `expired`, `max_attempts`, `blocked`. ## Shield SMS pumping is fraud where someone farms revenue-share by triggering verification codes to numbers they control. SimpleSMS blocks it before you're charged: - **US and Canada only.** Country code +1 also covers Jamaica, the Dominican Republic and the Bahamas, the classic pumping destinations. We allowlist real US and Canadian area codes and reject the rest. - **Velocity limits** per destination, per account and per source. - **No VoIP.** Disposable VoIP numbers are rejected. Blocked attempts return `403` with code `verification_blocked`, a `reason`, and `charged: false`. ## Sandbox Test keys never send a real message. Any code you send returns a verification whose code is `111111`, and these codes are deterministic: | Code | Result | | --- | --- | | `111111` | approved | | `000000` | invalid | | `222222` | expired | | `333333` | max attempts | Sandbox has **no resend cooldown**, so you can iterate on a "resend code" button freely. Live enforces 60 seconds per number. To test that path deterministically, send to `+15005550003`; it always returns the cooldown `429` with `retry_after` in the body. ## Retrieve a verification `GET /v1/verify/{id}` returns the object with its current status, attempt count, and whether it was charged. --- # Webhooks Add an endpoint URL in the [console](/console/webhooks) and every event (inbound messages, delivery updates, verification results) is POSTed to it as JSON. That plus an API key is everything you need: send with the API, receive with webhooks. Events are also pollable via [`GET /v1/events`](/docs/messages) if you prefer pull over push. ## Payload Webhook bodies are exactly the event objects from `/v1/events`: ```json { "id": "evt_a1B2c3D4e5F6g7H8", "object": "event", "type": "message.received", "created_at": "2026-08-13T00:41:00.000Z", "data": { "message_id": "msg_x9Y8z7W6v5U4t3S2", "from": "+14155550132", "to": "+15005550100", "body": "Hey, got your message!" } } ``` ## Event types | Type | Fires when | | --- | --- | | `message.sent` | An outbound message was accepted by the carrier | | `message.delivered` | The carrier confirmed delivery | | `message.failed` | Delivery failed permanently | | `message.received` | An inbound SMS arrived on one of your numbers | | `number.purchased` | A number was added to your account | | `number.released` | A number was released | | `number.sender_updated` | A live number's standing with the carriers changed (`test_only`, `pending`, `active`, `action_needed`). See [Numbers](/docs/numbers#sender-status) | | `verification.sent` | A verification code was sent | | `verification.approved` | A code was checked successfully | | `verification.failed` | A code check failed | | `verification.blocked` | A verification was blocked by fraud protection | | `deliverability.degraded` | Your delivery rate dropped sharply for all traffic, a carrier, or a sending number ([Deliverability](/docs/deliverability)) | | `spend.threshold_reached` | Estimated spend crossed one of your [spend limit](/docs/spend-limits) alert thresholds | | `automation.run.started`, `automation.run.completed`, `automation.run.failed` | An [automation](/docs/automations) run began, or ended (with `reason`) | | `test.ping` | You pressed "Send test" in the console | Endpoints receive all events by default; pass an `events` array when creating one to filter. ### message.failed ```json { "type": "message.failed", "data": { "message_id": "msg_a1B2c3D4e5F6g7H8", "to": "+15005550009", "code": "undeliverable", "reason": "stat:UNDELIV err:001", "failure": { "code": "invalid_number", "title": "Number not in service", "explanation": "The destination number does not exist or is no longer assigned to a phone.", "action": "Recommended: stop sending to this number and ask the recipient for an up-to-date one. Retrying will not help.", "carrier_code": "001" } } } ``` `failure` is the same object as on the message (see [delivery failures](/docs/errors#delivery-failures)); `code` and `reason` are kept for existing integrations. ## Verify signatures Every request carries a `simplesms-signature` header: ``` simplesms-signature: t=1755043260,v1=5257a869e7... ``` The event id (`evt_...`, the same across retries) is in `simplesms-event-id`. Both are also sent under the header names from before the product was renamed, with identical values, and always will be: `dsms-signature` / `dsms-event-id` and `resms-signature` / `resms-event-id`. A handler that reads either keeps verifying; new handlers should read the `simplesms-` pair. `v1` is `HMAC-SHA256(secret, \`${t}.${rawBody}\`)`, the same scheme Stripe uses. Your signing secret (`whsec_...`) is shown next to the endpoint in the console. To replace a secret, choose **Rotate secret** on the endpoint in the [console](/console/webhooks) (admins only). The old secret stops verifying immediately: there is no overlap period, and retries of earlier failures are signed with the new secret too. Update your receiver as soon as you rotate; anything it rejects in between is retried on the usual schedule and can be replayed from the delivery log. Rotations appear in the [audit log](/docs/audit-logs) as `webhook.secret_rotated`. ```ts import { createHmac, timingSafeEqual } from "crypto"; function verifyWebhook(rawBody: string, header: string, secret: string): boolean { const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("="))); if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5 min tolerance const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); } ``` Compute the HMAC over the **raw request body**; parse the JSON only after the signature checks out. Both SDKs ship this check, with the 5-minute replay window built in. They throw (`code: "invalid_signature"`) on a bad signature and return the parsed event otherwise: ```js import { verifyWebhook } from 'joinsimplesms'; const event = await verifyWebhook({ payload: rawBody, // string or Buffer, unparsed signature: req.headers['simplesms-signature'], secret: process.env.SIMPLESMS_WEBHOOK_SECRET, }); ``` ```python from joinsimplesms import verify_webhook event = verify_webhook( request.body, # raw bytes, unparsed request.headers["simplesms-signature"], os.environ["SIMPLESMS_WEBHOOK_SECRET"], ) ``` Pass `headers: req.headers` (Node) or `request.headers` (Python) in place of the signature and the SDK reads it from whichever header name arrived. Events about a message, verification, or number that belongs to one of your [customers](/docs/customers) carry `data.customer_id`. ## Retries Respond with any 2xx within 5 seconds. Anything else (including a timeout) is retried with backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours. Deliveries can arrive out of order and, rarely, more than once; use the `id` field to deduplicate. If all six attempts fail, the event goes to **Undelivered events** on the console's Webhooks page, where it stays for 30 days. **Replay** sends it again with one click and clears it if your endpoint answers 2xx. We alert our own team too. Your endpoint keeps receiving new events: we never pause or disable an endpoint because it failed. Only you can do that. ## Delivery log Every attempt is logged for 30 days with the HTTP status your endpoint returned, the time it took to respond, the attempt number, and the first 2 KB of the response body. Open **Deliveries** next to an endpoint in the console, or use the API: ```bash curl "https://api.joinsimplesms.com/v1/webhooks/deliveries?endpoint_id=we_...&limit=25" \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` ```json { "data": [{ "id": "whd_-O9xk2...", "object": "webhook_delivery", "endpoint_id": "we_a1B2c3D4e5F6", "event_id": "evt_a1B2c3D4e5F6g7H8", "event_type": "message.received", "attempt": 2, "ok": false, "status_code": 500, "error": null, "latency_ms": 212, "response_body": "Internal Server Error", "replay": false, "created_at": "2026-10-01T12:00:00.000Z" }], "has_more": false, "next_cursor": null } ``` Filter with `endpoint_id`, `event_id` (every attempt for one event), or both. `GET /v1/webhooks` lists your endpoint ids. `error` is `timeout` or `unreachable` when your endpoint never answered. We store your endpoint's response, not a second copy of the event, which is always at `GET /v1/events`. ## Replay Send one stored event to **one** endpoint, now: ```bash curl -X POST https://api.joinsimplesms.com/v1/events/evt_.../replay \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "endpoint_id": "we_..." }' ``` The response has your endpoint's status code and latency. A replay is not retried; if it fails, replay it again. It goes to the endpoint you name even if the endpoint is paused or does not subscribe to that event type, because you asked for it. In the console, use **Replay** on any row of the delivery log. ## Test it Press **Send test** next to any endpoint in the console; a signed `test.ping` fires immediately and the console shows your endpoint's response code and latency. In the sandbox, `POST /v1/test/inbound` emits a real `message.received` through the same pipeline, so you can rehearse your inbound handler before going live. ## Delivery log and replay Every attempt (first try, retries, tests, replays) is logged per endpoint. Open **Deliveries** next to an endpoint in the console to filter by result and event type, replay a single event, or **Replay all failed** for the last 24 hours to 7 days. Replay-all re-sends each event whose deliveries *all* failed in that window (events a retry already recovered are skipped), once per event, up to 50 per run; they go out within 5 minutes and appear in the log marked `replay`. Because replays reuse the original event `id`, your existing deduplication handles them. **Events** edits an endpoint's subscription in place (same URL, same secret); **Clone** copies the subscription to a new endpoint with its own secret. The whole log exports as CSV from the console or `GET /v1/exports/webhook_deliveries?endpoint_id=we_...&ok=false`. --- # Migrate from Twilio Verify Two calls become two calls. The main differences: no Verify Service to create, no phone number to buy, and you're billed only when a code actually verifies. ## Send a code ```js // Twilio await twilio.verify.v2.services(SERVICE_SID) .verifications.create({ to: phone, channel: 'sms' }); // SimpleSMS await fetch('https://api.joinsimplesms.com/v1/verify', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIMPLESMS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: phone }), }); ``` ## Check a code ```js // Twilio const check = await twilio.verify.v2.services(SERVICE_SID) .verificationChecks.create({ to: phone, code }); if (check.status === 'approved') { /* ... */ } // SimpleSMS const res = await fetch('https://api.joinsimplesms.com/v1/verify/check', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIMPLESMS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: phone, code }), }); const { verified } = await res.json(); if (verified) { /* ... */ } ``` ## In Python ```python # Twilio client.verify.v2.services(SERVICE_SID).verifications.create(to=phone, channel="sms") check = client.verify.v2.services(SERVICE_SID).verification_checks.create(to=phone, code=code) ok = check.status == "approved" # SimpleSMS: pip install joinsimplesms from joinsimplesms import SimpleSMS sms = SimpleSMS() # reads SIMPLESMS_API_KEY sms.verify.send(to=phone) ok = sms.verify.check(to=phone, code=code)["verified"] ``` ## What maps to what | Twilio | SimpleSMS | | --- | --- | | `to` | `to` (or `phone`, both work) | | Account SID + Auth Token | one API key | | Verify Service SID | nothing; no service to create | | A purchased phone number | nothing; we send from our pool | | `check.status === 'approved'` | `verified === true` | | `status: 'pending'` | `verified: false`, `status: 'pending'` | | 404 on bad code | `200` with `verified: false` (a wrong code isn't an exception) | | Fraud Guard | Shield, always on | | ~$0.05 per attempt | $0.025, only when verified | ## Things that get simpler - **No Verify Service.** Delete the `VA...` SID from your config. - **No number.** Delete the number provisioning step entirely. - **Billing follows success.** Twilio charges per verification attempt; we charge when `verified` comes back true, so pumping attacks and abandoned signups cost you nothing. - **Attempt/expiry state is in the response.** `attempts_remaining` and `expires_in` let you render "2 tries left" and a countdown without tracking anything yourself. ## Things to watch - **US and Canada only** right now. If you verify internationally, keep Twilio for those routes or talk to us. - **The SDK is optional.** The plain `fetch` calls above are the whole integration; `npm install joinsimplesms` adds typed errors, retries, and the CLI. - Sandbox has no resend cooldown so you can iterate; live enforces 60 seconds per number. --- # Numbers ## Search available numbers `GET /v1/numbers/available?area_code=415` Returns up to 5 available numbers. In the sandbox this is a deterministic fake inventory; with live access it searches real US/Canada inventory across 200+ area codes. ## Purchase a number `POST /v1/numbers` with `{ "phone_number": "+1..." }` (optionally `"customer_id": "cus_..."` to assign it to one of your [customers](/docs/customers)) Adds the number to your account (quota applies: default 2 live numbers, 3 sandbox). Returns the Number object. Emits a `number.purchased` event. Send an `Idempotency-Key` header and a retried purchase returns the original `201`. Without one, the retry gets "You already own this number." ## List your numbers `GET /v1/numbers` (`?customer_id=cus_...` for one customer's). Each number carries `customer_id` (`null` when unassigned) and `sender` (below). `GET /v1/numbers/+14155550132` returns one. ## Sender status A live US number works the moment you buy it, with one restriction: until it is registered with the carriers it is **test only**. It can text **your verified numbers** (your own phone, and any number you verify under Billing → Verified numbers), and nobody else. That is enough to build and test your integration end to end while the registration is in review. Once the number is linked to an approved [registration](/docs/compliance) it can text anyone. Every number carries its standing: ```json "sender": { "state": "pending", "registration_id": "reg_a1B2c3D4e5F6", "reason": null, "updated_at": "2026-10-04T16:20:00.000Z" } ``` | State | Meaning | Can text | | --- | --- | --- | | `test_only` | No registration has been submitted for it | Your verified numbers | | `pending` | A registration is in carrier review, or approved and being linked | Your verified numbers | | `active` | Linked to an approved registration | Anyone | | `action_needed` | Something needs you: the registration was rejected, its website check fails, or the link failed. `reason` says exactly what to do | Your verified numbers | `sender` is `null` on sandbox numbers (they never reach a real phone) and on numbers that need no registration. You do not have to do anything to move a number along. Submit one registration and every live number on the account follows it: `pending` while the carriers review, then linked and `active` after approval, usually within minutes. A number bought later links on its own. Each change emits `number.sender_updated` (`data`: `phone_number`, `state`, `previous_state`, `registration_id`, `reason`). A send to anyone else from a number that is not `active` answers **403** `sender_not_registered` with an `X-SimpleSMS-Sender-State` header and a message that says what to do. Nothing is sent and nothing is charged. Broadcasts, scheduled sends and automations follow the same rule: those recipients fail with `sender_not_registered`, never silently. ### Choose a registration for a number `POST /v1/numbers/+14155550132/registration` with `{ "registration_id": "reg_..." }` Only needed when your account has more than one approved registration (the number is `action_needed` until you choose), or to retry a link that failed. You can also pass `registration_id` when you buy the number. One registration holds up to **49 numbers**, the most carriers allow; attaching a 50th answers `409`. Returns the number with its new `sender`. ## Assign a number to a customer `PATCH /v1/numbers/+15005550132` with `{ "customer_id": "cus_..." }`, or `{ "customer_id": null }` to unassign. Messages to and from the number are attributed to that customer from then on; earlier messages keep theirs. ## Release a number `DELETE /v1/numbers/+15005550132` Marks the number released (live mode removes it from its registration, then disconnects it at the carrier). Rate limited to 10 releases per 30 minutes. Emits `number.released`. > Live number purchase and release require live access; sandbox > numbers work for everyone immediately. ## Buy in bulk `POST /v1/numbers/bulk-purchase` ```json { "area_code": "415", "quantity": 10, "pool_id": "pool_a1B2c3D4e5F6", "tags": ["spring"] } ``` Up to 50 per call. Your number limit is checked for the whole `quantity` first: if it doesn't fit you get `429 quota_exceeded` saying how many more you can add, and nothing is bought. After that, a number the carrier refuses is listed in `failed` and replaced from spare inventory; `shortfall` is how many the area code couldn't supply. Test keys mint sandbox numbers. ## Tags, labels, and bulk actions Every number carries an optional `label` ("Front desk") and up to 20 `tags` (lowercased). Filter on them anywhere: `GET /v1/numbers?tag=spring&pool=pool_...&mode=live&q=front`. `POST /v1/numbers/bulk` applies one action to up to 1000 numbers: | `action` | Extra field | | --- | --- | | `add_tags` / `remove_tags` | `tags: [...]` | | `set_label` | `label` (`null` clears) | | `add_to_pool` / `remove_from_pool` | `pool_id` | | `release` | `confirm: "RELEASE "` | The response is always one result per id you sent, so a batch where 3 of 143 ids were wrong tells you which 3: ```json { "object": "bulk_result", "action": "add_tags", "requested": 143, "succeeded": 140, "failed": 3, "results": [{ "id": "+14155550132", "ok": true }, { "id": "+1415555", "ok": false, "error": "invalid_id" }] } ``` Per-id errors: `invalid_id`, `duplicate`, `not_found`, `too_many_tags`, `mode_mismatch` (a test key releasing a live number), `carrier_error`. **Release is guarded.** Up to 100 per call, and `confirm` must be `"RELEASE "`, so a script that built the wrong list fails before anything is disconnected. Released live numbers go back to carrier inventory and may not be recoverable. ## Sender pools A pool is a named group of your numbers. Send with `"from": "pool_..."` and SimpleSMS picks a member, **the same one for each recipient every time**, so replies stay in one thread on their phone. Live keys draw only live members; test keys only sandbox members. ```bash curl https://api.joinsimplesms.com/v1/numbers/pools \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Support"}' ``` `GET`/`PATCH`/`DELETE /v1/numbers/pools/:id`, and `POST /v1/numbers/pools/:id/clone` copies a pool (same description and members, new id). Numbers can sit in several pools. Deleting a pool keeps its numbers. Membership is set with the bulk actions above. ## Export `GET /v1/numbers/export` returns CSV (`phone_number, mode, label, tags, pools, pool_ids, created_at`) and takes the same filters as the list. The console exports a filter or an exact selection. --- # Customers For platforms (SaaS, agent builders, agencies) that send on behalf of other businesses. A customer is a label you control: assign numbers to it, and every message, verification, event and webhook from those numbers carries its `customer_id`, with usage you can read back per customer to rebill. Entirely optional. Accounts that never create a customer see no change. ## Create a customer `POST /v1/customers` ```bash curl -X POST https://api.joinsimplesms.com/v1/customers \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Dental", "external_id": "acct_42", "metadata": { "plan": "pro" } }' ``` ```json { "id": "cus_a1B2c3D4e5F6g7", "object": "customer", "name": "Acme Dental", "external_id": "acct_42", "metadata": { "plan": "pro" }, "created_at": "2026-10-01T12:00:00.000Z", "updated_at": "2026-10-01T12:00:00.000Z" } ``` | Parameter | Type | Notes | | --- | --- | --- | | `name` | string | Optional, up to 200 characters. | | `external_id` | string | Optional. Your id for them; unique per account (`409` if taken). | | `metadata` | object | Optional. Up to 50 string values. Keys up to 40 characters, no `. # $ / [ ]`. | ## Retrieve, update, delete, list - `GET /v1/customers/{id}` also returns `phone_numbers` assigned to it. - `PATCH /v1/customers/{id}` changes only the fields you send; `metadata` replaces the whole object, `null` clears a field. - `DELETE /v1/customers/{id}` deletes it and unassigns its numbers (they stay on your account). Messages, events and usage already attributed keep the id: they record what happened. - `GET /v1/customers?limit=25&cursor=...` lists newest first; `?external_id=acct_42` finds one by your id. ## Attribute traffic - **Numbers**: `POST /v1/numbers` with `customer_id`, or `PATCH /v1/numbers/{number}` with `{ "customer_id": "cus_..." }`. - **Outbound messages** inherit the customer of their `from` number, or take an explicit `customer_id`. A number assigned to one customer can't send as another (`400`); unassigned numbers can send for anyone. - **Inbound messages** (and STOP/HELP replies) inherit the customer of the number they arrived on. - **Verifications** take an optional `customer_id` on `POST /v1/verify`. - **Events and webhooks** about any of the above carry `data.customer_id`. - `GET /v1/messages?customer_id=cus_...` lists one customer's messages. ## Usage `GET /v1/customers/{id}/usage?start=2026-10-01&end=2026-10-31` ```json { "object": "customer_usage", "customer_id": "cus_a1B2c3D4e5F6g7", "mode": "live", "start": "2026-10-01", "end": "2026-10-31", "messages_sent": 1204, "messages_failed": 3, "messages_received": 377, "verifications_sent": 88, "verifications_approved": 81, "numbers": 2, "phone_numbers": ["+14155550132", "+14155550133"], "daily": [{ "date": "2026-10-01", "messages_sent": 40, "...": 0 }] } ``` UTC days, inclusive, up to 366 days; the default is this month to date. Usage follows the key you call with: a test key reports sandbox traffic and a live key live traffic, so test runs never show up in what you rebill. `numbers` is the count assigned right now. ## With the SDKs ```js const acme = await sms.customers.create({ name: 'Acme Dental', externalId: 'acct_42' }); await sms.numbers.update('+14155550132', { customerId: acme.id }); await sms.messages.list({ customerId: acme.id }); await sms.customers.usage(acme.id, { start: '2026-10-01', end: '2026-10-31' }); ``` ```python acme = client.customers.create(name="Acme Dental", external_id="acct_42") client.numbers.update("+14155550132", customer_id=acme["id"]) client.messages.list(customer_id=acme["id"]) client.customers.usage(acme["id"], start="2026-10-01", end="2026-10-31") ``` ## Responsibility A customer label is bookkeeping for you. It does not register the business with carriers, make it our customer, or change who is responsible for its traffic: you are, as if it were your own ([Terms §2.4](/terms), [Messaging Policy §7](/messaging-policy)). Each end business still needs its own brand and campaign registration. --- # Lookup ## Look up a number `GET /v1/lookup/+14155550132` ```json { "phone_number": "+14155550132", "valid": true, "line_type": "mobile", "carrier": { "name": "Verizon Wireless", "type": "mobile" }, "caller_name": null } ``` Test keys return deterministic fixtures; live keys return real carrier data (cached 24h). Lookups count against a daily quota (default 250/day live, 100/day sandbox). --- # Inbox & conversations Every message on your numbers is grouped into conversations, one per (your number, counterparty) pair. The console inbox is a shared view: your whole team sees the same threads and the same unread state. ## How threading works The thread key is `{ourDigits}_{theirDigits}`. Inbound messages increment the conversation's unread counter; opening the thread in the console clears it. `message.received` webhook events carry a `conversation` field with the same key so your own systems can thread without re-deriving it. ## Attribution Messages composed in the console are stamped with the sender's name and show up in the thread as "sent by Alice". API sends are attributed to the key. ## Media Inbound MMS attachments are stored on the message (`media` array) and shown in the thread. Outbound MMS returns `mms_not_enabled` until numbers are provisioned for MMS. --- # Contacts A contact is a phone number with properties, consent, and history. Contacts are keyed by phone number: one contact per number per account (up to 50,000), and every write is an upsert rather than a duplicate. Everything on this page is in the console and on the API (scope: `contacts`). ## Fields - `phone_number` (E.164), `name`, `first_name`, `last_name`, `email`, `company`, `state`, `source`, `notes` - `tags`: up to 20 free-form labels - `fields`: up to 20 custom key/values (account id, plan, location...), usable in segments and in merge fields as `{{field:key}}` The console's contact page adds the history: messages, consent events, and the broadcasts the number was in. ## API Sync contacts from your own database instead of uploading files. Scope: `contacts`. `POST /v1/contacts` creates a contact, or updates the one that already has that number (`201` created, `200` updated). ```bash curl -X POST https://api.joinsimplesms.com/v1/contacts \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+14155550132", "name": "Ada", "tags": ["vip"], "fields": { "plan": "pro" } }' ``` ```json { "id": "ct_a1B2c3D4e5F6", "object": "contact", "phone_number": "+14155550132", "name": "Ada", "tags": ["vip"], "fields": { "plan": "pro" }, "notes": null, "created_at": "2026-10-04T12:00:00.000Z", "updated_at": "2026-10-04T12:00:00.000Z" } ``` | Parameter | Type | Notes | | --- | --- | --- | | `phone_number` | string | Required. US or Canada; stored as E.164. | | `name` | string | Optional, up to 120 characters. | | `tags` | string[] | Optional. Up to 20, 40 characters each. | | `fields` | object | Optional. Up to 20 string values. Keys up to 40 characters, no `. # $ / [ ]`. | | `notes` | string | Optional, up to 2,000 characters. | | `first_name`, `last_name` | string | Optional, up to 80 characters each. `name` defaults to the two joined. | | `email` | string | Optional. Must look like an email address. | | `company`, `state`, `source` | string | Optional. `source` is where the contact came from and defaults to `api`. | The response also carries these, plus `import_id` and `opt_in` (the opt-in evidence an import stored), as `null` when unset. - `POST /v1/contacts/import` takes `{ "contacts": [ ... ] }`, up to 500 per request, and answers `{ "created", "updated", "skipped" }`. A row that can't be imported is listed in `skipped` with its `index` and the reason; the rest still land. - `POST /v1/contacts/bulk` applies one action to up to 500 contact ids: `{ "action": "add_tags" | "remove_tags" | "delete", "ids": [...], "tags": [...] }`. It answers `{ "updated", "deleted", "missing" }`; ids that don't exist are counted in `missing`. - `GET /v1/contacts?limit=25&cursor=...` lists newest first; `?tag=vip` narrows to a tag, `?phone_number=+14155550132` finds one by number. `?segment_id=` narrows to a saved segment and `?q=` searches name, email, company and digits. - `GET /v1/contacts/{id}` retrieves one, with its `consent_status`, its topic subscriptions, and the segments it is in. Here and on `PATCH` and `DELETE`, `{id}` may also be the contact's phone number. - `PATCH /v1/contacts/{id}` changes only the fields you send. Here `tags` and `fields` **replace** what is stored (this is how you remove a tag), and `null` clears `name`, `notes` or any other optional property. Moving a contact to a number another contact holds answers `409`. - `DELETE /v1/contacts/{id}` removes the contact. Messages and [opt-out records](/docs/opt-out) for the number are untouched. `POST` and import follow the CSV rule: existing contacts are enriched, never wiped. Values you leave out are kept and tags are added. ## CSV import Console → Contacts → Import. Drop a CSV: phone, first name, last name, email, company, state, tags, opt-in status, opt-in date and source are detected from the headers (or, failing that, from the values), and every other column becomes a custom field. You can change any mapping or skip a column. **Check this file** is a dry run. Nothing is written; you get a line like: > 18,421 contacts found · 17,982 valid mobile numbers · 231 landlines · > 208 malformed · 16,904 eligible to receive a broadcast plus how many will be added and updated, and a CSV of the rows left out with the reason for each. - **Malformed** rows are not US or Canada numbers. **Duplicates** keep the first row for each number. - **Landlines** are only known if you tick "Check line types". On a live account each lookup is a paid carrier query, so only the first 500 numbers are checked; in sandbox the check is free and uses the [sandbox fixture](/docs/sandbox). Without it the line says "valid numbers", not "valid mobile numbers". Landlines found are left out of the import. - **Already opted out** numbers are imported and stay opted out. An import never opts anyone back in. - **Opt-in columns**: a row marked opted in (or carrying an opt-in date or source) stores that as evidence in the [consent ledger](/docs/opt-out). A row marked unsubscribed is imported and recorded as an opt-out. - Existing contacts are enriched, never wiped: a row with only a name and phone will not erase tags you added by hand. Files are read in the browser in pieces and committed 500 rows per request. The ceiling is 50,000 rows (the contact limit) and 25 MB. After an import: **Create a segment from these contacts**, or **Send a broadcast to them**. From your own system, the same thing in JSON: ```bash curl -X POST https://api.joinsimplesms.com/v1/contacts/import \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true, "contacts": [ {"phone_number": "+14155550132", "first_name": "Jane", "opt_in": {"status": true, "at": "2026-03-01", "source": "web_form"}} ]}' ``` Up to 500 contacts per request. Without `dry_run` it upserts and also returns `import_id`; pass that on later requests to group one import. ## Segments A segment is a saved, named filter over contacts: an audience. (Not to be confused with the parts a long SMS is split into, which the message composer also calls segments.) It is evaluated when it is used, so a contact tagged tomorrow is in tomorrow's broadcast. ```bash curl -X POST https://api.joinsimplesms.com/v1/segments \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Tennessee Pro, marketing on", "match": "all", "rules": [ {"field": "state", "op": "is", "value": "TN"}, {"field": "field", "key": "plan", "op": "is", "value": "pro"}, {"field": "topic", "key": "tp_marketing", "op": "is", "value": "subscribed"} ]}' ``` | `field` | Operators | Notes | | --- | --- | --- | | `tag` | `is`, `is_not`, `exists`, `not_exists` | has / lacks the tag | | `first_name`, `last_name`, `name`, `email`, `company`, `state`, `source` | `is`, `is_not`, `contains`, `not_contains`, `exists`, `not_exists` | case-insensitive | | `field` (+ `key`) | same as above | a custom field | | `topic` (+ `key` = topic id) | `is`, `is_not` | value `subscribed` or `unsubscribed` | | `status` | `is`, `is_not` | value `subscribed` or `opted_out` | | `import` | `is`, `is_not` | value = an `import_id` | | `created_at` | `before`, `after` | an ISO date | `match` is `all` or `any`; up to 10 rules per segment and 100 segments. `GET /v1/segments/{id}/preview?topic_id=tp_marketing` returns `matched`, `opted_out`, `topic_unsubscribed` and `eligible` (they add up). `GET /v1/contacts?segment_id=` lists the members, and `POST /v1/batches` takes `segment_id` as its audience. Segments are evaluated in memory over your contacts, their opt-out state and their topic preferences; there is no per-rule cost. ## Subscription topics Topics let a person stop one kind of message without stopping all of them. Every account starts with **Marketing** (`tp_marketing`), **Account alerts** (`tp_account_alerts`) and **Product updates** (`tp_product_updates`); add, rename or delete them (up to 20) under Contacts → Segments or with `/v1/topics`. ```bash # Unsubscribe a number from Marketing only curl -X PUT https://api.joinsimplesms.com/v1/contacts/+14155550132/topics/tp_marketing \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"subscribed": false, "source": "preference_page"}' ``` - No record means subscribed. A topic only ever **narrows** who you text. - A broadcast or batch sent with a `topic_id` skips anyone unsubscribed from it, at validation and again at send time. - **STOP still stops everything.** An opted-out number gets nothing whatever its topics say, and subscribing someone to a topic does not opt them back in. `consent_status` on the contact is the one to check first. - Preferences belong to the phone number, not the contact record: they survive deleting the contact, and work for numbers that are not contacts. - Every change is written to the consent ledger (`topic_unsubscribe` / `topic_subscribe`, in `GET /v1/consent/{phone}` and the ledger export) and to the [audit log](/docs/audit-logs). ## Bulk edits Tick contacts (or "select all shown" after filtering) to add or remove tags or delete them in one go. **Tags** on a row edits that contact's tags inline. ## Export Console → Contacts → Export downloads the whole book as CSV, custom fields as columns. ## Templates Console → Templates (or `/v1/templates`) stores reusable bodies with merge fields: `{{first_name}}`, `{{name}}`, `{{phone}}`, `{{field:company}}`. An unknown field like `{{nickname}}` renders as an empty string; text that is not merge syntax at all (`{{Name}}`, uppercase) is sent exactly as typed, and the console editor flags both. It also previews the rendered text against a sample contact, with length and segment count. ```bash curl -X POST https://api.joinsimplesms.com/v1/templates \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Order ready", "body": "Hi {{first_name}}, your order is ready."}' ``` `GET /v1/templates`, `GET|PATCH|DELETE /v1/templates/{id}` complete the set. ## Exports and saved views `GET /v1/exports/{kind}` streams CSV for `messages`, `deliveries` (outbound status + failure reason), `events`, `opt_outs`, `webhook_deliveries`, `usage` (daily counters), `usage_monthly` and `audit_log` (the [audit log](/docs/audit-logs), with its own filters), with `from`/`to`, `status`, `number` and `type` filters. Exports are capped at 50,000 rows; narrow the date range for more. Cells that a spreadsheet would run as a formula (`=`, `+`, `-`, `@`) are prefixed with `'`. ```bash curl "https://api.joinsimplesms.com/v1/exports/deliveries?status=failed&from=2026-09-01" \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" -o failed.csv ``` The console's Messages, Events and Contacts pages export the current filter, and **Save view** keeps a filter set for one click later (views are personal to your login). ## Names in the inbox Inbound messages resolve the sender against contacts, so threads show "Jane Doe" instead of a raw number the moment a contact exists. --- # Teams One account, many users. The owner and admins manage the account; members work the inbox. ## Roles | Role | Can | | --- | --- | | `admin` | Everything: keys, billing, webhooks, numbers, team, plus all member abilities. | | `member` | Inbox, contacts, broadcasts, templates, messaging. | | `viewer` | Read-only: sees everything a member sees (inbox, messages, contacts, deliverability, usage, spend), changes nothing. | Admin-only routes return `403` to members and viewers; any change a viewer attempts returns `403`. Admins change roles from Console → Team, and every invite, role change, and removal is recorded in the [audit log](/docs/audit-logs). ## Invites Console → Settings → Team → enter an email, pick a role, and select Invite. We email the person a link; it is single-use, expires after 7 days, and only the invited address can accept it. They sign in (Google, GitHub, or email) with that address and land on your team. An account that already belongs to another team must use a different sign-in. Pending invitations are listed with the members. From the `…` menu an admin can resend the email, copy the link, or revoke the invitation. ## Managing members From the `…` menu next to a member, an admin can change their role or remove them. Anyone except the owner can leave the team from the same menu next to their own name. ## Signatures Each user can set a signature (Console → Team → your profile); it is appended to messages they compose in the console. --- # Batches & broadcasts A batch sends one message body to many recipients as individual texts. Recipients never see each other, and variables personalize each body. Recipients come from a CSV upload (console), `recipients[]` (API), or contacts carrying a tag. ## Send a batch ```bash curl -X POST https://api.joinsimplesms.com/v1/batches \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+15005550110", "body": "Hi {{first_name}}, order {{order_id}} ships today. Reply STOP to opt out.", "recipients": [ {"to": "+14155550132", "variables": {"first_name": "Jane", "order_id": "A-1042"}}, {"to": "(415) 555-0133", "variables": {"first_name": "Sam", "order_id": "A-1043"}} ] }' ``` The response is a `batch` object (`bc_` id) with live `counts`, plus a `validation` summary of any rows that were dropped. To send to contacts instead, pass `"tags": ["vip", "beta"]` (any of the tags) in place of `recipients`. Add `scheduled_at` (up to 30 days out) to send later. ## Validate first: dry runs Add `"dry_run": true` and nothing is sent. You get counts, every rejected row with its reason, a rendered preview, the segment count and the estimated cost: | Reason | Meaning | |---|---| | `invalid` | Not a valid US/Canada number (e.g. a +44 number) | | `duplicate` | Same number as an earlier row - the first one is kept | | `opted_out` | Replied STOP to you | | `missing_variable` | A variable the body uses is blank on this row | | `landline` | Only with `check_line_types: true` (first 500 numbers) | | `empty_after_merge` | The rendered body is empty | A row is counted under its first failing reason, so `valid` plus the rejected counts always equals the number of rows you sent. A variable that no row has at all (a typo like `{{frist_name}}`) fails the whole request with `unknown_variables` instead of sending blanks. The estimate is messages × the per-message rate (sandbox: $0). Segments are reported too: one emoji or curly quote switches the whole message to UCS-2, 70 characters per segment instead of 160. ## Variables Any recipient variable (CSV column) is available as `{{column_name}}`; headers are snake_cased ("First Name" → `{{first_name}}`). Contact batches also get `{{name}}`, `{{first_name}}`, `{{phone}}` and `{{field:company}}`, rendered at send time so a contact edit made after scheduling still lands. Unresolvable tags render empty, never as the raw tag. ## Pause, resume, cancel, retry | Endpoint | What it does | |---|---| | `POST /v1/batches/{id}/pause` | Holds everything not yet handed to the carrier | | `POST /v1/batches/{id}/resume` | Re-queues held messages | | `POST /v1/batches/{id}/cancel` | Cancels a scheduled batch, or stops one mid-send | | `POST /v1/batches/{id}/retry` | Re-sends failures that can succeed on a retry | A message already in flight when you pause or cancel still sends. Retry only re-queues transient failures (rate limits, quota, carrier errors), at most 3 attempts per recipient, and only once the batch is `complete`; opt-outs, invalid numbers and anyone this batch already messaged are never retried. Two retries at once can't double-send. ## Progress `GET /v1/batches/{id}` returns `counts`: `total = queued + sent + failed + opted_out + canceled`, always. "Sent" means accepted by the carrier. `GET /v1/batches/{id}/recipients?status=failed` lists rows with `error` and `retryable`. In the console, the batch page shows live progress and a **Download failed rows** CSV (your original columns plus the reason), ready to fix and re-upload. ## Audience and topic Besides `recipients`, a batch can go to contacts: `segment_id` (a saved [segment](/docs/contacts#segments), evaluated when the batch is created), `tags`, or `contact_ids`. `topic_id` names a [subscription topic](/docs/contacts#subscription-topics). Recipients unsubscribed from it are removed at validation (counted as `topic_unsubscribed`) and checked again when each message is sent, exactly like opt-outs; at send time they are counted with `opted_out` and their row's `error` is `topic_unsubscribed`. Omit `topic_id` and only opt-outs apply. Console broadcasts default to Marketing. ## Compliance check The console runs a check before a broadcast is sent (it is part of the dry run). The rules are fixed; no AI is involved: | Check | Result | | --- | --- | | Sender | Pass in sandbox or with an approved registration; a warning otherwise | | Opt-out wording | Warning when the message has no "Reply STOP" instruction | | Content | **Blocks** on a live sender when the content screen refuses the message; a warning in sandbox | | Public link shortener | Warning | | Quiet hours | Warning when the send time is outside 8am-9pm on either US coast. We do not know recipients' time zones and do not hold messages for you | | Consent on file | Warning when contacts in the audience have no opt-in record, and always for an uploaded list | | Topic | Warning when no topic is set | | Merge variables, empty audience | **Blocks** (the API refuses these too) | Warnings never stop a send: consent, timing and wording are your responsibility under the [Messaging Policy](/messaging-policy). **Send test** texts the first rendered message to your own verified phone. ## Results `GET /v1/batches/{id}/results` (and the console's broadcast page) reports what happened after the queue did its part: ```json { "attempted": 100000, "delivered": 97921, "failed": 1228, "pending": 851, "replies": 1402, "stops": 312, "spent_usd": 1142.28, "failures": [ { "code": "invalid_number", "title": "Number not in service", "count": 640, "explanation": "The destination number does not exist or is no longer assigned to a phone.", "action": "Recommended: stop sending to this number..." } ] } ``` - `attempted = delivered + failed + pending`. Skipped (opted out or unsubscribed), canceled and still-queued recipients are reported separately. - `delivered` and `failed` come from carrier delivery receipts. `pending` was accepted by the carrier with no final receipt; `no_receipt` of those never got one. Sandbox outcomes are simulated. - `replies` are messages recipients sent to the sending number within 72 hours of the batch finishing (STOP keywords are not counted as replies). `stops` are recipients who opted out in that time and are still opted out. - `spent_usd` is the sum of each message's recorded `price`. - `failures` groups failed recipients by [failure code](/docs/errors), plus the reasons a send can be refused before a message exists (allowance reached, sending number released, empty after merge...). In the console, click a reason for the explanation and a CSV of exactly those recipients. - `partial: true` means a read bound was hit on a very large or very busy account: delivered and replies are then floors. Events: `batch.created`, `batch.paused`, `batch.resumed`, `batch.canceled`, `batch.retried`, `batch.complete` (and the legacy `broadcast.complete`, still sent for existing subscribers). ## Opt-outs are enforced per recipient Opted-out numbers are dropped at validation, and every recipient is checked again at send time. Anyone who replies STOP while a batch is running is counted in `opted_out` - never texted, never silently dropped from the math. ## Limits and pacing Up to 10,000 recipients per batch. Messages go out at about 100 per minute, interleaved fairly with your other scheduled sends. Each message counts against your normal quota and billing; a batch is exactly N messages. --- # Scheduled messages Pass `scheduled_at` (ISO timestamp or epoch ms, up to 30 days out) to `POST /v1/messages` and the message is queued instead of sent: ```bash curl -X POST https://api.joinsimplesms.com/v1/messages -H "Authorization: Bearer $SIMPLESMS_API_KEY" -H "Content-Type: application/json" -d '{"to":"+14155550132","from":"+15005550110","body":"Reminder!","scheduled_at":"2026-09-01T15:00:00Z"}' ``` The response is a `scheduled_message` with a `job_` id. Delivery happens on the next queue flush after the timestamp (within a minute). ## The schedule reserves nothing Opt-out and quota are re-checked at send time, not at scheduling. A recipient who opts out between scheduling and sending is skipped; a schedule does not hold quota. ## Listing and canceling `GET /v1/scheduled_messages` lists pending messages; `DELETE /v1/scheduled_messages/job_...` cancels one that has not sent yet. Cancellation races are settled atomically; a job mid-send cannot be canceled. To send one message to many people later, schedule a [batch](/docs/broadcasts) instead. --- # Automations Your app says what happened. SimpleSMS decides what to text and when. ```js await sms.events.track({ user_id: "123", phone: "+14155550132", event: "trial_started" }); ``` A **flow** starts on one event and runs a list of steps for that person: ``` trial_started -> Wait 1 hour -> Send SMS -> Wait until subscription_created arrives: end not in 3 days: continue -> Send SMS (reminder) ``` Build flows in [Console → Automations](/console/automations) or with the API below. Three templates ship in the console: a welcome series, this trial reminder, and an abandoned-onboarding nudge. ## Track an event ```bash curl -X POST https://api.joinsimplesms.com/v1/track \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event":"trial_started","user_id":"123","phone":"+14155550132","properties":{"plan":"pro"}}' ``` | Field | | | --- | --- | | `event` | Required. Letters, digits, and `_ . : -`, up to 64 characters. | | `phone` | The person's number in E.164. | | `user_id` | Your own id for the person, up to 128 characters. | | `properties` | Up to 20 values (text, number, true/false). Usable as merge fields and in conditions. | Pass `phone`, `user_id`, or both. **A call with both links the two**, so later events need only `user_id`. An event for a `user_id` that has never been linked is stored, starts nothing, and answers with `"phone": null`. The response lists what the event did: ```json { "id": "tev_...", "object": "tracked_event", "event": "trial_started", "user_id": "123", "phone": "+14155550132", "test": false, "runs_started": ["run_..."], "runs_notified": 0 } ``` `Idempotency-Key` works as on `POST /v1/messages`. The limit is 100 calls per 10 seconds per key. `GET /v1/events` is a different thing: the log of [webhook events](/docs/webhooks) SimpleSMS sends you. ## Steps | Step | Fields | | --- | --- | | `send_sms` | `body`, optional `from` (defaults to the flow's number) | | `wait` | `seconds` (1 minute to 30 days) | | `wait_for_event` | `event`, `timeout_seconds`, `on_received`, `on_timeout` | | `condition` | `check`, `on_met`, `on_not_met` | | `end` | none | A branch (`on_received`, `on_timeout`, `on_met`, `on_not_met`) is `"continue"`, `"end"`, or `"goto:"`. A `goto` may only point at a **later** step, so a flow can never loop and a run sends at most one message per send step. A `check` is one of: - `{ "kind": "event_received", "event": "onboarding_completed" }`: true if the person sent that event since the run started. - `{ "kind": "property", "property": "plan", "op": "eq", "value": "pro" }`: a property of the event that started the run. - `{ "kind": "contact_field", "field": "company", "op": "exists" }`: the [contact](/docs/contacts) with that number (`name`, `tag`, or a custom field). Operators: `eq`, `neq`, `contains`, `gt`, `lt`, `exists`, `not_exists`. `wait_for_event` counts the event if it arrived **any time since the run started**, not only after the step began: someone who subscribed during an earlier wait has subscribed. Message bodies take [merge fields](/docs/broadcasts): every event property by name (`{{plan}}`), plus `{{first_name}}`, `{{name}}`, `{{phone}}` and `{{field:company}}` from the contact. A field with no value sends as nothing; a message that merges to nothing is skipped. ## Create and activate ```bash curl -X POST https://api.joinsimplesms.com/v1/automations \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Trial reminder", "from": "+15005550100", "trigger": { "event": "trial_started" }, "steps": [ { "type": "wait", "seconds": 3600 }, { "type": "send_sms", "body": "Your trial is live, {{first_name}}. Reply STOP to opt out." }, { "type": "wait_for_event", "event": "subscription_created", "timeout_seconds": 259200, "on_received": "end", "on_timeout": "continue" }, { "type": "send_sms", "body": "Your trial ends soon. Pick a plan to keep your account." } ] }' ``` A flow is created as a `draft`. The response carries `issues`: everything that stops it from running, each naming its step. `POST /v1/automations/{id}/activate` turns it on (and answers 400 with the same `issues` if it is not ready); `POST /v1/automations/{id}/pause` stops new runs and holds the ones in progress where they are. Activating again resumes them within 5 minutes. A trigger can filter on properties: `"trigger": { "event": "trial_started", "filters": [{ "property": "plan", "op": "eq", "value": "pro" }] }`. | Endpoint | | | --- | --- | | `GET /v1/automations` | All flows, with run counts | | `POST /v1/automations` | Create a draft | | `GET` / `PATCH` / `DELETE /v1/automations/{id}` | Read, edit, delete | | `POST /v1/automations/{id}/activate` · `/pause` | Turn on, hold | | `GET /v1/automations/{id}/runs` | Runs, newest first | | `GET` / `DELETE /v1/automations/{id}/runs/{run_id}` | One run with its timeline; cancel it | All of it needs the `automations` scope on a restricted key. ## Sandbox and live The flow's `from` number decides. A flow sending from a sandbox number listens only to events tracked with a **test key**, and behaves like any sandbox send: [magic numbers](/docs/sandbox) work, nothing is billed, and only your own verified phone gets a real text. A flow sending from a live number listens only to **live-key** events. So the same event name can drive a sandbox copy and a live copy of a flow without either seeing the other's traffic. ## What a run guarantees - **One run per person per flow.** A repeat of the trigger while a run is in progress is ignored (counted as `triggers_skipped`). Once the run ends, the next trigger starts a new one. - **No duplicate texts.** Each send step is attempted at most once. If our worker fails mid-send, the step is recorded as `send_unconfirmed` and not retried. - **Edits never change a run in flight.** Saving an active flow publishes a new version; runs finish on the version they started with. - **Opt-outs win.** Every message goes through the same checks as `POST /v1/messages`: the [opt-out list](/docs/opt-out), content screening, quotas and your [spend limit](/docs/spend-limits). A run whose recipient has opted out ends (`opted_out`) the next time it wakes, before anything is sent. Set `topic_id` on a flow to also skip people unsubscribed from that topic. - **A failed send does not stop the run.** It is recorded on the run's timeline with the error code and the run continues. Transient carrier failures are retried by the normal [message retry](/docs/errors) path. - **Waits are checked every minute.** A step due at 14:00:20 runs by 14:01. A first step that sends goes out with the track call itself. ## Runs ```json { "id": "run_...", "object": "automation_run", "automation_id": "auto_...", "status": "waiting", "to": "+14155550132", "step": 2, "waiting_for_event": "subscription_created", "waiting_until": "2026-10-07T15:00:00.000Z", "end_reason": null, "messages_sent": 1, "timeline": [ { "at": "...", "type": "started", "detail": "trial_started" }, { "at": "...", "type": "sent", "step_id": "s2", "message_id": "msg_..." }, { "at": "...", "type": "waiting_for_event", "step_id": "s3", "detail": "subscription_created" } ] } ``` `status` is `running`, `waiting`, `completed`, or `failed`. `end_reason` says why: `finished`, `end_step`, `branch_end` (completed); `opted_out`, `canceled`, `flow_deleted`, `expired` (a run older than 90 days), `error` (failed). Three [webhook events](/docs/webhooks) follow a run: `automation.run.started`, `automation.run.completed` and `automation.run.failed`, each with `automation_id`, `run_id`, `to` and, at the end, `reason`, `messages_sent` and `messages_failed`. ## Limits and retention - 20 steps per flow, 50 flows per account, 20 active at once, 5 trigger filters. - Waits and timeouts: 1 minute to 30 days. A run ends after 90 days whatever it is waiting for. - Tracked events are kept for 30 days. A run is kept for 30 days after it ends. The `user_id` to phone links are kept until you close your account. --- # Auto-replies & office hours Each number can answer inbound texts automatically: an away message, an out-of-office, or a first-touch acknowledgement. ## Configuration Per number: `enabled`, `message` (max 320 chars), and optional office hours (`tz` as an IANA zone, open `days`, `start`/`end` as HH:MM, and `mode`): - `always`: reply to every eligible inbound. - `after_hours`: the out-of-office pattern: reply only OUTSIDE the hours. ## Guardrails (not configurable) - STOP / START / HELP keywords always win; the auto-reply never answers them. - Verification codes are never answered. - Opted-out counterparties are never texted. - One auto-reply per conversation per 4 hours, claimed atomically; two simultaneous inbound messages cannot double-send. ## Testing Works identically in sandbox: simulate an inbound with `POST /v1/test/inbound` and the reply appears in the thread with an `auto_reply: true` marker on its event. --- # Number porting You can bring a number you already own. Porting is a carrier-side process with paperwork and multi-day timelines, so it runs as a tracked request rather than an instant API call. ## What we need - The number, your current carrier, and the account number with them - The last 4 of your account PIN (if your carrier uses one) - The name of the person authorized to approve the transfer Submit from Console → Numbers → Port a number (admins only). ## Timeline | Status | Meaning | | --- | --- | | `requested` | We received your request. | | `submitted` | Filed with the carrier. | | `foc_set` | The carrier set a Firm Order Commitment date. | | `complete` | The number is live on SimpleSMS. | | `rejected` | The carrier rejected it; the note says why (usually a detail mismatch). | The status timeline is visible in the console; keep the old service active until the port completes. ## Port many at once Console → Numbers → Port many numbers takes a CSV: either one column of numbers (the form supplies carrier, account number, PIN and authorized name for all of them) or a header row with `number`, `carrier`, `account number`, `pin`, `authorized name` columns for numbers spread across old carriers. Every row is checked before anything is filed: | Row status | Meaning | | --- | --- | | ready | Will be filed. | | not a valid number | Not a US/Canada number. | | duplicate | Appears earlier in the file. | | already yours | Already on your SimpleSMS account. | | port already open | A request for it is in flight (rejected ones can be re-filed). | | missing details | Carrier, account number or authorized name missing. | Only ready rows are filed. You can choose a pool and tags for the numbers to join when their port completes. Accounts with live access can run a portability check first: current carrier and whether the number can port, per number. ## Moving from Twilio, Telnyx or Plivo Console → Numbers → Import from Twilio / Telnyx / Plivo reads your account with the credentials you paste (Twilio Account SID + Auth Token, Telnyx API key, Plivo Auth ID + Auth Token). They are used once, for read-only requests, and never stored. You get a plan to review: - **Numbers to port**, each marked will port / already on SimpleSMS / port already open / not SMS-enabled / not US-Canada. - **Pools to recreate** from Twilio Messaging Services, Telnyx messaging profiles, or Plivo Powerpacks. Numbers join them as their ports complete. - **Webhook URLs found** (inbound and status callbacks), offered as SimpleSMS webhook endpoints. Your handler has to accept SimpleSMS's JSON events, not the old provider's callback format. Opt-out lists aren't exposed by any of the three APIs; SimpleSMS enforces STOP/START itself, and you can import an existing suppression list with `POST /v1/consent/import`. Running the plan only creates SimpleSMS-side things (port requests, pools, webhooks); nothing changes at the old provider until each carrier port completes. --- # Opt-out, consent & TCPA Honouring opt-out is a legal obligation, not a feature. SimpleSMS enforces it on your behalf for every send, keeps an append-only consent ledger you can export, and gives you the events to keep your own systems in sync. There is nothing to configure; every account gets this by default. ## Revocation in plain English The FCC's April 2025 revocation rule requires honouring a revocation expressed by any reasonable means, not just the standard keywords. SimpleSMS detects revocation in three tiers, in order: | Tier | Detects | Example | | --- | --- | --- | | Keywords | The exact CTIA keywords | "STOP" | | Phrases | Common plain-English forms, deterministically | "please stop texting me", "remove me from your list" | | AI | Everything else revocation-shaped, with a confidence score | "i would rather you didn't message this number" | All three record the opt-out, send the single confirmation reply, block future sends, and emit `message.opted_out` with a `method` field (`keyword`, `phrase`, or `ai`) so you can see how it was detected. A revocation is never answered by an auto-reply. ## Keywords | Reply | Effect | | --- | --- | | `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, `OPTOUT`, `REVOKE`, `ARRET` | Opts the number out of **your** messages. We send one confirmation and nothing more. | | `START`, `UNSTOP`, `YES` | Opts back in. Someone who was opted out gets one "you are resubscribed" reply; an ordinary "yes" from someone who never opted out gets no reply. | | `HELP`, `INFO`, `AIDE` | Sends your help reply, even to an opted-out number, and emits `message.help_requested`. | A second `STOP` from a number that is already opted out changes nothing: no second confirmation, no second event. ### Replies speak for your business The confirmation, resubscribe and help replies name **you**, not Delivered, because the person texting has never heard of us: - A number linked to a [registration](/docs/compliance) sends exactly the HELP, STOP and opt-in messages that registration filed with the carriers, including your support contact. - A number assigned to a [customer](/docs/customers) is answered in that customer's name. - Otherwise the reply uses your account name. An account name that is an email address is never shown; the reply says "this number" instead. Keyword matching is exact: the message has to *be* the keyword, ignoring case, surrounding whitespace and punctuation. "please stop by tomorrow" is an ordinary message; the phrase tier only fires on messaging-directed forms like "stop texting me". ## One suppression list per account Every account has a single suppression list, and every ordinary message passes through it before it can reach a carrier. It is account-wide on purpose: an opt-out applies to **all** your numbers, sender pools and [customers](/docs/customers), so a recipient who texts STOP to one of your numbers cannot be texted from another. | Path | How it honours the list | | --- | --- | | `POST /v1/messages`, console sends | Checked first; refused with `recipient_opted_out`. | | Batches and broadcasts | Opted-out recipients are skipped and counted, then re-checked at send time. | | Scheduled sends, automations | Re-checked when the send actually runs, not when it was scheduled. | | Automatic carrier retries | Re-checked before every attempt. | | Auto-replies | Never sent to an opted-out number. | | STOP / START / HELP replies | Exempt: these are the replies the rules require. | | `POST /v1/verify` | Exempt and logged (see below). | The list fails closed. If we cannot read it, the send is not attempted: you get `503` with `Retry-After`, nothing is sent and nothing is billed. We would rather make you retry than text someone who said stop. You manage the same list through the API above: read it (`GET /v1/consent`), add to it (`POST /v1/consent/{phone}`, or `/v1/consent/import` for a list from another provider) and export it (`/v1/consent/export`). The list is yours alone. An opt-out silences **your** traffic to that number, not everyone's. Someone who unsubscribes from one sender does not stop receiving login codes from another; that would turn an unsubscribe into an account lockout. ## What gets blocked `POST /v1/messages` to an opted-out number returns: ```json { "error": { "code": "recipient_opted_out", "message": "This recipient opted out of your messages on Oct 5, 2026 at 8:42 AM UTC by replying STOP. Nothing was sent and you were not charged. They can opt back in by texting START to your number.", "param": "to", "opted_out_at": "2026-10-05T08:42:00.000Z", "opted_out_via": "sms_keyword", "opted_out_method": "keyword", "failure": { "code": "opted_out", "title": "Recipient opted out", "explanation": "...", "action": "Recommended: ..." } } } ``` The status is `403`. Nothing leaves our network and nothing is billed. You do not have to check consent before sending: send, and handle this one code. A refused send also leaves a trace, so "why wasn't this delivered?" is answered wherever you look: - a `message.blocked` event (and webhook) with `to`, `from`, `reason: "opted_out"` and the same `opted_out_*` fields; - a `blocked_send` entry in the number's consent history (at most one a day per number, so a retry loop cannot bury the opt-out itself); - `blocked_sends` and `last_blocked_at` on `GET /v1/consent/{phone}`. Broadcast recipients who opted out are skipped and counted, never messaged. Scheduled sends re-check consent at send time. **One-time passcodes are exempt.** `POST /v1/verify` still delivers, because a user asking to log in is asking for that code, and blocking it locks them out of their own account. Every such send is written to the consent ledger and emits `verification.sent_to_opted_out` so the pattern stays auditable. ## The consent ledger Every consent change is appended to a per-number history that is never deleted: opt-outs (with the detected text and method), opt-ins, imports, API changes, and verification exemptions. That history is your TCPA audit trail. ```bash # Current state + full history for one number curl https://api.joinsimplesms.com/v1/consent/+14155550132 \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" # Set consent from your own system (CRM sync, web form, support tool) curl -X POST https://api.joinsimplesms.com/v1/consent/+14155550132 \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "opted_out", "note": "revoked by email"}' # The whole suppression list, paginated curl "https://api.joinsimplesms.com/v1/consent?limit=100" \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" # Bulk import (up to 500 per request), e.g. when migrating from Twilio curl -X POST https://api.joinsimplesms.com/v1/consent/import \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"phone_numbers": ["+14155550132", "+16155550176"]}' # Export everything as CSV curl https://api.joinsimplesms.com/v1/consent/export \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" ``` Opt-ins set via the API can carry `proof`: the source, time, page, exact disclosure wording, IP and user agent (see [Opt-in proof](/docs/compliance#opt-in-proof)). `/v1/consent/export?type=ledger` exports every consent event with those proof columns. The [console Compliance page](/console/compliance) shows the same ledger with search, per-number history, import and export. ## Topics are narrower than an opt-out [Subscription topics](/docs/contacts#subscription-topics) let a person stop one kind of message (say Marketing) and keep another. They sit on top of everything on this page and never replace it: the opt-out check runs first on every send, an opted-out number gets nothing whatever its topics say, and subscribing a number to a topic does not opt it back in. Topic changes appear in the ledger as `topic_unsubscribe` and `topic_subscribe`; opt-in evidence stored by a [contact import](/docs/contacts#csv-import) appears as `import`. Neither changes `status`. ## Events | Event | When | | --- | --- | | `message.opted_out` | A revocation was detected (any tier) or set via API. Payload includes `method` and, for AI detections, `confidence`. | | `message.opted_in` | A recipient opted back in (START or API). Keyword opt-ins carry `resubscribed`: `true` when the number had been opted out. | | `message.help_requested` | A recipient texted HELP, INFO or AIDE. The help reply has already been sent. | | `message.blocked` | A send was refused because the recipient is opted out. Carries `to`, `from`, `reason` and when and how they opted out. | | `verification.sent_to_opted_out` | A passcode went to an opted-out number under the exemption. | Subscribe to these and mirror the state in your own database. You should never re-add a number that opted out, even though we block it. Imports do not emit per-number events; the ledger records each one. ## Testing it Opt-out works in the sandbox exactly as it does live, scoped to your account, so you can rehearse the whole path before going live. The phrase tier is deterministic, so it is testable too: ```bash curl -X POST https://api.joinsimplesms.com/v1/test/inbound \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from":"+15005550006","to":"","body":"please stop texting me"}' ``` The next send to that number returns 403, and `GET /v1/consent/+15005550006` shows the opt-out with `"method": "phrase"`. Send `START` to opt back in; the history keeps both entries. This page is educational, not legal advice. Your own counsel decides what your consent program needs; SimpleSMS gives you enforcement and records by default. --- # Compliance & registration Carriers only deliver business texting at volume from a registered **brand** (your business) and **campaign** (what you send, and how people opted in). Most registrations are rejected for the same few reasons: a privacy policy without the SMS clause, an opt-in form missing "Msg & data rates may apply", a pre-checked consent box. SimpleSMS checks for all of them before anything is filed, writes the copy you are missing, and files the registration for you. Registration is free. It works with test keys exactly as it does with live keys. ## How it works 1. **You create a registration**: business name, website, the page where people opt in, what you will send (the use case), and 2 to 5 real examples of those texts. In the console you only type your website: we [fill in the rest](#fill-in-from-your-website) for you to review. 2. **We check your website and your examples** right away (details below). Every finding comes with the evidence (the URL and the text we found, or what we looked for) and, if it fails, the exact text to paste. 3. **You fix and recheck** until every check passes and the examples are yours. The registration is then `ready`. 4. **You submit.** SimpleSMS's compliance team files the brand and campaign with the carriers, usually within 1 business day (`submitted`, then `in_review`). 5. **Carriers decide**, usually in 3 to 7 business days: `approved`, or `rejected` with a plain-language reason and the exact fix. Fix it, recheck, and submit again. Every registration answers five questions at all times, in its `guidance` field and on the [console Registration page](/console/registration): what happened, why, who acts next (`you`, `delivered`, `carriers`, or `nobody`), exactly what to do, and what happens after. ## Fill in from your website Give us the website and we suggest the rest of the registration: business name, a legal name if the site states one, the use case, a description, the opt-in, privacy and terms pages, support email, contact phone, address and industry. The console form does this when you click **Fill in from my website**; over the API it is one call: ```bash curl -X POST https://api.joinsimplesms.com/v1/registrations/prefill \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"website": "https://acmeplumbing.com"}' ``` ```json { "object": "registration_prefill", "reachable": true, "error": null, "fields": { "website": "https://acmeplumbing.com/", "business_name": "Acme Plumbing", "use_case": "account_notification", "description": "Licensed plumbers serving Springfield since 1998. Acme Plumbing sends account alerts and notifications to customers who have opted in. ...", "opt_in_url": "https://acmeplumbing.com/text-updates", "privacy_url": "https://acmeplumbing.com/privacy", "terms_url": "https://acmeplumbing.com/terms", "support_email": "help@acmeplumbing.com", "brand": { "legal_name": "Acme Plumbing LLC", "vertical": "CONSTRUCTION", "contact_email": "help@acmeplumbing.com", "contact_phone": "+14155550132" } }, "suggested": ["business_name", "use_case", "description", "opt_in_url", "privacy_url", "terms_url", "support_email", "brand.legal_name", "brand.vertical", "brand.contact_email", "brand.contact_phone"], "sources": { "business_name": "page", "opt_in_url": "page" }, "candidates": { "opt_in": [{ "url": "https://acmeplumbing.com/text-updates", "confidence": 1, "reason": "Has a phone number field and SMS consent wording" }], "privacy": [{ "url": "https://acmeplumbing.com/privacy", "confidence": 1, "reason": "Link labelled \"Privacy Policy\"" }], "terms": [{ "url": "https://acmeplumbing.com/terms", "confidence": 1, "reason": "Link labelled \"Terms of Service\"" }] }, "about": "Licensed plumbers serving Springfield since 1998.", "ai": false } ``` - `fields` is shaped like the body of `POST /v1/registrations`. Review it, add what only you know, and send it on. **We never suggest your EIN or entity type.** - `suggested` names every field we filled in, and `sources` says where each came from: `page` (read from your pages by fixed rules) or `ai`. - `candidates` lists the pages that could be your opt-in, privacy policy and terms, best first, each with the reason. `fields` holds the best one when we are confident enough; pick another from the list if we chose wrong. - A site we cannot read returns `reachable: false` with the reason in `error`, and no suggestions. **What we read, and AI.** We fetch your homepage and the few pages it links to that look like your sign-up, contact, privacy policy and terms pages: public pages only, under the same rules as the website check below. Names, contact details, address and page links are read with fixed rules. Where AI drafting is available (`ai: true`), a third-party AI model also reads the text of those public pages to draft the description, pick the use case and industry, and write three example messages (`fields.sample_messages`). It sees nothing from your account, is not trained on what it reads ([Privacy §4](/privacy)), and can only choose a business name the page states and an opt-in page from `candidates`. If its examples would not pass the [example check](#your-example-messages) they are left out. **Suggestions are drafts.** Nothing is saved or filed by this call. Example messages we draft are starter drafts: send them as `starter_messages` when you create the registration and it keeps `samples_source: "starter"` until you edit them (send `sample_messages`) or confirm them (`samples_confirmed: true`). Suggestions can be wrong; you are responsible for what you submit. One read per website per account every 10 minutes: asking again sooner returns the same suggestions. Limit: 10 websites a minute, separate from the website check's limit. ## Use cases | `use_case` | Registry use case | For | | --- | --- | --- | | `2fa` | 2FA | One-time passcodes | | `account_notification` | ACCOUNT_NOTIFICATION | Account alerts and notices | | `customer_care` | CUSTOMER_CARE | Support conversations | | `delivery_notification` | DELIVERY_NOTIFICATION | Order and delivery status | | `fraud_alert` | FRAUD_ALERT | Suspicious-activity alerts | | `security_alert` | SECURITY_ALERT | Account security notices | | `higher_education` | HIGHER_EDUCATION | Campus and enrollment updates | | `marketing` | MARKETING | Offers and promotions | | `mixed` | MIXED | Service messages and marketing | | `polling_voting` | POLLING_VOTING | Surveys (not political) | | `public_service_announcement` | PUBLIC_SERVICE_ANNOUNCEMENT | Community alerts | | `low_volume` | LOW_VOLUME | Small mixed programs | Pick the one that matches what you actually send. Sending a different kind of message than you registered is a policy violation ([Messaging Policy §6](/messaging-policy)); register another campaign instead. ## What the website check looks for | Finding | Passes when | | --- | --- | | `site_reachable` | Your website loads publicly over http(s). | | `business_identity` | The business name appears in the page text (legal suffixes like LLC are ignored). | | `privacy_policy_exists` | A privacy policy loads (we follow the "Privacy" link if you do not give a URL). | | `privacy_no_sharing` | It says mobile / SMS opt-in data is not shared with third parties. | | `terms_exist` | Terms of service load (we follow the "Terms" link). | | `terms_sms_program` | The terms describe the SMS program, with STOP and HELP. | | `optin_reachable` | The opt-in page loads. | | `optin_phone_field` | It has a phone number field, or a "Text KEYWORD to NUMBER" instruction. | | `optin_brand` | The disclosure names your brand. | | `optin_message_types` | It says what messages to expect. | | `optin_frequency` | "Message frequency varies", or a cadence like "4 msgs per week". | | `optin_rates` | "Msg & data rates may apply". | | `optin_stop` | "Reply STOP to cancel". | | `optin_help` | "Reply HELP for help". | | `optin_links` | Links to the privacy policy and terms. | | `optin_not_prechecked` | The consent checkbox starts unchecked. | The check is deterministic pattern matching; no AI is involved. (The only place AI may appear in registration is the optional drafting in [Fill in from your website](#fill-in-from-your-website).) The generated copy is written to pass it: paste the fix, recheck, and that finding passes. Without an opt-in URL we check your homepage, and a missing phone field there is a warning rather than a failure. We fetch only the public pages you name (and the policy links on your homepage), from our servers, with a short timeout and a size cap. Addresses that are not on the public internet are never fetched. We keep the findings and their short evidence snippets, not the pages. ## Your example messages Carriers compare the examples on your registration with the texts you actually send. Examples that do not match your traffic are one of the most common reasons a registration is rejected, and a sender suspended later. So the examples we file are **yours**, not ours. Send them as `sample_messages`: 2 to 5 strings, 20 to 320 characters each, written as a recipient would get them (a real-looking name, order number or code, not a template variable). `description` (optional, 40 to 500 characters) says what you send and to whom; leave it out and we file a generated one. If you create a registration without `sample_messages`, it comes back with **starter drafts** for your use case and `samples_source: "starter"`: our templates, or the drafts written for your business if you sent `starter_messages` from [Fill in from your website](#fill-in-from-your-website) (drafts that would not pass the checks below are ignored). Starter drafts are never filed: edit them, or send `samples_confirmed: true` to `/recheck` if they already read like your texts. Either makes `samples_source` `"customer"`. `sample_check` holds the findings, in the same shape as the website check: | Finding | Passes when | | --- | --- | | `samples_confirmed` | The examples are yours (edited or confirmed), not starter drafts. | | `samples_count` | There are 2 to 5. | | `samples_length` | Each is 20 to 320 characters. | | `samples_brand` | Every example names your business (the name without LLC / Inc, its leading word, or its initials). | | `samples_opt_out` | At least one says how to stop ("Reply STOP to opt out"). Required for `marketing` and `mixed`; a warning for other use cases; not asked of `2fa`. | | `samples_links` | No public link shorteners. | | `samples_placeholders` | No unfilled variables like `{{name}}` or `[Brand]`. | | `samples_distinct` | No two examples are the same. | | `samples_use_case` | `2fa` only: an example shows a code. | | `samples_description` | Your own description, if you wrote one, is 40 to 500 characters. | These are deterministic rules; no AI is involved in the check. They cannot tell whether an example matches what you will send: that part is on you, and it is what carriers judge. A registration is `ready` only when the website check passes, the example check passes, and `samples_source` is `"customer"`. A recheck that changes only the examples does not fetch your website again. ## What we file, and the copy we keep When you submit, we store an exact copy of what was filed: your business details, example messages, description, the opt-in story, and how the checks stood. A resubmission adds a new copy; a copy is never changed. Read them at `GET /v1/registrations/{id}/submissions` or under **Filed versions** on the [console Registration page](/console/registration), so you can always show what the carriers reviewed. ## Generated copy Every registration includes `generated`, built from your business name and use case: - `opt_in_cta`: the disclosure to place next to the phone field - `privacy_clause` and `sms_terms`: paste into your privacy policy and terms - `sample_messages`: the starter drafts (what is filed is the top-level `sample_messages`, repeated in `campaign.sample_messages`) - `help_reply`, `stop_reply`, `opt_in_confirmation` - `brand` and `campaign`: the full registration draft, including `message_flow`, keywords, and the embedded-link, embedded-phone, age-gated and direct-lending flags. `brand.missing` lists what the registry still needs from you (legal name, EIN, address, ...); send it in `brand`. ## API ```bash # Create (runs the website check immediately) curl -X POST https://api.joinsimplesms.com/v1/registrations \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "business_name": "Acme Plumbing LLC", "website": "https://acmeplumbing.com", "opt_in_url": "https://acmeplumbing.com/text-updates", "use_case": "customer_care", "support_email": "help@acmeplumbing.com", "sample_messages": [ "Acme Plumbing: Hi Sam, your plumber Dana arrives tomorrow between 9 and 11 AM. Reply STOP to opt out.", "Acme Plumbing: Your job #4821 is done. Reply here with any questions about the repair." ], "brand": { "legal_name": "Acme Plumbing LLC", "ein": "12-3456789" } }' # Fix the site, then recheck (any field can be corrected in the same call) curl -X POST https://api.joinsimplesms.com/v1/registrations/reg_.../recheck \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"opt_in_url": "https://acmeplumbing.com/signup"}' # Submit once status is "ready" curl -X POST https://api.joinsimplesms.com/v1/registrations/reg_.../submit \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" # Get one / its filed copies / list all curl https://api.joinsimplesms.com/v1/registrations/reg_... \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" curl https://api.joinsimplesms.com/v1/registrations/reg_.../submissions \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" curl https://api.joinsimplesms.com/v1/registrations \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" ``` A response, trimmed: ```json { "id": "reg_a1B2c3D4e5F6", "object": "registration", "status": "checks_failed", "guidance": { "what_happened": "The website check found 1 problem: consent box is not pre-checked.", "why": "Carrier reviewers reject registrations whose website or opt-in is missing these disclosures. Fixing them now avoids a rejection later.", "next_actor": "you", "next_step": "Fix each failed item using its copy-paste text, publish the change on your website, then click Recheck website. If a URL was wrong, correct it when you recheck.", "after": "When every check passes the registration becomes Ready to submit." }, "check": { "passed": 15, "failed": 1, "findings": [{ "id": "optin_not_prechecked", "status": "fail", "evidence": { "url": "https://acmeplumbing.com/text-updates", "snippet": "" }, "why": "Consent must be an action the person takes...", "fix": { "summary": "Remove the `checked` attribute from the consent checkbox...", "copy": null } }] }, "rejection": null, "generated": { "opt_in_cta": "By checking this box, I agree to receive ..." } } ``` States: `draft` → `checks_failed` / `ready` → `submitted` → `in_review` → `approved` / `rejected`. Recheck works in `draft`, `checks_failed`, `ready` and `rejected`; submit only in `ready`. Anything else returns **409** `invalid_state` with a message saying what to do instead. Creating and rechecking share a budget of 20 website checks per minute per account. ## Rejections A rejection carries `rejection.code`, a plain `explanation`, the exact `fix`, any `note` from our compliance team, and `related_findings`; those findings are marked `flagged_by_rejection` on the next recheck. Codes: `website_unreachable`, `brand_mismatch`, `privacy_sharing_clause`, `terms_missing_sms`, `optin_disclosure_incomplete`, `optin_not_verifiable`, `prechecked_consent`, `samples_missing_brand`, `samples_use_case_mismatch`, `message_flow_unclear`, `url_shortener_or_link_mismatch`, `restricted_content`, `other`. ## Events `registration.updated` fires on every status change (pollable at `/v1/events`, pushed to webhooks). `data` has `id`, `status`, `previous_status`, `change` (e.g. `resubmitted`), and `reason_code` on rejections. ## Sending before approval A live number is **test only until its registration is approved**: it can text your verified numbers (your own phone, and any number you verify under Billing → Verified numbers), so you can build and test with real texts while the carriers review. It cannot text anyone else yet. Carriers block traffic from unregistered numbers, so we stop it before it leaves rather than let it fail downstream. A send to an unverified recipient from a number that is not yet active answers: ``` HTTP/1.1 403 X-SimpleSMS-Sender-State: test_only | pending | action_needed { "error": { "code": "sender_not_registered", "param": "from", "message": "+14155550132 is still being registered with the carriers, so it cannot text this recipient yet. ..." } } ``` Nothing is sent and nothing is charged. Once the registration is approved, every live number on the account is linked to it automatically and becomes `active`; watch `sender.state` on [the number](/docs/numbers#sender-status) or subscribe to `number.sender_updated`. Test keys and sandbox numbers are never affected. ## Going live Submitting a registration from a sandbox account is also your request for live access. There is no second form: we read the business, website, use case and opt-in from the registration. You can keep building in the sandbox while both are reviewed. A registration must be complete before it can be submitted. If business details are missing (legal name, tax ID, address, industry, contact email), submit answers **400** naming them, and the registration stays `ready`. Carrier review usually takes 3 to 7 business days. If it runs past 10 days we flag the registration (`review_stalled: true`), our team follows up with the carriers, and your numbers show `action_needed` with an explanation, so a review never sits silently. ## Opt-in proof [Messaging Policy §1.4](/messaging-policy) asks you to keep evidence of every consent. Record it with the opt-in and it lives in your consent ledger, next to the opt-in it proves: ```bash curl -X POST https://api.joinsimplesms.com/v1/consent/+14155550132 \ -H "Authorization: Bearer $SIMPLESMS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "opted_in", "proof": { "source": "web_form", "collected_at": "2026-09-30T18:04:05Z", "page_url": "https://acmeplumbing.com/text-updates", "disclosure": "By checking this box, I agree to receive ...", "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 ...", "registration_id": "reg_a1B2c3D4e5F6" } }' ``` | Field | | | --- | --- | | `source` | Required. `web_form`, `keyword`, `paper`, `checkout`, or `verbal`. | | `collected_at` | When they consented (ISO or epoch ms). Defaults to now. | | `page_url` | Required for `web_form`. | | `disclosure` | The exact wording they saw (up to 2,000 characters). | | `ip`, `user_agent` | For online forms. | | `registration_id`, `campaign_id` | The registration or carrier campaign it was collected under. | `GET /v1/consent/{phone}` returns `proof` on the history entry, and `GET /v1/consent/export?type=ledger` exports every consent event with `proof_*` columns. Keep proof for at least four years after your last message to the number. This page is educational, not legal advice. --- # Security best practices SimpleSMS secures the platform; you secure your side of it. This page covers what we do, what you should do, and how to recognize the attacks that target messaging accounts. ## Protect your API keys - Keys are shown **once** at creation and stored only as salted hashes on our side. If you lose one, roll it - there is nothing to recover. - Keep keys in environment variables or a secrets manager, never in source control, client-side code, or chat logs. A key in a public repo should be treated as compromised and rotated immediately. - Rotate keys periodically (we recommend at least every 90 days) and on any team departure. Rotation is instant in the console under **API keys**. - Use test keys everywhere except production. Test keys cannot send real traffic, so a leaked test key cannot run up a bill or spam anyone. - Give each service a [restricted key](/docs/authentication) with only the scopes it needs, and set a [spend limit](/docs/spend-limits) so a leaked live key has a ceiling. - Review the [audit log](/docs/audit-logs) for key and team changes you don't recognize. ## Protect your account - Console passwords must be at least 12 characters with uppercase, lowercase, numeric, and special characters. Use a password manager and a unique password. - Accounts lock temporarily after repeated failed sign-in attempts, and console sessions expire after 30 minutes of inactivity. - Only invite teammates who need access, and remove them when they leave. ## Recognize social engineering Messaging accounts are a target: attackers want your sending capability, your keys, or your customers' trust. - **We will never ask for your password or a full API key** - not by email, not by text, not in support conversations. Anyone who does is not us. - Verify unusual requests **out of band**: if an email or message asks you to change payment details, share credentials, or approve access, confirm through a channel you already trust (the console, or a contact you already have) before acting. - Check sender addresses carefully. Our email comes from the joinsimplesms.com domain and links point to joinsimplesms.com; lookalike domains are the most common phishing tell. - Spear phishing is personalized - a message knowing your name, role, or vendor list is not proof it is genuine. - Report anything suspicious to [support@joinsimplesms.com](mailto:support@joinsimplesms.com); we investigate every report. ## Protect your users - Send only to recipients who opted in, honor opt-outs (we enforce STOP and its variants platform-wide), and identify yourself as the sender - see [Opt-out & consent](/docs/opt-out). - Never put credentials, one-time codes you did not generate, or sensitive personal data in outbound messages beyond what the use case requires. - Verify [webhook signatures](/docs/webhooks) so spoofed callbacks cannot inject fake delivery or inbound events into your systems. ## What we do on our side - TLS for all data in transit; encryption at rest; API keys and verification codes stored as salted hashes, never logged in plaintext. - Real-time content filtering, velocity limits, and 24x7 automated fraud monitoring with automatic suspension of abusive traffic. - Production access restricted to authorized personnel and audited. - Details in the [Privacy Policy](/privacy). Found a vulnerability? Email [support@joinsimplesms.com](mailto:support@joinsimplesms.com) with details; we respond quickly and appreciate responsible disclosure. --- # Deliverability Every final outcome of an outbound message (delivered or failed) is counted by day, by the recipient's carrier, and by the number you sent from. Read the numbers from the API, or open [Console → Deliverability](/console/deliverability) for trends, your worst carriers and numbers, and a breakdown of failure reasons. Test keys report sandbox traffic; live keys report live traffic. Counts only: no message content is part of this data. ## GET /v1/deliverability ```bash curl "https://api.joinsimplesms.com/v1/deliverability?group_by=carrier&start_date=2026-09-01&end_date=2026-09-30" \ -H "Authorization: Bearer ssms_sk_live_..." ``` | Parameter | Default | Notes | | --- | --- | --- | | `group_by` | `day` | `day`, `carrier`, or `number` | | `start_date` | 6 days before `end_date` | `YYYY-MM-DD`, UTC, inclusive | | `end_date` | today | `YYYY-MM-DD`, UTC, inclusive. Ranges are limited to 90 days. | ```json { "object": "deliverability", "mode": "live", "start_date": "2026-09-01", "end_date": "2026-09-30", "group_by": "carrier", "totals": { "key": "total", "delivered": 9412, "failed": 88, "total": 9500, "delivery_rate": 0.9907, "failure_reasons": { "unknown_subscriber": 51, "carrier_violation": 37 } }, "data": [ { "key": "example_wireless", "delivered": 2100, "failed": 61, "total": 2161, "delivery_rate": 0.9718, "failure_reasons": { "carrier_violation": 37, "unknown_subscriber": 24 } } ] } ``` `day` rows come back in date order; `carrier` and `number` rows worst first. The carrier is known when the recipient has been looked up recently (`GET /v1/lookup`); otherwise it is `unknown`. Numbers are keyed by their 10 digits. Needs the `messages:read` scope on a restricted key. ## Degradation alerts Every 15 minutes we compare each account's last 2 hours with its previous 7 days, for all traffic, each carrier, and each sending number. When the delivery rate falls sharply (at least 15 points and at least 20% below normal, with at least 20 recent and 100 baseline outcomes, so a handful of failures can't trigger it), we: - show the alert at the top of the Deliverability page, - send a `deliverability.degraded` [webhook event](/docs/webhooks), - and alert our team. ```json { "type": "deliverability.degraded", "data": { "alert_id": "dal_...", "dimension": "number", "key": "4155550132", "window_rate": 0.41, "baseline_rate": 0.97, "window_total": 230, "baseline_total": 18400, "window_hours": 2 } } ``` `dimension` is `account` (all traffic), `carrier`, or `number`. Each series alerts at most once every 6 hours. --- # Spend limits Set a monthly cap in USD and live messages stop at it instead of running up a bill. Alerts fire as you approach it. Set it in [Console → Settings](/console/settings) (admins) or with the API. ## How it works - Each live outbound message counts at the published rate ($0.009) against this month's estimated spend, **before** it is sent. If it would take you past the cap, it is not sent and you are not charged. - A message the carrier rejects is refunded against the cap, as it is on your bill. - The month resets on the 1st, UTC. - Sandbox (test keys) is free and never counted. Free-tier accounts are capped by their quota and cost nothing. - The cap covers outbound messages. Numbers, lookups, and verifications are billed as usual and are not counted toward it. - The estimate is for your protection; your invoice is the source of truth. When the cap is reached, sends answer: ```json { "error": { "code": "spend_limit_reached", "message": "Monthly spend limit reached: $50.00 of your $50.00 limit is used this month, so this message was not sent and you were not charged. Sending resumes on the 1st (UTC). To keep sending now, raise or remove the limit in the console (Settings → Spend limit) or with PATCH /v1/spend-limit." } } ``` (HTTP 403). Raising or removing the limit takes effect on the next send. ## Alerts `alert_thresholds` are percentages of the cap (default 50, 80, and 100). Each one fires once per month: an email to the account owner and a `spend.threshold_reached` [webhook event](/docs/webhooks): ```json { "type": "spend.threshold_reached", "data": { "threshold_percent": 80, "spent_usd": 40.01, "limit_usd": 50, "month": "2026-10" } } ``` Changing the cap re-arms the alerts for the new amount. ## API ```bash curl https://api.joinsimplesms.com/v1/spend-limit -H "Authorization: Bearer ssms_sk_live_..." curl -X PATCH https://api.joinsimplesms.com/v1/spend-limit \ -H "Authorization: Bearer ssms_sk_live_..." -H "Content-Type: application/json" \ -d '{"monthly_limit_usd": 50, "alert_thresholds": [50, 80, 100]}' ``` ```json { "object": "spend_limit", "monthly_limit_usd": 50, "alert_thresholds": [50, 80, 100], "applies_to": ["outbound_sms"], "estimated_cost_per_message_usd": 0.009, "current_month": { "month": "2026-10", "spent_usd": 12.6, "remaining_usd": 37.4 }, "updated_at": "2026-10-01T17:02:11.000Z" } ``` `monthly_limit_usd` is 1 to 1,000,000, or `null` for no cap. Up to 5 thresholds, whole percentages from 1 to 100. Needs the `billing` scope on a restricted key. Every change is recorded in the [audit log](/docs/audit-logs). --- # Audit log Every administrative change to your account is recorded with who made it, what they changed, when, and from which IP address. Admins see it in [Console → Audit log](/console/audit-log); the API serves the same entries. ## What is recorded | Action | When | | --- | --- | | `api_key.created`, `api_key.rolled`, `api_key.revoked` | Key changes in the console | | `team.invite_created`, `team.invite_accepted`, `team.invite_revoked` | Invitations | | `team.role_changed`, `team.member_removed`, `team.member_left` | Membership changes | | `webhook.created`, `webhook.updated`, `webhook.deleted` | Webhook endpoints | | `webhook.secret_rotated` | A webhook endpoint's signing secret was replaced (the secret itself is never logged) | | `spend_limit.updated` | [Spend limit](/docs/spend-limits) changes | | `number.purchased`, `number.released`, `number.assigned` | Numbers (bought, released, or assigned to a customer) | | `consent.updated`, `consent.imported` | Manual opt-out / opt-in overrides and imports | | `contact.topic_updated` | A contact's subscription to a [topic](/docs/contacts#subscription-topics) changed | | `contacts.imported` | A contact import finished (row counts, never the rows) | | `segment.created`, `segment.updated`, `segment.deleted` | Audience segments | | `topic.created`, `topic.updated`, `topic.deleted` | Subscription topics | | `settings.updated` | Account name, auto-replies, verified recipients, live-access requests | | `number.registration_set`, `number.sender_linked`, `number.sender_unlinked` | A number was attached to a [registration](/docs/numbers#sender-status), linked with the carriers, or unlinked (on release or when moved) | | `registration.approved`, `live_access.approved` | Our team approved a registration, or live access for the account | | `automation.created`, `automation.updated`, `automation.activated`, `automation.paused`, `automation.deleted` | [Automations](/docs/automations) (name, trigger event and step count; never message text) | Entries never contain message content, secrets, or full invite links. ## GET /v1/audit-logs ```bash curl "https://api.joinsimplesms.com/v1/audit-logs?limit=25" -H "Authorization: Bearer ssms_sk_live_..." ``` ```json { "data": [ { "id": "aud_a1B2c3D4e5F6g7H8", "object": "audit_log", "action": "api_key.created", "actor": { "type": "user", "id": "uid_...", "email": "dev@example.com", "name": "Dev" }, "target": { "type": "api_key", "id": "key_x9Y8z7W6v5U4" }, "metadata": { "mode": "live", "name": "Billing worker", "scopes": ["messages:send"] }, "ip": "203.0.113.7", "created_at": "2026-10-01T17:02:11.000Z" } ], "has_more": true, "next_cursor": "..." } ``` Newest first. Pass `next_cursor` back as `cursor` for the next page; `limit` is 1 to 100 (default 25). `actor.type` is `user` (console) or `api_key` (with the key's id and name). Needs the `audit_logs:read` scope on a restricted key. ### Filters | Parameter | Matches | | --- | --- | | `action` | One action (`number.released`), or a whole group with a trailing dot (`team.`, `api_key.`) | | `actor` | The actor's id (exact), or any part of their email, case-insensitive | | `target_type` | The target's type: `api_key`, `member`, `invite`, `webhook`, `number`, `phone`, ... | | `created_after` | Entries at or after this time (ISO 8601) | | `created_before` | Entries at or before this time (ISO 8601); a bare date means the end of that day, UTC | ```bash curl "https://api.joinsimplesms.com/v1/audit-logs?action=team.&created_after=2026-09-01&created_before=2026-09-30" \ -H "Authorization: Bearer ssms_sk_live_..." ``` Filters apply before paging, so a page holds `limit` matching entries. One request scans at most 5,000 entries: on a long log with a rare filter, a page can come back short (or empty) with `has_more: true`. Keep following `next_cursor` until `has_more` is `false`. A date range narrows the scan itself, so it is the fastest filter. An invalid filter returns `400 invalid_request`. ## Export as CSV `GET /v1/exports/audit_log` streams the same entries as CSV and takes the same filters. Columns: `id`, `created_at`, `action`, `actor_type`, `actor_id`, `actor_email`, `actor_name`, `target_type`, `target_id`, `ip`, `metadata` (JSON). Up to 50,000 rows per export; same `audit_logs:read` scope. In the console, **Export CSV** on the Audit log page downloads the current filter (admins only). ```bash curl "https://api.joinsimplesms.com/v1/exports/audit_log?created_after=2026-01-01" \ -H "Authorization: Bearer ssms_sk_live_..." -o audit-log.csv ``` --- # Errors Every error is JSON with a stable envelope: ```json { "error": { "code": "invalid_request", "message": "`to` must be a valid US/Canada number in E.164 format.", "param": "to", "request_id": "req_8Fq2ZkT0aLw4nXcV7pHs" } } ``` ## Request IDs Every response from an endpoint that takes your API key, success or error, carries an `X-Request-Id` header (`req_…`), and error bodies repeat it as `request_id`. Look it up in the console under **Logs** to see the request and response as we received and sent them (API keys, secrets, verification codes, and card data are redacted; bodies are truncated at 8 KB; logs are kept for 30 days). Include it when you contact support. ## Codes | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | A parameter is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | Missing, malformed, revoked, or unknown key. | | 403 | `live_access_required` | Needs live access (or a live key before approval). | | 403 | `test_mode_only` | Sandbox-only endpoint called with a live key. | | 403 | `tenant_suspended` | Account suspended. | | 403 | `forbidden` | Key is valid but can't act on this resource (e.g. a `from` you don't own). | | 403 | `insufficient_scope` | A restricted key called outside its scopes; `required_scope` names the one needed. See [scopes](/docs/authentication). | | 403 | `spend_limit_reached` | Your monthly [spend limit](/docs/spend-limits) is reached; the message was not sent or charged. | | 403 | `sender_not_registered` | The `from` number is not linked to an approved registration yet, so it can only text your verified numbers. `X-SimpleSMS-Sender-State` says where it stands. See [Numbers](/docs/numbers#sender-status). | | 404 | `not_found` | No such resource on your account. | | 409 | `idempotency_conflict` | Idempotency-Key reused with a different payload. | | 429 | `rate_limited` | Too many requests; check `Retry-After`. | | 429 | `quota_exceeded` | A daily quota was reached. | | 502 | `carrier_error` | The carrier permanently rejected the message, or did not answer after we handed it over (so we will not risk sending it twice). Temporary carrier problems do not return this: the send answers `202` with the message `queued` and we retry it ([Messages](/docs/messages#automatic-retries)). | | 500 | `internal_error` | Our fault. Retry, and tell us if it persists. | ## Delivery failures A message that was accepted but not delivered has `status: "failed"` and a `failure` object, on the message and in the `message.failed` webhook: `{ code, title, explanation, action, carrier_code }`. The codes are stable; switch on `code`, show `title` and `explanation` to your team, and follow `action`. `carrier_code` is the carrier's own code when it sent one, for support conversations. | Code | Title | What happened, and what to do | | --- | --- | --- | | `carrier_filtered` | Filtered as spam | The recipient's mobile carrier filtered this message as spam or unwanted traffic, so it never reached the phone. **Recommended:** identify your business in the first words, avoid link shorteners and all-caps, make sure the recipient opted in, and keep similar messages from going out in bursts. Repeated filtering on the same content usually means the wording needs to change. | | `unreachable` | Phone unreachable | The number is valid, but the phone could not be reached: it was switched off, out of coverage, or its inbox was full until the carrier gave up. **Recommended:** retry later (hours, not seconds). If it keeps failing, confirm the number with the recipient. | | `invalid_number` | Number not in service | The destination number does not exist or is no longer assigned to a phone. **Recommended:** stop sending to this number and ask the recipient for an up-to-date one. Retrying will not help. | | `opted_out` | Recipient opted out | The recipient has unsubscribed from your messages (for example by replying STOP), so the message was not sent. **Recommended:** do not retry. Only send again if the recipient opts back in, for example by texting START to your number. | | `content_blocked` | Content blocked | The message content is not allowed on the network, for example a prohibited topic or a blocked link. **Recommended:** review the Messaging Policy, change the wording or link, and send again. Resending the same text will fail the same way. | | `rate_limited` | Sending too fast | Too many messages went to this number or through this route in a short time, so this one was held back. **Recommended:** slow down and retry with backoff. Honor the Retry-After header when the API returns 429. | | `landline` | Number can't receive texts | The destination is a landline or another line that does not accept text messages. **Recommended:** use a mobile number for this recipient. A lookup (GET /v1/lookup) tells you the line type before you send. | | `carrier_rejected` | Rejected by the carrier | The carrier refused the message before trying to deliver it, for example because the route or sender is not allowed to reach this number. **Recommended:** check that the sending number is set up for this kind of traffic. If every message to a carrier is rejected, contact support with the message id. | | `spend_limit_reached` | Spending limit reached | Sending this message would take your account past the monthly spending limit you set, so it was not sent and nothing was charged. **Recommended:** raise or remove the limit (console Settings → Spend limit, or PATCH /v1/spend-limit), or wait until the 1st (UTC). Refused messages are not queued: send them again once there is room. | | `sender_not_registered` | Sending number not registered yet | The number this was sent from is not linked to an approved registration yet, so it can only text numbers you have verified. This recipient is not one of them, so the message was not sent and nothing was charged. **Recommended:** open the number in the console (Numbers) to see where its registration stands and what, if anything, is needed from you. Once the number shows Active, send again. Meanwhile you can text your verified numbers. | | `unknown` | Not delivered | The carrier reported the message as undelivered without giving a reason. **Recommended:** retry once later. If the same number keeps failing, contact support with the message id. | Some sends are refused before a message exists: the recipient opted out, the sending number is not registered yet, the content is blocked, the number is rate limited, or the send would pass your [spending limit](/docs/spend-limits). Those answer `403` or `429` and the error carries the same object, so one handler covers both: ```json { "error": { "code": "recipient_opted_out", "message": "This recipient opted out of your messages on Oct 5, 2026 at 8:42 AM UTC by replying STOP. Nothing was sent and you were not charged. They can opt back in by texting START to your number.", "param": "to", "opted_out_at": "2026-10-05T08:42:00.000Z", "opted_out_via": "sms_keyword", "opted_out_method": "keyword", "failure": { "code": "opted_out", "title": "Recipient opted out", "explanation": "...", "action": "Recommended: ...", "carrier_code": null } } } ``` Every code except `content_blocked`, `spend_limit_reached` and `sender_not_registered` (sandbox numbers are never registered) has a [sandbox number](/docs/sandbox) that produces it, so you can test the handling before going live. ## Success codes `200` and `201` mean done. `202` (on `POST /v1/messages`) means accepted and `queued`: a temporary carrier problem is being retried for you. A replayed idempotent request returns the original status code with an `Idempotent-Replayed: true` header. ## Rate limits Default 60 requests/minute per key. `429`s include a `Retry-After` header. Successful quota-limited calls include `X-Quota-Remaining`. --- # Pricing | | Price | | | --- | --- | --- | | Outbound SMS | **$0.009** | per message | | Inbound SMS | **$0.004** | per message | | Phone number | **$0.95** | per number, per month | | Carrier lookup | **$0.008** | per lookup | | Phone verification | **$0.025** | per successful verification | US & Canada long code. Billed monthly in arrears, in USD. We file your A2P 10DLC registration at no extra charge; carriers usually review it in 3-7 business days, and you can text your own verified numbers the same day. You're billed when the carrier accepts the message: failed sends cost nothing. ## Free tier The free tier needs no credit card: - **100 live outbound SMS per month** (10 per day) - **1 phone number** - Live texts go only to numbers you've verified (up to 3) - **Unlimited sandbox**: every endpoint, no caps, no card, no verification - 30-day log retention Add a payment method to lift every cap, text anyone, and unlock live carrier lookups. ## How billing works Usage accrues through the month; we charge your card on the 1st. There is no platform fee, no minimum, and no commitment; a month with no traffic costs nothing. Every response that consumes quota returns `X-Quota-Remaining`. When you run out on the free tier you get a `429` with code `quota_exceeded` and a message saying whether it was the daily or the monthly limit. Want a hard ceiling? Set a monthly [spend limit](/docs/spend-limits) with alerts at the thresholds you choose. Sandbox (test keys) is unlimited and free forever. It is never metered, never capped, and never requires a verified recipient; build the whole integration before you spend anything. See [the pricing page](/pricing) for the full rate table and a cost estimator. --- # Changelog ## 2026-10-05: Opt-outs you can see - **`recipient_opted_out`**: a send to an opted-out number now answers `403` with its own code (it was the generic `forbidden`) and says when and how the person opted out, in the message and as `opted_out_at`, `opted_out_via` and `opted_out_method`. `failure.code` is still `opted_out`. If you matched on `forbidden` for this case, match the new code. - **`message.blocked`**: every refused send emits an event and webhook, is noted in the number's consent history, and is counted on `GET /v1/consent/{phone}` (`blocked_sends`, `last_blocked_at`). - **`message.help_requested`**: emitted when a recipient texts HELP. - **Replies in your name**: STOP, START and HELP replies now name your business, and a registered number sends exactly the replies its registration filed, with your support contact. They used to name Delivered. - **START is confirmed**: someone who was opted out gets one "you are resubscribed" reply. A repeat STOP no longer sends a second confirmation. - `consent.get()` is also available as `contacts.consent(phone)` in the Node and Python SDKs. - **The suppression list fails closed**: if it cannot be read, a send answers `503` with `Retry-After` instead of going out unchecked. ## 2026-10-05: Delivered is now SimpleSMS The product has a new name and a new home, **joinsimplesms.com**. It is the same service, the same account, the same prices and the same terms; nothing you built has to change. Apart from the API host, everything issued under the Delivered name keeps working, with no end date: - **API host: this one changes.** `api.deliveredsms.com` and `mcp.deliveredsms.com` are retired and answer `410 host_retired` with the new host in the body. Point your base URL at `https://api.joinsimplesms.com/v1` (MCP: `https://mcp.joinsimplesms.com`). Paths, request shapes and your key are unchanged. - **API keys.** `dsms_sk_...` keys (and `resms_sk_...`) authenticate forever. Only newly created keys get the new `ssms_sk_` prefix. - **Webhook headers.** Every delivery carries `simplesms-signature` / `simplesms-event-id` and, with identical values, `dsms-signature` / `dsms-event-id` (and the `resms-` pair). Signing secrets are unchanged. - **Response header.** `X-Delivered-Sender-State` is still sent, alongside `X-SimpleSMS-Sender-State`. - **Node package.** `npm install deliveredsms` still works: it re-exports the new `joinsimplesms` package, and `Delivered` / `DeliveredError` are aliases of `SimpleSMS` / `SimpleSMSError` (the same classes). - **CLI.** `deliveredsms` and `dsms` still run; the new command is `simplesms`. A key saved in `~/.deliveredsms.json` is still read. - **Environment variables.** The SDKs and CLI read `SIMPLESMS_API_KEY` and `SIMPLESMS_BASE_URL` first, then `DELIVERED_API_KEY` and `DELIVERED_BASE_URL`. - **Agent skills.** `/skills/delivered` and `/skills/delivered-verify` redirect to `/skills/simplesms` and `/skills/simplesms-verify`. - **Your data.** Numbers, webhook endpoints, registrations, opt-outs and message history were not migrated or re-created. They are where they were. What does change, and when you would notice: - **Texts we send for you name SimpleSMS.** The Verify template when you send no `app_name` (`SimpleSMS code: 482193...`), and the sandbox suffix on texts to your own phone. Your own message bodies and your own `app_name` are untouched. - **Webhook requests identify as `SimpleSMS-Webhooks/1.0`** in `User-Agent` (was `Delivered-Webhooks/1.0`). Verify the signature, not the user agent. - **The console lives at joinsimplesms.com.** You sign in with the same account; the first visit to the new address asks you to sign in once. To move over at your own pace: point your base URL at `api.joinsimplesms.com`, install `joinsimplesms`, rename `Delivered` to `SimpleSMS`, and read `simplesms-signature` in your webhook handler. None of it is required. ## 2026-10-04: Sender status - **Numbers show where they stand.** Every number carries `sender` (`test_only`, `pending`, `active`, `action_needed` with a plain reason). New `GET /v1/numbers/{number}`, and the `number.sender_updated` event. See [Numbers](/docs/numbers#sender-status). - **Test only until registered.** A live US number that is not linked to an approved registration can text your verified numbers and nobody else. Other sends answer `403 sender_not_registered` with `X-Delivered-Sender-State`. This replaces the `X-Delivered-Registration` / `X-Delivered-Warning` headers, which are gone: unregistered traffic is now stopped instead of warned about. - **Automatic linking.** When a registration is approved, the account's live numbers are linked to it and become `active`; numbers bought later link on their own. `POST /v1/numbers/{number}/registration` chooses a registration explicitly (up to 49 numbers each). - **One form to go live.** Submitting a registration from a sandbox account is also the live-access request. Submit now requires complete business details and answers `400` naming what is missing. ## 2026-10-04: Automations - **Event-driven flows**: `POST /v1/track` (`events.track` in the SDKs) records what a person did; a flow listening for that event sends, waits, waits for another event with a timeout, and branches. See [Automations](/docs/automations). - **`/v1/automations`**: create, edit, activate, pause, and read runs with a step-by-step timeline. New key scope `automations`. - **Webhook events**: `automation.run.started`, `automation.run.completed`, `automation.run.failed`. - **Console**: Automations, with three starter templates and a "Send a test event" button that works in the sandbox. ## 2026-10-04: Contacts API - **`/v1/contacts`**: create-or-update by phone number, retrieve, update, delete, and list with `tag` and `phone_number` filters. The same address book the console uses. See [Contacts](/docs/contacts). - **`POST /v1/contacts/import`**: bulk upsert, up to 500 per request, with skipped rows reported by index. - **`POST /v1/contacts/bulk`**: add tags, remove tags, or delete for up to 500 contacts in one call. - New key scope `contacts`. `contacts` resource in the Node and Python SDKs. ## 2026-10-01: Message observability - **Message timeline**: every message records each step with a timestamp (`accepted` → `validated` → `queued` → `sent_to_carrier` → `carrier_accepted` → `delivered`/`failed`; inbound: `received`) as `timeline` on `GET /v1/messages/{id}`. - **`segments`, `encoding`, `price`, `destination_carrier`** on every message. - **Readable failures**: failed messages, `message.failed` events, and refused sends (opt-out, content block, rate limit, spend limit) carry a `failure` object with a stable code, a plain explanation, and what to do. See [delivery failures](/docs/errors#delivery-failures). - **List filters**: `status`, `direction`, `to`, `from`, `created_after`, `created_before` (alongside `customer_id`) on `GET /v1/messages`, with full pages. Automatic carrier retries show up on the timeline per attempt. - **Sandbox numbers** for every failure, opt-out, rate limiting, and a really delayed delivery. The happy-path number now settles to `delivered` instead of staying `sent`. - **Idempotency-Key** now applies to scheduled sends too. - **Console**: message filters and a message inspector (timeline latencies, cost, failure, related webhook events). ## 2026-10-01: Reliability - **Automatic carrier retries**: temporary carrier failures no longer fail a send. `POST /v1/messages` answers `202` with the message `queued` and we retry it after 30s, 2m and 10m. New message fields: `attempts`, `next_attempt_at`. A message is never submitted twice. - **Missing delivery reports**: a sent message with no delivery report after 72 hours is marked `receipt_status: "missing"`. Its status stays `sent`. - **Idempotency-Key** on `POST /v1/verify` and `POST /v1/numbers`. Failed requests now release their key. - **Webhook delivery log**: status code, latency, attempt and the first 2 KB of your endpoint's response for every attempt, in the console and at `GET /v1/webhooks/deliveries`. `GET /v1/webhooks` lists endpoints. - **Replay to one endpoint**: `POST /v1/events/{id}/replay`, and per-row Replay in the console. - **Undelivered events**: events whose retries ran out are kept for 30 days with one-click replay, instead of being dropped. - **[Status page](/status)** (and `/status.json`) for the API, sending, inbound and webhooks. ## 2026-10-01: Customers and the Python SDK - **Customers** (`/v1/customers`): for platforms sending on behalf of other businesses. Assign numbers to a customer and its messages, verifications, events and webhooks carry `customer_id`; `GET /v1/customers/{id}/usage` reports per-customer usage by day. See [Customers](/docs/customers). - `customer_id` on `POST /v1/messages`, `POST /v1/verify`, `POST /v1/numbers`, new `PATCH /v1/numbers/{number}`, and filters on `GET /v1/messages` and `GET /v1/numbers`. Numbers now always include `customer_id` (`null` when unassigned). - **Python SDK**: `pip install joinsimplesms`. No dependencies, typed, retries, idempotent sends, webhook verification. - **Node SDK**: consent and customers resources, `listAll()` iterators, and `verifyWebhook()`. ## 2026-10-01: Compliance autopilot (registration) - **Website disclosure check**: `POST /v1/registrations` checks your website, privacy policy, terms and opt-in page against what carrier reviewers look for, with evidence and copy-paste fixes for every finding. - **Generated copy**: opt-in disclosure, privacy clause, SMS terms, samples, HELP/STOP replies, and the full brand + campaign draft. - **Registration workflow**: recheck, submit, carrier review, rejections with plain-language reasons and fixes, resubmission; `registration.updated` events. See [Compliance & registration](/docs/compliance). - **Opt-in proof**: `proof` on `POST /v1/consent/{phone}` and `/v1/consent/export?type=ledger`. - Live sends without an approved registration carry an `X-Delivered-Registration` warning header. Nothing is blocked. ## 2026-10-01: Account controls - **Deliverability**: `GET /v1/deliverability` and Console → Deliverability report delivery rates by day, carrier, and sending number, with failure reasons. Sharp drops raise a `deliverability.degraded` webhook event. - **Spend limits**: a monthly USD cap on live messaging with alert thresholds (`GET`/`PATCH /v1/spend-limit`). Sends past the cap return `spend_limit_reached` and are not charged; `spend.threshold_reached` fires at each threshold. - **Audit log**: key, team, webhook, spend-limit, number, consent, and settings changes, with actor and IP. Console → Audit log and `GET /v1/audit-logs`. - **Key scopes**: restrict a key to the endpoint families it needs; others answer `403 insufficient_scope`. Existing keys keep full access. - **Viewer role**: read-only teammates. ## 2026-09-30: Spam scores retired - `GET /v1/lookup/{phone}/spam` now answers **410 Gone** (`endpoint_retired`). Its scores came from data SimpleSMS no longer uses. `GET /v1/lookup/{phone}` (line type and carrier) is unchanged. The MCP `lookup_spam` tool and the CLI's `--spam` flag are gone; the SDK's `lookup.spam()` is deprecated and throws `endpoint_retired`. ## 2026-09-22: Pricing - **Outbound SMS is $0.009 per message**, all-in. Carrier fees are still included and A2P 10DLC registration is still free; the rate simply now covers what a US text costs to deliver. Verification, numbers, lookups, and the free tier are unchanged. - The pricing comparison now includes business-texting products (Text Request, Solutions by Text) alongside the API providers. ## 2026-08-14: Consent autopilot (TCPA) - **Revocation in plain English**: "please stop texting me" now opts a number out, not just the STOP keyword. Three detection tiers (keyword, phrase, AI with confidence scores), per the FCC's April 2025 reasonable-means rule. - **Consent ledger**: every opt-out, opt-in, import and verification exemption is appended to a per-number history that is never deleted. - **Consent API**: `GET/POST /v1/consent/{phone}`, paginated `GET /v1/consent`, bulk `/v1/consent/import` (up to 500), and CSV `/v1/consent/export`. - **Console Compliance page**: suppression list with per-number history, import, export, and the verification-exemption audit log. - `message.opted_out` events now carry `method` and (for AI detections) `confidence`; `verification.sent_to_opted_out` is now subscribable as a webhook event. ## 2026-08-06: Early access launch - **Sandbox-first API surface**: `/v1/messages` (send, get, list, Idempotency-Key support), `/v1/numbers` (search, purchase, release), `/v1/lookup` (+`/spam`), `/v1/events`, and `POST /v1/test/inbound` for simulating inbound SMS. - **Self-serve console** at [/console](/console): instant free sandbox keys, no card. - **Live mode** (after live-access review): real number provisioning across 200+ US/Canada area codes and real SMS delivery. - **Agent surface**: OpenAPI ([yaml](/api/v1/openapi.yaml) · [json](/api/v1/openapi.json)), `llms.txt`, single-file docs (`llms-full.txt`), markdown twins of every docs page, an [MCP server](/.well-known/mcp.json), and an agent skill ([simplesms](/skills/simplesms/SKILL.md)). ### Known limitations - Live delivery receipts are not yet emitted; a live message's status stays `sent` (sandbox simulates the full lifecycle). Webhook endpoints shipped 2026-08-13.