SMS & phone numbers

Tandem gives an app a US/CA phone number that can send and receive SMS and MMS over a simple HTTP API — no Telnyx account, no carrier paperwork in your code. You buy a number, complete A2P compliance once (see the SMS agent quickstart for the exact steps, or use the portal’s Phone & SMS wizard), attach the number to a service, and your app sends through our API with an injected key.

This guide is for the app developer: the env vars an attached number injects, how to send, and how to receive.

The SMS_* env contract

Attaching a number to a service (attach_phone_number_to_service, or the portal’s attach action) injects four runtime env vars. The keys are stable; values may change if a number is re-attached or credentials rotate, so always read them from the environment rather than hard-coding.

Env var What it is
SMS_API_URL Full URL of the send endpoint (https://portal.launchtandem.com/api/sms/send).
SMS_API_KEY Per-number bearer key for the send API (prefix sms_). Treat it like a password.
SMS_PHONE_NUMBER The attached number in E.164 (e.g. +14155550123) — your from.
SMS_WEBHOOK_SECRET HMAC secret used to verify our inbound-forward signatures (see below).

Detaching removes all four vars and revokes the key, so a leaked key can’t outlive the attachment.

Reading the values back

To run the same app locally (or anywhere off-platform) you need the real SMS_API_KEY, and list_env_vars / the portal Environment table only show masked previews. Read the injected values back with reveal_sms_credentials (MCP) or the eye icon beside an attached app under Phone & SMS → Settings → Attached apps (portal):

{ "name": "reveal_sms_credentials", "arguments": { "numberId": "…" } }

It returns one entry per attached service — apiUrl, apiKey, phoneNumber, webhookSecret — and pass serviceId to narrow to one. It reveals what is already stored; it never mints or rotates a key, needs developer+ on the org (the same gate as the bucket/database/email reveals), and every reveal is audit-logged. A standalone key from create_sms_api_key is different: that one is shown once and can only be revoked, because nothing else keeps a copy.

Sending

POST to SMS_API_URL with SMS_API_KEY as a bearer token. The number is implied by the key — you only pass the recipient and content.

curl -s "$SMS_API_URL" \
  -H "Authorization: Bearer $SMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+14155559876", "text": "Your code is 123456" }'

The response is 202 Accepted with the queued message, the number of segments, the message kind, and an estimated cost:

{ "message": { "id": "…", "status": "queued", "to": "+14155559876" },
  "parts": 1, "kind": "sms", "estimatedCostCents": 0.6 }

Notes:

  • Compliance gate. Sends are rejected (422 not_send_eligible, with the current setupStatus embedded) until the number’s compliance path is approved. Check status any time via get_sms_setup_status (MCP) or the portal.
  • allowPending is for smoke-testing, not for delivery. A toll-free number whose verification is still pending will accept a send with "allowPending": true, but carriers reject pre-approval toll-free traffic outright (Telnyx error 40329): the message finalizes as delivery_failed and never reaches the handset. The response says so in warning. Wait for setupStatus.canSend before sending anything a recipient must actually receive.
  • status is not proof of delivery. The send response is 202 with status: "queued"/"sent". The final verdict arrives later on the message row — poll list_sms_messages (or the portal message log) or subscribe with statusUrl on set_sms_inbound_webhook. Terminal statuses are delivered, delivery_failed, delivery_unconfirmed, sending_failed and expired; once a message reaches one it never moves back.
  • Rate limit. The send key is limited to TANDEM_SMS_SEND_RPM sends/minute (default 60); over it you get 429 with a Retry-After header.
  • Opt-outs. STOP/HELP are handled automatically by the carrier; a send to a recipient who replied STOP comes back as a friendly “recipient opted out” error, not a cryptic carrier code.
  • Cost. Per-message rates are published at /pricing; carrier surcharges pass through at cost.

Sending MMS

Add mediaUrls (up to 10 publicly reachable HTTPS URLs). Include text too if you want a caption; omit it for media-only.

curl -s "$SMS_API_URL" \
  -H "Authorization: Bearer $SMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+14155559876", "text": "Here you go", "mediaUrls": ["https://cdn.example.com/receipt.png"] }'

The response kind will be "mms". MMS is billed per part at the MMS rate. The media URLs you pass must be reachable by the carrier at send time.

