# tel — https://tel.rodmena.co.uk A self-hosted phone line. Telnyx supplies the number and moves the audio; every routing decision is made by this service. ## The line - Number: +447537179434 - Inbound calls ring the SIP endpoint for 15s, then the configured mobile for 20s, then take a voicemail of at most 120s. - The caller ID presented on the forwarded leg is our own number; the original caller is announced by a whisper before you are connected. - Inbound SMS is stored and notified. Outbound SMS and outbound announcement calls are authenticated APIs. ## Endpoints GET|HEAD /health 200 ok / 503 degraded, with per-dependency detail GET|HEAD /ping liveness only GET /docs this guide GET /llms.txt this guide Authenticated with `Authorization: Bearer `: POST /v1/messages {"to": "+44...", "text": "...", "channel": "sms"} -> 202 channel is sms | whatsapp | telegram. Add "at": "" or "delay": to defer it: the reply is {id: "sch_...", status: "scheduled", fires_at} and nothing is sent yet. "store_body": false delivers the text but never writes it (the record keeps body null, redacted true); the reply echoes "body_stored". Refused with 422 when scheduled or on a non-sms channel. Use it for one-time codes and other live secrets. POST /v1/calls {"to": "+44...", "text": "...", "repeat": 1..3} -> 202 {id} also accepts "at" / "delay" places a call from the number that speaks `text` (voice OTP, alerts). Destinations limited to ['+44']. GET /v1/calls recent call detail records, with recordings GET /v1/calls/{id} one call, 404 if unknown GET /v1/recordings/{id}/audio the voicemail audio (audio/mpeg), once stored GET /v1/messages recent messages (redacted bodies are flagged) GET /v1/messages/{id} one message DELETE /v1/messages/{id}/body drop a stored body now, before retention expires GET /v1/usage cost so far plus the live quota (limit, used, remaining, resets_at) for this key GET /v1/me this key: tenant, permissions, numbers POST /v1/me/rotate replace this key; the reply is the only copy and the previous key stops working immediately GET /v1/scheduled deferred sends not yet transmitted GET /v1/scheduled/{id} one of them DELETE /v1/scheduled/{id} cancel before it fires (409 once it has) GET /v1/voicemails ?unread=true&from=+44...&since=&until=&limit= POST /v1/voicemails/{id}/read mark read DELETE /v1/voicemails/{id}/read mark unread List endpoints accept `?limit=` (max 500), `?direction=inbound|outbound` and `?since=`. ### Command line `telctl` ships with the service and drives only this API: export TEL_URL=https://tel.rodmena.co.uk TEL_API_KEY=... telctl health | usage | docs telctl sms send --to +44... --text "..." telctl call --to +44... --text "..." [--repeat 2] telctl calls [--direction outbound] [--since ISO] | telctl calls get telctl messages [...] | telctl messages get telctl --json raw JSON for scripts; exit 1 on any non-2xx, 2 if unreachable Provider webhooks, Ed25519-verified and restricted by source IP at nginx: POST /v1/telnyx/voice returns the TeXML dial plan POST /v1/telnyx/voice/leg/sip SIP leg result; returns the next step POST /v1/telnyx/voice/leg/pstn cell leg result; returns the next step POST /v1/telnyx/voice/recorded caller finished the voicemail; says goodbye POST /v1/telnyx/voice/status call lifecycle / CDR POST /v1/telnyx/voice/recording voicemail ready POST /v1/telnyx/messaging inbound SMS and delivery receipts GET /v1/telnyx/whisper TeXML played to the answering handset GET /v1/telnyx/announce/{token} TeXML for an outbound call; single-use token ## Errors Every failure a caller can act on carries a machine-readable `reason` and a `retryable` flag; branch on those, never on the prose in `detail`. reason status retryable meaning destination_rejected 422 no the carrier refused THIS number (invalid, unallocated); do not retry it carrier_unavailable 503 yes carrier down, erroring or rate-limiting us; honour Retry-After carrier_account 502 no refused for OUR account (auth, balance, profile); not the destination's fault carrier_rejected 502 no refused for a reason we have not classified; never treat as the number's fault database_unavailable 503 yes our database is down or not answering; honour Retry-After (5 s) database_busy 503 yes contention (lock or statement timeout, deadlock); retry the request Carrier failures also carry `carrier: {status, code, title}` with the carrier's own response. A refused message or call is never charged to your budget. A 429 means your own budget is exhausted and is refused before the carrier is contacted. Requests are bounded: a database that stops answering yields 503 within about 5 s, never an open-ended wait. {"detail": "sms: the carrier refused the destination", "reason": "destination_rejected", "retryable": false, "carrier": {"status": 400, "code": "10002", "title": "Invalid phone number"}} ## Providers and numbers (multi-provider CaaS) The admin registers provider accounts and assigns numbers — each number belongs to exactly one tenant, carries its own routing (SIP endpoint, fallback, greeting, voice, notify email), and webhooks for it arrive on that provider's scoped routes `/v1/telnyx/p/{provider}/...`, verified with that provider's key. Tenants transmit from numbers they own and from numbers the admin has granted them send rights on; inbound always goes to the owner. `"from"` on POST /v1/messages, /v1/calls and /v1/otp selects among several. POST /v1/providers {name, api_key, webhook_public_key, ...} (admin; key sealed at rest, never returned) GET /v1/providers list (admin) DELETE /v1/providers/{name} disable POST /v1/numbers {e164, provider, tenant?, routing...} (admin) PATCH /v1/numbers/{e164} {tenant} reassign ownership (admin) POST /v1/numbers/{e164}/senders {tenant} grant send rights (admin) DELETE /v1/numbers/{e164}/senders/{tenant} revoke them (admin) GET /v1/numbers admin: all; tenant: its own DELETE /v1/numbers/{e164} disable ## Tenants (multi-tenant CaaS) The admin key can mint per-platform keys; each tenant is authorised by auth.rodmena.app (role `caas-standard`: sms.send, calls.place, cdr.read, recordings.read, otp.issue, subscriptions.manage) and sees only its own traffic in every read endpoint and in `/v1/usage`. POST /v1/tenants {"name": "futex"} -> 201 {api_key} (admin; key shown once) optional "role": "caas-sms" grants sms_send only (default "caas-standard": all six permissions) GET /v1/tenants list (admin) DELETE /v1/tenants/{name} disable the key immediately (admin) ## OTP POST /v1/otp {"to": "+44...", "channel": "sms"|"voice"} -> 202 {id, expires_in} POST /v1/otp/verify {"to": "+44...", "code": "123456"} -> {verified, reason} Codes are 6 digits, hashed at rest, single use, 300 s TTL, scoped to the issuing tenant. Five wrong attempts burn the code (429); more than 3 codes per number per 15 minutes is refused (429). Delivery consumes the ordinary SMS/call budgets. ## Receiving events (for other platforms) Self-serve (needs `subscriptions.manage`): POST /v1/subscriptions {"url": "https://...", "events": ["*"]} -> 201 {secret} (secret shown once; https on a public host only) GET /v1/subscriptions your subscriptions DELETE /v1/subscriptions/{id} stop delivery immediately Or statically (`TEL_EVENT_TARGETS`, JSON: `[{"url", "secret", "events"?}]`). Either way tel POSTs every event as JSON: {"id": "", "type": "", "occurred_at": "", "data": {...}} message.received {id, from, to, text, parts} message.status {id, status: sent|finalized|delivery_failed, to} call.completed {id, direction, from, to, disposition, started_at, ended_at, duration_seconds} voicemail.ready {call_id, from, recording_id, duration_seconds} Headers: `X-Tel-Event`, `X-Tel-Delivery` (deduplicate on it — delivery is at-least-once), `X-Tel-Timestamp` (unix seconds) and `X-Tel-Signature: v1=` where hex = HMAC-SHA256(secret, "{timestamp}." + raw body). Verify in constant time and reject timestamps older than 300 s: expected = "v1=" + hmac.new(secret, f"{ts}.".encode() + body, "sha256").hexdigest() ok = hmac.compare_digest(expected, header) and abs(time.time() - int(ts)) <= 300 Answer 2xx within 10 s. Anything else is retried three times with exponential backoff, and never delays the provider webhook that produced the event. ## Channels `sms` needs nothing beyond a number. `whatsapp` needs the sending number's provider to carry a WhatsApp Business account (`waba_id`); until then the send is refused 409 `channel_not_provisioned` and costs nothing. `telegram` cannot address a phone number on its own — a bot may only reply to a chat the recipient opened — so the first send to an unknown recipient is refused 409 `telegram_not_linked` with a one-time `t.me/?start=` link; once the recipient follows it, sends to that number work like any other. ## Scheduling A send carrying `at` or `delay` is not held by tel — it registers a timer with RunFlow, which calls back when the instant arrives. Two consequences worth knowing: if RunFlow cannot be reached the schedule is refused with 503 rather than accepted against a clock that was never set, and a fire lands at or just after `fires_at`, never before. ## Limits - Outbound spend is capped per calendar month in TokenGate. Exceeding the SMS budget returns 429; exceeding the call-minute budget returns 429 for POST /v1/calls and withholds the billed forwarding leg on inbound calls while still ringing SIP and still taking a voicemail. - Outbound calls are additionally limited to a GB-only carrier profile with a daily spend cap at Telnyx. - Message bodies and voicemail audio are redacted after 365 days. - Webhook signatures older than 300s are rejected. ## Notes for integrators - Every dialled number comes from configuration. A number arriving in a webhook is never dialled. - Webhook delivery is at-least-once; events are deduplicated on the provider event id for 86400s. - SMS is billed per segment (160 GSM characters, 70 if the text is non-ASCII). WhatsApp and Telegram are billed one unit per message whatever the length. Either way `parts` on a send response tells you how many you were charged for.