Telephony & SMS API¶
Telephony (Spec 3) lets an account register a verified phone number and be reached by an automated voice call — either as a notification channel or, more commonly, as the final rung of an escalation chain that pages a human until they press a key to acknowledge. SMS (Wave 9, Amendment A2) is a sibling channel over the same verified phone number — a notify-only text, never an ack surface — available as both a notification channel and an escalation-step channel.
Interactive sessions only
The account-phone endpoints on this page require an interactive session (cookie or short-lived Bearer JWT). A static access token cannot manage a user's own phone number — it receives 403 static_token_not_permitted. A machine credential cannot answer a phone call, so there is nothing for it to authenticate a call to.
Account phone number¶
Each account has at most one phone number, used for escalation/ notification calls and texts. It must be verified before it can receive any real notification or escalation delivery, by voice or SMS one-time code.
Set or replace your phone number¶
phone must be E.164 format: + followed by a non-zero digit and 1–14 more digits (2–15 digits total after the +), no spaces or punctuation — e.g. +4915112345678. Stages the number on the account — phone_verified_at stays null until verification succeeds — and sends a freshly-generated 6-digit code via delivery_method. Replacing an already-verified number re-starts verification from scratch; the previous number and its verification state are both discarded on the next successful POST.
| Field | Required | Description |
|---|---|---|
phone | yes | E.164 number, as above. |
delivery_method | no | sms (default, Wave 9) or voice. Omit the field entirely to get SMS. |
SMS-first (Wave 9): voice is now the fallback, not the default
Before Wave 9 this endpoint only ever placed a voice call. delivery_method now defaults to "sms" — the voice branch is unchanged (byte-identical behavior, same message, same call setup) and remains available by explicitly passing "voice". An invalid value (anything other than sms/voice) is rejected with 422 validation_failed.
SMS body: "Your b'nerd verification code: 123456" — code-first, no marketing, well under one SMS segment.
Voice call: unchanged from before Wave 9 — reads the code back digit by digit; see What the call sounds like below for the equivalent escalation-call experience (verification calls use the same voice synthesis, just for a code instead of an alert).
Only one pending code at a time
A new POST invalidates any previously-issued code for this account — only the most recently requested code can succeed, regardless of which delivery_method either request used.
Response 200
{
"metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
"data": { "message": "verification_sent" }
}
Response 422
code | When |
|---|---|
phone_required | phone missing or blank |
phone_invalid_format | phone is not valid E.164 |
phone_country_not_allowed | phone's country isn't on the allowed list — checked before anything else is created |
validation_failed | delivery_method present but not sms/voice |
Response 429 — more than 4 requests per hour from the same credential (rack-attack throttle keyed across all three auth paths — static token header, Authorization: Bearer, or the dashboard's session cookie). Added in Wave 9 alongside the SMS default specifically to close an SMS-pumping/toll-fraud surface: before this throttle, the only limit on this endpoint was the generic 300/min api/token bucket, which doesn't key on a cookie session at all — a dashboard session could otherwise trigger unbounded real Twilio sends to any E.164 number. Verifying the code is not covered by this throttle — it has its own independent 5-attempt gate (below), which is the one that actually needs to bound guessing.
Verify your phone number¶
Confirms the 6-digit code — however it was delivered — and sets phone_verified_at. The code is single-use — a correct submission consumes it immediately, it cannot be replayed even within its remaining TTL.
- Codes expire 15 minutes after being issued.
- A code allows 5 attempts; the 6th wrong attempt (even if it would otherwise have been within the TTL) fails closed.
- Identical behavior regardless of
delivery_method— this endpoint has no awareness of how the code was sent, only that it matches.
Response 200
{
"metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
"data": {
"phone": "+4915112345678",
"phone_verified_at": "2026-06-30T20:03:00Z"
}
}
Response 422 / 429
| Status | code | When |
|---|---|---|
422 | verification_not_found | No pending verification for this account (never requested, or already consumed) |
422 | invalid_code | Code doesn't match — counts against the 5-attempt limit |
422 | verification_expired | More than 15 minutes since the code was issued |
429 | max_attempts_reached | 5 wrong attempts already made against this code |
On any of these, request a fresh code with POST /accounts/me/phone rather than retrying verify.
Remove your phone number¶
Clears phone and phone_verified_at and discards any pending verification. Idempotent — calling it with no phone set still returns 200.
Response 200
{
"metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
"data": { "message": "phone_removed" }
}
Phone fields on GET /accounts/me¶
The account payload includes:
| Field | Description |
|---|---|
phone | E.164 phone number, or null if never set. |
phone_verified_at | Timestamp the number was confirmed via POST /accounts/me/phone/verify, or null if unset/unverified. |
Phone notification channel¶
phone is one of the notification channels (alongside in_app and email) an account can opt into per category via PATCH /notification_preferences. It delivers a short automated voice call summarizing the notification — it does not support an acknowledgement gather (no "press 1"); that only exists on the escalation phone channel below.
Two hard gates apply, enforced server-side regardless of preference state:
- Verified phone required. If the account's
phone_verified_atisnull, the phone channel silently skips delivery for that account — no error, no call, just an info-level log line. There is no way to receive a phone notification with an unverified number. - No category defaults to
phone. Every seeded notification category'sdefault_channelsisin_app/emailonly — an account must explicitly enablephonefor a category it wants voice delivery on.
Dashboard: locked until verified
The notification-preferences matrix in the dashboard locks the phone column (with an explanatory tooltip) until the account has a verified phone number, so nobody can opt into a channel that will silently never fire.
SMS notification channel¶
sms (Wave 9) is a sibling notification channel to phone, opted into per category the same way (PATCH /notification_preferences, { "channel": "sms", "enabled": true }). It delivers a short text — never a voice call, and like the phone channel, never an acknowledgement surface.
Shares phone's verified-phone-required gate exactly (same recipient.phone_verified? predicate, same silent skip + info-level log line on an unverified account) and its no-category-default rule — sms never appears in any category's default_channels; it is opt-in only, same reasoning as phone.
Quiet hours and absence SKIP for SMS — they HOLD for email
This is the one place SMS's behavior genuinely diverges from email's, worth knowing if you're used to email's digest semantics: during your configured quiet hours or an active absence, a non-mandatory-category SMS is skipped outright — nothing is held or queued to arrive later, unlike email's quiet-hours/digest hold (there is no SMS equivalent of NotificationDigestItem). The three mandatory-instant categories (security, platform_incident, alert_escalation) bypass both gates and always send, exactly as they do for email/phone.
Body: "b'nerd {category label}: {summary}" — kept short, no URLs, no @-addresses (PII-safe by construction, since a URL/email in an SMS is itself a phishing-shaped payload). Falls back to a generic sentence for categories with no richer summary available.
Every send writes a usage row — kind: "sms", segments computed from the body length.
Dashboard: same lock condition as phone
The notification-preferences matrix's sms column is forced into the matrix (no category's default_channels would otherwise surface it) and locked on the identical "verify your phone number" condition as the phone column, since both share the exact same server-side recipient.phone_verified? predicate — but the tooltip wording is per-channel, not shared text: it names "SMS notifications" or "voice escalation calls" depending on which column you're looking at, not a generic "phone" message.
Phone escalation¶
An escalation step with phone in its channels places an outbound voice call to every resolved target of that step — independent of the target's own notification preferences; this is a paging action, not a preference-gated notification. There is no separate action field: the phone channel always places a call, every time the step fires. Only accounts with a verified phone are called; an unverified target in that step is silently skipped for the call (no error). A roster target's resolved set also includes the roster's external phone contacts — those are exempt from the verification gate entirely (recorded consent is their lawful basis instead; see DSGVO: consent replaces verification).
{
"step": {
"position": 3,
"target_type": "roster",
"target_roster_id": "roster-uuid",
"channels": ["phone"],
"delay_seconds": 600,
"ack_timeout_seconds": 300,
"repeat": 3
}
}
What the call sounds like¶
- The callee's phone rings; on answer, a voice reads the alert summary and prompts: "Press 1 to acknowledge this alert."
- Pressing 1 (DTMF) acknowledges the alert — equivalent to
POST /projects/{project_id}/alerts/{id}/ack, attributed to the called account (or, for a roster's external phone contact, to that contact'slabel) withvia_channel: "phone". It halts the escalation chain exactly like an in-app or signed-email ack, including the ack-window dead-man's-switch. If the alert was already acknowledged by the time the digit lands (e.g. acked elsewhere first), the call plays a "already acknowledged" message and does nothing further. - Pressing anything else, or nothing before the call ends, re-prompts once; an unanswered/no-input call simply ends with no acknowledgement recorded.
Retry on provider failure¶
repeat on a step with phone in channels is the number of retry attempts on a provider error (call couldn't be placed at all — not "nobody answered"), with exponential backoff between attempts. It is not a "call N times regardless" setting, and it never itself advances or exhausts the escalation chain — that remains entirely the job of the step's own ack_timeout_seconds watchdog. If every retry is exhausted with no successful call, the chain simply times out and advances/exhausts on schedule, same as if nobody had answered.
SMS escalation¶
An escalation step with sms in its channels texts every resolved target of that step — the same target set phone resolves (accounts ∪ the roster's external phone contacts), same verified/consented gating, delivered independently of the target's own notification preferences (a paging action, not a preference-gated notification, exactly like phone).
{
"step": {
"position": 2,
"target_type": "roster",
"target_roster_id": "roster-uuid",
"channels": ["sms"],
"delay_seconds": 300,
"ack_timeout_seconds": 300,
"repeat": 3
}
}
Notify-only — SMS can never acknowledge an alert
Unlike phone's "press 1," an escalation SMS carries no way to acknowledge — no reply-to-ack, no tokenized link. The message body tells the recipient to check the dashboard or answer the escalation call instead: "Check the dashboard or answer the escalation call to acknowledge." This is a deliberate v1 scope boundary, not an oversight: the CallToken signed-token machinery that makes "press 1" safely attributable stays phone-only: building an equivalent for SMS (a two-way, authenticated reply channel, or a per-message signed ack link) would be materially new infrastructure, not a small extension of the existing text-out-only send path.
Each target's SMS is dispatched as its own async job (mirroring how phone dispatches one call per target) — a step targeting a whole roster doesn't wait on one send before starting the next, and a provider failure sending to one target never blocks or delays delivery to the others. repeat and its retry-on-provider-failure semantics work exactly like phone's (above): retries a genuine send failure with exponential backoff, capped at repeat attempts, and never itself advances/exhausts the chain — that's still entirely the ack_timeout_seconds watchdog's job. Only accounts with a verified phone are texted; an unverified target is silently skipped (warning logged) exactly like the phone channel.
Every successful send writes a usage row attributed to the alert's project's organization — unambiguous, since escalation always has a project in context.
Usage metering (CommunicationUsage)¶
Wave 9 (Amendment A2.5) introduces an internal, org-attributed ledger of every real voice call and SMS the platform places — verification, escalation, and notification alike. There is no customer-facing endpoint for this data yet, and no billing/pricing built on it — this section documents what's recorded and why, for anyone reasoning about spend, abuse, or a future usage-reporting surface, not a live product feature you can query today.
Every real send (never a NullCaller/NullSms dev/test one) writes one row with an organization, a kind (voice_call or sms), a unit_type (seconds or segments), and a units count — plus whichever of project/account/phone_contact identifies who the send was to/for. Writing a usage row never blocks the underlying send — a metering failure is logged and swallowed, the same fail-open philosophy every other ledger in this platform (the alert timeline, for one) already follows.
Voice calls: seconds, filled in from the provider's status callback¶
An escalation call writes its usage row at placement time with units: 0 — the real duration isn't known yet — then the same Twilio status callback that updates the call's own delivery status (TelephonyController#status) fills in units from the provider's reported call duration once the call ends. This duration update is not gated on the call's signed token still being valid (unlike the call's own status record) — a long call's final callback can legitimately arrive after the token's short validity window expires, and unlike the token-gated status update, there's still real billing data worth capturing at that point.
Verification voice calls: units stays 0 forever — a known, permanent architectural gap
A verification voice call (POST /accounts/me/phone with delivery_method: "voice") writes its usage row with status: "placed_unmetered" and units: 0 — and that units value never gets filled in. Unlike an escalation call, a verification call is placed with no status callback registered with the provider at all (it hands back inline call instructions instead of asking for one), and has no signed call token a future callback endpoint could authenticate against without new infrastructure. The row exists purely for visibility — "a verification call was placed" — not for accurate duration billing. Closing this gap would require a dedicated token/callback mechanism for verification calls specifically; it isn't planned for v1.
SMS: segments, an honest GSM-7 approximation¶
Every SMS write records units as an estimated segment count — ceil(body length / 153) — the concatenated-SMS per-segment size (153, not the single-segment 160, since the 7-character difference is reserved for concatenation headers on any message needing more than one segment).
A documented approximation, not exact billing
This formula slightly over-counts a body in the 154–160 character range (real GSM-7 would still fit that in one segment) — accepted as-is, not treated as a bug. It also currently has a latent (not yet triggered) under-count risk in the opposite direction: a body containing any character outside the GSM-7 alphabet — German umlauts (ä/ö/ü), ß — forces the entire message onto UCS-2 encoding, whose real segment size (70 single / 67 concatenated) is smaller than GSM-7's, so this formula would under-count such a body. Every SMS body b'nerd sends today is English-only ASCII (verification codes, alert summaries), so this gap has never actually fired — flagged here so it isn't rediscovered as a surprise the day a body needs non-ASCII text.
Provider and status: telling a rejected send apart from a delivered one¶
Every row also carries a provider ("twilio" by default; "sipgate" only for a send that actually went out through SipgateSms) and a status reflecting what actually happened — not just that a send was attempted. For SMS specifically: "sent" (delivered to the provider successfully), "failed" (a real, rejected attempt — e.g. sipgate's 402 when its account balance is exhausted, or a Twilio/sipgate auth error), "blocked" (the country allowlist rejected the destination before any network call was even made — no attempt happened at all), or "null" (a dev/test NullSms send — never written to the ledger as a "real send" row in the first place, listed here only to be complete about the status vocabulary). A "failed" row is a genuine, distinguishable outcome, not indistinguishable from "sent" — this matters specifically for sipgate, whose adapter deliberately returns a failure result instead of raising (see sipgate below for why that's the right call for avoiding a retry storm on a persistent problem like an exhausted balance), which means a rejected sipgate send reaches the usage-recording code path far more often in practice than a rejected Twilio one ever did (a Twilio provider error raises and is never usage-recorded at all, both before and after this change).
Attribution: who a usage row is billed to¶
- Project-scoped sends (escalation calls/SMS) — unambiguous: the alert's project's organization.
- Account-only sends (phone verification; the SMS/notification channel's fallback when no organization is otherwise in context) — an account can belong to multiple organizations, and neither the phone- verification endpoint nor a bare notification delivery has an organization in scope to attribute to directly. These resolve to the account's oldest membership in a non-deleted organization (deterministic — ties on identical join timestamps break on membership id) — a single rule, applied identically everywhere it's needed, not reinvented per caller. When an account has zero organization memberships (or only deleted-org ones), the usage row is skipped entirely and a warning is logged — metering must never block the underlying verification/notification send.
Operator setup (voice & SMS providers)¶
Platform configuration, not a customer setting
This section explains what a b'nerd operator must configure for phone calls to actually happen. It is not something you configure per organization or project.
By default the platform runs on a NullCaller/NullSms: it logs what it would have said/sent and to which number, but places no real call or text. This keeps dev/test/CI environments — and unconfigured production deployments — safe from accidentally dialing or texting anyone. Real voice calls require all of:
| Setting | Purpose |
|---|---|
TWILIO_ACCOUNT_SID | Twilio account identifier |
TWILIO_AUTH_TOKEN | Twilio auth token — also used to verify inbound Twilio webhook signatures |
TWILIO_PHONE_NUMBER | The caller ID b'nerd places calls from |
HQ_BASE_URL | Public base URL Twilio is told to call back into (voice/gather/status webhooks and the account-verification call's TwiML) |
If any of the three TWILIO_* variables is unset, the platform silently falls back to NullCaller for every call — account verification and escalation calls alike — with no error surfaced to the caller of POST /accounts/me/phone or to the escalation engine. If you set up phone verification or a phone-channel escalation step and no phone ever actually rings, check that Twilio is configured before assuming a bug.
How these get set
These four values are provided as (protected/masked) GitLab CI variables on the hq project and flow into the deployment automatically on every pipeline run — the CI job passes them to helm upgrade (as app.secrets.twilio_* / app.secrets.hq_base_url), which the chart writes into the environment's Rails-app Secret and injects into both the app and worker pods' env. There is nothing to configure by hand in the cluster itself: set the CI variables once per environment (prod, stage) and the platform runs on NullCaller until they're set, then picks up real calls on the next deploy.
SMS has two independent providers: Twilio or sipgate¶
Voice is Twilio-only — there is no alternate voice provider. SMS, however, can be sent through either Twilio or sipgate, b'nerd's production choice for SMS (no Twilio SMS number is provisioned in production — sipgate is the real, live-verified path there). Both are configured independently of each other and of voice.
| Setting | Purpose |
|---|---|
TWILIO_MESSAGING_SERVICE_SID | Twilio Messaging Service SID — gates TwilioSms, alongside the shared TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN credentials voice and Twilio SMS both use. |
SIPGATE_TOKEN_ID + SIPGATE_TOKEN | A sipgate Personal Access Token pair (not the sipgate account password), scoped sessions:sms:write. Both required together. |
SIPGATE_SMS_ID | The sipgate SMS extension (an s0-style identifier) whose account-configured, verified caller ID sipgate sends from. |
SMS_PROVIDER | Optional. Only consulted when both Twilio and sipgate are fully configured — see the precedence rule below. |
Precedence, exactly in this order:
- Neither configured →
NullSms(no real send, same safety default as voice'sNullCaller). - Exactly one configured → that one is used, regardless of
SMS_PROVIDER's value — the env var can't conjure the other provider's missing credentials into existence. - Both configured →
SMS_PROVIDER("sipgate"or"twilio") is a tie-breaker only. Left unset, it defaults to sipgate (logged, not silent) — sipgate is the newer, preferred provider.
sipgate: the sender number isn't per-message
Unlike Twilio's from:, sipgate's smsId (SIPGATE_SMS_ID) doesn't carry a number in the request at all — it identifies which sipgate SMS extension is sending, and that extension's caller ID is verified, account-side configuration in the sipgate web console, not something this adapter chooses or sends per request. The API itself is POST /v2/sessions/sms (Basic auth with the PAT pair, JSON body {smsId, recipient, message}) — a success is 204 No Content with no message id in the response at all, unlike Twilio's sid. Usage rows for a sipgate send therefore carry provider: "sipgate" and provider_sid: null — this is expected, not a gap (see Usage metering).
sipgate 402 (balance exhausted) is a recorded failure, never a retry storm
If the sipgate account's balance runs out, sipgate responds 402 — handled identically to any other rejected send (401/403/a network timeout): recorded as a genuine "failed" outcome, never raised. This is deliberate: SmsEscalationJob's self-managed retry triggers on a raised exception, so a persistent problem like an exhausted balance would otherwise be retried uselessly, identically, up to step.repeat times against a provider that's going to reject it every time. Returning a normal "failed" result instead means no retry ever fires for this class of problem.
Deliberate deviation from the original design: no from-number fallback for Twilio SMS
An earlier design allowed Twilio SMS to fall back to sending from TWILIO_PHONE_NUMBER (the same number voice calls from) if no Messaging Service was configured — the lead rejected this fallback before it shipped. TWILIO_PHONE_NUMBER is one of voice's own required env vars, so that fallback would have meant every voice-Twilio-configured environment automatically started sending real SMS too, the moment this feature merged — silently, with no separate action taken to opt into it. That's the opposite of this whole port's deploy-safety philosophy (NullSms/NullCaller until an explicit, dedicated opt-in — nobody should "wake up" sending texts through a number that was only ever configured for calls). A dedicated Messaging Service is Twilio's own best practice for SMS regardless, so requiring it costs nothing beyond one more variable to set deliberately, when SMS is actually wanted. The same "explicit opt-in, no accidental default" philosophy is why sipgate needs all three of its own variables set together, rather than partially degrading to some other send path.
All of these — sipgate's included — are set the same way as the voice TWILIO_* variables above: (protected/masked) GitLab CI variables flowing through to the app/worker Secret on deploy, nothing to configure by hand in the cluster.
Country allowlist (applies to every provider)¶
Every outbound phone call and SMS — Twilio voice, Twilio SMS, sipgate SMS, and even the Null variants (for test parity: a test asserting something would have sent must exercise the same gate a real send does, not silently bypass it) — is checked against a country allowlist before any network connection is made at all.
| Setting | Purpose |
|---|---|
ALLOWED_PHONE_COUNTRY_CODES | Optional. Comma-separated bare ITU calling codes, no + (e.g. "49,43,41"). Replaces the default entirely when set — not merged with it. |
The default (no override set) is geographic Europe: the EU/EEA, Switzerland, the UK, and the rest of the continent (Balkans, Baltics, the microstates) — deliberately excluding Russia/Kazakhstan (+7) and the transcontinental/ Caucasus codes (Turkey, Georgia, Azerbaijan, Armenia).
POST /accounts/me/phone's primary defense is at input time: it checks the destination itself and rejects an out-of-allowlist number immediately with 422 phone_country_not_allowed, before a PhoneVerification row is even created — this is the path a customer normally hits when trying to register a number outside the allowed list.
The provider-level check documented above (inside every adapter, independent of that controller) is a backstop, not the primary path — it exists for the cases the input-time check can't cover: a number that was verified before the allowlist existed (or before an operator narrowed it), or an ALLOWED_PHONE_COUNTRY_CODES change that retroactively excludes an already-verified number. In those rarer cases, the destination is rejected silently from the caller's perspective — no error surfaces to the escalation/notification engine, exactly like an unconfigured provider falling back to Null. The usage row for that attempt (if one is written at all) gets status: "blocked", and a masked-number warning is logged. If a customer reports an escalation text or call that never arrived (not a verification code — that path 422s up front), check whether their number has since fallen outside the configured allowlist before assuming a provider outage.
Inbound Twilio webhooks are not a customer-facing API¶
Twilio calls back into /telephony/voice/:call_token, /telephony/gather/:call_token, and /telephony/status/:call_token to drive the call and report its outcome. These are not part of the public API — they are session-less, authenticated only by the X-Twilio-Signature header (verified against TWILIO_AUTH_TOKEN) plus a short-lived, signed call token embedded in the URL. There is no reason (and no way) for a customer or integration to call them directly, and they are intentionally excluded from the OpenAPI reference.
SMS has no equivalent inbound webhook at all — sending is fire-and-forget (units for an SMS usage row is computed synchronously from the message body at send time, unlike a voice call's duration, so there's nothing an async delivery-status callback would need to fill in later).