TwelveAI logoTwelveAI
Documentation

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.

Two ideas do most of the work. (1) Grounding: the model may only state facts a tool returned this turn. (2) The gate: a write is never executed until your policies allow it and the user confirms.

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   # sandbox

Quickstart

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.

npm install twelveai
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.

POST /v1/chat
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.

twelve.manage.*
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 else
The twelveai CLI (TWELVE_API_KEY in the env)
npx -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 usage
The SDK also ships request-verification helpers (verifySignedRequest 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/classify

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

Pricing. The keyword layer is always free. An AI pass bills its few tokens at your normal per-token pricing - billing.charged in the response says exactly what, with free: true when nothing was.
curl
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/chat

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

Request
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" }'
200 Response
{
  "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

FieldTypeDescription
messagestringThe user's message. Required unless resuming, or unless you send attachments.
attachmentsarrayUp 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.
customerIdstringYour customer id for this end-user (alias: userId). Applies their tier caps.
tierstringSync the customer's tier on this turn (last-write-wins).
tierLimitsobjectSent 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.
namestringCustomer name. Stored on the customer only if provided.
emailstringCustomer email. Stored on the customer only if provided.
channelstringOrigin channel, e.g. "whatsapp", "mobile", "voice".
intentstringForce a specific agent instead of auto-routing.
confirmedbooleanResend true (with the continuation) to execute a gated write.
metadataobjectArbitrary key/values stored with the turn and echoed back.
continuationstringToken from the previous turn: continue, or (with toolResults) resume.
toolResultsarrayResults for paused client-side tools when resuming: [{ id, result }].
historyarrayYour own longer conversation memory: [{ role: "user"|"assistant", content }]. Optional; the last 10 messages are kept server-side regardless.
reasoningstringRouting depth: "off" (keywords only), "auto" (default: one micro AI pass on weak signals), "always" (AI reasoning on every fresh turn; its tokens are billed).
customerTokenstringYour end-user's short-lived session token, forwarded verbatim as Authorization on tools with auth type customer_token. Never stored.
sandboxbooleanRun this turn in sandbox: tools return sample data, nothing real is called.

Response fields

FieldTypeDescription
okbooleanWhether the turn succeeded.
messagestring|nullThe grounded, user-facing reply. Present even when pending or escalated.
intentstring|nullThe classified agent/intent.
confidencenumber|nullRouting confidence for the intent (same scale as /v1/classify). Null on resumes and guide fallback.
toolCallsarrayExecuted tools: { name, arguments, result }. unconfigured:true = no endpoint attached.
pendingConfirmationobject|nullA gated write awaiting confirmed:true: { tool, arguments }.
pendingToolCallsarray|nullClient-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.
escalatedbooleanTrue when a policy routed the write for human review (nothing executed).
policystring|nullName of the policy rule that gated/blocked/escalated, if any.
autonomystring|nullHow it cleared: auto, full, confirmed, pending, escalated, blocked.
continuationstring|nullToken to send back next turn. It changes every turn.
transcriptstring|nullWhat we heard from an attached voice note. Absent when no audio was sent.
feeobject|nullA policy-attached transaction fee: { feeNgn, amountNgn, totalNgn }.
sandboxbooleanWhether this turn ran against stubbed sample data.
usageobject{ inputTokens, outputTokens }.
billingobject{ charged, inputNgn, outputNgn, audioNgn, balanceNgn, lowBalance }. Sandbox turns still bill their tokens (they run the model); they just never touch real systems.
latencyMsnumberEnd-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.

200OKThe turn ran. It may still hold a pendingConfirmation, an escalation, or a tool with unconfigured:true.
400Bad requestMissing/invalid input, e.g. no message and no continuation.
401UnauthorizedMissing or invalid x-api-key.
402Payment requiredPrepaid billing balance empty. Top up, then retry.
404Not foundUnknown resource, e.g. a customer that does not exist.
429Rate limitedToo many requests for this key. Back off and retry.
500Server errorUnexpected. Retry idempotent reads; never blind-retry a write without a continuation.

Confirmations (gated writes)

POST /v1/chat

Money-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).

We retain only the last 10 messages (customer and AI combined) plus system context under the token, not the whole history. Live figures (a balance, a status) are always re-fetched via tools, so answers never go stale. Pass your own history array if you need longer memory.

Streaming (SSE)

POST /v1/chat/stream

Streams 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/chat

Send 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" }],
});
Text inside an image is treated strictly as data, never as instructions, so an image cannot hijack the agent. Money movement still goes through your tools and the normal confirmation: the account is independently resolved before anything is sent, so a misread digit is caught. Each image counts toward the turn's tokens.

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`.
WhatsApp, two ways. Easiest: store your WhatsApp Cloud API access token once in Settings, then just pass the media id and we download + transcribe it for you: 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.

Voice notes and images just work here, we fetch the media with your stored token and transcribe/read it. Turns are billed exactly like the API (tokens, plus per-minute for voice).

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" } },
  ],
});
Transfers. We never move money or hold a wallet, your own banking API always does the debit. A confirmed transfer is handed back to your server (pendingToolCalls) and you execute it on your own rails, then resume with the result, which the AI relays.

Metadata

POST /v1/chat

Attach 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/customers

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

Customer
{
  "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.

Sync on a chat
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",
});
Sending 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.

FieldTypeDescription
auto_approveactionThe write runs without extra confirmation (within limits).
require_confirmationactionThe write pauses for the user to confirm (the default gate).
escalateactionThe write is routed to your human queue for review, not executed.
blockactionThe write is refused with a message.
feeactionA 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.

Response
{
  "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/plugins

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

Install / uninstall
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 bills
Each pack carries a visibility 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/agents

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

Create, configure, delete
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 deletable
Least privilege. An agent's permissions 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/:name

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

Attach endpoint
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.

Paste one real response
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.
Test 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.

Transfers are handed off, never executed by us. 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.
In live mode a capability tool with no endpoint returns { ok:false, unconfigured:true } (we never fabricate data). In sandbox it returns its sample response.

URL variables

GET /v1/tools

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

Placeholders in a URL
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}
What a tool declares
{
  "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.

The list comes from each tool’s schema, so it cannot drift out of date: if a tool gains an argument, the placeholder appears on its own. The AI fills each one from the conversation - you only decide where it goes in the URL.

Custom tools

POST /v1/tools

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

Create a tool
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/import

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

Preview, then import
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:

FieldTypeDescription
client-fetchno credentialPick "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.
jwksno secretEvery call carries a short-lived JWT signed with our private key, bound to that one request. Verify against /.well-known/jwks.json.
signedHMACX-Engine-Timestamp + X-Engine-Signature = HMAC-SHA256(secret, "ts.METHOD.path.body"), 5-minute window. Generate the secret under Settings.
customer_tokenper-userPass customerToken on each /v1/chat call; it's forwarded verbatim as Authorization. Scoped to the end-user's own session, never stored.
oauth2client credentialsToken URL + client id/secret; we fetch short-lived scoped tokens ourselves.
bearer / api_keystaticThe 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.

Manifest entry
{
  "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 tools. A tool marked 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.

FieldTypeDescription
Bills & utilitiesaggregatorBiller directory, meter/smartcard validation, and real package prices with zero setup. The payment itself is still a gated write on your rails.
Identity (KYC)statelessverify_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 railslist + resolveThe 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.

Where the money sits. TwelveAI provides the rails; the money never sits with TwelveAI. Funds are held by our licensed banking partner, Safe Haven Microfinance Bank, which is NDIC insured. Account issuing, transfers and bank validation all run on the banking partner, and the bank code returned everywhere in this API is the partner's (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.

FieldTypeDescription
Transfer fee₦50 flatTaken from the customer alongside the amount on every transfer out, and refunded with the amount when the payout fails.
Swap rateprovider + your marginThe 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.
Amountsmajor unitsNaira 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/account

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

Open an 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"
  }'
201 Response
{
  "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"
  }
}
201Created{ ok, created: true, account }. The customer now has an account number for deposits and a naira wallet.
200Existing{ ok, created: false, account }. The customer already had one; nothing changed.
400Not verified{ ok: false, error, verification: "failed" | "name_mismatch" }. The BVN did not verify, or the name differs from the record.
403Rails not activeLive 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.

FieldTypeDescription
GET .../accountobjectThe account number, name, bank and status: { ok, hasAccount, account }. account is null (and hasAccount false) until the account is opened.
GET .../balancesarrayEvery 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 .../statementarrayLedger 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.pdfPDFOne 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.
GET .../balances
{
  "ok": true,
  "customerId": "cus_123",
  "hasAccount": true,
  "balances": [
    { "currency": "NGN", "available": 47500, "pending": 0 },
    { "currency": "USD", "available": 100, "pending": 0 }
  ]
}
GET .../statement?limit=20&currency=NGN
{
  "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/transfers

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

Send money out
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"
  }'
201 Response
{
  "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
  }
}
201Created{ ok, replayed: false, transfer } with status processing, or succeeded when the provider settles synchronously.
200Replayed{ ok, replayed: true, transfer }: the idempotency key was already used by this customer. Nothing new moved.
400RefusedName mismatch (verifiedAccountName in the body), an invalid destination, or an insufficient balance.
409Key conflictThe idempotency key was used by a different customer.
422Provider 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/banks

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

FieldTypeDescription
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.
List banks, resolve an account
# 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"
200 Response (banks)
{
  "ok": true,
  "count": 1,
  "banks": [{ "name": "GTBank", "bankCode": "NG::000013", "nibssCode": "058", "type": "commercial" }]
}
200 Response (resolve)
{
  "ok": true,
  "account": { "accountName": "CHINEDU EZE", "accountNumber": "0123456789", "bankCode": "NG::000013", "nibssCode": "058", "bankName": "GTBank" }
}

NGN/USD swaps

GET / POST /v1/rails/customers/:customerId/swaps

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

Quote, then swap
# 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" }'
GET .../quote
{
  "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.

POST .../swaps (settled)
{
  "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
  }
}
201Created{ ok, replayed: false, swap } with status processing, or settled when the provider settles synchronously.
200Replayed{ ok, replayed: true, swap } for a reused idempotency key.
422Provider 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.

A full sandbox run
# 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"
FieldTypeDescription
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/resetowner or adminDeletes 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|livequeryOn 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:

FieldTypeDescription
AccountsinstantAny 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.
TransferssecondsDebit 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.
SwapsinstantSettle 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.
Chatreal ledgerOn 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.
Eventssandbox: truerail.deposit, rail.transfer.updated, rail.swap.updated and rail.account.opened published from the sandbox carry sandbox: true so automations can filter on it.
Going live changes nothing in your code except the key. Enroll on the Rails page (agreement, owner BVN, company CAC), swap 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.

FieldTypeDescription
GET /v1/railsenrollmentThe workspace's enrollment state (agreement, BVN, CAC, active), plus sandbox: { available, accounts, wallets, lastActivityAt } and mode.
GET /v1/rails/overviewtotalsCustomer funds, transfer counts by status, usd: { customerFundsUsd, pendingUsd, ngnValueAtProviderRate, providerNgnPerUsd } and swaps: { <status>: count }.
GET /v1/rails/walletslist?currency=NGN|USD. Every wallet with its customer, available and pending, the naira value, last movement and account; plus a total.
GET /v1/rails/entrieslistEvery ledger line across customers, newest first.
GET /v1/rails/transferslistEvery transfer out (/:reference for one, with its entries and timeline).
GET /v1/rails/swapslistEvery swap (?direction=, ?status=; /:reference for one, with entries and timeline).
GET /v1/rails/accountslistEvery customer account number the workspace has issued.
GET /v1/rails/ratesratesThe 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/revenuewindow?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/healthoperationsStuck transfers and swaps (each with alerted), the rate feed's staleness (stale after 5 minutes), and swaps: { settledLast24h, averageSettleMinutes, waiting }.
GET /v1/rails/integrityledgerWallets 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.

Delivery
// 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"
}
FieldTypeDescription
rail.account.openedeventA customer got their own account number. Fields: customerId, accountNumber, bankName, accountName, sandbox.
rail.depositeventMoney landed on a customer's account number. Fields: customerId, amount, currency, senderName, senderBank, senderAccountNumber, sessionId, narration, reference, balance, sandbox.
rail.transfer.updatedeventA transfer changed status: processing, succeeded, failed (refunded), reversed. Fields: customerId, status, amount, currency, reference, failureReason, sandbox.
rail.transfer.stuckeventA payout has waited on the provider for more than 15 minutes; fires once per transfer. Fields: reference, customerId, amount, fee, status, minutesInFlight.
rail.swap.updatedeventA swap changed status: processing, settled, failed (refunded), reversed. Fields: reference, customerId, direction, amountIn, amountOut, rate, status, sandbox.
rail.swap.stuckeventA 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.paideventMoney moved from your workspace float to a customer's wallet. Fields: reference, customerId, customerName, amount, currency, note, sandbox.
rail.float.loweventThe 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:

Customers
  • customer.created A customer record was created (first chat or via the API).
  • customer.tier_changed A customer moved up or down a tier.
  • customer.blocked A customer's status was set to blocked.
  • customer.identity.verified A BVN or NIN verified and the identity profile was recorded. Never carries the number or the photo.
  • beneficiary.saved A verified recipient was saved for a customer.
Conversations
  • ai.response.completed An agent answered a turn, with the tool-call count and token usage.
  • conversation.completed One turn: the customer's message and the reply, on any channel.
  • conversation.ended A customer went quiet for 10 minutes; first message, last reply and the intents seen.
  • followup.due A reminder an agent scheduled came due.
Actions
  • action.confirmation_required An agent paused a write for the customer to confirm.
  • action.escalated A write was handed to human review.
  • action.executed A customer confirmed and an action ran.
  • action.blocked A write was stopped by your policies or guardrails.
  • kyc.verification A 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.
Money rails
  • rail.account.opened A customer got their own account number.
  • rail.deposit Money landed on a customer's account number: sender, session id, narration, balance.
  • rail.transfer.updated A transfer moved: processing, succeeded, failed (refunded), reversed.
  • rail.transfer.stuck A payout has waited on the provider for more than 15 minutes; fires once per transfer.
  • rail.swap.updated An NGN/USD swap moved: processing, settled, failed (refunded), reversed.
  • rail.swap.stuck A swap has waited on the provider for more than an hour; fires once per swap.
  • rail.float.paid Money moved from your workspace float to a customer's wallet.
  • rail.float.low The workspace float dropped below your threshold (at most once a day).
Business
  • business.payment.succeeded A payment on your TwelveAI Business account succeeded.
  • business.payment.failed A customer tried to pay your business and the charge failed.
  • business.refund.processed Your business refunded a customer, fully or partly.
  • business.deposit.received Money arrived on your business account number by bank transfer.
Workspace
  • schedule.completed A scheduled agent run or Morning Brief completed (or failed).
  • instinct.finding An instinct-enabled agent spotted something off and reported it.
  • billing.low_balance Your prepaid TwelveAI balance dropped to or below your alert threshold (your credit, not a customer's).

Billing & usage

GET /v1/usage

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

Customers
  • customer.created A customer record was created (first chat or via the API).
  • customer.tier_changed A customer moved up or down a tier.
  • customer.blocked A customer's status was set to blocked.
  • customer.identity.verified A BVN or NIN verified and the identity profile was recorded. Never carries the number or the photo.
  • beneficiary.saved A verified recipient was saved for a customer.
Conversations
  • ai.response.completed An agent answered a turn, with the tool-call count and token usage.
  • conversation.completed One turn: the customer's message and the reply, on any channel.
  • conversation.ended A customer went quiet for 10 minutes; first message, last reply and the intents seen.
  • followup.due A reminder an agent scheduled came due.
Actions
  • action.confirmation_required An agent paused a write for the customer to confirm.
  • action.escalated A write was handed to human review.
  • action.executed A customer confirmed and an action ran.
  • action.blocked A write was stopped by your policies or guardrails.
  • kyc.verification A 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.
Money rails
  • rail.account.opened A customer got their own account number.
  • rail.deposit Money landed on a customer's account number: sender, session id, narration, balance.
  • rail.transfer.updated A transfer moved: processing, succeeded, failed (refunded), reversed.
  • rail.transfer.stuck A payout has waited on the provider for more than 15 minutes; fires once per transfer.
  • rail.swap.updated An NGN/USD swap moved: processing, settled, failed (refunded), reversed.
  • rail.swap.stuck A swap has waited on the provider for more than an hour; fires once per swap.
  • rail.float.paid Money moved from your workspace float to a customer's wallet.
  • rail.float.low The workspace float dropped below your threshold (at most once a day).
Business
  • business.payment.succeeded A payment on your TwelveAI Business account succeeded.
  • business.payment.failed A customer tried to pay your business and the charge failed.
  • business.refund.processed Your business refunded a customer, fully or partly.
  • business.deposit.received Money arrived on your business account number by bank transfer.
Workspace
  • schedule.completed A scheduled agent run or Morning Brief completed (or failed).
  • instinct.finding An instinct-enabled agent spotted something off and reported it.
  • billing.low_balance Your prepaid TwelveAI balance dropped to or below your alert threshold (your credit, not a customer's).

Versioning

GET /v1/version

The 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:

FieldTypeDescription
Money movementyour rails, or managedBy 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 IPsGET /.well-known/egress-ipsOur outbound tool calls come from static IPs. Allowlist them at your firewall.
Signing keysGET /.well-known/jwks.jsonPublic keys that verify our jwks-signed requests. No shared secret exists for this mode.
Webhooksx-webhook-secretYour own secret, sent verbatim on every delivery, so you verify origin by comparison.
Credentialsencrypted or absentStored endpoint credentials are encrypted at rest and never returned by any API. Client-fetch and jwks modes share no credential at all.
Zero-credential optionclient-fetchThe 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.

1

Fund your billing balance. Open Billing & Costs and top up. Live turns need a positive balance.

2

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.

3

Set limits. Define tiers and caps under Customers, and opt into rules under Policy.

4

Test in the Playground. Chat as a customer. Toggle Sandbox for sample data; turn it off to hit your live endpoints.

5

Watch it run. Every turn appears in Requests; usage rolls up under Analytics.

Endpoint reference

POST/v1/classifyIntent + confidence + entities; keyword-first with an AI reasoning pass on weak signals (reasoning: off | auto | always).
POST/v1/chatA grounded turn (start, continue, or resume).
POST/v1/chat/streamStreaming turn (SSE).
GET/v1/manifestPer-active-agent tool-call contract - code against this.
GET/v1/versionVersion + changelog.
GET/v1/usageToken usage summary.
GET/v1/usage/analyticsDaily series: turns, tokens, latency, intents, channels.
GET/v1/usage/eventsRecent request traces (/:id for one full trace).
GET / POST / PUT/v1/agentsList, create (custom agents), or configure agents (DELETE /:intent).
GET/v1/pluginsAgent marketplace catalog (POST /:id/install, /:id/uninstall).
GET / POST/v1/customersList or create/update customers.
GET / PATCH/v1/customers/:idFetch or update one customer.
GET/v1/customers/:id/eventsOne customer's chats.
GET/v1/billingBilling balance + transactions.
POST/v1/billing/topupFund the balance (min ₦5,000; verify with /topup/verify).
GET/v1/keysReveal your keys (POST /v1/keys/rotate to rotate).
GET / PUT/v1/settingsGuardrails, webhook + secret, tiers, policies, WhatsApp, signing.
GET / POST/v1/toolsEvery tool with its binding state; POST creates a custom tool.
GET / PUT/v1/tools/:nameAttach or update a tool's endpoint.
POST/v1/tools/:name/testCall a tool's endpoint and report the HTTP status.
POST/v1/tools/importImport an OpenAPI document (dryRun previews).
GET / PUT/v1/bank-railsBank list + account resolution config (POST /test to check).
GET/v1/railsMoney rails enrollment state + sandbox summary (POST /sla, /verify-bvn, /verify-cac to enroll).
GET / POST/v1/rails/customers/:id/accountA customer's account number; POST opens one (BVN verified, name matched).
GET/v1/rails/customers/:id/balancesNGN and USD wallets: [{ currency, available, pending }].
GET/v1/rails/customers/:id/statementLedger lines (?limit, ?currency); /statement.pdf?month=YYYY-MM for a PDF.
GET / POST/v1/rails/customers/:id/transfersList transfers out; POST sends one (idempotencyKey required, ₦50 fee, bankCode "NG::000013" or "058").
GET/v1/rails/banksThe banks the partner can pay into (?q= filters): [{ name, bankCode, nibssCode, type }].
GET/v1/rails/banks/resolveWhose account a bankCode + accountNumber pair is; 404 when the bank does not know it. Nothing moves.
GET/v1/rails/customers/:id/quoteNGN/USD quote: ?direction=NGN_USD|USD_NGN&amount=. Nothing moves.
GET / POST/v1/rails/customers/:id/swapsList swaps; POST swaps between the naira and dollar wallets.
GET/v1/rails/overviewWorkspace totals (also /wallets, /entries, /transfers, /swaps, /accounts, /rates, /revenue, /health, /integrity; format=csv on lists).
POST/v1/rails/sandbox/depositSimulate a deposit on a sandbox account: { customerId, amount }.
POST/v1/rails/sandbox/resetWipe every sandbox rails row for the workspace (owner or admin).
GET/.well-known/jwks.jsonPublic signing keys.
GET/.well-known/egress-ipsStatic outbound IPs for allowlisting.
GET/llms.txtThis 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.