Cloudflare Workers SMS: Send Text Messages from the Edge
You can send an SMS from a Cloudflare Worker with a single fetch call — no SDK to bundle, no form-encoded legacy API, no carrier registration paperwork. This guide builds a complete, production-ready Worker in TypeScript: it stores the API key as a Wrangler secret, sends the message through the senderZ REST API, handles every documented error class, and adds retries with idempotency keys so a retry never double-texts anyone. Copy the final Worker, run wrangler deploy, and you have an SMS-capable HTTP service running on Cloudflare's edge network.
Why Send SMS from a Cloudflare Worker?
Workers are a natural place to trigger text messages. They already sit in your request path (form submissions, checkout webhooks, cron triggers), they speak fetch natively, and they bill per request rather than per idle server-hour. What they cannot do is talk to a cellular network — a Worker has no SIM card. Sending SMS from a Worker always means calling a messaging API over HTTPS.
The friction is in which API you call. Traditional SMS providers were designed a decade before Workers existed: they expect application/x-www-form-urlencoded bodies, HTTP Basic auth built from an account SID and token, and — for US traffic — 10DLC carrier registration that takes days to weeks before your first message goes out.
The senderZ API was designed for the environment Workers provide:
- Plain JSON over HTTPS. One
POSTwith aBearertoken. No signature computation, no multipart forms, no SDK required (though a TypeScript SDK exists if you want one). - Asynchronous by design. The API returns
202 Acceptedin a fraction of a second and delivers through its own queue. Your Worker never blocks on carrier handoff, which keeps you far away from CPU-time limits. - No 10DLC registration. senderZ routes messages through iMessage first (over the internet, no carrier involvement) with SMS fallback delivered as person-to-person traffic. You get an API key and send the same day.
- iMessage upgrade for free. Set
channel: "auto"and recipients on Apple devices get a blue-bubble iMessage instead of an SMS — same request, better deliverability.
Common Worker + SMS patterns this guide covers: OTP codes on login, alert texts from a cron trigger, and "new lead" notifications fired from a form handler.
Set Up the Worker: Wrangler, Secrets, and the API Key
Scaffold a Worker if you do not have one:
npm create cloudflare@latest sms-worker
cd sms-worker
Next, get a senderZ API key. Sign up, create a key in the portal, and note the tf_live_ prefix — the whole flow takes about two minutes.
Store the key as a secret — never in code
API keys must never appear in wrangler.jsonc, source files, or git history. Cloudflare Workers have a dedicated mechanism for this: encrypted secrets, which are injected into the env parameter at runtime and are not visible in the dashboard or Wrangler output after you set them.
npx wrangler secret put SENDERZ_API_KEY
# Paste your tf_live_... key when prompted
For local development with wrangler dev, create a .dev.vars file in the project root:
# .dev.vars — local development only
SENDERZ_API_KEY="tf_test_your_sandbox_key"
Add .dev.vars* to .gitignore before your first commit. Note the tf_test_ prefix here: senderZ sandbox keys let you exercise the full send path locally without delivering real messages (more on that below).
Type the environment
With TypeScript, declare the secret on your Env interface so the compiler catches a missing binding:
export interface Env {
SENDERZ_API_KEY: string
}
A Complete Cloudflare Workers SMS Example in TypeScript
Before writing Worker code, here is the raw API call it wraps:
curl -X POST https://api.senderz.com/v1/messages \
-H "Authorization: Bearer tf_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"channel": "auto",
"body": "Your verification code is 482913"
}'
The API validates the request, writes the message, enqueues it for delivery, and responds with 202 Accepted:
{
"message_id": "01J1N7Z8Q3W9F5T2B6XKD4RVMC",
"status": "queued",
"channel": "auto",
"estimated_delivery_ms": 2000
}
Now the full Worker. It exposes POST / accepting { "to": "+1555...", "message": "..." }, validates input before spending an upstream call, and maps senderZ errors to sensible responses:
export interface Env {
SENDERZ_API_KEY: string
}
interface SendAccepted {
message_id: string
status: string
channel: string
estimated_delivery_ms: number
}
interface SendError {
error: string
code: string
}
type SendResult =
| { ok: true; data: SendAccepted }
| { ok: false; status: number; data: SendError }
async function sendMessage(
env: Env,
payload: { to: string; body: string; channel?: 'auto' | 'imessage' | 'sms' },
idempotencyKey: string
): Promise<SendResult> {
const res = await fetch('https://api.senderz.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.SENDERZ_API_KEY}`,
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ channel: 'auto', ...payload }),
})
// 202 = queued for live sends; 201 = sandbox keys resolve synchronously
if (res.status === 202 || res.status === 201) {
return { ok: true, data: (await res.json()) as SendAccepted }
}
const data = (await res
.json()
.catch(() => ({ error: 'Non-JSON upstream response', code: 'UPSTREAM_ERROR' }))) as SendError
return { ok: false, status: res.status, data }
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return Response.json(
{ error: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' },
{ status: 405 }
)
}
let input: { to?: string; message?: string }
try {
input = await request.json()
} catch {
return Response.json(
{ error: 'Request body must be valid JSON', code: 'BAD_REQUEST' },
{ status: 400 }
)
}
// Validate before making the upstream call
if (!input.to || !/^\+[1-9]\d{6,14}$/.test(input.to)) {
return Response.json(
{ error: 'to must be E.164 format, e.g. +15551234567', code: 'BAD_REQUEST' },
{ status: 400 }
)
}
if (!input.message || input.message.trim().length === 0 || input.message.length > 5000) {
return Response.json(
{ error: 'message is required and must be 1-5000 characters', code: 'BAD_REQUEST' },
{ status: 400 }
)
}
const result = await sendMessage(
env,
{ to: input.to, body: input.message },
crypto.randomUUID()
)
if (!result.ok) {
// Never leak your API key or upstream auth details to callers
const status = result.status >= 500 ? 502 : result.status
return Response.json(
{ error: result.data.error, code: result.data.code },
{ status }
)
}
return Response.json(
{ message_id: result.data.message_id, status: result.data.status },
{ status: 202 }
)
},
} satisfies ExportedHandler<Env>
Deploy and test:
npx wrangler deploy
curl -X POST https://sms-worker.your-subdomain.workers.dev \
-H "Content-Type: application/json" \
-d '{"to": "+15551234567", "message": "Deployed from the edge"}'
The Worker validates locally first — a malformed phone number never costs you an upstream round trip — and the X-Idempotency-Key header is already in place for the retry logic in the next section.
Error Handling for SMS in Workers
Every senderZ error response has the same shape — { "error": "...", "code": "..." } — where error is a human-readable explanation and code is a stable machine-readable string. Branch on code, never on the message text. These are the codes a send from a Worker can hit:
| HTTP | code | Meaning | Retry? |
|------|--------|---------|--------|
| 400 | VALIDATION_ERROR | A field is missing or malformed — including a to that is not valid E.164 | No — fix the request |
| 401 | INVALID_API_KEY | Key missing, malformed, or revoked | No — check your secret |
| 403 | TRIAL_EXPIRED | Trial ended without a subscription | No — subscribe |
| 403 | QUOTA_EXCEEDED | Monthly message quota reached on your plan | No — upgrade or wait for the period to reset |
| 404 | INVALID_TEMPLATE | The named template does not exist for your account | No — fix the template name |
| 422 | IDEMPOTENCY_KEY_REUSED | Same key, different request body | No — generate a new key |
| 429 | RATE_LIMIT_EXCEEDED | Requests too fast for your plan | Yes — back off, honor Retry-After |
Three rules worth encoding:
- Never retry 4xx except 429
RATE_LIMIT_EXCEEDED. A validation error will fail identically on attempt two, and a quota block stays blocked until your plan changes. - Rate limits and quotas are different failures.
429 RATE_LIMIT_EXCEEDEDis transient — back off and honor theRetry-Afterheader the API returns.403 QUOTA_EXCEEDEDis a plan limit — retrying cannot succeed until the quota changes. - Treat 5xx and network failures as retryable, but cap attempts — and always reuse the same idempotency key across attempts (next section).
A 202 means queued, not delivered. Compliance and routing run after the API accepts the request, so some failures never appear as HTTP errors: if the recipient previously texted STOP, the message record's status becomes blocked with error_reason: "opted_out"; if no sending line is available, it becomes failed with error_reason: "no_phone_available". Read these from the status API — or receive them as a webhook the moment the status changes.
Retries and Idempotency for Worker SMS
The dangerous failure mode in messaging is not a dropped send — it is a double send. A network timeout after the API accepted your request looks identical to a timeout before it. Retry blindly and your user gets the same OTP twice.
senderZ solves this with the X-Idempotency-Key header (the Idempotency-Key alias also works). Send the same key with the same request body and the API replays the original response without enqueuing anything new. Send the same key with a different body and you get 422 IDEMPOTENCY_KEY_REUSED — a guardrail against accidental key reuse. That makes this retry wrapper safe:
const RETRYABLE = new Set([429, 500, 502, 503, 504])
async function sendWithRetry(
env: Env,
payload: { to: string; body: string },
maxAttempts = 3
): Promise<SendResult> {
// ONE key for all attempts — this is what makes retries safe
const idempotencyKey = crypto.randomUUID()
let delayMs = 500
let last: SendResult = {
ok: false,
status: 500,
data: { error: 'No attempts made', code: 'RETRY_EXHAUSTED' },
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
last = await sendMessage(env, payload, idempotencyKey)
if (last.ok) return last
if (!RETRYABLE.has(last.status)) return last
if (attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
delayMs *= 2 // 500ms → 1s → 2s
}
}
return last
}
If the text is a side effect rather than the point of the request — a "new signup" alert, for example — do not make the user wait for it. Hand the send to ctx.waitUntil so the Worker responds immediately and the send completes in the background:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// ... handle the user-facing work first ...
ctx.waitUntil(
sendWithRetry(env, { to: '+15559876543', body: 'New signup on your site' })
)
return Response.json({ ok: true })
},
} satisfies ExportedHandler<Env>
To confirm delivery later, poll the status API with the message_id you stored:
curl https://api.senderz.com/v1/messages/01J1N7Z8Q3W9F5T2B6XKD4RVMC \
-H "Authorization: Bearer tf_live_YOUR_KEY"
{
"message_id": "01J1N7Z8Q3W9F5T2B6XKD4RVMC",
"direction": "outbound",
"status": "delivered",
"channel": "sms",
"to": "+15551234567",
"from": "+15555550100",
"error_reason": null,
"attempt_count": 1,
"sent_at": "2026-07-01T16:20:01Z",
"delivered_at": "2026-07-01T16:20:03Z",
"created_at": "2026-07-01T16:20:00Z"
}
For production systems, register a webhook instead of polling — senderZ POSTs status transitions to your URL as they happen, signed with HMAC-SHA256 in the X-Senderz-Signature header.
Test the SMS Worker Locally Without Sending Real Texts
Run the Worker locally with the sandbox key from your .dev.vars:
npx wrangler dev
API keys prefixed tf_test_ route through a synthetic pipeline: no real SMS or iMessage goes out, nothing counts against your quota, and responses come back synchronously with 201 and "sandbox": true. Reserved recipient numbers produce deterministic outcomes — send to +15555550001 and the sandbox resolves the message as delivered every time:
curl -X POST http://localhost:8787 \
-H "Content-Type: application/json" \
-d '{"to": "+15555550001", "message": "sandbox test"}'
Sandbox message IDs carry an sb_ prefix so they are impossible to confuse with production records, and they still appear in GET /v1/messages/:id, so your status-checking code paths get exercised too. The full list of reserved numbers and outcomes — including ones that simulate failures and opt-outs — is in the sandbox testing guide.
This split maps cleanly onto Worker environments: tf_test_ key in .dev.vars for wrangler dev, tf_live_ key in the encrypted secret for production. Same code, zero accidental texts from your laptop.
FAQ
Cloudflare Workers SMS — FAQ
Can a Cloudflare Worker send SMS without a third-party API?
No. Workers run JavaScript at the edge but have no access to a cellular network, so there is no native SMS capability. Every Worker that sends texts does it by calling a messaging API over HTTPS. senderZ fits this model well because the API is plain JSON with Bearer auth — one fetch call, no SDK or form encoding required.
Do I need 10DLC registration to send SMS from a Cloudflare Worker?
Not with senderZ. 10DLC applies to A2P traffic routed through carrier gateways — the path traditional providers use. senderZ delivers via iMessage first (internet-routed, no carrier involvement) with SMS fallback sent as person-to-person traffic from real devices, so there is no brand registration, campaign approval wait, or per-message carrier surcharge.
How do I keep my SMS API key out of source control in a Worker?
Use wrangler secret put SENDERZ_API_KEY for production — the value is encrypted, injected into the env parameter at runtime, and never visible in the dashboard afterward. For local development, put a sandbox key in a .dev.vars file and add .dev.vars* to .gitignore. Never put keys in wrangler.jsonc or source files.
Can I send iMessage instead of SMS from a Cloudflare Worker?
Yes. Set channel to "auto" in the request body and senderZ checks whether the recipient's number is registered with iMessage. If it is, the message delivers as a blue-bubble iMessage over the internet; if not, it falls back to SMS. You can also force a channel with "imessage" or "sms".
What happens if my Worker retries a send after a timeout?
If you pass the same X-Idempotency-Key header on the retry, senderZ replays the original response and does not enqueue a second message — the recipient never gets a duplicate. Generate one key per logical send (crypto.randomUUID() works in Workers) and reuse it across every retry attempt of that send.
Ready to wire SMS into your Worker? Get your API key and send your first message in a few minutes, then test the full flow with a sandbox key before going live. Every plan starts with a 14-day free trial — no credit card, no carrier registration, and unlimited messages to existing contacts.