Receiving inbound messages

Point a number’s inbound messages at your app with set_sms_inbound_webhook (MCP) or the portal:

{ "name": "set_sms_inbound_webhook",
  "arguments": { "numberId": "…", "url": "https://app.example.com/sms/inbound",
    "statusUrl": "https://app.example.com/sms/status" } }

The call returns a signing secret once — it’s the same value injected as SMS_WEBHOOK_SECRET when the number is attached. When someone texts your number, the platform stores the message and POSTs a JSON copy to your URL:

{ "type": "sms.inbound",
  "number": "+14155550123",
  "message": {
    "telnyxMessageId": "…",
    "from": "+14155559876",
    "to": "+14155550123",
    "text": "hello",
    "media": [],
    "parts": 1,
    "encoding": "GSM-7",
    "receivedAt": "2026-07-17T12:00:00.000Z"
  } }

Inbound messages POST to url. Terminal outbound delivery status POSTs to optional statusUrl (same HMAC headers, no body — the text is already on list_sms_messages). statusUrl is off unless you set it — we never POST status to the inbound URL. Poll list_sms_messages or subscribe.

{ "type": "sms.status",
  "number": "+14155550123",
  "message": {
    "telnyxMessageId": "…",
    "to": "+14155559876",
    "from": "+14155550123",
    "status": "delivered",
    "errorCode": null,
    "errorDetail": null,
    "parts": 1,
    "terminal": true,
    "occurredAt": "2026-07-17T12:00:01.000Z"
  } }

Delivery is retried up to 3 times with backoff, then dead-lettered. A copy is always stored regardless of forwarding (readable via list_sms_messages / the portal message log). remove_sms_inbound_webhook stops forwarding without deleting stored messages.

Verify the signature

Every forward carries X-Tandem-Signature: sha256=<hex> — an HMAC-SHA256 of the raw request body keyed by SMS_WEBHOOK_SECRET. Verify it before trusting the payload, using a constant-time compare over the raw bytes (not the re-serialized JSON):

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(header ?? "");
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

The header X-Tandem-Number also carries the recipient number for quick routing.

Inbound MMS media

Inbound MMS media arrives as entries in message.media (each with url, contentType, sha256, size). Download and store any media you need promptly: the url points at Telnyx-hosted media that expires ~30 days after receipt (telnyxExpiresAt on each item, copiedToStorage: false). Don’t treat the URL as durable.

Speak-app surface (multi-tenant apps)

The SMS_* env contract above binds one number to one service — right for a customer’s app. Tandem Speak serves users from every org, each with many numbers, so it does not attach numbers to its service. It uses the server-to-server surface under /api/speak-app/*, the phone-number twin of /api/mail-app/*, authenticated with the speak entry of TANDEM_ID_APP_SECRETS (Authorization: Bearer …; any other bearer 404s).

Route Purpose
GET /api/speak-app/principals/:principalId/phone-numbers Every number the principal can see, each with caps { view, send, manage } — org role (viewer = view, developer = +send, admin/owner = +manage) merged with explicit spine grants on the phone_number (sms.read / sms.send / sms.number.manage), plus setupStatus, voice, inboundHook, source: "org" | "grant". Unknown or disabled principal → [].
POST /api/speak-app/phone-numbers/:id/credentials { forPrincipalId, inboundWebhookUrl?, replaceInboundHook? } Requires caps.send. Re-mints the per-number key named speak:<principalId> (the previous one is revoked — one live Speak key per number+principal; the full key is returned once) and, when inboundWebhookUrl is given, points the number’s single inbound hook at it and returns the whsec_ secret. Same URL → same secret; a different existing URL → 409 inbound_hook_conflict unless replaceInboundHook.
POST /api/speak-app/phone-numbers/:id/calls { forPrincipalId, to, clientState?, timeoutSecs? } Requires caps.send and a voice-enabled number. Launch dials Telnyx on Speak’s behalf and records the call — see docs/voice-telnyx-boundary.md “Origination (proxied)”.

Speak then sends with POST /api/sms/send and the minted key exactly like any tenant, and verifies inbound forwards with the per-number secret. Numbers are still bought, released and made compliant in the portal / MCP / this REST surface — Speak only discovers and uses them.

Related