A bot can have a real phone number. People text it, the text lands in a thread, and the bot’s reply goes back as an SMS. The cloud API owns the number, the carrier, texting compliance, and the consent record. The user’s runtime (a cloud computer or Desktop) owns the conversation. All routes are on api.allternit.com and take a Clerk session or a compute-scoped API key, like the channel address routes. When the carrier is not configured they answer 503 {"error":"phone_not_configured"}.

Turn it on (operators)

Migration 024_phone_numbers.sql is applied by hand to the production database, like 020 and 021.

Numbers

  • GET /api/v1/phone/numbers/search?country=US&areaCode=415&locality=&type=local|toll_free&limit=10 returns {carrier, numbers:[{e164, type, locality, monthlyCost}]}.
  • POST /api/v1/phone/numbers with {e164, runtimeId, botId} buys the number, assigns it to the bot, and creates its relay address (provider sms). Returns 201 {number}. Phone numbers need a paid plan: Free gets 402 phone_requires_plan. Each plan holds a limited number of numbers (PLAN_NUMBER_LIMITS in routes/phone.rs: Plus 3, Super 5, Ultra 10), past which the route returns 403 number_limit. Porting a number follows the same rules. Errors: 402 phone_requires_plan, 409 number_taken, 403 number_limit, 404 runtime_not_found.
  • POST /api/v1/phone/numbers/port with {e164, runtimeId, botId} starts a port-in (Telnyx). The number starts texting through Allternit when the port completes. GET /api/v1/phone/numbers/:id/port returns {port:{orderId,state}, number}.
  • GET /api/v1/phone/numbers lists the caller’s numbers. DELETE /api/v1/phone/numbers/:id releases the number and revokes its relay address.
  • PATCH /api/v1/phone/numbers/:id with {botId, runtimeId?, botName?} moves the number to another of your bots (and, with runtimeId, another of your computers). Returns 200 {number, previousBotId}; the number’s runtime is told to resync at once. Invites still open on the number follow it; consent, opt-outs and registration stay with the number; past conversations stay with the old bot. Errors: 400 bad_request (empty botId), 404 number_not_found, 404 runtime_not_found, 403 runtime_cannot_move_number. See Moving a number.
A number’s smsState is pending_registration, active, rejected or blocked. A new US number is pending_registration until the carrier approves its registration.

Registration

US carriers require registration before a number can text at volume.
  • POST /api/v1/phone/numbers/:id/registration takes the business form (legal name, EIN, website, address, contact, use case, sample messages, optional optInPageUrl). Local numbers register for 10DLC, toll-free numbers for toll-free verification; kind overrides. The stored copy masks the EIN.
  • GET /api/v1/phone/numbers/:id/registration returns the state (pending, approved, rejected, with rejectionReason) and the number’s smsState. It re-reads a pending registration from the carrier. A rejected registration can be submitted again.
  • The carrier message flow is generated, not taken from the form: one opt-in path, the person texts the number first after seeing it on the opt-in page, plus the STOP/START/HELP keywords and the terms and privacy links. Carriers reject flows that list several paths (signed forms, verbal consent) without proof for each. optInPageUrl is the business’s own page; when it is empty the registration uses the hosted page GET /sms/:numberId (public HTML: the number, the use case, the texting terms; 404 for a number with no registration or a released one). A free-text optInWorkflow is still accepted and stored but never sent to the carrier.
  • 10DLC is two steps at the carrier: the business brand is verified first (usually hours to a few days), then the texting campaign is reviewed. The submit files the brand right away; if the brand is still being verified, the campaign is filed automatically as soon as the brand clears (on the carrier’s status webhook or the next GET). campaignId stays null until then. A campaign the carrier refuses outright turns the registration rejected with a rejectionReason starting campaign:. Submitting the form again for a rejected 10DLC registration whose brand cleared corrects the existing campaign in place (PUT /10dlc/campaign/{id} at Telnyx, which puts it back into review) and keeps the same registration: no new brand or campaign is filed. A registration rejected at the brand step files a new one.
  • The campaign sent to the carrier includes the HELP, STOP and START replies (naming the business display name and contact email), and declares that messages may include links and phone numbers.
  • Waiting, not rejected. When the carrier refuses for a reason on Allternit’s side (our carrier account’s balance or level, or a carrier outage), the registration stays pending with a rejectionReason starting waiting:, and is retried automatically every 30 minutes. Only a refusal about the business’s own details turns it rejected.
  • Individuals (sole proprietors, no EIN). Send entityType: "SOLE_PROPRIETOR", contactFirstName, contactLastName and mobilePhone (instead of an EIN) and useCase: "SOLE_PROPRIETOR". The carrier texts a code to that mobile; POST /api/v1/phone/numbers/:id/registration/otp with {"pin":"123456"} checks it (400 when it doesn’t match) and files the campaign; an empty body sends a new code. Carriers allow one number per individual this way, with lower sending limits.
See Phone invites for single-use invite links that record explicit consent.

Texts in

The carrier posts to the number’s relay address. Before anything is queued the cloud verifies the carrier signature, drops duplicates, and handles keywords:
  • STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT: the sender is added to the number’s opt-out list, gets a confirmation, and nothing reaches the runtime.
  • HELP: gets a help reply.
  • START, UNSTOP: removes the opt-out and confirms.
An opted-out sender never reaches the runtime. Every consent change is logged. A person who texts first has consented to the bot answering; on an active number their first text gets the registered opt-in confirmation once (sender name, frequency, rates, STOP/HELP, no sharing for marketing) before the bot’s reply. STOP, START and HELP replies use the registered sender name and contact email, the same strings the carrier campaign declares. The runtime receives one normalised request at /webhooks/channels/sms:

Texts out

POST /api/v1/channels/sms/send with {numberId, to, text} returns {ok:true, messageId, parts}. It refuses with: POST /api/v1/phone/numbers/:id/consent with {e164, source, evidence} records explicit consent you hold (for example a web form). It allows calls, not texts: the carrier campaign registers texting-first opt-in only. It never lifts a STOP; only the person’s own START does.

Outbound calls

POST /api/v1/phone/calls/outbound with {numberId, to, botId, purpose} returns {consentRef, basis, expiresAt} when the callee texted or called the number first, or has an explicit consent record, and hasn’t opted out. Otherwise 403 {"error":"no_consent"}. The dial itself belongs to the voice service, which presents the consentRef.

Warm transfers

A warm transfer rings a third party, so the call worker refuses it unless the control carries a consentRef. Cloud-api attaches one when it forwards POST /api/v1/voice/calls/{callId}/control with {"action":"transfer","to":"+1…","mode":"warm"}; cold transfers need none. The reference lives 30 minutes in call_consents with purpose: "transfer". STOP always wins over both bases.

Transfer targets

The owner approves who a bot may transfer to: PUT /api/v1/voice/bot-config/{botId} with {"transferTargets":[{"e164":"+14155550100","label":"Front desk"}]}. The list replaces the stored one (up to 20 E.164 numbers, deduplicated; [] clears it; leaving the field out keeps it). The response includes transferTargets. Each newly approved target logs an explicit consent row with source owner_transfer_target on the bot’s live numbers. Cloud-api migration 036 adds voice_bot_config.transfer_targets; apply it by hand to prod like 020 and 021. The bot call settings screen in allternit-ai edits this list as “Transfer targets” (a small separate change on the app side).