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.

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. A toll-free number that is still pending can send limited traffic by passing "allowPending": true — you’ll get a throttle warning back. Check status any time via get_sms_setup_status (MCP) or the portal.
  • 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" } }

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"
  } }

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.

Related