Build conversational banking on TwelveAI.
Send a customer message to one endpoint. TwelveAI classifies intent, calls the right tools grounded in real data, gates money-moving actions behind confirmation, and returns a structured result. Base URL https://api.twelveai.app.
Overview
A request flows: authenticate with your API key; the router classifies intent and narrows the tool set to that agent; the act loop calls tools and feeds results back; the policy layer decides whether a money-moving action runs, must be confirmed, escalated, or blocked; and a grounded response returns with the reply, tool calls, structured output, and usage. Reads answer directly. Writes pause for confirmation.
Authentication
Every request carries your key in the x-api-key header. Each workspace has a live key (sk_live_…) and a sandbox key (sk_test_…). The workspace is derived from the key, so a key only ever touches its own data. Keep keys server-side.
x-api-key: sk_live_xxxxxxxxxxxxxxxx # live
x-api-key: sk_test_xxxxxxxxxxxxxxxx # sandboxQuickstart
Authenticate and send your first message. Pick your language, in every one you send a message and receive a grounded, structured result.
curl https://api.twelveai.app/v1/chat \
-H "x-api-key: sk_live_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{ "message": "What is my balance?", "customerId": "cust_123" }'Making a request
POST /v1/chatThe core endpoint. Send a message and, to tie the turn to one of your users and apply their tier, a customerId. Everything else is optional. The response is grounded and structured.
curl https://api.twelveai.app/v1/chat \
-H "x-api-key: sk_live_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{ "message": "What is my balance?", "customerId": "cust_123" }'{
"ok": true,
"sandbox": false,
"message": "I've prepared a transfer of ₦50,000 to JANE DOE. Confirm to send.",
"intent": "transfer",
"toolCalls": [
{ "name": "resolve_account",
"arguments": { "account_number": "0123456789", "bank_code": "058" },
"result": { "ok": true, "data": { "account_name": "JANE DOE" } } }
],
"pendingConfirmation": { "tool": "transfer_funds", "arguments": { "amount": 50000 } },
"escalated": false,
"autonomy": "pending",
"usage": { "inputTokens": 812, "outputTokens": 96 },
"billing": { "charged": 3.1, "balanceNgn": 4820.5, "lowBalance": false },
"latencyMs": 2140
}Request fields
| Field | Type | Description |
|---|---|---|
| message | string | The user's message. Required unless resuming with a continuation + toolResults. |
| customerId | string | Your customer id for this end-user (alias: userId). Applies their tier caps. |
| tier | string | Sync the customer's tier on this turn (last-write-wins). |
| name | string | Customer name. Stored on the customer only if provided. |
| string | Customer email. Stored on the customer only if provided. | |
| channel | string | Origin channel, e.g. "whatsapp", "mobile", "voice". |
| intent | string | Force a specific agent instead of auto-routing. |
| confirmed | boolean | Resend true (with the continuation) to execute a gated write. |
| metadata | object | Arbitrary key/values stored with the turn and echoed back. |
| continuation | string | Token from the previous turn: continue, or (with toolResults) resume. |
| toolResults | array | Results for paused client-side tools when resuming. |
| sandbox | boolean | Run this turn in sandbox: tools return sample data, nothing real is called. |
Response fields
| Field | Type | Description |
|---|---|---|
| ok | boolean | Whether the turn succeeded. |
| message | string|null | The grounded, user-facing reply. Present even when pending or escalated. |
| intent | string|null | The classified agent/intent. |
| toolCalls | array | Executed tools: { name, arguments, result }. unconfigured:true = no endpoint attached. |
| pendingConfirmation | object|null | A gated write awaiting confirmed:true: { tool, arguments }. |
| pendingToolCalls | array|null | Client-side tools you must run, then resume with toolResults. |
| escalated | boolean | True when a policy routed the write for human review (nothing executed). |
| policy | string|null | Name of the policy rule that gated/blocked/escalated, if any. |
| autonomy | string|null | How it cleared: auto, full, confirmed, pending, escalated, blocked. |
| continuation | string|null | Token to send back next turn. It changes every turn. |
| usage | object | { inputTokens, outputTokens }. |
| billing | object|null | { charged, balanceNgn, lowBalance }. Null in sandbox. |
Status codes & errors
Every endpoint returns JSON with an ok boolean. On failure, ok is false and error is a readable string.
| 200 | OK | The turn ran. It may still hold a pendingConfirmation, an escalation, or a tool with unconfigured:true. |
| 400 | Bad request | Missing/invalid input, e.g. no message and no continuation. |
| 401 | Unauthorized | Missing or invalid x-api-key. |
| 402 | Payment required | Prepaid billing balance empty. Top up, then retry. |
| 404 | Not found | Unknown resource, e.g. a customer that does not exist. |
| 429 | Rate limited | Too many requests for this key. Back off and retry. |
| 500 | Server error | Unexpected. Retry idempotent reads; never blind-retry a write without a continuation. |
Confirmations (gated writes)
POST /v1/chatMoney-moving actions never execute on the first call. TwelveAI returns pendingConfirmation (the exact tool + arguments) and a continuation. You collect the user's approval (PIN, OTP, biometric), then resend with confirmed: true and that continuation.
// 1) first call returns a pending write, nothing moved yet:
let res = await twelve.post("/v1/chat", {
message: "Send 50,000 to 0123456789 at GTBank",
customerId: "cust_123",
});
// res.data.pendingConfirmation = { tool: "transfer_funds", arguments: {…} }
// 2) after the user approves (PIN/OTP), resend with confirmed: true:
res = await twelve.post("/v1/chat", {
continuation: res.data.continuation,
message: "yes",
confirmed: true,
});Multi-turn & continuation
The continuation is a short, opaque token. We keep the recent conversation server-side under it, and it expires about an hour after the last turn. It is not a fixed session id: every turn returns a new token, so always send back the most recent one.
history array if you need longer memory.Streaming (SSE)
POST /v1/chat/streamStreams the reply over Server-Sent Events: token events as it is generated, then a done event carrying the same structured result. Resume a paused turn on /v1/chat, not the stream.
const res = await twelve.post("/v1/chat/stream", {
message: "What's my balance?",
customerId: "cust_123",
}, { responseType: "stream" });
res.data.on("data", (c) => process.stdout.write(c.toString()));
// events: "token" (each fragment), then "done" (the structured result)Client-side execution
Can't give us network access to a system? Declare a tool as client-executed (no binding). TwelveAI pauses and returns pendingToolCalls plus a continuation; you run it inside your perimeter and resume with the result. We never hold your keys.
// A client-executed tool has no binding. TwelveAI pauses:
// res.data.pendingToolCalls = [{ id, name, arguments }]
// run it in your perimeter, then resume:
await twelve.post("/v1/chat", {
continuation: res.data.continuation,
toolResults: [{ id: "call_1", result: { balance: 904500 } }],
});Metadata
POST /v1/chatAttach a metadata object to any request. It is stored with the turn and surfaced in the Requests explorer. Content retention (input, response, metadata, tool calls) is always on: TwelveAI uses recent conversation content to keep context.
await twelve.post("/v1/chat", {
message: "Buy 500 airtime for 08012345678",
customerId: "cust_123",
metadata: { sessionId: "sess_88", device: "ios" },
});Sandbox
Use your sk_test_… key, or pass sandbox: true on a turn, to run against stubbed tool responses. Every tool returns its sample response, so you can build and test the whole conversation before wiring a real API. Sandbox turns run the model (so they count tokens) but never touch real systems.
Customers
GET / POST /v1/customersA customer is one of your end-users, keyed by an opaque id you choose. We store no personal data by default: just the id, a tier, and daily usage. You may also send a name and email and we store them, but nothing is collected unless you provide it. A customer is created automatically the first time you pass its id on a chat.
{
"ok": true,
"customer": {
"externalId": "cust_123",
"tier": "premium",
"effectiveTier": "premium",
"name": "Ada Okafor",
"email": "ada@example.com",
"today": { "count": 0, "sum": 0 },
"cap": { "maxDailySumNgn": 500000, "maxPerTxnNgn": 200000, "breachAction": "escalate" }
}
}Use GET /v1/customers for the list (with usage vs cap), GET /v1/customers/:id for one (with tier history), and GET /v1/customers/:id/events for that customer's chats.
Tiers & daily caps
Tiers map to daily caps (a daily total, a per-transfer max, and transfers/day). Define them under Customers → Manage tiers. When a customer would exceed a cap, TwelveAI blocks or escalates it, per the tier's setting. You are the source of truth for a tier; we never infer it.
await twelve.post("/v1/chat", {
customerId: "cust_123",
tier: "premium", // syncs the tier and applies its caps to THIS turn
message: "Send 50,000 to 0123456789 at GTBank",
});customer.tier_changed webhook.Policies & autonomy
Before any write runs, one deterministic policy layer decides the outcome, in order: (1) your custom rules, (2) tier caps, (3) built-in guardrails, (4) the agent's autonomy. The first terminal decision wins. Nothing is model-decided.
| Field | Type | Description |
|---|---|---|
| auto_approve | action | The write runs without extra confirmation (within limits). |
| require_confirmation | action | The write pauses for the user to confirm (the default gate). |
| escalate | action | The write is routed to your human queue for review, not executed. |
| block | action | The write is refused with a message. |
| fee | action | A transaction fee is attached to the write. |
Autonomy levels (per agent) are the fallback gate: suggest, confirm, auto (acts under an amount + velocity limit), and full. Configure rules under Policy, autonomy under Agents & Intents.
Escalation
When a policy escalates a write, TwelveAI pauses it, tags the turn escalated: true with the matching policy, tells the user it is under review, and fires the action.escalated webhook. Approving is just resending with confirmed: true. Escalated turns are flagged and filterable in the Requests explorer.
{
"ok": true,
"message": "This needs a quick review before it can go through. I've flagged it.",
"intent": "transfer",
"toolCalls": [],
"pendingConfirmation": { "tool": "transfer_funds", "arguments": { "amount": 900000 } },
"escalated": true,
"policy": "High-value review",
"autonomy": "escalated"
}Guardrails
Guardrails are simple workspace-wide limits set in the console: a maximum transfer amount, blocked recipient accounts, and a tiered transaction-fee schedule. They run through the same policy layer, so there is one enforcement path.
Beneficiaries
Client mode (default): we hold only an opaque reference and your systems keep the account details, so we store no payee PII, and the beneficiary tools are handed to your systems to run (client execution). Managed mode: we host the store so find/save/list run against our data, which also powers the new_beneficiary policy signal.
Tools & endpoints
PUT / POST /v1/tools/:nameCapability tools (get_balance, buy_airtime) call your endpoints. Attach one per tool in the console under Agents & Intents → Manage tools, or via PUT /v1/tools/:name. Use {customer_id} for the end-user and {arg} for a tool argument.
curl -X PUT https://api.twelveai.app/v1/tools/get_balance \
-H "x-api-key: sk_live_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{
"method": "GET",
"url": "https://api.yourbank.com/v1/customers/{customer_id}/balance",
"auth": { "type": "bearer", "token": "YOUR_API_TOKEN" }
}'// Confirm an endpoint returns 200 before it goes near a customer:
await twelve.post("/v1/tools/get_balance/test", {
method: "GET",
url: "https://api.yourbank.com/v1/customers/{customer_id}/balance",
});
// { ok: true, status: 200, statusText: "OK", sample: {…} }When a tool returns, our AI reads the JSON and turns it into a grounded reply, never restating raw JSON or inventing values.
transfer_funds comes back in pendingToolCalls with the verified details; you collect the PIN, initiate on your rails, and resume with the result. Policies still block/escalate first.{ ok:false, unconfigured:true } (we never fabricate data). In sandbox it returns its sample response.Tool Manifest
Declare your own HTTP tools with a manifest entry: a name, a JSON-Schema for parameters, and a binding (method + URL with {customer_id} and {arg} placeholders). TwelveAI calls it, applies the gate for writes, and grounds the answer. Set a workspace Tool base URL so bindings can use relative paths.
{
"name": "get_statement",
"description": "Get a summary of the user's recent transactions.",
"sideEffect": "read",
"parameters": {
"type": "object",
"properties": { "count": { "type": "integer", "minimum": 1, "maximum": 50 } }
},
"binding": {
"method": "GET",
"url": "https://api.yourbank.com/v1/customers/{customer_id}/transactions",
"resultPath": "data"
},
"auth": { "type": "bearer", "token": "YOUR_API_TOKEN" }
}Billing & usage
GET /v1/usageBilling is per token, split into input and output, shown on every turn. Fund a prepaid NGN billing balance; a turn is blocked with 402 when empty. Set a low-balance threshold to be emailed before you run dry.
Related reads: GET /v1/usage/analytics (daily series), GET /v1/usage/events (request traces), and GET /v1/billing (billing balance + transactions).
Webhooks
Set a webhook URL and subscribe to events under Webhooks (or PUT /v1/settings). We POST a JSON body with the event name, its fields, and an at timestamp; delivery is queued and retried. Set your own webhookSecret and we send it verbatim in the x-webhook-secret header so you can verify a call is really from us.
// set your signing secret once:
await twelve.put("/v1/settings", { webhookUrl: "https://you.com/hooks", webhookSecret: "whsec_…" });
// verify on your endpoint (Express):
app.post("/hooks", (req, res) => {
if (req.get("x-webhook-secret") !== process.env.TWELVE_WEBHOOK_SECRET) return res.status(401).end();
const { event, ...data } = req.body; // e.g. "action.executed"
res.sendStatus(200);
});Events: customer.created, customer.tier_changed, customer.blocked, beneficiary.saved, action.confirmation_required, action.escalated, action.blocked, action.executed, ai.response.completed, billing.low_balance.
Versioning
GET /v1/versionThe API is versioned in the path (/v1). We add fields without breaking existing ones; a published endpoint's behavior does not change in place. GET /v1/version returns the current version and recent releases, and every chat response carries an engine-version header.
Using the dashboard
The console is where you configure and observe everything above. Sign in with your workspace email and password; the browser talks to the API through a server proxy so your key never reaches the browser.
Fund your billing balance. Open Billing & Costs and top up. Live turns need a positive balance.
Connect your tools. Under Agents & Intents, open an agent, click Manage tools, and attach the endpoint for each capability tool. Hit Test to confirm a 200.
Set limits. Define tiers and caps under Customers, and opt into rules under Policy.
Test in the Playground. Chat as a customer. Toggle Sandbox for sample data; turn it off to hit your live endpoints.
Watch it run. Every turn appears in Requests; usage rolls up under Analytics.
Endpoint reference
| POST | /v1/chat | A grounded turn (start, continue, or resume). |
| POST | /v1/chat/stream | Streaming turn (SSE). |
| GET | /v1/version | Version + changelog. |
| GET | /v1/usage | Token usage summary. |
| GET | /v1/usage/events | Recent request traces. |
| GET / POST | /v1/customers | List or create/update customers. |
| GET / PATCH | /v1/customers/:id | Fetch or update one customer. |
| GET | /v1/customers/:id/events | One customer's chats. |
| GET | /v1/billing | Billing balance + transactions. |
| GET | /v1/keys | Reveal your live + sandbox keys. |
| GET / PUT | /v1/settings | Alerts, retention, webhook, secret, tiers, policies. |
| GET / PUT | /v1/tools/:name | Attach or update a tool's endpoint. |
| POST | /v1/tools/:name/test | Call a tool's endpoint and report the HTTP status. |
Manage keys, endpoints, agents, and billing in the dashboard, or explore live in the Playground.
Try it without writing code first.