Subcore AI Docs
Agents API

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.

RouteWhat it doesSuccess
GET /agents/{slug}/twilioThe agent's connection, without its secrets200 { "twilio": TwilioConnection }, or null for none
PUT /agents/{slug}/twilioConnect an account, or replace the connection, once Twilio accepts it201 connected, 200 replaced: { "twilio": TwilioConnection, "clearedPhoneNumber": … }
DELETE /agents/{slug}/twilioDisconnect, clearing the agent's number204, no body
GET /agents/{slug}/twilio/numbersEvery number in the account, where its calls and texts go now, and which agents use it200 { "numbers": [TwilioNumber] }
PUT /agents/{slug}/twilio/numberMake one of the account's numbers the agent's number, or clear it200 { "number": TwilioNumber }, or null after a clear
PUT /agents/{slug}/twilio/webhooksSend the number's incoming calls and texts to the agent200 { "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:

ValueLooks likeWhat it is for
Account SIDAC and 32 hex charactersNames the account. It is not a secret
API key SIDSK and 32 hex charactersNames 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 secret32 letters and digitsThe key's password. Twilio shows it once, when the key is created, so it has to be copied then
Auth token32 lowercase hex charactersOptional. 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"
}
FieldTypeDescription
accountSidstringThe account: AC and 32 lowercase hex characters
apiKeySidstringThe API key: SK and 32 lowercase hex characters
authTokenStoredbooleanWhether an auth token is stored
verifiedAtstringWhen 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
updatedAtstringWhen 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}/twilio

Connects the agent to an account, or replaces the connection it has. The body is the whole connection:

FieldTypeDescription
accountSidstringAC followed by 32 hex characters
apiKeySidstringSK followed by 32 hex characters
apiKeySecretstringThe key's secret, 32 letters and digits
authTokenstring | nullThe 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:

FieldTypeDescription
twilioTwilioConnectionThe connection as stored
clearedPhoneNumberstring | nullThe 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:

  1. Whether the API key can list the account's numbers. That shows the key works and belongs to that account.
  2. Whether the auth token opens the account, when you send one.
  3. 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 authToken out, or sending null, 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. AC and SK must 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_SECRET or INVALID_AUTH_TOKEN. No refusal quotes a secret back.
  • Any other field is 400 UNKNOWN_FIELD. That includes phoneNumber: the number is chosen on its own.
  • 409 CHANGED_DURING_CHECK means the agent's number changed while Subcore was asking Twilio. Nothing was stored, so send the PUT again.

Get the connection

GET /agents/{slug}/twilio
curl -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}/twilio
curl -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"]
}
FieldTypeDescription
phoneNumberstringThe number, in E.164
sidstringTwilio's id for the number, PN and 32 hex characters
friendlyNamestring | nullThe name the number has in Twilio. null when it has none
capabilitiesobjectvoice, sms and mms, each true or false
voiceUrlstring | nullWhere Twilio sends the number's incoming calls now. null when nothing is set
smsUrlstring | nullWhere Twilio sends its incoming texts now, unless a Messaging Service decides instead. null when nothing is set
messagingServiceSidstring | nullThe Messaging Service that decides where the number's texts go, when one does. See below
isAgentNumberbooleanWhether this is the agent's own phoneNumber
usedBystring[]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/numbers

Every 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/number

Makes 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_CONNECTED for an agent with no account connected, clearing included.
  • 409 NUMBER_NOT_IN_ACCOUNT for a number the account does not hold. Its error says where to choose one.
  • 400 INVALID_PHONE_NUMBER when phoneNumber is missing, or neither E.164 nor null. Any other field is 400 UNKNOWN_FIELD, and a body that is not a JSON object is 400 INVALID_JSON or INVALID_BODY.
  • 400 TWILIO_REJECTED when Twilio no longer accepts the stored key, and 503 TWILIO_UNAVAILABLE when it does not answer in time.
  • 409 CHANGED_DURING_CHECK when 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/webhooks

Sets 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.ai

Only 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.

RefusalWhat to do
409 CHANGED_DURING_CHECKSend the same request again
503 TWILIO_UNAVAILABLERetry, unless its error says the account holds more than can be listed
400 TWILIO_REJECTEDAsk your client for a working API key, or their current auth token, and connect again
409 NOT_CONNECTEDConnect an account first
409 NUMBER_NOT_IN_ACCOUNTChoose one of the numbers the account holds
409 NO_PHONE_NUMBERChoose the agent's number first
409 NUMBER_FROM_TWILIOSet the number with PUT /agents/{slug}/twilio/number instead
409 NUMBER_ROUTED_ELSEWHEREYour client removes the TwiML App or SIP trunk from the number in Twilio
409 AMBIGUOUS_DEPLOYMENTName the deployment in the body
409 NO_DEPLOYMENTCheck the slug you named, or that a deployment serves your agents by routing key
409 TWILIO_HOST_UNKNOWNFinish setting up the deployment

Every code is listed with the others on errors.

On this page