Behind the scenes8 min read
Adding an SMS API to your app: a checklist
What to get right before an SMS API goes live: test keys, number checks, idempotent retries, 429s, signed webhooks, opt-outs, masked logs and spend caps.
In short
- The OWASP API Security Top 10 for 2023 lists unrestricted resource consumption as API4, naming paid SMS sends and an attack that triggers tens of thousands of password reset texts.
- OWASP says to configure spending limits for every service provider an API uses, and to set up billing alerts where a spending limit is not possible.
- RFC 9110 does not count POST as idempotent, so a retried SMS send needs an idempotency key to avoid sending the same text twice.
- RFC 6585, published in April 2012, defines the 429 Too Many Requests status, which may carry a Retry-After header saying how long to wait.
- The OWASP Logging Cheat Sheet says access tokens shouldn't be written to logs directly, and lists telephone numbers among data that may need special treatment first.

30: error codes in the CaptiFi Connect API, each with a fixed HTTP status to branch on. Source: CaptiFi Connect, Errors and error codes, read 22 October 2026.
Adding an SMS API to an app takes an afternoon. Making it safe to leave running takes a checklist, and cost is the item that's easiest to forget. The OWASP API Security Top 10, in its 2023 edition, puts "Unrestricted Resource Consumption" fourth on its list and names SMS outright: resources "paid for per request, such as sending emails/SMS/phone calls". Its first example attack is a password reset form that sends a code by text. A script calls it "tens of thousands of times", the back end dutifully asks the SMS provider to send "tens of thousands of text messages", and the company loses "thousands of dollars in a matter of minutes".
The eight checks below come in roughly the order you'll meet them. The examples use our own API, and the points apply whichever provider you pick.
1. Keep keys on the server, and build with a test key
An SMS API key spends money, so it belongs in a server-side secret store and nowhere else, which rules out mobile apps, browser JavaScript and the repository. Anyone who finds it can send texts on your account, which is the OWASP attack without the bother of a form. Give each system its own key so you can revoke one without breaking the rest; our glossary entry on API keys has the basics.
Build against a test key first. CaptiFi Connect's test keys start cc_test_ and live keys cc_live_. A test send goes through the same checks as a live one and fires the same signed webhooks, but nothing reaches a phone and nothing is charged. Test keys are capped at 50 numbers a request and 300 texts an hour, which is plenty for development, and since they send nothing, a leaked one costs you nothing. The test keys page lists what test mode skips.
2. Check numbers before you send to them
Your app will collect numbers in every format people type, and some won't be mobiles. Parse each one into E.164 (+447700900123) when it's entered, so the person can fix a typo while they're still on the page, and store that form. Our post on formatting UK numbers for E.164 covers the parsing and the edge cases.
Your provider should check too, and tell you what it refused. When CaptiFi Connect gets a send, numbers it can't text come back in a rejected list, each with a reason: invalid_number, unsupported_destination for numbers outside the UK, Guernsey, Jersey and the Isle of Man, not_mobile, duplicate, opted_out, and two for texts that ran too long or came out empty after merge fields were filled in. Refused numbers aren't charged, and the rest of the send goes ahead. Read that list and update your records from it, or the same bad numbers will turn up in every send.
3. Make every send safe to retry
Sending a text is a POST, and RFC 9110, the June 2022 edition of HTTP Semantics, doesn't count POST as idempotent. It says a client "SHOULD NOT automatically retry a request with a non-idempotent method" unless it has some way to know the request is safe to repeat. A dropped connection after a send leaves you not knowing whether the text went, and a blind retry may send it twice.
The fix is an idempotency key: a unique value per logical send, stored with the record before the first attempt and sent in an Idempotency-Key header on every retry. The IETF's draft for the header has expired without becoming an RFC, and the header name it uses is the one CaptiFi Connect takes. CaptiFi Connect accepts keys up to 200 characters. A retry with the same key and body returns the original answer with meta.replayed set to true and sends nothing; the same key with a different body gets a 409 idempotency_conflict. If the retries run out without an answer, don't make up a new key and try again. Look the send up first (CaptiFi Connect's GET /messages filters by client_ref, your own reference for the send), and only send again once you know the first one didn't go. Our post on sending a text exactly once goes through the retry loop step by step.
4. Handle 429 and branch on error codes
Expect the API to limit how fast you can call it. RFC 6585, published in April 2012, defines the status for it: 429 Too Many Requests, whose response "MAY include a Retry-After header indicating how long to wait before making a new request". Read that header, wait, and retry with the same idempotency key. CaptiFi Connect allows each key 20 requests a second with bursts up to 40, and always sends Retry-After in whole seconds.
Beyond 429, branch on the error code, not the message. CaptiFi Connect returns every error in one shape, an error object with a stable code, a human message that may be reworded, and details where there are specifics. The codes break down like this.
Counted from the error code table, 30 codes in all. The seven reasons a single number can be refused within a send are listed separately on the page and are not counted here.
Source: CaptiFi Connect, Errors and error codes, read 22 October 2026
See the figures as a table
| Error codes | |
|---|---|
| 400 | 1 |
| 401 | 1 |
| 402 | 1 |
| 403 | 3 |
| 404 | 1 |
| 409 | 9 |
| 413 | 1 |
| 415 | 1 |
| 422 | 10 |
| 429 | 1 |
| 500 | 1 |
Most of them are 422s (the request needs fixing) and 409s (it clashes with something that already exists or has already happened). The rule from our errors reference is short: retry a 429 after the wait and a 500 or a dropped connection after a pause, with the same idempotency key; don't retry any other 4xx as it is. A 402 insufficient_credit means nothing was sent and details.needed says how many credits the send wanted.
Put your own limits on the endpoints in your app that trigger texts, too. OWASP's advice is to "Limit/throttle how many times or how often a single API client/user can execute a single operation", and its examples are exactly the flows that send codes: validating an OTP, requesting password recovery. A rate limit per phone number and per IP address on those forms removes most of the attack in the opening paragraph. Our post on SMS pumping covers the fraud that aims at them.
5. Verify webhooks, then answer fast
Delivery reports and replies arrive as webhooks, which means anyone who learns your endpoint's address can post to it. OWASP's API10:2023 makes the general point: developers "tend to trust data received from third-party APIs more than user input". Check the signature on every request before you act on it.
For CaptiFi Connect the signature header carries a Unix timestamp and an HMAC SHA-256 of that timestamp, a full stop and the raw body, keyed with the endpoint's secret. Compute it over the body exactly as it arrived (before your framework parses it), compare in constant time, and refuse anything signed more than five minutes ago. Then answer with a 2xx within 8 seconds and do the slow work in a queue. A delivery that times out or fails is retried after 1, 5, 30, 120, 360 and 720 minutes, so store each delivery id and skip repeats, and never let a late message.sent overwrite a delivered. Our post on webhook signatures explains why each of those steps is there.
Test the handler for every event you subscribe to, including the ones a test send can't produce. A test text fires message.sent and then message.delivered, but because nothing reaches a phone you'll never get message.received or opt_out.created from one. The test button on each endpoint, or POST /webhooks/{id}/test, posts a signed webhook.test event, which proves the address and the signature check work. For the reply and opt-out handlers themselves, write tests against the example payloads in the webhooks guide, so your code meets them before a customer's STOP does.
6. Treat opt-outs as data your app owns
When someone texts STOP to a number you send from, CaptiFi Connect adds them to your opt-out list and, if you've subscribed to it, sends an opt_out.created webhook. Your app should record it too, against the customer, so the opt-out survives a migration, an export or a switch of provider, and so no other part of your system tries to text them. Sends to a number on the list are refused with the reason opted_out, which is the safety net and shouldn't be the only one.
Texts from a sender name can't be replied to, so STOP can't work on them. For marketing from a name, ask the API to add an opt-out line: with append_opt_out, CaptiFi Connect adds "Text STOP to" your dedicated number when the account has one, or an opt-out link for each recipient when it doesn't. Our guide to STOP replies covers the rules behind it.
7. Log what happened, not who it happened to
SMS integrations log a lot, and the logs fill up with phone numbers and keys. The OWASP Logging Cheat Sheet lists "Access tokens" among the things that "should usually not be recorded directly in the logs, but instead should be removed, masked, sanitized, hashed, or encrypted", and puts "telephone numbers" in a second list of personal data that "may also need to be treated in some special manner before the event is recorded".
In practice: log the message id the API gave you and your own reference, never the full key, and mask numbers (our own logs keep the first three and last three characters, as in +44•••123). CaptiFi Connect takes a client_ref of up to 64 characters on each send and returns it on every message webhook, so a booking id can tie the whole history together without a phone number appearing anywhere in your logs. Keep numbers out of idempotency keys for the same reason.
8. Put a ceiling on what it can spend
OWASP's last line on resource consumption is the one to act on: "Configure spending limits for all service providers/API integrations. When setting spending limits is not possible, billing alerts should be configured instead." Prepaid credit is a spending limit by design. A CaptiFi Connect send that would cost more than the balance is refused whole, and each send's response includes the remaining balance in meta.balance.
Two more habits keep costs where you expect them. Price large or unusual sends first: POST /messages/quote takes the same body as a send and returns the credits it would use, the encoding and the opt-out line without sending anything. And watch the encoding. One character outside the GSM alphabet switches a text to Unicode, which fits fewer characters into each SMS part, and every part is a credit. The character counter shows the effect before you write the template, which is cheaper than finding it in the balance afterwards.
Sources
Read for this post on 22 October 2026.
- OWASP: API Security Top 10, 2023 edition
- OWASP: API4:2023 Unrestricted Resource Consumption
- OWASP: API10:2023 Unsafe Consumption of APIs
- OWASP: Logging Cheat Sheet
- IETF: RFC 6585, Additional HTTP Status Codes (April 2012), section 4
- IETF: RFC 9110, HTTP Semantics (June 2022)
- IETF Datatracker: The Idempotency-Key HTTP Header Field (draft)
- CaptiFi Connect: Errors and error codes
- CaptiFi Connect: Webhooks
Written by the CaptiFi Connect team. Published 22 October 2026. More in Behind the scenes
Send your first text this week
Set up your account and contacts now. Until we've approved it, a person reads each text before it goes out.