Twilio accounts
Connect an agent's own Twilio account, choose its number from that account, and point the number at the agent.
An agent can place calls and send texts through its own Twilio account instead of your deployment's. The account belongs to your client, the business the agent answers for: they sign up with Twilio, Twilio bills them, and they create an API key for Subcore to use. Your product connects that key here, and your deployment reads it back with the agent's configuration on every call. An agent with no account connected keeps using the deployment's own Twilio credentials, as it always has.
Every step is a route on this API, so the whole flow can live inside your own product, and your client never needs the Subcore console.
| Route | What it does | Success |
|---|---|---|
GET /agents/{slug}/twilio | The agent's connection, without its secrets | 200 { "twilio": TwilioConnection }, or null for none |
PUT /agents/{slug}/twilio | Connect an account, or replace the connection, once Twilio accepts it | 201 connected, 200 replaced: { "twilio": TwilioConnection, "clearedPhoneNumber": … } |
DELETE /agents/{slug}/twilio | Disconnect, clearing the agent's number | 204, no body |
GET /agents/{slug}/twilio/numbers | Every number in the account, where its calls and texts go now, and which agents use it | 200 { "numbers": [TwilioNumber] } |
PUT /agents/{slug}/twilio/number | Make one of the account's numbers the agent's number, or clear it | 200 { "number": TwilioNumber }, or null after a clear |
PUT /agents/{slug}/twilio/webhooks | Send the number's incoming calls and texts to the agent | 200 { "number": TwilioNumber, "previous": … } |
All six need the
manage-agents permission, like
the rest of /agents.
The calls and texts themselves run on your deployment, which has to be set up
once before any of this reaches a caller: kit 0.22.0 or later, reading its
configuration from api.subcore.ai, with a key allowed to read Twilio
credentials. See what your deployment needs.
The flow
Your client creates an API key
They create it in their own Twilio console, for Subcore. Your product collects the account SID, the key's SID and secret, and the auth token if the agent should receive texts. See what your client creates.
Connect the account
PUT /agents/{slug}/twilio with those values. Subcore checks them with Twilio
before it stores anything. See connect an account.
Pick a number from the account
GET /agents/{slug}/twilio/numbers lists the numbers the account holds, and
PUT /agents/{slug}/twilio/number makes one of them the number the agent calls
and texts from. See choose the agent's number.
Send its calls and texts to the agent
PUT /agents/{slug}/twilio/webhooks sends the number's incoming calls and
texts to the agent. Choosing a number does not do this for you. See
point the number at the agent.
Call it and text it
Make a real call to the number and send it a real text. The Playground in the Subcore console does not go through Twilio, so it cannot test this.
What your client creates in Twilio
Your client does this part in their own Twilio console, under Account → API keys & tokens, and gives your product three values, or four:
| Value | Looks like | What it is for |
|---|---|---|
| Account SID | AC and 32 hex characters | Names the account. It is not a secret |
| API key SID | SK and 32 hex characters | Names the key your client creates for Subcore, a Standard API key. The key does all of the work in Twilio: placing calls, sending texts, reading the account's numbers and setting their webhooks |
| API key secret | 32 letters and digits | The key's password. Twilio shows it once, when the key is created, so it has to be copied then |
| Auth token | 32 lowercase hex characters | Optional. Only incoming texts need it |
A key made only for Subcore can be revoked in Twilio without touching anything else your client runs, which is why the work is done with it rather than with the account's own credentials.
The auth token is what lets the agent receive texts. Twilio signs every text it
forwards to your deployment with the account's auth token and nothing else, and
an API key cannot check that signature. So with no token stored, the deployment
refuses every incoming text to that agent with a 503. Calls, and the texts the
agent sends, work either way. The token is the account's master credential, so
ask for it only when your client wants the agent to receive texts.
Nothing here notices when your client revokes the key or rotates the auth token
in Twilio. The next request made with the old value fails: on these routes a
revoked key is 400 TWILIO_REJECTED, and at the deployment a text signed with a
new token no longer verifies. Send the new values with
another PUT.
The TwilioConnection object
{
"accountSid": "AC91db41b217eb50ad8d1efe2e98e89b5d",
"apiKeySid": "SKdcbc75091596bec79d4a36cc5342d953",
"authTokenStored": true,
"verifiedAt": "2026-09-24T15:04:05.123Z",
"updatedAt": "2026-09-24T15:04:05.123Z"
}| Field | Type | Description |
|---|---|---|
accountSid | string | The account: AC and 32 lowercase hex characters |
apiKeySid | string | The API key: SK and 32 lowercase hex characters |
authTokenStored | boolean | Whether an auth token is stored |
verifiedAt | string | When Twilio last accepted the stored key, and the token if there is one. That is the last connect or replace. ISO 8601 UTC with milliseconds and a Z |
updatedAt | string | When the connection was last written, in the same form |
No route here returns the API key secret or the auth token, not even the one that stores them. The only answer that carries them is the config read your deployment makes, and only to a key allowed to read them.
Connect an account
PUT /agents/{slug}/twilioConnects the agent to an account, or replaces the connection it has. The body is the whole connection:
| Field | Type | Description |
|---|---|---|
accountSid | string | AC followed by 32 hex characters |
apiKeySid | string | SK followed by 32 hex characters |
apiKeySecret | string | The key's secret, 32 letters and digits |
authToken | string | null | The account's auth token, 32 lowercase hex characters. null, or left out, for none |
curl -X PUT -H "Authorization: Bearer $SUBCORE_API_KEY" \
-H "Content-Type: application/json" \
"https://api.subcore.ai/agents/clinic-one/twilio" \
-d '{
"accountSid": "AC91db41b217eb50ad8d1efe2e98e89b5d",
"apiKeySid": "SKdcbc75091596bec79d4a36cc5342d953",
"apiKeySecret": "R8nv9DNPpzdJUjykdMWtZKg8MHV97NMD",
"authToken": "f10f5865a07223801b74f027a69d1c5e"
}'{
"twilio": {
"accountSid": "AC91db41b217eb50ad8d1efe2e98e89b5d",
"apiKeySid": "SKdcbc75091596bec79d4a36cc5342d953",
"authTokenStored": true,
"verifiedAt": "2026-09-24T15:04:05.123Z",
"updatedAt": "2026-09-24T15:04:05.123Z"
},
"clearedPhoneNumber": null
}201 means the agent was connected, and 200 that a connection it already had
was replaced. Both answer with the same two fields:
| Field | Type | Description |
|---|---|---|
twilio | TwilioConnection | The connection as stored |
clearedPhoneNumber | string | null | The number the agent had, when the account does not hold it and it was cleared. null when nothing was cleared |
Before it stores anything, Subcore asks Twilio three things, in this order:
- Whether the API key can list the account's numbers. That shows the key works and belongs to that account.
- Whether the auth token opens the account, when you send one.
- Whether the account holds the agent's
phoneNumber, when it has one.
The first two decide whether the connection is stored. Twilio refusing either
is 400 TWILIO_REJECTED, and its error says which credential was refused and
carries Twilio's own error code, such as 20003 for a wrong or revoked key.
Twilio not answering in time, or answering with a server error or a 429, is
503 TWILIO_UNAVAILABLE, which is worth retrying. Neither stores anything.
The third decides what happens to the number the agent already has, and never
refuses the connect. A number the account holds is kept. One it does not hold is
cleared in the same write, because the agent could not call or text from it
through this account, and clearedPhoneNumber names it so your product can say
so. The next step is then to
choose one of the account's numbers. A lookup that
Twilio refuses or does not answer fails the connect instead, so a number is
never cleared on no evidence.
The rules:
- The body replaces the whole connection. Leaving
authTokenout, or sendingnull, deletes a token stored before, so send it with every replace. - The hex in either SID may be in either case. It is lowercased before Twilio is
asked, so the connection is checked, stored and served in one lowercase
spelling.
ACandSKmust be uppercase, and nothing is trimmed, so a stray space is refused. - A value in the wrong shape is refused before Twilio is asked, with
400 INVALID_ACCOUNT_SID,INVALID_API_KEY_SID,INVALID_API_KEY_SECRETorINVALID_AUTH_TOKEN. No refusal quotes a secret back. - Any other field is
400 UNKNOWN_FIELD. That includesphoneNumber: the number is chosen on its own. 409 CHANGED_DURING_CHECKmeans the agent's number changed while Subcore was asking Twilio. Nothing was stored, so send the PUT again.
Get the connection
GET /agents/{slug}/twiliocurl -H "Authorization: Bearer $SUBCORE_API_KEY" \
"https://api.subcore.ai/agents/clinic-one/twilio"{
"twilio": {
"accountSid": "AC91db41b217eb50ad8d1efe2e98e89b5d",
"apiKeySid": "SKdcbc75091596bec79d4a36cc5342d953",
"authTokenStored": true,
"verifiedAt": "2026-09-24T15:04:05.123Z",
"updatedAt": "2026-09-24T15:04:05.123Z"
}
}An agent with no account connected answers { "twilio": null }.
Disconnect
DELETE /agents/{slug}/twiliocurl -X DELETE -H "Authorization: Bearer $SUBCORE_API_KEY" \
"https://api.subcore.ai/agents/clinic-one/twilio"204 with no body. The connection and its stored secrets are removed, and the
agent's phoneNumber is cleared in the same write, since the number belongs to
the account being disconnected. From then on the agent calls and texts through
your deployment's own Twilio credentials.
The number's webhooks in Twilio are left as they are, still naming the agent. Until your client points the number somewhere else, calls to it still reach the agent, and texts to it are refused, because nothing can verify them any more. List the numbers before you disconnect to see where each one points.
Disconnecting is safe to repeat. An agent with no account connected answers
204 too, and nothing changes, its number included.
Deleting the agent removes its connection along with everything else, and it too leaves the number's webhooks alone, still naming an agent that no longer answers. Point the number somewhere else first.
The TwilioNumber object
The three number routes answer with numbers in this shape:
{
"phoneNumber": "+15035550142",
"sid": "PNd4a9b711c6c6276c95feb694d8b7b706",
"friendlyName": "Front desk",
"capabilities": { "voice": true, "sms": true, "mms": false },
"voiceUrl": "https://clinics.example.com/a/4382273586774aa5bb1e0f9b587ca22c/incoming-call",
"smsUrl": "https://clinics.example.com/a/4382273586774aa5bb1e0f9b587ca22c/sms/incoming",
"messagingServiceSid": null,
"isAgentNumber": true,
"usedBy": ["clinic-one"]
}| Field | Type | Description |
|---|---|---|
phoneNumber | string | The number, in E.164 |
sid | string | Twilio's id for the number, PN and 32 hex characters |
friendlyName | string | null | The name the number has in Twilio. null when it has none |
capabilities | object | voice, sms and mms, each true or false |
voiceUrl | string | null | Where Twilio sends the number's incoming calls now. null when nothing is set |
smsUrl | string | null | Where Twilio sends its incoming texts now, unless a Messaging Service decides instead. null when nothing is set |
messagingServiceSid | string | null | The Messaging Service that decides where the number's texts go, when one does. See below |
isAgentNumber | boolean | Whether this is the agent's own phoneNumber |
usedBy | string[] | The slugs of every agent in your organization whose phoneNumber is this number, sorted, this one included. It describes the number, so it reads the same whichever agent's URL you ask through. A deployment's slug appears too if it carries the number, since GET /agents lists deployments as well. [] when none does |
Messaging services
A number in a Twilio Messaging Service ignores its own smsUrl unless the
service has "use inbound webhook on number" turned on. Otherwise its texts go
wherever the service sends them. Nothing on the number says so, so Subcore asks
every Messaging Service in the account for its numbers, and sets
messagingServiceSid to the service that decides. A number whose service
defers to the number has null, since its own smsUrl still decides.
List the account's numbers
GET /agents/{slug}/twilio/numbersEvery number in the connected account, read with the stored API key, across as many pages as Twilio takes to list them.
curl -H "Authorization: Bearer $SUBCORE_API_KEY" \
"https://api.subcore.ai/agents/clinic-one/twilio/numbers"{
"numbers": [
{
"phoneNumber": "+15035550142",
"sid": "PNd4a9b711c6c6276c95feb694d8b7b706",
"friendlyName": "Front desk",
"capabilities": { "voice": true, "sms": true, "mms": false },
"voiceUrl": "https://demo.twilio.com/welcome/voice/",
"smsUrl": null,
"messagingServiceSid": null,
"isAgentNumber": false,
"usedBy": []
},
{
"phoneNumber": "+15035550199",
"sid": "PNc644dd9175f80d61dc0082ebbd543314",
"friendlyName": "Reminders",
"capabilities": { "voice": true, "sms": true, "mms": true },
"voiceUrl": null,
"smsUrl": null,
"messagingServiceSid": "MGd6146c4092c918fa67c76c7eb107bcd9",
"isAgentNumber": false,
"usedBy": []
}
]
}Show your client voiceUrl and smsUrl before you
point a number at the agent, since that
replaces them.
An agent with no account connected is 409 NOT_CONNECTED. A stored key that
Twilio no longer accepts is 400 TWILIO_REJECTED. The listing is refused
rather than answered in part when the account is too large for it. See
limits.
Choose the agent's number
PUT /agents/{slug}/twilio/numberMakes one of the account's numbers the agent's phoneNumber, or clears it.
Pick the number from the list and send it in
E.164:
curl -X PUT -H "Authorization: Bearer $SUBCORE_API_KEY" \
-H "Content-Type: application/json" \
"https://api.subcore.ai/agents/clinic-one/twilio/number" \
-d '{ "phoneNumber": "+15035550142" }'{
"number": {
"phoneNumber": "+15035550142",
"sid": "PNd4a9b711c6c6276c95feb694d8b7b706",
"friendlyName": "Front desk",
"capabilities": { "voice": true, "sms": true, "mms": false },
"voiceUrl": "https://demo.twilio.com/welcome/voice/",
"smsUrl": null,
"messagingServiceSid": null,
"isAgentNumber": true,
"usedBy": ["clinic-one"]
}
}Subcore looks the number up in the account before it records it, and refuses a
number the account does not hold with 409 NUMBER_NOT_IN_ACCOUNT. Recording is
all this route does. The number's webhooks in Twilio stay as they are, so its
calls and texts keep going wherever they went until you
point it at the agent. The answer's usedBy
already includes this agent. The route is safe to repeat, and choosing another
number moves the agent to it.
Because nothing is pointed here, a number without SMS is accepted, and so is one
a TwiML App or an Elastic SIP Trunk routes. capabilities shows whether a number
can text. Nothing in the list shows that kind of routing, and pointing such a
number is refused later with
409 NUMBER_ROUTED_ELSEWHERE.
This is the number the agent calls and texts from. From kit 0.22.0, your
deployment uses it whenever nothing more specific names a number: an outbound
call or a proactive text sent without a from, and a takeover reply in a
conversation that never recorded which number the customer texted. A reply to
an incoming text goes out from the number the customer texted.
An agent has one number, and several agents may share one. A booking agent and
a payments agent can both call out from the same line: nothing checks that a
number is free, and usedBy shows which agents have it. Incoming calls and
texts work differently, because a number has one set of webhooks. Only the agent
they name receives them, so a customer who calls back, or replies to a text,
reaches that agent, whichever agent contacted them.
Sending null in place of a number clears the agent's number. That asks Twilio
nothing:
curl -X PUT -H "Authorization: Bearer $SUBCORE_API_KEY" \
-H "Content-Type: application/json" \
"https://api.subcore.ai/agents/clinic-one/twilio/number" \
-d '{ "phoneNumber": null }'{ "number": null }Clear it only if the agent should not call or text out on its own. Without a
number, a connected agent's sends are refused rather than made from your
deployment's default number, which lives in another account. From kit 0.22.0,
an outbound call (POST {tenantPath}/outbound) that names no from answers
400, a proactive text (POST {tenantPath}/sms/send) that names none answers
409, and so does a takeover reply or handback message in a conversation that
never recorded which number the customer texted. All three carry
code: "NO_SENDER_NUMBER", so branch on the code rather than on the status,
which differs by door. A 400 from outbound without that code is a different
problem, such as a missing phone.
While an account is connected, this route is the only way to set the agent's
number. PUT /agents/{slug}
refuses to change a connected agent's phoneNumber with
409 NUMBER_FROM_TWILIO, so the number is always one the account held when it
was chosen. If your client later releases it in Twilio, it stays the agent's
number until you choose another, and pointing it answers
409 NUMBER_NOT_IN_ACCOUNT. Besides a clear here, the number is also cleared by
disconnecting, and by
replacing the connection with an account that does not
hold it.
The refusals:
409 NOT_CONNECTEDfor an agent with no account connected, clearing included.409 NUMBER_NOT_IN_ACCOUNTfor a number the account does not hold. Itserrorsays where to choose one.400 INVALID_PHONE_NUMBERwhenphoneNumberis missing, or neither E.164 nornull. Any other field is400 UNKNOWN_FIELD, and a body that is not a JSON object is400 INVALID_JSONorINVALID_BODY.400 TWILIO_REJECTEDwhen Twilio no longer accepts the stored key, and503 TWILIO_UNAVAILABLEwhen it does not answer in time.409 CHANGED_DURING_CHECKwhen the agent's Twilio account was replaced by another, or disconnected, while Subcore was looking the number up. Nothing was recorded, so send the PUT again.
Point the number at the agent
PUT /agents/{slug}/twilio/webhooksSets the voice and SMS webhooks of the agent's number to the agent's own doors
on your deployment, both with POST, so Twilio sends the number's incoming
calls and texts to this agent:
https://{twilioHost}{tenantPath}/incoming-call
https://{twilioHost}{tenantPath}/sms/incoming{tenantPath} is the agent's tenantPath,
and {twilioHost} is the host your deployment announces for Twilio to call. See
a host Twilio can call. Both webhooks are set in one
request to Twilio, so a number is never left half pointed: when the request
fails, nothing changed.
curl -X PUT -H "Authorization: Bearer $SUBCORE_API_KEY" \
"https://api.subcore.ai/agents/clinic-one/twilio/webhooks"{
"number": {
"phoneNumber": "+15035550142",
"sid": "PNd4a9b711c6c6276c95feb694d8b7b706",
"friendlyName": "Front desk",
"capabilities": { "voice": true, "sms": true, "mms": false },
"voiceUrl": "https://clinics.example.com/a/4382273586774aa5bb1e0f9b587ca22c/incoming-call",
"smsUrl": "https://clinics.example.com/a/4382273586774aa5bb1e0f9b587ca22c/sms/incoming",
"messagingServiceSid": null,
"isAgentNumber": true,
"usedBy": ["clinic-one"]
},
"previous": {
"voiceUrl": "https://demo.twilio.com/welcome/voice/",
"smsUrl": null
}
}previous holds what the number pointed at before, so your product can show
your client what changed, or put it back. The route is safe to repeat, and a
repeat reports the URLs the first one set as previous.
Pointing a number that several agents share moves its incoming calls and texts to this agent. The others keep calling and texting out from it.
Which deployment
Subcore points the number at the deployment that serves your agents by routing key. When your organization has exactly one, send no body. With several, name the one to use by its slug:
curl -X PUT -H "Authorization: Bearer $SUBCORE_API_KEY" \
-H "Content-Type: application/json" \
"https://api.subcore.ai/agents/clinic-one/twilio/webhooks" \
-d '{ "deployment": "clinics-east" }'Without a name, several deployments are 409 AMBIGUOUS_DEPLOYMENT, and its
error lists their slugs:
{
"error": "Several deployments serve this organization's agents by routing key (clinics-east, clinics-west); name one: { \"deployment\": \"<slug>\" }",
"code": "AMBIGUOUS_DEPLOYMENT",
"timestamp": 1789569967913
}None at all, or a name that is not one of them, is 409 NO_DEPLOYMENT. A deployment that is not a non-empty string is
400 INVALID_DEPLOYMENT, and any other field is 400 UNKNOWN_FIELD.
When the texts still go elsewhere
When the answer's number.messagingServiceSid is set, the voice webhook took
effect but the number's texts still go to that
Messaging Service. They reach the agent only once your
client removes the number from the service, or turns on "use inbound webhook on
number" for it, in their Twilio console.
A number that a TwiML App or an Elastic SIP Trunk routes ignores the URLs this
route sets, so it is refused with 409 NUMBER_ROUTED_ELSEWHERE, and its error
names what routes it. Removing that routing is your client's change to make in
Twilio.
The order of the checks
Once the body is read, 409 NOT_CONNECTED comes first, then
409 NO_PHONE_NUMBER for an agent with no number chosen, then the deployment:
NO_DEPLOYMENT, AMBIGUOUS_DEPLOYMENT and 409 TWILIO_HOST_UNKNOWN, which
means the deployment has not announced a host Twilio can call. Twilio is asked
last: 409 NUMBER_NOT_IN_ACCOUNT if the number has left the account since it
was chosen, then NUMBER_ROUTED_ELSEWHERE. None of them changes anything in
Twilio.
What your deployment needs
Everything above runs on api.subcore.ai. The calls and texts themselves run on
your deployment, which needs four things before a connected agent works. Without
the first three, the agent keeps calling and texting through the deployment's
own Twilio account, which cannot use your client's numbers.
Kit 0.22.0 or later
An earlier kit ignores an agent's Twilio account and keeps using the
deployment's own, which cannot send from your client's numbers or verify the
texts sent to them. It does not announce the host webhooks needs either.
Its configuration from api.subcore.ai
SUBCORE_CONSOLE_URL=https://api.subcore.aiOnly this service hands a deployment its agents' Twilio credentials. A deployment that reads its configuration anywhere else gets none, and sends through its own account for every agent, with nothing in its logs to say why. See connecting your deployment.
A key that may read Twilio credentials
In the Subcore console, under Settings → API keys, an organization admin ticks
Read Twilio credentials on the key the deployment runs with, its
SUBCORE_API_KEY. No key has it until it is ticked, and the manage-agents
permission does not include it. A key without it reads the agents'
configuration without their credentials, exactly as before. A key with it can
read the Twilio credentials of every agent in your organization, so tick it only
on a deployment's key, never on the key your product calls /agents with.
A host Twilio can call
Subcore points numbers at the deployment's HOSTNAME setting and at no other
address it has, because the deployment rebuilds the signed URL of every incoming
text from HOSTNAME, so a text sent to any other host fails the signature
check. HOSTNAME must therefore be the lowercase public host name Twilio can
reach, with a port only if Twilio has to use one.
From kit 0.22.0 the deployment announces it, and Subcore reads the announcement
when the deployment is refreshed in the console: open the deployment's own page
and press its refresh button, Read the running deployment now. Until that
has happened on 0.22.0, webhooks answers 409 TWILIO_HOST_UNKNOWN. It answers
the same for a HOSTNAME Twilio cannot reach, such as the kit's default
localhost:3000 or a private address, and for one with an uppercase letter.
Errors, limits and caching
All six routes behave like the rest of /agents. A key without the
manage-agents permission is refused 403 FORBIDDEN before Twilio is asked
anything. They share the budget of 120 requests a minute, answer
Cache-Control: no-store, refusals included, and treat a slug in another
organization as 404 NOT_FOUND. A body over 4 KiB is
413 PAYLOAD_TOO_LARGE.
Subcore gives each request it makes to Twilio 10 seconds, and a listing 25
seconds in all. Allow for that in your own timeouts: connecting makes up to three
requests to Twilio one after another, and the number routes can take the full 25
seconds on a large account. A listing also stops at 20,000 numbers, and at
20,000 Messaging Services. An account larger than that is
refused with 503 TWILIO_UNAVAILABLE rather than listed in part, and its
error says a retry will not help.
| Refusal | What to do |
|---|---|
409 CHANGED_DURING_CHECK | Send the same request again |
503 TWILIO_UNAVAILABLE | Retry, unless its error says the account holds more than can be listed |
400 TWILIO_REJECTED | Ask your client for a working API key, or their current auth token, and connect again |
409 NOT_CONNECTED | Connect an account first |
409 NUMBER_NOT_IN_ACCOUNT | Choose one of the numbers the account holds |
409 NO_PHONE_NUMBER | Choose the agent's number first |
409 NUMBER_FROM_TWILIO | Set the number with PUT /agents/{slug}/twilio/number instead |
409 NUMBER_ROUTED_ELSEWHERE | Your client removes the TwiML App or SIP trunk from the number in Twilio |
409 AMBIGUOUS_DEPLOYMENT | Name the deployment in the body |
409 NO_DEPLOYMENT | Check the slug you named, or that a deployment serves your agents by routing key |
409 TWILIO_HOST_UNKNOWN | Finish setting up the deployment |
Every code is listed with the others on errors.