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://ai.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
The fastest path is the official Node SDK, twelveai on npm (Node 18+, zero dependencies). One call runs the whole protocol: routing, continuations, tool hand-offs, confirmations. Money moves always pause for your own PIN flow.
import { TwelveAI } from "twelveai";
const twelve = new TwelveAI({ apiKey: process.env.TWELVE_API_KEY });
const res = await twelve.chat({
message: "what's my balance?",
customerId: "cus_123",
});
console.log(res.message); // "Your current balance is NGN 307.05."Or call the API directly from any language, in every one you send a message and receive a grounded, structured result. AI coding agents can read the machine-readable guide at ai.twelveai.app/llms.txt.
curl https://ai.twelveai.app/v1/chat \
-H "x-api-key: sk_live_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{ "message": "What is my balance?", "customerId": "cus_123" }'SDK & CLI
The twelveai package covers the whole platform, not just conversations. Everything the dashboard does is also an SDK call and a terminal command, so your workspace can live in version-controlled JSON and CI.
import { TwelveAI } from "twelveai";
const twelve = new TwelveAI({ apiKey: process.env.TWELVE_API_KEY });
// Conversations
const c = await twelve.classify({ message: "a list of my payments", reasoning: "auto" });
const r = await twelve.chat({ message: "what's my balance?", customerId: "cus_123" });
// money moves pause -> finish them after your PIN flow:
// await twelve.resume(r.continuation, [{ id, result }]);
// Manage: everything the dashboard does
await twelve.manage.plugins.install("bills");
await twelve.manage.agents.create({ intent: "loans", keywords: ["loan"], systemPrompt: "..." });
await twelve.manage.customers.upsert({ externalId: "cus_123", tier: "premium" });
const usage = await twelve.manage.usage.summary();
// and twelve.api(method, path, body) for anything elsenpx -p twelveai twelveai plugins install bills
npx -p twelveai twelveai agents create --file agent.json
npx -p twelveai twelveai chat "what's my balance?" --customer cus_123 --sandbox
npx -p twelveai twelveai usageverifySignedRequest for HMAC, createJwksVerifier for signed JWTs, verifyWebhook) and an MCP server (npx -y -p twelveai twelveai-mcp) so an AI coding agent can read your live tool contract while it builds your integration.Classify (level 0)
POST /v1/classifyThe zero-commitment integration: keep every flow you already have and use TwelveAI purely as the router. Send a message; get back intent, confidence, extracted entities (amount, account number, phone, bank, currency), and an agent preview of exactly what /v1/chat would run. Nothing is stored and no tools execute.
Classification is keyword-first (deterministic and free). When the keyword signal is weak - nothing matched, or only a generic verb like “pay” inside “payments” - a single micro AI reasoning pass picks the intent from your own agent list, so “a list of my payments” resolves to transactions rather than transfer. The response’s method field tells you which layer decided: keywords or ai.
Control it per request with reasoning: always (reason over every message, best results - the default), auto (AI only on weak signals), or off (keywords only, free). Tune it live on the dashboard’s Classify page.
billing.charged in the response says exactly what, with free: true when nothing was.curl https://ai.twelveai.app/v1/classify \
-H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
-d '{"message": "i need a list of my payments", "reasoning": "always"}'
# -> { "intent": "transactions", "confidence": 0.85, "method": "ai", ... }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://ai.twelveai.app/v1/chat \
-H "x-api-key: sk_live_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{ "message": "What is my balance?", "customerId": "cus_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, or unless you send attachments. |
| attachments | array | Up to 4 images or voice notes: { url }, { data, mediaType }, or { whatsappMediaId } (we fetch it with your stored WhatsApp token). Images read, voice transcribed; then routed and grounded. |
| 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). |
| tierLimits | object | Sent with tier, saves that tier's caps into your tier catalog from the request: { maxPerTxnNgn, maxDailySumNgn, maxDailyCount, breachAction }. Your system stays the source of truth. |
| 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: [{ id, result }]. |
| history | array | Your own longer conversation memory: [{ role: "user"|"assistant", content }]. Optional; the last 10 messages are kept server-side regardless. |
| reasoning | string | Routing depth: "off" (keywords only), "auto" (default: one micro AI pass on weak signals), "always" (AI reasoning on every fresh turn; its tokens are billed). |
| customerToken | string | Your end-user's short-lived session token, forwarded verbatim as Authorization on tools with auth type customer_token. Never stored. |
| 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. |
| confidence | number|null | Routing confidence for the intent (same scale as /v1/classify). Null on resumes and guide fallback. |
| 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. Each is { id, name, arguments } plus, for a client-fetch binding, request: { method, url, body } - the resolved call with no credentials attached. No request = a money move for your PIN flow and rails. |
| 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. |
| transcript | string|null | What we heard from an attached voice note. Absent when no audio was sent. |
| fee | object|null | A policy-attached transaction fee: { feeNgn, amountNgn, totalNgn }. |
| sandbox | boolean | Whether this turn ran against stubbed sample data. |
| usage | object | { inputTokens, outputTokens }. |
| billing | object | { charged, inputNgn, outputNgn, audioNgn, balanceNgn, lowBalance }. Sandbox turns still bill their tokens (they run the model); they just never touch real systems. |
| latencyMs | number | End-to-end processing time for the turn. |
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: "cus_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 does not expire, so a customer can pick a conversation back up any time later. It is not a fixed session id: every turn returns a new token, so always send back the most recent one (the previous one is retired once you do).
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: "cus_123",
}, { responseType: "stream" });
res.data.on("data", (c) => process.stdout.write(c.toString()));
// events: "token" (each fragment), then "done" (the structured result)Images & voice notes
POST /v1/chatSend images alongside (or instead of) text with an attachments array, up to four. TwelveAI reads each image once, routes the turn to the right agent from what the image shows, and grounds the reply on the extracted content. A photo of account details lands on your transfer agent; a statement on your transactions agent, even with no typed message.
// Attach an image and (optionally) a message. TwelveAI reads the
// image, routes it to the right agent, and grounds the reply on what it
// contains. Pass a URL, or inline base64 with a mediaType. Up to 4 images.
await twelve.post("/v1/chat", {
customerId: "cus_123",
message: "Send 5,000 to this account",
attachments: [
{ url: "https://your-cdn.com/account-details.jpg" },
// or inline:
// { data: "<base64-bytes>", mediaType: "image/png" },
],
});
// Image only (no text) works too, e.g. a photo of a cheque or receipt:
await twelve.post("/v1/chat", {
customerId: "cus_123",
attachments: [{ url: "https://your-cdn.com/cheque.jpg" }],
});Voice notes. Audio attachments (a customer's voice note from WhatsApp, your app, or a call) are transcribed to text first, then routed and grounded exactly like a typed message, so a spoken "send 5k to Mum" works the same as typing it. The response echoes what was heard as transcript.
// A voice note is sent the same way: an audio attachment. TwelveAI
// transcribes it, then routes and grounds on what was said, exactly like text.
await twelve.post("/v1/chat", {
customerId: "cus_123",
attachments: [
{ url: "https://your-cdn.com/voice-note.ogg", mediaType: "audio/ogg" },
// or inline: { data: "<base64-audio>", mediaType: "audio/webm" }
],
});
// The response echoes what we heard as `transcript`.attachments: [{ whatsappMediaId: "<id>" }]. Or download the note on your side and send the bytes as base64 data with a mediaType (e.g. audio/ogg). Either way, don't pass a raw WhatsApp media URL as url, it needs your Meta token to fetch and we won't have it.WhatsApp channel
Connect your WhatsApp Business number and TwelveAI runs the whole conversation, you don't wire the messaging loop. In your dashboard Settings, store your Cloud API token, copy the Callback URL, set it plus a Verify token in Meta (WhatsApp → Configuration), add your app secret, and subscribe to messages.
From then on we receive each message (text, voice notes, images), identify your number by its phone_number_id, run the turn, and reply over the Cloud API. A money-moving action comes back as tappable Confirm / Cancel buttons; a Confirm hands the transfer to your own rails to execute. We never move money or hold a wallet, and the customer's PIN/step-up stays with your systems or a WhatsApp Flow.
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.
// TwelveAI pauses and hands the work back:
// res.data.pendingToolCalls = [
// // client-fetch: the resolved request, NO credentials attached -
// // perform it against your own API with your own auth:
// { id: "call_1", name: "get_balance", arguments: {},
// request: { method: "GET", url: "https://api.yourbank.com/v1/customers/cus_123/balance" } },
// // a money move has no request - collect the PIN, execute on your rails:
// { id: "call_2", name: "transfer_funds", arguments: { amount: 50000, ... } },
// ]
// run them in your perimeter, then resume:
await twelve.post("/v1/chat", {
continuation: res.data.continuation,
toolResults: [
{ id: "call_1", result: { balance: 904500 } },
{ id: "call_2", result: { ok: true, reference: "trf_881" } },
],
});pendingToolCalls) and you execute it on your own rails, then resume with the result, which the AI relays.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: "cus_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 (yours, if you pasted one on the binding - see Tools & endpoints), so you can build and test the whole conversation before wiring a real API. Sandbox turns never touch real systems and move no money, but the model still runs, so the turn's tokens are billed like any other.
Money is the exception to the stubs: on a sandbox turn the money tools run for real against a sandbox ledger with pretend naira, so balances, transfers and swaps behave like live without touching a bank. See Rails sandbox.
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": "cus_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, via PUT /v1/settings (customerTiers + defaultTier), or straight from a chat request with tierLimits (below). 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: "cus_123",
tier: "premium", // syncs the tier and applies its caps to THIS turn
// optional: SAVE the tier's caps into your tier catalog from the request
// itself (e.g. your KYC tiers), so your system stays the source of truth:
tierLimits: { maxPerTxnNgn: 200000, maxDailySumNgn: 500000 },
message: "Send 50,000 to 0123456789 at GTBank",
});tierLimits with a tier upserts that tier's caps in your catalog and applies them from that very turn - a KYC upgrade in your system takes effect on the customer's next message with zero console edits. Every tier move is recorded and, if you subscribe, emitted as a 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.
Agent marketplace
GET /v1/pluginsInstall ready-made agents with one call - POST /v1/plugins/:id/install - or from the dashboard's Agent marketplace. The catalog covers balance, transactions, transfers, airtime, bills & utilities (validated meters and smartcards, real package prices), beneficiaries, savings locks, budgets, scheduled payments, identity verification (KYC), wallet funding, FX & USD, customer care, and a business assistant. Installing a pack adds the agent, its keywords, its tools, and its autonomy defaults; reads bind to your endpoints (or managed rails where available), and money writes are always client-executed on your rails.
const { data } = await twelve.get("/v1/plugins"); // the catalog, with installed/configured flags
await twelve.post("/v1/plugins/bills/install");
await twelve.post("/v1/plugins/bills/uninstall");
// or: npx -p twelveai twelveai plugins install billsvisibility field: public (anyone can install) or private (built for one tenant; nobody else ever sees it in the catalog). If you already built an agent with the same intent, install returns 409 so a pack never overwrites your own work.Versions and changelogs. updateAvailable says a newer version exists, and releases carries the changes between the version you run and the current one, with breaking flagged - so you read what an update does before adopting it. Running an agent you did not install from the marketplace? POST /v1/plugins/:id/track follows its releases without touching your prompt or tools.
Custom agents
POST /v1/agentsBuild your own agent when no pack fits - every agent you create is private to your workspace. Send a systemPrompt (its instructions; the grounding contract still applies on top) plus the tools it may call (names from your Tools & Endpoints list), or omit both for a classifier-only intent that participates in /v1/classify routing while you handle the flow yourself.
await twelve.post("/v1/agents", {
intent: "loans",
label: "Loans",
keywords: ["loan", "borrow", "repayment"],
systemPrompt: "You help customers check their active loans and repayment schedules. Answer only from tool results.",
tools: ["list_loans"],
requiresCustomer: true, // refuse turns with no customer id before any tool runs
});
await twelve.put("/v1/agents", { intent: "loans", enabled: false }); // toggle, keywords, permissions, autonomy
await twelve.delete("/v1/agents/loans"); // custom + classifier-only agents are deletablepermissions array gates its tools: a tool whose permission scope is not granted is refused at runtime, and the dashboard warns you when an agent's tools need a scope it doesn't have.Tools & endpoints
PUT /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 (list everything with GET /v1/tools). Use {customer_id} for the end-user and {arg} for a tool argument.
curl -X PUT https://ai.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" }
}'You never describe your payloads. Paste one real example response from your endpoint as sampleResponse (up to 20KB): the payload location is derived from it automatically (envelopes like data/result are unwrapped), and the sandbox answers with exactly that sample. Set resultPath yourself only to override.
await twelve.put("/v1/tools/get_balance", {
method: "GET",
url: "https://api.yourbank.com/v1/customers/{customer_id}/balance",
auth: { type: "bearer", token: "YOUR_API_TOKEN" },
sampleResponse: { status: "success", data: { balance: 30705, currency: "NGN" } },
});
// resultPath "data" is derived from the sample; the sandbox answers with it.// 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.URL variables
GET /v1/toolsYou never guess which placeholder a tool understands. Every tool declares the arguments it fills into your URL, and GET /v1/tools returns them per tool as variables - each with a name, type, the schema’s own description, and whether it is required. Use any of them as {name} anywhere in the URL, path or query, plus {customer_id}, which is always available and resolves to the end-user.
GET https://api.yourbank.com/v1/users/{customer_id}/transactions?start={start_date}&end={end_date}
GET https://api.yourbank.com/v1/bills/validate?type={bill_type}&biller={biller}&ref={customer_reference}{
"name": "validate_bill_customer",
"variables": [
{ "name": "customer_id", "type": "string", "required": false },
{ "name": "bill_type", "type": "string", "required": true },
{ "name": "biller", "type": "string", "required": true },
{ "name": "customer_reference", "type": "string", "required": true }
]
}The dashboard shows the same list as click-to-insert chips, and on an agent’s marketplace page before you install it (toolSpecs on GET /v1/plugins). Packs with domain jargon also ship a glossary in details.concepts - what bill_type, biller, customer_reference and variation_code mean and which values we send.
Custom tools
POST /v1/toolsCreate a brand-new tool for anything your API can do, then attach it to a custom agent. Every {placeholder} in the URL automatically becomes a required tool argument the AI fills from the conversation ({customer_id} stays reserved for the end-user and hidden from the model), and a pasted sampleResponse derives the payload location exactly like a binding.
await twelve.post("/v1/tools", {
name: "list_loans",
description: "Active loans and repayment schedule for a customer.",
sideEffect: "read", // "write" tools default to client execution
method: "GET",
url: "https://api.yourbank.com/v1/loans/{customer_id}?status={status}",
sampleResponse: { status: "success", data: [{ id: "loan_1", outstanding: 120000 }] },
});
// -> { ok: true, name: "list_loans", arguments: ["status"], resultPath: "data" }OpenAPI import
POST /v1/tools/importConnect your whole API in one step: point us at your OpenAPI document (a url, or paste the JSON as spec) and each operation becomes a tool - GETs as grounded reads, other methods as confirmation-gated writes. Path parameters named like customerId/user_id map to the end-user automatically and are hidden from the model. Always start with dryRun: true to preview; deprecated operations are skipped, imports cap at 40 tools, and existing tool names are never clobbered.
const preview = await twelve.post("/v1/tools/import", {
url: "https://api.yourbank.com/openapi.json",
dryRun: true, // -> { ok, dryRun: true, tools: [...], skipped: [...], baseUrl }
});
await twelve.post("/v1/tools/import", { url: "https://api.yourbank.com/openapi.json" });
// -> { ok: true, created: ["get_transactions", ...], skipped: [], count: 12 }Endpoint authentication
You choose, per tool, how TwelveAI authenticates to your endpoint - including options where no standing credential is ever shared with us:
| Field | Type | Description |
|---|---|---|
| client-fetch | no credential | Pick "my app calls it": we resolve the request and hand it back in pendingToolCalls[].request; your server performs it with its own auth. Zero credentials shared. |
| jwks | no secret | Every call carries a short-lived JWT signed with our private key, bound to that one request. Verify against /.well-known/jwks.json. |
| signed | HMAC | X-Engine-Timestamp + X-Engine-Signature = HMAC-SHA256(secret, "ts.METHOD.path.body"), 5-minute window. Generate the secret under Settings. |
| customer_token | per-user | Pass customerToken on each /v1/chat call; it's forwarded verbatim as Authorization. Scoped to the end-user's own session, never stored. |
| oauth2 | client credentials | Token URL + client id/secret; we fetch short-lived scoped tokens ourselves. |
| bearer / api_key | static | The classic credential, stored encrypted. |
Our outbound tool calls come from static IPs published at /.well-known/egress-ips, so you can allowlist us at your firewall.
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" }
}Reading your live contract back: GET /v1/manifest returns the tool-call contract per active agent - every tool with its schema, side effect, and execution mode, credentials never included. Code against it: turn an agent off and its tool calls can never reach you.
automatic: true is invoked by the engine itself and never appears as a model decision - today that is save_beneficiary, run right after a verified resolve_account so a payee is saved without the customer being asked. Don’t build a hand-off path for it.Managed lookups
Some capabilities work before you attach any endpoint, running on platform-side lookups. Your own endpoint, when attached, always wins. Money itself is a separate, opt-in product: see Money rails.
| Field | Type | Description |
|---|---|---|
| Bills & utilities | aggregator | Biller directory, meter/smartcard validation, and real package prices with zero setup. The payment itself is still a gated write on your rails. |
| Identity (KYC) | stateless | verify_identity checks a BVN/NIN via the platform provider. Nothing is stored; the outcome arrives as a kyc.verification webhook with the document number masked to its last 4. Each check bills a flat NGN fee. |
| Bank rails | list + resolve | The bank list and account-name resolution behind transfers. Use the managed default or point PUT /v1/bank-rails at your own endpoints (POST /v1/bank-rails/test checks them safely). |
Money rails: overview
GET / POST /v1/rails/...By default TwelveAI never touches money: transfers are handed back to your own rails to execute. Money rails are the opt-in alternative for a workspace that has no rails of its own, or wants a second set. Enroll, and every customer can get a real account number, a naira wallet, a dollar wallet, transfers out to any Nigerian bank, and NGN/USD swaps. The AI agents use them in chat (get_balance, get_statement, get_funding_account, open_account, transfer_funds, get_fx_quote, swap_currency) and your own app uses the same rails over REST, with the same x-api-key, so a mobile app or a backend can show balances and move money without a conversation.
NG::000013). The engine keeps the ledger and the records, and checks the partner's balance against every wallet nightly.Enrollment is three steps on the console's Rails page, on your live key: accept the rails agreement, verify the workspace owner's BVN, and verify the company's CAC registration. Until then, live writes answer 403; reads answer whatever the enrollment state. The whole rails API also works for every workspace, before enrollment, on the sandbox key: see Rails sandbox.
| Field | Type | Description |
|---|---|---|
| Transfer fee | ₦50 flat | Taken from the customer alongside the amount on every transfer out, and refunded with the amount when the payout fails. |
| Swap rate | provider + your margin | The customer rate is the provider rate adjusted by your workspace's own margin: added when the customer buys dollars, taken off when they sell. The margin is read-only on GET /v1/rails/rates; ask for a change with POST /v1/rails/margin-request. |
| Amounts | major units | Naira and dollars with two decimals on every request and response. The ledger itself holds integer minor units. |
| Responses | { ok, ... } | Every endpoint answers { ok: true, ... } or { ok: false, error }. Writes are rate-limited per API key with the standard window. |
Customers & opening an account
POST /v1/rails/customers/:customerId/accountEvery rails endpoint is scoped to one customer by the path segment :customerId: the same id you already pass as customerId on chat turns, so the customer the AI talks to and the customer your app reads are one record. Nothing exists for a customer until an account is opened, and an account needs a BVN: no account number and no wallet exist before it is verified. A NIN can verify a name but never holds money.
Open the account with firstName, lastName, an optional email, and the bvn. The BVN is verified with the identity provider and the name you send must match the verified record (case and order ignored; a middle name on the record is fine). The BVN goes to the provider once and is never stored, only its last four digits. Calling it again for the same customer returns the existing account.
curl -X POST https://ai.twelveai.app/v1/rails/customers/cus_123/account \
-H "x-api-key: $TWELVE_API_KEY" \
-H "content-type: application/json" \
-d '{
"firstName": "Adaeze",
"lastName": "Okafor",
"email": "adaeze@example.com",
"bvn": "22212345678"
}'{
"ok": true,
"created": true,
"account": {
"customerId": "cus_123",
"accountNumber": "9012345678",
"accountName": "ADAEZE OKAFOR",
"bankName": "Wema Bank",
"bankCode": "NG::000017",
"currency": "NGN",
"status": "active",
"identityType": "bvn",
"identityLast4": "5678",
"openedAt": "2026-09-15T13:41:09.000Z"
}
}| 201 | Created | { ok, created: true, account }. The customer now has an account number for deposits and a naira wallet. |
| 200 | Existing | { ok, created: false, account }. The customer already had one; nothing changed. |
| 400 | Not verified | { ok: false, error, verification: "failed" | "name_mismatch" }. The BVN did not verify, or the name differs from the record. |
| 403 | Rails not active | Live writes need the workspace enrolled. Use the sandbox key until then. |
Balances, statements & the account
GET /v1/rails/customers/:customerId/...Four reads cover what a customer sees. They answer in every enrollment state, so an app can show the empty state before the workspace goes live.
| Field | Type | Description |
|---|---|---|
| GET .../account | object | The account number, name, bank and status: { ok, hasAccount, account }. account is null (and hasAccount false) until the account is opened. |
| GET .../balances | array | Every wallet the customer holds: [{ currency, available, pending }]. NGN is always listed (zero before any deposit); USD appears once their first swap into dollars settles. |
| GET .../statement | array | Ledger lines, newest first. ?limit= (default 50) and ?currency=NGN|USD to filter to one wallet; omit for both. Each line carries type, direction, amount, balance_after, description, counterparty and reference. |
| GET .../statement.pdf | One month as application/pdf: ?month=YYYY-MM (default last month). Workspace header, account number, opening balance, every entry with a running balance per currency, closing balances. Owner, admin or member. |
{
"ok": true,
"customerId": "cus_123",
"hasAccount": true,
"balances": [
{ "currency": "NGN", "available": 47500, "pending": 0 },
{ "currency": "USD", "available": 100, "pending": 0 }
]
}{
"ok": true,
"customerId": "cus_123",
"count": 2,
"entries": [
{ "reference": "rsw_x", "type": "swap_out", "direction": "debit", "amount": 152500, "currency": "NGN",
"balance_after": 47500, "description": "Swap ₦152,500 to $100 at 1,525 NGN/USD", "counterparty": "TwelveAI FX", "at": "..." },
{ "reference": "dep-1", "type": "deposit", "direction": "credit", "amount": 200000, "currency": "NGN",
"balance_after": 200000, "description": "Bank transfer from CHIDI", "counterparty": "CHIDI", "at": "..." }
]
}Entry types on a statement line: deposit (money landed on the account number), transfer (a payout out), fee (the ₦50 transfer fee), transfer_refund and fee_refund (a failed payout put both back), swap_out (the source wallet of a swap), swap_in (the destination wallet, on settlement), swap_refund (a failed or reversed swap put the source back).
Transfers
GET / POST /v1/rails/customers/:customerId/transfersSend money from a customer's naira wallet to any bank account. idempotencyKey is required and is yours: use something you already have, like an order id or a payout batch line. The same key returns the same transfer (200, replayed: true) and never debits twice, even across a timeout. bankCode is the partner's code (NG::000013, from GET /v1/rails/banks); a bare NIBSS code (058) is accepted too and mapped to it. The transfer's destination comes back as { bankCode, nibssCode, bankName, accountNumber, accountName }, with nibssCode the three-digit code when known.
Before anything moves, the destination is resolved on the banking partner (the same check as GET /v1/rails/banks/resolve) and the call is refused with 400 (carrying verifiedAccountName) when accountName does not match the verified holder. A flat ₦50 fee is taken with the amount; when the payout fails, the amount and the fee are both refunded to the wallet.
curl -X POST https://ai.twelveai.app/v1/rails/customers/cus_123/transfers \
-H "x-api-key: $TWELVE_API_KEY" \
-H "content-type: application/json" \
-d '{
"amount": 25000,
"bankCode": "NG::000013",
"accountNumber": "0123456789",
"accountName": "Chinedu Eze",
"narration": "Rent, September",
"idempotencyKey": "order_8821_payout"
}'{
"ok": true,
"replayed": false,
"transfer": {
"reference": "rtx_01J8...",
"customerId": "cus_123",
"status": "processing",
"amount": 25000,
"fee": 50,
"currency": "NGN",
"destination": { "bankCode": "NG::000013", "nibssCode": "058", "bankName": "GTBank", "accountNumber": "0123456789", "accountName": "CHINEDU EZE" },
"narration": "Rent, September",
"failureReason": null,
"channel": "api",
"createdAt": "2026-09-15T13:44:02.000Z",
"succeededAt": null,
"failedAt": null
}
}| 201 | Created | { ok, replayed: false, transfer } with status processing, or succeeded when the provider settles synchronously. |
| 200 | Replayed | { ok, replayed: true, transfer }: the idempotency key was already used by this customer. Nothing new moved. |
| 400 | Refused | Name mismatch (verifiedAccountName in the body), an invalid destination, or an insufficient balance. |
| 409 | Key conflict | The idempotency key was used by a different customer. |
| 422 | Provider refused | { ok: false, error, transfer }: the payout was rejected; amount and fee are back in the wallet. |
Status flow: pending → processing → succeeded | failed | reversed. A failed or reversed transfer has its amount and fee back in the wallet. Every change fires rail.transfer.updated; a transfer that waits on the provider longer than usual fires rail.transfer.stuck once. List a customer's transfers with GET .../transfers?status=&limit=&cursor= (newest first, with status, fee and destination).
Banks
GET /v1/rails/banksTwo reads back every transfer form, and neither moves money. GET /v1/rails/banks?q= lists the banks the partner can pay into, filtered by name or code. GET /v1/rails/banks/resolve?bankCode=&accountNumber=&mode= asks the partner whose account a pair is; use it to fill in and check accountName before you send. Both honour ?mode=sandbox|live.
| Field | Type | Description |
|---|---|---|
| GET /v1/rails/banks | ?q= | { ok, count, banks: [{ name, bankCode, nibssCode, type }] }. bankCode is the partner's code ("NG::000013") and is what a transfer takes; nibssCode is the three-digit NIBSS code ("058"), null when the bank has none. |
| GET /v1/rails/banks/resolve | ?bankCode=&accountNumber=&mode= | { ok, account: { accountName, accountNumber, bankCode, nibssCode, bankName } } when the partner knows the account; 404 { ok: false, error } when it does not. bankCode accepts the partner code or a NIBSS code. |
# The bank list, filtered by name or code
curl "https://ai.twelveai.app/v1/rails/banks?q=gtb" -H "x-api-key: $TWELVE_API_KEY"
# Whose account is this? Nothing moves.
curl "https://ai.twelveai.app/v1/rails/banks/resolve?bankCode=NG::000013&accountNumber=0123456789" \
-H "x-api-key: $TWELVE_API_KEY"{
"ok": true,
"count": 1,
"banks": [{ "name": "GTBank", "bankCode": "NG::000013", "nibssCode": "058", "type": "commercial" }]
}{
"ok": true,
"account": { "accountName": "CHINEDU EZE", "accountNumber": "0123456789", "bankCode": "NG::000013", "nibssCode": "058", "bankName": "GTBank" }
}NGN/USD swaps
GET / POST /v1/rails/customers/:customerId/swapsA customer with a naira wallet can hold dollars too. Quote first with GET .../quote?direction=NGN_USD|USD_NGN&amount=: NGN_USD pays naira and receives dollars, USD_NGN pays dollars and receives naira, and amount is always what the customer pays, in the source currency. The quote carries the customer's effective rate (naira per dollar), the providerRate it was built from, and your margin; amountOut is rounded down. Nothing moves on a quote.
# Ask first: what will 50,000 naira buy right now? Nothing moves on a quote.
curl "https://ai.twelveai.app/v1/rails/customers/cus_123/quote?direction=NGN_USD&amount=50000" \
-H "x-api-key: $TWELVE_API_KEY"
# Then swap
curl -X POST https://ai.twelveai.app/v1/rails/customers/cus_123/swaps \
-H "x-api-key: $TWELVE_API_KEY" \
-H "content-type: application/json" \
-d '{ "direction": "NGN_USD", "amount": 50000, "idempotencyKey": "swap_2026-09-15_cus_123_1" }'{
"ok": true,
"quote": {
"direction": "NGN_USD",
"fromCurrency": "NGN",
"toCurrency": "USD",
"amountIn": 152500,
"amountOut": 100,
"rate": 1525,
"providerRate": 1500,
"marginBps": 100,
"marginNgn": 1500,
"quotedAt": "...",
"providerSampledAt": "..."
}
}Then POST .../swaps with direction, amount and an idempotencyKey. The source wallet is debited at once; the destination wallet is credited when the provider settles, and a rail.swap.updated event fires on every state change. A USD wallet appears on the customer's balances when their first swap into dollars settles. Same key, same swap.
{
"ok": true,
"replayed": false,
"swap": {
"reference": "rsw_x",
"customerId": "cus_123",
"status": "settled",
"direction": "NGN_USD",
"amountIn": 152500, "currencyIn": "NGN",
"amountOut": 100, "currencyOut": "USD",
"rate": 1525,
"providerRate": 1500,
"marginNgn": 1500,
"failureReason": null,
"channel": "api",
"createdAt": "...", "settledAt": "...", "failedAt": null
}
}| 201 | Created | { ok, replayed: false, swap } with status processing, or settled when the provider settles synchronously. |
| 200 | Replayed | { ok, replayed: true, swap } for a reused idempotency key. |
| 422 | Provider refused | { ok: false, error, swap }: the amount is back in the source wallet. |
Status flow: pending → processing → settled | failed | reversed. A failed or reversed swap has its amountIn back in the source wallet. A swap that waits on the provider longer than usual fires rail.swap.stuck once. List a customer's swaps with GET .../swaps?status=&limit=&cursor=; read the live rates and your margin from GET /v1/rails/rates.
Rails sandbox
POST /v1/rails/sandbox/...Every workspace has the whole rails in the sandbox from day one: no agreement, no owner BVN, no CAC. Use your sandbox key (sk_test_…) and every path above works unchanged: open accounts, read balances and statements, send transfers, swap, export CSV. Sandbox rows live beside live rows and the two never mix; nothing in the sandbox touches a bank, so no real money can move. The console's Money page shows it under the Sandbox switch.
# Sandbox key, no enrollment needed. Same paths, same shapes.
# 1. Open an account (any 11-digit BVN passes; the number starts with 99)
curl -X POST https://ai.twelveai.app/v1/rails/customers/cus_123/account \
-H "x-api-key: sk_test_xxx" -H "content-type: application/json" \
-d '{ "firstName": "Ada", "lastName": "Okafor", "bvn": "22222222222" }'
# 2. Put pretend money on it
curl -X POST https://ai.twelveai.app/v1/rails/sandbox/deposit \
-H "x-api-key: sk_test_xxx" -H "content-type: application/json" \
-d '{ "customerId": "cus_123", "amount": 50000 }'
# 201 { "ok": true, "mode": "sandbox", "balance": 50000, "entry": { "type": "deposit", "amount": 50000, ... } }
# 3. Read any workspace list for the sandbox explicitly (a sk_test_ key defaults to it)
curl "https://ai.twelveai.app/v1/rails/wallets?mode=sandbox" -H "x-api-key: sk_test_xxx"
# Start over (owner or admin)
curl -X POST https://ai.twelveai.app/v1/rails/sandbox/reset -H "x-api-key: sk_test_xxx"| Field | Type | Description |
|---|---|---|
| POST /v1/rails/sandbox/deposit | { customerId, amount } | Simulates a deposit: credits the customer's sandbox naira wallet and publishes rail.deposit with sandbox: true. 201 { ok, mode: "sandbox", customerId, balance, entry }. 400 when the customer has no sandbox account yet. Any member role. |
| POST /v1/rails/sandbox/reset | owner or admin | Deletes every sandbox account, wallet, entry, transfer and swap the workspace has: 200 { ok, mode: "sandbox", deleted: { accounts, wallets, entries, transfers, swaps } }. Live data is not touched. |
| ?mode=sandbox|live | query | On every workspace read under /v1/rails/* (overview, lists, rates, revenue, health, integrity, CSV exports, the statement PDF). Defaults to live once the rails are active, sandbox before that; every response echoes mode. With the live key the customer API is live unless you pass ?mode=sandbox. |
How the sandbox behaves, so your tests mean something:
| Field | Type | Description |
|---|---|---|
| Accounts | instant | Any 11-digit BVN passes and no identity provider is called. The account opens at "TwelveAI Sandbox Bank" with a stable number starting 99 and the given name upper-cased. |
| Transfers | seconds | Debit the amount plus the same ₦50 fee, answer processing, and settle to succeeded a couple of seconds later, firing rail.transfer.updated like live. A transfer to account 0000000000 fails (and so does a narration containing "fail"); the failure refunds amount and fee exactly like live. Any 10-digit destination resolves to a name like SANDBOX ADA OBI. |
| Swaps | instant | Settle in the same request at a fixed provider rate of ₦1,500 per dollar, with your real margin applied, so customer quotes match what live would show. GET /v1/rails/rates?mode=sandbox returns that rate; /rates/history a flat series. |
| Chat | real ledger | On a sandbox turn (sandbox: true, or the sandbox key) the money tools run for real against the sandbox ledger instead of stubs, whatever the enrollment state. PUT /v1/settings { sandboxRails: false } keeps the stubbed samples instead. |
| Events | sandbox: true | rail.deposit, rail.transfer.updated, rail.swap.updated and rail.account.opened published from the sandbox carry sandbox: true so automations can filter on it. |
sk_test_ for sk_live_, and the same calls move real money. Enrollment writes, the nightly coverage check, the reconcile sweep, the rate feed and the monthly summary read live rows only.Workspace reads & exports
GET /v1/rails/...The lists behind the console's Money page are yours to read with the same key. Each takes the filters the console uses (?customer=, ?status=, ?from=, ?to=, ?q=, ?limit=, ?cursor=) and ?mode=sandbox|live.
| Field | Type | Description |
|---|---|---|
| GET /v1/rails | enrollment | The workspace's enrollment state (agreement, BVN, CAC, active), plus sandbox: { available, accounts, wallets, lastActivityAt } and mode. |
| GET /v1/rails/overview | totals | Customer funds, transfer counts by status, usd: { customerFundsUsd, pendingUsd, ngnValueAtProviderRate, providerNgnPerUsd } and swaps: { <status>: count }. |
| GET /v1/rails/wallets | list | ?currency=NGN|USD. Every wallet with its customer, available and pending, the naira value, last movement and account; plus a total. |
| GET /v1/rails/entries | list | Every ledger line across customers, newest first. |
| GET /v1/rails/transfers | list | Every transfer out (/:reference for one, with its entries and timeline). |
| GET /v1/rails/swaps | list | Every swap (?direction=, ?status=; /:reference for one, with entries and timeline). |
| GET /v1/rails/accounts | list | Every customer account number the workspace has issued. |
| GET /v1/rails/rates | rates | The provider rate (ngnPerUsd, usdPerNgn, sampledAt), your marginBps, the customer buy and sell rates, and whether a margin request is open. /rates/history?pair=USDNGN|NGNUSD&days=7 for the series. |
| GET /v1/rails/revenue | window | ?from=&to= (default the last 30 days): transferFeesNgn (net of refunds on failed transfers), fxMarginNgn (over swaps settled in the window), totalNgn, a daily series, and counts. |
| GET /v1/rails/health | operations | Stuck transfers and swaps (each with alerted), the rate feed's staleness (stale after 5 minutes), and swaps: { settledLast24h, averageSettleMinutes, waiting }. |
| GET /v1/rails/integrity | ledger | Wallets whose stored balance disagrees with their ledger. Empty is the answer you want. |
CSV exports. Add format=csv to /wallets, /entries, /transfers, /swaps, /accounts or /revenue and the response streams the whole filtered list as text/csv (cursor and limit ignored, at most 50,000 rows, plain-word headers, amounts in major units, ISO dates). Owner or admin only; other members get 403, and every export is logged as rails.exported.
A monthly summary can also be scheduled: POST /v1/schedules { kind: "rails_summary", name, cron, deliverEmail } emails the previous calendar month in plain words with every transfer and swap attached as a CSV. The console's Money page renders all of the above: Overview, Wallets, Transfers, Swaps, Rates, Revenue and Health tabs, with the Sandbox switch.
Rail events
The rails publish events like any other part of the platform: subscribe to them under Webhooks (they arrive with the event name, its fields, an at timestamp and your x-webhook-secret) or use them as triggers in Automations. Each carries the customer id and the reference, so you can update your own books without polling. Sandbox activity carries sandbox: true. Subscribe by name, or to "*" for every event including ones added later; the live catalog with fields and a sample body per event is at GET /v1/webhooks/events.
// rail.transfer.updated, delivered to your webhook URL (or as an Automation trigger)
{
"event": "rail.transfer.updated",
"customerId": "cus_123",
"reference": "rtx_01J8...",
"status": "succeeded",
"amount": 25000,
"currency": "NGN",
"failureReason": null,
"sandbox": false,
"at": "2026-09-15T13:44:40.000Z"
}| Field | Type | Description |
|---|---|---|
| rail.account.opened | event | A customer got their own account number. Fields: customerId, accountNumber, bankName, accountName, sandbox. |
| rail.deposit | event | Money landed on a customer's account number. Fields: customerId, amount, currency, senderName, senderBank, senderAccountNumber, sessionId, narration, reference, balance, sandbox. |
| rail.transfer.updated | event | A transfer changed status: processing, succeeded, failed (refunded), reversed. Fields: customerId, status, amount, currency, reference, failureReason, sandbox. |
| rail.transfer.stuck | event | A payout has waited on the provider for more than 15 minutes; fires once per transfer. Fields: reference, customerId, amount, fee, status, minutesInFlight. |
| rail.swap.updated | event | A swap changed status: processing, settled, failed (refunded), reversed. Fields: reference, customerId, direction, amountIn, amountOut, rate, status, sandbox. |
| rail.swap.stuck | event | A swap has waited on the provider for more than an hour; fires once per swap. Fields: reference, customerId, direction, amountIn, amountOut, status, minutesInFlight. |
| rail.float.paid | event | Money moved from your workspace float to a customer's wallet. Fields: reference, customerId, customerName, amount, currency, note, sandbox. |
| rail.float.low | event | The workspace float dropped below your threshold; at most once a day. Fields: availableNgn, thresholdNgn, sandbox. |
The rails are one group of the platform catalog. The full list, same names everywhere:
customer.createdA customer record was created (first chat or via the API).customer.tier_changedA customer moved up or down a tier.customer.blockedA customer's status was set to blocked.customer.identity.verifiedA BVN or NIN verified and the identity profile was recorded. Never carries the number or the photo.beneficiary.savedA verified recipient was saved for a customer.
ai.response.completedAn agent answered a turn, with the tool-call count and token usage.conversation.completedOne turn: the customer's message and the reply, on any channel.conversation.endedA customer went quiet for 10 minutes; first message, last reply and the intents seen.followup.dueA reminder an agent scheduled came due.
action.confirmation_requiredAn agent paused a write for the customer to confirm.action.escalatedA write was handed to human review.action.executedA customer confirmed and an action ran.action.blockedA write was stopped by your policies or guardrails.kyc.verificationA managed BVN/NIN check finished (verified, failed or pending); the number masked to its last 4. Kept for existing integrations; new ones should use customer.identity.verified.
rail.account.openedA customer got their own account number.rail.depositMoney landed on a customer's account number: sender, session id, narration, balance.rail.transfer.updatedA transfer moved: processing, succeeded, failed (refunded), reversed.rail.transfer.stuckA payout has waited on the provider for more than 15 minutes; fires once per transfer.rail.swap.updatedAn NGN/USD swap moved: processing, settled, failed (refunded), reversed.rail.swap.stuckA swap has waited on the provider for more than an hour; fires once per swap.rail.float.paidMoney moved from your workspace float to a customer's wallet.rail.float.lowThe workspace float dropped below your threshold (at most once a day).
business.payment.succeededA payment on your TwelveAI Business account succeeded.business.payment.failedA customer tried to pay your business and the charge failed.business.refund.processedYour business refunded a customer, fully or partly.business.deposit.receivedMoney arrived on your business account number by bank transfer.
schedule.completedA scheduled agent run or Morning Brief completed (or failed).instinct.findingAn instinct-enabled agent spotted something off and reported it.billing.low_balanceYour prepaid TwelveAI balance dropped to or below your alert threshold (your credit, not a customer's).
Billing & usage
GET /v1/usageBilling is per token, split into input and output, shown on every turn. Voice notes add a per-minute charge for transcription (billed on the audio length), surfaced as audioNgn on the 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.
Topping up: fund from Billing & Costs in the dashboard, or via POST /v1/billing/topup (returns a checkout URL) and POST /v1/billing/topup/verify to credit the balance once paid. The minimum top-up is ₦5,000, on your live key.
Related reads: GET /v1/usage/analytics (daily series: turns, tokens, latency, intents, channels), GET /v1/usage/events (request traces; /:id for one full trace), and GET /v1/billing (balance + transaction history).
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);
});Subscribe to a list of event names, or to "*" for everything, including events added later; an unknown name is refused with 400. The same events are the triggers behind Automations. The live catalog, with the fields each event carries and a sample body, is on the Webhooks page of the console and at GET /v1/webhooks/events. Rail event fields are detailed under Rail events.
customer.createdA customer record was created (first chat or via the API).customer.tier_changedA customer moved up or down a tier.customer.blockedA customer's status was set to blocked.customer.identity.verifiedA BVN or NIN verified and the identity profile was recorded. Never carries the number or the photo.beneficiary.savedA verified recipient was saved for a customer.
ai.response.completedAn agent answered a turn, with the tool-call count and token usage.conversation.completedOne turn: the customer's message and the reply, on any channel.conversation.endedA customer went quiet for 10 minutes; first message, last reply and the intents seen.followup.dueA reminder an agent scheduled came due.
action.confirmation_requiredAn agent paused a write for the customer to confirm.action.escalatedA write was handed to human review.action.executedA customer confirmed and an action ran.action.blockedA write was stopped by your policies or guardrails.kyc.verificationA managed BVN/NIN check finished (verified, failed or pending); the number masked to its last 4. Kept for existing integrations; new ones should use customer.identity.verified.
rail.account.openedA customer got their own account number.rail.depositMoney landed on a customer's account number: sender, session id, narration, balance.rail.transfer.updatedA transfer moved: processing, succeeded, failed (refunded), reversed.rail.transfer.stuckA payout has waited on the provider for more than 15 minutes; fires once per transfer.rail.swap.updatedAn NGN/USD swap moved: processing, settled, failed (refunded), reversed.rail.swap.stuckA swap has waited on the provider for more than an hour; fires once per swap.rail.float.paidMoney moved from your workspace float to a customer's wallet.rail.float.lowThe workspace float dropped below your threshold (at most once a day).
business.payment.succeededA payment on your TwelveAI Business account succeeded.business.payment.failedA customer tried to pay your business and the charge failed.business.refund.processedYour business refunded a customer, fully or partly.business.deposit.receivedMoney arrived on your business account number by bank transfer.
schedule.completedA scheduled agent run or Morning Brief completed (or failed).instinct.findingAn instinct-enabled agent spotted something off and reported it.billing.low_balanceYour prepaid TwelveAI balance dropped to or below your alert threshold (your credit, not a customer's).
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.
Security facts for reviewers
The short list your security team will ask for:
| Field | Type | Description |
|---|---|---|
| Money movement | your rails, or managed | By default every transfer executes on your own rails: TwelveAI verifies recipients, gates, and escalates, and holds nothing. A workspace can opt into TwelveAI money rails instead (agreement accepted, owner BVN and company CAC verified). Even then the money never sits with TwelveAI: funds are held by our licensed banking partner, which is NDIC insured, and the partner bank issues the account numbers. The engine keeps the ledger and the records, and checks the partner balance nightly against every wallet. The sandbox never touches a bank. |
| Egress IPs | GET /.well-known/egress-ips | Our outbound tool calls come from static IPs. Allowlist them at your firewall. |
| Signing keys | GET /.well-known/jwks.json | Public keys that verify our jwks-signed requests. No shared secret exists for this mode. |
| Webhooks | x-webhook-secret | Your own secret, sent verbatim on every delivery, so you verify origin by comparison. |
| Credentials | encrypted or absent | Stored endpoint credentials are encrypted at rest and never returned by any API. Client-fetch and jwks modes share no credential at all. |
| Zero-credential option | client-fetch | The engine can run with no access to your API whatsoever: it hands you resolved requests and you perform them. |
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/classify | Intent + confidence + entities; keyword-first with an AI reasoning pass on weak signals (reasoning: off | auto | always). |
| POST | /v1/chat | A grounded turn (start, continue, or resume). |
| POST | /v1/chat/stream | Streaming turn (SSE). |
| GET | /v1/manifest | Per-active-agent tool-call contract - code against this. |
| GET | /v1/version | Version + changelog. |
| GET | /v1/usage | Token usage summary. |
| GET | /v1/usage/analytics | Daily series: turns, tokens, latency, intents, channels. |
| GET | /v1/usage/events | Recent request traces (/:id for one full trace). |
| GET / POST / PUT | /v1/agents | List, create (custom agents), or configure agents (DELETE /:intent). |
| GET | /v1/plugins | Agent marketplace catalog (POST /:id/install, /:id/uninstall). |
| 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. |
| POST | /v1/billing/topup | Fund the balance (min ₦5,000; verify with /topup/verify). |
| GET | /v1/keys | Reveal your keys (POST /v1/keys/rotate to rotate). |
| GET / PUT | /v1/settings | Guardrails, webhook + secret, tiers, policies, WhatsApp, signing. |
| GET / POST | /v1/tools | Every tool with its binding state; POST creates a custom tool. |
| 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. |
| POST | /v1/tools/import | Import an OpenAPI document (dryRun previews). |
| GET / PUT | /v1/bank-rails | Bank list + account resolution config (POST /test to check). |
| GET | /v1/rails | Money rails enrollment state + sandbox summary (POST /sla, /verify-bvn, /verify-cac to enroll). |
| GET / POST | /v1/rails/customers/:id/account | A customer's account number; POST opens one (BVN verified, name matched). |
| GET | /v1/rails/customers/:id/balances | NGN and USD wallets: [{ currency, available, pending }]. |
| GET | /v1/rails/customers/:id/statement | Ledger lines (?limit, ?currency); /statement.pdf?month=YYYY-MM for a PDF. |
| GET / POST | /v1/rails/customers/:id/transfers | List transfers out; POST sends one (idempotencyKey required, ₦50 fee, bankCode "NG::000013" or "058"). |
| GET | /v1/rails/banks | The banks the partner can pay into (?q= filters): [{ name, bankCode, nibssCode, type }]. |
| GET | /v1/rails/banks/resolve | Whose account a bankCode + accountNumber pair is; 404 when the bank does not know it. Nothing moves. |
| GET | /v1/rails/customers/:id/quote | NGN/USD quote: ?direction=NGN_USD|USD_NGN&amount=. Nothing moves. |
| GET / POST | /v1/rails/customers/:id/swaps | List swaps; POST swaps between the naira and dollar wallets. |
| GET | /v1/rails/overview | Workspace totals (also /wallets, /entries, /transfers, /swaps, /accounts, /rates, /revenue, /health, /integrity; format=csv on lists). |
| POST | /v1/rails/sandbox/deposit | Simulate a deposit on a sandbox account: { customerId, amount }. |
| POST | /v1/rails/sandbox/reset | Wipe every sandbox rails row for the workspace (owner or admin). |
| GET | /.well-known/jwks.json | Public signing keys. |
| GET | /.well-known/egress-ips | Static outbound IPs for allowlisting. |
| GET | /llms.txt | This guide, machine-readable (no auth). |
Manage keys, endpoints, agents, and billing in the dashboard, or explore live in the Playground.
Try it without writing code first.