Numbers
A number belongs to an org and points at one agent in one environment: the carrier, the route, outbound.
A number reaches an agent in three writes: the carrier points it at the platform, the platform admits it,
and a route says which agent answers (Telephony is how the three fit). The endpoints below do all three. Every one takes the org's
key with the numbers scope and acts in the key's environment: a pc_live_ key imports into
production, a pc_test_ key into the sandbox, a person's key into the environment the request names.
The accounts — PUT · GET · DELETE /v1/carrier, GET /v1/carriers#
An org holds as many accounts as it has: several Twilio accounts, a SIP peer (its own PBX or a
carrier with no API here), a WhatsApp number at Meta. Each is one row, sealed under
PINECALL_VAULT_KEY, named by its own id and never by its secret.
{ "kind": "twilio", "account_sid": "AC…", "user": "SK…", "secret": "…", "label": "Clínica" }
{ "kind": "sip", "username": "pbx", "password": "…", "addresses": ["203.0.113.0/24"],
"outbound_host": "sip.carrier.example", "outbound_transport": "tls" }
{ "kind": "whatsapp", "phone_number_id": "1055…", "access_token": "…" }
PUT /v1/carrier adds an account, or replaces the secret of one the org already holds. A Twilio
account is opened once first: user is an API key SID (make one at Twilio → Account → API keys,
revoke it there any time) or the account SID again with the auth token, and a pair Twilio refuses
is 400 Twilio refused these credentials. A WhatsApp number is asked of Meta the same way first
— its number and name, with the token given — and an id or a token Meta does not open is 400 Meta does not open WhatsApp number <id> with this token: <Meta's words>. A peer's addresses are the networks it calls from:
each is an IPv4 address or a network no wider than a /24, public (a private, shared, loopback or
documentation range is 400), and each waits for the operator to approve it before 5060
opens to it or a trunk lists it (Operator API, "Carriers and the fence").
Its four outbound_* fields say where the platform dials it, and unsaid the platform dials with the pair it
registers with. outbound_username without outbound_password is refused.
GET /v1/carrier answers {kind, account, label, networks} (a peer's networks, each
{network, state}: waiting, approved or refused) for the org's only account, or the one
?account= names; with several and none named it is 409 naming them. GET /v1/carriers is every
one of them. DELETE /v1/carrier forgets an account; its numbers stay routed until each is let go.
From a terminal the same four endpoints are pinecall carriers — add twilio|sip|whatsapp with each
secret read on stdin, list, show, drop — and from the console they are Numbers ▸ Add a
number and Accounts (pinecall carriers).
An agent answers at as many numbers as the org routes to it: phone numbers from any of its accounts, hooked by the org or bought by the platform, and WhatsApp numbers, each one row. A call the agent places is shown as one of them, and dials through the account that number lives in.
What the accounts own — GET /v1/numbers/available#
{kind, numbers: [{number, name, imported, account}]}: every number every Twilio account of the
org owns, all of Twilio's pages, and whether this environment imported it. A SIP peer owns what it owns
and nobody here can list it: {kind: "sip", numbers: []}, and its import takes the number typed.
Importing one — POST /v1/numbers {number, agent, channel?, account?, hooked?, networks?, move?}#
Two ways to hook a number, both through this endpoint:
- We hook it (the default): the number is one of an account of the org. On Twilio the platform
finds the account's trunk that points at it (by its origination URI,
sip:<the world's SIP name>:5060;transport=udp, whatever the trunk is named), makes one if there is none, and attaches the number. On a SIP peer nothing outside is touched. With several accounts,accountsays which. - You hook it (
hooked: true): the org points the number at the platform itself, from any carrier or PBX:sip:+59829000000@<the world's SIP name>:5060(PINECALL_SIP_DOMAINorPINECALL_SANDBOX_SIP_DOMAIN, else the platform's name, Environment variables). Nothing outside is touched. The platform admits the number from the networks of the carrier named invia, one ofGET /v1/carriers/catalog(Twilio's when unsaid), or fromnetworksof the org's own, which wait for the operator's approval like a peer's: until then the number is routed and on no trunk, and the import's steps say so. A number hooked this way proves nothing of whose it is: on a deployment that setsPINECALL_APPROVE_HOOKED, the operator also approves the number itself (POST /v1/ops/numbers/{number}/approve, the numbers waiting listed byGET /v1/ops/numbers, operator-api.md), and until then a call to it is refused when it opens and its path sayswaitingat the fence. Unset (the default), it rings at once.
One org holds a number: importing a number another org holds is 409, and the sentence names
neither the org nor its trunk.
GET /v1/carriers/catalog is {carriers: [{kind, name, how, networks}], sells}: the carriers this
operator admits, automatic for one the platform drives through its API (Twilio), guided for one
whose portal the org types the address above into; sells says whether POST /v1/numbers/buy
has an account to buy on.
$ curl -X POST https://cloud.pinecall.io/v1/numbers -H "authorization: Bearer $PINECALL_KEY" \
-d '{"number": "+59829000000", "agent": "recepcion"}'
{"route": {"org": "org_…", "agent": "recepcion", "channel": "phone", "number": "+59829000000",
"label": null, "env": "production", "managed": false},
"steps": ["Twilio: a trunk sending calls to sip:sip.pinecall.io:5060;transport=udp: stands",
"Twilio: +59829000000 attached to it: done",
"LiveKit: trunk org_… admits +59829000000: done",
"LiveKit: rule org_…:production sends it to fleet pinecall: done",
"route: +59829000000 to recepcion in the production: done"],
"dry_run": false}
Each step is looked up before it is written, so a second run of an interrupted import writes
nothing twice: every step says stands. ?dry_run=true answers the same steps with to do and
writes nothing anywhere. A number lives on its environment's LiveKit, where an org has one inbound trunk
per fence (<org> for Twilio's networks, <org>:<peer> for a SIP peer, <org>:<number> for a
number hooked from networks of its own) and the environment's dispatch rule (<org>:production on
production's, <org>:sandbox on the sandbox's), dispatched to that environment's fleet. A number imported
in one environment that the other's own LiveKit still lists is taken off it first (LiveKit of the production: … off trunk …). A trunk or a rule left listing no number is deleted, because one that
lists none takes every number.
Refusals: 404 no account, or a number the account does not own; 400 a channel with no number
or a number that is not E.164; 409 several accounts and none named, a number another org's trunk
on this deployment already lists (livekit-sip refuses an INVITE two trunks list, so nothing is written),
or a number attached to another trunk of the same Twilio account — its name and where it points
are in the sentence, and move: true takes it off there first; 503 no PINECALL_DOMAIN; 502
Twilio's own sentence.
Buying one — POST /v1/numbers/buy {country, area_code?, agent, channel?}#
For an org with no account of its own: a number bought on the platform's own Twilio account (the
sealed credentials/twilio row of box_settings; country is two letters and area_code
digits, 422 otherwise, since both ride a path the platform signs), then hooked as an import and routed with
managed: true, in the key's environment. The numbers quota of that environment caps it, and is counted on
the managed numbers alone: 429 before Twilio is asked. 404 when Twilio has nothing for sale
there, 503 when the platform holds no Twilio account. ?dry_run=true names the number it would buy
and buys nothing.
The org's numbers — GET /v1/numbers, GET /v1/numbers/{number}/path#
One row per number in the key's environment, oldest first: {route, origin, rings, last_call_at, via, account}.
route is the number, its channel, the agent and the environment; origin is how the row was written —
bought (by the platform), imported (from one of the org's accounts), hooked (the org pointed the
number at the platform itself) or typed (by the operator, who can route a number into any org).
A row the org did not write is how it learns the operator did; importing the number again makes it
the org's. via is the catalog carrier a hooked number comes through, account the org's account it
lives in. last_call_at is when a
call to the number last reached the platform (kept at most once a minute), null when none ever did.
rings is what a call to it does now: ok, waiting (on its first call, or on the operator's
approval of a network, or of a number the org hooked where the platform asks for that) or broken
(nobody runs its agent),
read off the tables without asking any carrier. GET /v1/numbers/{number}/path says why: four
steps, each {step, state, says, fix} — the carrier (a Twilio number asks Twilio whether it is
still attached to the trunk pointed here), the fence, the environment's rule, the agent — with the worst
of them as rings and last_call_at; 404 for a number the org has not in the key's environment.
Letting one go, moving one — DELETE /v1/numbers/{number}, PUT /v1/numbers/{number}/env#
DELETE removes the route and takes the number off its trunk and its org's rules on its environment's
LiveKit; the account keeps it, so nobody is un-bought by a typo. PUT …/env {env} moves a number
into the other environment: its row, and off the old environment's LiveKit onto the new one's, trunk and rule;
the carrier is not touched. Where both environments share one LiveKit it moves between the two rules,
its trunk untouched. It is how a number tested in the sandbox goes live.
Dialling out — GET · POST /v1/carrier/outbound, POST /v1/agents/{slug}/dial#
The SFU keeps no outbound trunk: every leg is dialled with its trunk inline. What an account
needs is the other direction of its carrier. POST /v1/carrier/outbound (?account=,
?dry_run=true) makes it once: on Twilio the trunk's termination host
(<box>-<account>.pstn.twilio.com, one per account and platform), a credential list on that trunk,
and a credential per org on the list, its password minted here and kept sealed because Twilio
shows it once. Two orgs that brought the same account dial through one trunk, each with its own
credential. A credential this deployment made and no longer holds the password of is 409 naming it: it
is deleted in Twilio's console, then provisioned again. A SIP peer needs nothing made: it is
dialled at its outbound_host.
GET /v1/carrier/outbound is {ready, kind, from_numbers, steps_missing, guards}: whether the org
can place a call, one sentence per thing still missing, and the guards it dials under.
POST /v1/agents/{slug}/dial {to, from?, log?} places a call as the agent: 202 {call, agent, to, from, env, log_token} before anything rings. from is one of the agent's own numbers in the key's
environment, the first unsaid; the call is dialled through the account that number lives in, and a
number the org hooked itself or the platform bought dials through none (409). Nobody holding the
agent is 409: a phone would ring with no app to serve it. Every dial asked for is one row of the
org's dials ledger, taken or refused:
| refused when | status | lifted by |
|---|---|---|
to is not E.164, has no country calling code, is a satellite or global-service range (+870, +878, +881, +882, +883, +888, +979), or has fewer than five national digits |
400 |
a number somebody could answer |
| the number never called or wrote to this org in this environment | 403 |
an operator's dial_anywhere |
the number is on the org's do-not-call list in this environment (do_not_call): its newest fact is an opt-out — the caller asked the agent (call.opt_out), a person put it there, or the org imported it. A consent sent with the dial never lifts it |
403 |
a consent recorded at POST /v1/org/consents |
no consent on file for a +1 number (no_consent), nor one sent with the dial as consent: {kind: express|written, source, text?, evidence?}; any country when the org's policy says consent_everywhere |
403 |
the consent, recorded or sent |
outside the called number's hours in every zone it could be in (quiet_hours): for a +1 number 8:00 to 21:00 local (the Telemarketing Sales Rule), narrowed by the org's calling_hours and never widened, and a +1 number with no zone (toll-free) always; elsewhere the org's calling_hours, when it set some |
403 |
the hour |
more dials this minute than per_minute (6), refusals counted |
429 |
a wait |
more dials today than per_day (200) |
429 |
a wait |
the number itself rung per_number_day times in 24 hours (too_often): 3 for a +1 number unless the org sets its own, none elsewhere unless it does |
429 |
a wait |
The list, consent, the hours and the per-number count bind a call to somebody: the sandbox is
held to none of them, and neither is a number the person who asked verified as their own phone
(PUT /v1/line/from) — that is them testing. A consent sent with a dial is written down with the
call's id before anything rings, so the next call to the number needs none. The zones come from libphonenumber (phonenumbers); the org sets its hours
and count at PUT /v1/org/policy (Gateway API §7). The count and the row are one transaction under the org's lock: two dials at once cannot both take
the last slot. A placed call runs at most max_duration_s (600), which the media plane enforces.
A far end that is busy, declines or never answers ends the call busy or no_answer; anything
else, dial_failed.
A leg dialled into a live call (a warm transfer, room.invite) asks
GET /v1/agents/{slug}/outbound-trunk?to=&call=&from=, the worker's: the shape and the pace, not
the stranger fence, and the trunk inline in the answer. A cold transfer asks
POST /v1/agents/{slug}/cold-transfer?to=&call= before it sends the caller on: the same guards and
the same ledger row, and no trunk, since the carrier dials a REFER on the org's own bill; a
refusal leaves the caller where they are, call.transferred with ok: false.
The firewall#
livekit-sip listens on 5060, and the cloud's firewall in front of it opens it to the signalling networks of the carriers the operator admits and to the addresses he approved, and to nothing else: Terraform's rule, from the catalog and the approvals (Operator API).
Reconcile at start#
LiveKit keeps its trunks and rules in Redis, which can be emptied; the tables are the truth. At
start the gateway admits every routed phone number again on its environment's LiveKit with its fence and
its environment's rule, and takes it off the other environment's own LiveKit, one org's refusal logged and the
others going on; while an org is refused or a LiveKit cannot be reached (one still starting beside
the gateway), it tries the whole again, waiting 2 s, then twice as long each time, a minute at
most, until every number stands. It never touches the carrier, and it takes a number
off its org's trunks when nothing approved fences it any more. The operator's approval or refusal
of a network does the same for that org at once. Where the sandbox has a LiveKit of its own, the
start also admits there the rings production hands to a developer's own sandbox copy: one trunk
and one rule named hand-over, the trunk listing no number and admitting production's media
address alone, with its pair (Telephony, "A developer's own phone").
Consent and the do-not-call list#
Every fact about a number is a row, never changed: a consent given (express or written, where it
came from, the words the person agreed to, a proof) or an opt-out, by whom and on which call. What
stands is the newest row, so a person who opted out and later consented is called again, and the
history of both is kept. An erasure of the contact leaves these rows: an opt-out has to outlive the
person's data, or the next list the org imports calls them again.
| endpoint | scope | what |
|---|---|---|
POST /v1/org/consents {number, kind, source, text?, evidence?} |
talk | one fact written; what stands for the number after |
GET /v1/org/consents/{number} |
calls | {number, standing: consented|opted_out|unknown, rows}, newest first |
DELETE /v1/org/consents/{number} |
talk | the number put on the list: an opt-out written, the history kept |
GET /v1/org/dnc?after= |
calls | the numbers whose newest fact is an opt-out, newest first, next for the page after |
POST /v1/org/dnc {numbers, source} |
talk | the org's own list, or its scrub of the National Do Not Call Registry, onto the list at once: {added, refused} |
The runtime does not query the National Registry: its subscription is the seller's by law. The org scrubs and imports what it found.