To send SMS with Node.js, make a POST request with the built-in fetch (Node 18+) to an SMS API's send endpoint. Pass your API key in a header, the recipient and message in a JSON body, and an Idempotency-Key header so retries can't send the same text twice. The rest of this post covers the parts that cause trouble in production: reading errors, retrying safely and the compliance step.
The code below uses Ready's Messaging API, because we make Ready and these are the request shapes we can vouch for. The patterns carry over to any provider: one key per logical message, sort errors into "retry" or "fix your request", and keep consent outside the HTTP call. If you want the wider architecture (queues, webhooks, rate limits), our SMS API integration walk-through goes deeper. This post stays on one function you can paste in today.
What you need before the first request
Four things, and only the last one takes calendar time:
- Node 18 or newer.
fetch,AbortSignal.timeoutandcrypto.randomUUIDare built in, so there's nothing to install. - An API key from your Ready account, stored in an environment variable (
READY_API_KEY). Never ship it to a browser. - A card on file. Sending requires one. Your first 20 texts are free and count only for one-to-one sends, not campaigns.
- A sending number and, soon after, a 10DLC registration. An account can send up to 500 texts a month before it registers, at its normal per-text rate. Past that, it has to register. More on this below, because it's the step developers most often leave until something breaks.
Use the 20 free texts for testing before you pay for registration. They end the moment a registration is paid for.
How to send SMS with Node.js: the complete fetch call
Here is the whole thing: a single function with a timeout, an idempotency key and error handling that doesn't throw away the response body.
// send-sms.js (Node 18+, ESM)
import { randomUUID } from "node:crypto";
const READY_API = "https://api.tryready.com";
export class ReadyApiError extends Error {
constructor(status, body, retryAfter) {
super(`Ready API returned ${status}`);
this.status = status;
this.body = body;
this.retryAfter = retryAfter; // seconds the server asked you to wait (429), or null
}
}
export async function sendSms({ to, body, idempotencyKey, consentAttested }) {
const res = await fetch(`${READY_API}/v1/messages`, {
method: "POST",
headers: {
"X-API-Key": process.env.READY_API_KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
to, // E.164, e.g. "+12125550199"
body,
consent_attested: consentAttested, // from YOUR consent record, see below
}),
signal: AbortSignal.timeout(10_000), // don't hang forever
});
// Read the body as text first so a non-JSON error page can't crash the parse.
const raw = await res.text();
let parsed;
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
if (!res.ok) throw new ReadyApiError(res.status, parsed, Number(res.headers.get("retry-after")) || null);
return parsed;
}
// Usage
const key = randomUUID(); // generate ONCE per logical message, then store it
await sendSms({
to: "+12125550199",
body: "Your order #1042 has shipped.",
idempotencyKey: key,
consentAttested: true,
}); A few details worth noticing:
tois E.164. A bare 10-digit US number is treated as +1, but normalize to+1XXXXXXXXXXin your own code anyway, so your database and your logs agree.- The function returns the parsed body and nothing more. Log it and check the messages API docs for the response shape, rather than hard-coding field names from a blog post.
- Optional fields exist if you need them:
from(one of your numbers),media_url(sends a picture as MMS) andfrom_strategy(local,evenorrandom) to choose among your numbers.
If you're sending the same body to many people, don't loop over this function. POST /v1/messages/bulk takes to as an array with the same body and consent_attested fields. Details are in the bulk messages docs.
Reading errors without guessing
Most broken SMS integrations fail quietly. The cause is usually a catch that logs "SMS failed" and drops the status code and body. Keep both. Then sort every failure into one of three buckets:
| What happened | Example | Retry? | What to do |
|---|---|---|---|
| No response at all | Timeout, DNS failure, connection reset | Yes, with the same key | Back off and retry |
409 idempotency_in_progress | The first attempt is still running | Yes, with the same key | Wait a few seconds and retry |
| 429 | Sending too fast for the carrier-paced limit | Yes, with the same key | Wait for the Retry-After header, then retry |
500 send_failed or a 503 | Not sent, not charged | Yes, with a new key | The same key replays the error |
504, or a code ending in OUTCOME_UNKNOWN | May have gone out | Not blindly | Check GET /v1/messages first; a new key can send twice |
| Any other 4xx status | Bad number format, bad key, missing field | No | Log the body, fix the request or the data |
The 4xx bucket is where people waste money and time. A malformed number or a revoked API key will fail identically on attempt one and attempt five, so retrying it only fills your logs. Route 4xx errors to a dead-letter table with the response body attached, and alert on the rate.
Some refusals aren't bugs. They're the platform doing its job:
- Opted-out numbers, numbers on your Ban list and known landlines are refused up front and not charged. Your code should treat these as final and never retry them. To show opt-out status in your own UI,
GET /v1/opt-outs/:phonereturns it for a single number, andGET /v1/opt-outslists them. - Quiet hours. A text sent during the recipient's quiet hours is held and sent later, and charged only when it sends, if sending opens within about 6 hours. Otherwise the API refuses it and you send it again once sending opens. A successful response therefore means "accepted", not "on the phone right now". If your product shows delivery status, drive it from delivery events, which are covered in the quickstart guide. Don't infer it from the HTTP 200.
To catch bad numbers before you ever send, GET /v1/validate/:phone checks a number. It's a good fit at signup-form time, when the user is still there to fix a typo.
Retries that never send twice
Retries cause double sends in one specific way. Your request reaches the server and the text goes out, but the response is lost on the way back (a timeout, a deploy, a dropped connection). Your code sees a failure, retries and sends a second text. The customer gets "Your order shipped" twice, or worse, gets charged-for reminders twice.
The Idempotency-Key header fixes this, as long as you follow one rule: the key belongs to the message, not to the HTTP attempt. Generate it once when you decide to send, store it next to the message row and reuse it on every retry. If you call randomUUID() inside the retry loop, the header does nothing.
// retry.js
import { sendSms, ReadyApiError } from "./send-sms.js";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isRetryable(err) {
// 429 (not stored) and 409 idempotency_in_progress (the first attempt is still running): wait and retry with the same key.
// Any other API answer is final for this key: 500 send_failed or a 503 = not sent, resend with a NEW key; other 4xx = fix the request.
if (err instanceof ReadyApiError) return err.status === 429 || (err.status === 409 && err.body?.code === "idempotency_in_progress");
return true; // network errors and timeouts: the request may or may not have landed
}
export async function sendWithRetry(message, maxAttempts = 4) {
// message.idempotencyKey was generated once and saved with the message row
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await sendSms(message);
} catch (err) {
if (!isRetryable(err) || attempt === maxAttempts) throw err;
// honor Retry-After when the server sent one, else 2s, 4s, 8s + jitter
const delay = err.retryAfter ? err.retryAfter * 1000 : 2000 * 2 ** (attempt - 1) + Math.random() * 250;
await sleep(delay);
}
}
} The same principle applies if a job runner (BullMQ, a cron, a serverless retry) re-runs your whole worker: read the stored key from the database, don't mint a new one. With the key in place, a timeout becomes safe to retry instead of a 50/50 guess.
The compliance step people skip
The code above is the easy part. Two non-code steps decide whether your texts arrive and whether you stay out of legal trouble.
What consent_attested actually means
consent_attested: true is your promise that the recipient agreed to receive texts. When a request includes it, Ready records that attestation: who sent it, to how many and when. A request that leaves it out leaves no record. That builds an audit trail on our side. It is not a per-contact consent record. We don't store when or where a recipient opted in or what wording they saw. (A contact who signs up on a Ready-hosted opt-in page is the exception: Ready stores that sign-up, with the time, IP address and exact wording, and support@tryready.com can send you a copy.)
That record belongs in your database. At minimum, keep the opt-in timestamp, the exact consent language and the page URL or form it came from. Then set consent_attested from that record instead of hard-coding true:
if (!contact.smsConsentAt) return; // no consent record, no text
await sendWithRetry({
to: contact.phone,
body,
idempotencyKey: msg.idempotencyKey,
consentAttested: true,
}); Consent is the sender's responsibility. Ready's STOP handling, quiet-hours holds and optional litigator/DNC scrubbing reduce risk, but they don't make anyone immune to TCPA claims, which can run $500 to $1,500 per text. Our opt-out handling guide covers the STOP rules your app should mirror in its own UI.
Registration (10DLC)
US carriers filter unregistered application-to-person traffic. That's the A2P 10DLC system, and it applies to nearly every business sending from a standard number. Ready runs brand and campaign registration in the app:
- At submit: $25, which is the first month. There is no setup fee.
- Monthly after that: $25 for registration + $2 per number.
- Timing: most approvals take 2 to 7 days.
GET /v1/registration returns your registration status, so a deploy script or admin page can check it before you turn on production sends. If you're a platform texting on behalf of your own customers, each customer can be a sub-account (POST /v1/sub-accounts) with its own numbers and its own registration. Then one customer's bad list doesn't drag down everyone else's deliverability.
What each text costs (segment math)
Ready bills per segment: $0.0075/segment on Standard, plus a $0.0045 carrier fee itemized separately. Replies you receive are billed the same way. That all-in figure excludes the compliance surcharge. Scrubbing and landline validation each add $0.003 per segment when switched on for an account, and both are off by default.
Segment rules matter more than the rate:
- Plain GSM-7 text: 160 characters fit in one segment, and longer messages split into 153-character segments.
- One emoji switches the whole message to unicode: 70 characters for a single segment, 67 per segment once it splits.
- Ready swaps curly quotes, long dashes and ellipses for plain ones before sending, so text pasted from a doc won't silently triple your count.
Example (assumptions stated): 2,000 shipping notices a month.
- "Your order #1042 has shipped." is 29 characters, so 1 segment. 2,000 × $0.0075 = $15, plus 2,000 × $0.0045 in carrier fees.
- Add an emoji and a tracking line to reach 140 characters. That's now 3 unicode segments, so 6,000 segments: 6,000 × $0.0075 = $45, plus 6,000 × $0.0045.
One emoji tripled the bill. Count characters in a unit test before a template ships.
Throughput is metered in segments too. A fresh standard campaign without vetting typically gets roughly 1 to 4 segments per second. The 3-segment version of a message therefore also takes about three times as long to go out.
Where this fits, and when a raw CPaaS is the better call
To be straightforward about pricing: Twilio lists around $0.0083/segment, well under our Standard rate. If your team wants to build registration flows, opt-out storage, quiet-hours logic and number validation itself, and per-segment cost is what you're optimizing, a raw CPaaS is a reasonable choice. Our Twilio comparison works through that tradeoff in more detail.
Ready makes sense when you'd rather not build those pieces. In-app 10DLC registration, STOP handling, quiet-hours holds, optional scrubbing and validation, sub-accounts and an inbox are already behind the same POST /v1/messages call you just wrote.
FAQ
How do I send an SMS from Node.js without a library? Use the built-in fetch in Node 18 or newer to POST JSON to your SMS provider's send endpoint. With Ready, that's POST https://api.tryready.com/v1/messages with an X-API-Key header and a body containing to, body and consent_attested. No SDK is required.
How do I stop my Node.js app from sending the same text twice? Send an Idempotency-Key header with a UUID that you generate once per message and reuse on every retry. If the first request went through but the response was lost, the retry with the same key won't send a second text. Generating a new key per attempt defeats the purpose.
Do I need 10DLC registration to send SMS from my app? Yes, for ongoing business texting to US numbers from a standard 10-digit number. On Ready an account can send up to 500 texts a month before registering, and then it must register. Most approvals take 2 to 7 days.
Which SMS API errors should I retry? Retry timeouts, network failures and a 409 idempotency_in_progress with backoff and the same idempotency key. A 500 send_failed or a 503 was not sent: send it again with a new key, because the same key replays the error. Don't retry other 4xx responses, because they mean the request or data is wrong and will fail the same way every time. Opted-out numbers, numbers on your Ban list and known landlines are refused up front and aren't charged.
What does consent_attested do in the Ready API? It's your declaration that the recipient agreed to receive texts, and Ready records it per send as part of an audit trail. It doesn't store the recipient's opt-in details. Keep the opt-in time, wording and source URL in your own database. (A contact who signs up on a Ready-hosted opt-in page is the exception: Ready stores that sign-up, with the time, IP address and exact wording, and support@tryready.com can send you a copy.)
The short version
Use one fetch call, one idempotency key per message stored with that message, and a retry policy that only retries what can succeed on a second try. Then do the non-code work: keep real consent records, set consent_attested from them and get your 10DLC registration submitted early, because approval runs on carrier time, not yours.
If you want to try the code above against a live endpoint, create an account, add a card and use your 20 free texts to test before you register. The quickstart has the rest.