12TwelveAI Business
Docs/API Reference

API reference

Every endpoint, in the open — with request samples in cURL, Node, Python, PHP, Go, Ruby and Java. REST over HTTPS, JSON in and out, amounts in kobo. Base URL: https://api.twelveai.app

Getting started

Each key is a public/secret pair, mode-scoped. Your secret key is tw_test_… (test) or tw_live_… (live); the matching public key is tw_pub_test_… / tw_pub_live_…. Authenticate every request with the secret key as a bearer token — never expose it in client-side code. Connect platforms use an OAuth access token (tw_at_…) on the same endpoints, limited to the granted scopes.

Authentication header
Authorization: Bearer tw_live_xxxxxxxxxxxxxxxxxxxx

Every endpoint below includes a ready-to-run request in your language — switch tabs on the Request panel.

Payments

POST/api/business/v1/payments/initialize

Initialize a payment

Starts a payment and returns a branded checkout_url to redirect your customer to.

ParameterDescription
email*Customer email.
amount*Amount in kobo. Min 100.
referenceYour order id. Auto-generated if omitted.
callback_urlWhere the customer is redirected after payment. Defaults to your dashboard payment page.
customer_nameStored on the payment.
customer_phoneStored on the payment.
metadataEchoed back on verify + webhook.
application_fee_percentConnect only: your platform cut as a percentage of the charge (e.g. 2.5). Defaults to your app’s configured %. Capped at net.
application_fee_bpsConnect only: the same cut in basis points (250 = 2.5%). Ignored if application_fee_percent is set.
application_fee_capConnect only: cap the % fee at this max amount in kobo (e.g. 10000 = ₦100, so "2% capped at ₦100"). Defaults to your app’s configured cap. 0 = uncapped.
Request
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \
  -H "Authorization: Bearer tw_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "email": "customer@example.com",
  "amount": 50000,
  "reference": "order-12345"
}'
Response
{
  "success": true,
  "data": {
    "reference": "order-12345",
    "checkout_url": "https://checkout.twelveai.app/c/order-12345",
    "authorization_url": "https://checkout.paystack.com/…",
    "amount": 50000,
    "currency": "NGN"
  }
}
Redirect the customer to checkout_url (our branded page), or use authorization_url to skip straight to the processor.
GET/api/business/v1/payments/verify/:reference

Verify a payment

Re-checks a payment. Use when a webhook was missed. Idempotent.

Request
curl "https://api.twelveai.app/api/business/v1/payments/verify/order-12345" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "success": true,
  "data": {
    "reference": "order-12345",
    "status": "successful",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "paid_at": "2026-06-12T12:34:56Z",
    "customer": { "email": "customer@example.com", "name": null },
    "metadata": { "order_id": "12345" }
  }
}
GET/api/business/v1/payments

List payments

Cursor-paginated, newest first, scoped to the mode of the key you send.

ParameterDescription
statusFilter: successful | pending | failed.
limitDefault 50, max 200.
cursorPrevious response's next_cursor.
Request
curl "https://api.twelveai.app/api/business/v1/payments?status=successful&limit=50" \
  -H "Authorization: Bearer tw_live_..."
Response
{
  "success": true,
  "data": {
    "payments": [
      {
        "reference": "order-12345",
        "amount": 50000,
        "fee": 750,
        "net": 49250,
        "currency": "NGN",
        "status": "successful",
        "settlement_status": "settled",
        "customer_email": "customer@example.com",
        "paid_at": "2026-06-12T12:34:56Z",
        "created_at": "2026-06-12T12:30:00Z"
      }
    ],
    "next_cursor": null
  }
}
GET/api/business/v1/saved-cards

List saved cards

Reusable card authorizations captured on successful payments. Use the authorization_code to re-charge.

ParameterDescription
emailFilter to one customer.
limitDefault 50, max 200.
cursorPagination cursor.
Request
curl "https://api.twelveai.app/api/business/v1/saved-cards?email=customer@example.com" \
  -H "Authorization: Bearer tw_live_..."
Response
{
  "success": true,
  "data": {
    "saved_cards": [
      {
        "authorization_code": "AUTH_xxxxxxxx",
        "customer_email": "customer@example.com",
        "brand": "visa",
        "last4": "4081",
        "exp_month": "08",
        "exp_year": "2029",
        "bank": "GTBank",
        "reusable": true
      }
    ],
    "next_cursor": null
  }
}
Treat authorization codes as secrets — anyone holding one can charge that card through your account. Store them server-side only.
POST/api/business/v1/payments/charge-authorization

Charge a saved card

Synchronous re-charge against a saved authorization_code. Live mode only.

ParameterDescription
authorization_code*From GET /saved-cards.
email*The card owner’s email.
amount*Amount in kobo.
referenceYour reference for this charge.
metadataEchoed back on verify + webhook.
Request
curl -X POST https://api.twelveai.app/api/business/v1/payments/charge-authorization \
  -H "Authorization: Bearer tw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
  "authorization_code": "AUTH_xxxxxxxx",
  "email": "customer@example.com",
  "amount": 50000
}'
Response
{
  "success": true,
  "message": "Charged.",
  "data": {
    "reference": "subscription-may-2026",
    "status": "successful",
    "amount": 50000,
    "net": 49250,
    "paid_at": "2026-05-01T08:00:00Z"
  }
}
Live mode only — saved-card re-charges always move real money. There is no test-mode charge-authorization.

Balance & money

GET/api/business/v1/balance

Get balance

Wallet balances (kobo), verification status, and this month's live usage.

Request
curl "https://api.twelveai.app/api/business/v1/balance" \
  -H "Authorization: Bearer tw_live_..."
Response
{
  "success": true,
  "data": {
    "available_balance": 4925000,
    "pending_balance": 150000,
    "currency": "NGN",
    "verification_status": "verified",
    "monthly_cap": null,
    "monthly_used": 12500000
  }
}
GET/api/business/v1/transactions

List transactions

Unified money-movement ledger (live): card payments + incoming bank transfers + payouts, newest first. Connect scope: transactions:read.

ParameterDescription
limitDefault 50, max 200.
offsetPagination offset.
Request
curl "https://api.twelveai.app/api/business/v1/transactions?limit=50" \
  -H "Authorization: Bearer tw_live_..."
Response
{
  "success": true,
  "data": {
    "transactions": [
      {
        "kind": "payment",
        "direction": "credit",
        "reference": "order-12345",
        "amount_minor": 50000,
        "fee_minor": 750,
        "currency": "NGN",
        "status": "successful",
        "counterparty": "customer@example.com",
        "created_at": "2026-06-12T12:34:56Z"
      }
    ],
    "next_offset": 50
  }
}
kind is payment | collection | payout; direction is credit | debit. Paginate with next_offset.
GET/api/business/v1/customers

List customers

Your customers, aggregated by email across payments. Connect scope: customers:read.

ParameterDescription
qFilter by email (substring).
limitDefault 50, max 200.
Request
curl "https://api.twelveai.app/api/business/v1/customers?limit=50" \
  -H "Authorization: Bearer tw_live_..."
Response
{
  "success": true,
  "data": {
    "customers": [
      {
        "email": "customer@example.com",
        "payments": 3,
        "total_minor": 150000,
        "last_seen": "2026-06-12T12:34:56Z"
      }
    ]
  }
}
POST/api/business/v1/transfers

Create a payout

Withdraw available balance to the account's saved settlement account. Live only. Connect scope: payouts:write. GET the same path lists payouts.

ParameterDescription
amount*Amount in NGN (major units), min 5,000.
memoOptional note on the payout.
Request
curl -X POST https://api.twelveai.app/api/business/v1/transfers \
  -H "Authorization: Bearer tw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 25000
}'
Response
{
  "success": true,
  "message": "Payout submitted. Status will update when the bank confirms.",
  "data": {
    "payout": {
      "reference": "TWAI_PO_…",
      "status": "processing",
      "amount": 2500000,
      "currency": "NGN",
      "destination": {
        "bank_name": "GTBank",
        "account_number": "0123456789",
        "account_name": "Greyark Technologies LLC"
      },
      "created_at": "2026-06-12T12:34:56Z"
    }
  }
}
Live only, and always to the saved settlement account — a Connect platform can never route funds to an arbitrary destination. Min ₦5,000; the unverified monthly cap still applies.

Banks

GET/api/business/v1/banks

List banks

Supported payout banks with their codes. Use a returned code as bank_code when resolving an account or creating a payout. No scope required.

ParameterDescription
countryISO country code. Default 'NG'.
Request
curl "https://api.twelveai.app/api/business/v1/banks?country=NG" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "success": true,
  "data": {
    "banks": [
      { "code": "NG::000013", "name": "Guaranty Trust Bank" },
      { "code": "NG::000014", "name": "Access Bank" },
      { "code": "NG::000015", "name": "Zenith Bank" }
    ]
  }
}
The list is cached and rarely changes. A code returned here is guaranteed usable in /banks/resolve and in a payout.
GET/api/business/v1/banks/resolve

Resolve an account

Verify a 10-digit NUBAN and return the account holder name. Use this to confirm a destination before a payout. No scope required; rate-limited to 30/min.

ParameterDescription
account_number*10-digit NUBAN.
bank_code*A code from GET /banks (a legacy CBN code or bank name is also accepted).
Request
curl "https://api.twelveai.app/api/business/v1/banks/resolve?account_number=0123456789&bank_code=NG::000013" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "success": true,
  "data": {
    "account_number": "0123456789",
    "account_name": "JOHN A ADEYEMI",
    "bank_code": "NG::000013",
    "bank_name": "Guaranty Trust Bank"
  }
}

Webhooks

POST/api/business/v1/webhooks

Configure webhooks

Sets (or replaces) the URL we POST events to for this mode. Signing is optional and bring-your-own.

ParameterDescription
url*Your https endpoint.
signing_secretOptional — your own secret. We sign x-twelveai-signature with it. Omit to keep the current one; pass "" to clear it (unsigned).
generate_secretOptional — set true to have us mint a whsec_… secret (returned once). Ignored if signing_secret is set.
Request
curl -X POST https://api.twelveai.app/api/business/v1/webhooks \
  -H "Authorization: Bearer tw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://yourdomain.com/webhooks/twelve",
  "signing_secret": "my_own_secret_123"
}'
Response
{
  "success": true,
  "message": "Webhook saved. Deliveries are signed — store your signing_secret to verify them.",
  "data": {
    "id": 12,
    "mode": "live",
    "url": "https://yourdomain.com/webhooks/twelve",
    "signing_secret": "my_own_secret_123",
    "secret_set": true,
    "enabled": true
  }
}

Connect (OAuth)

GET/oauth/authorize

Authorize (browser redirect)

Send the business you want to connect to this URL in their browser. On approval we redirect back to your redirect_uri with a one-time code and your metadata.

ParameterDescription
client_id*Your platform app id.
redirect_uri*Must exactly match a registered URI.
scopeOptional. Space-separated. Defaults to the scopes you chose when registering the app; omit it to grant them all.
metadataEchoed back verbatim; use it to correlate / for CSRF. May be a JSON object.
modetest | live (live needs app approval). Default test.
code_challengeOptional PKCE (S256).
Request
GET https://dashboard.twelveai.app/oauth/authorize
  ?client_id=twc_...
  &redirect_uri=https://yourapp.com/twelveai/callback
  &metadata=<opaque, echoed back to you>
  &mode=live
  &code_challenge=...&code_challenge_method=S256
Response
# On approval we redirect the browser to:
https://yourapp.com/twelveai/callback?code=tw_ac_...&metadata=...
POST/oauth/token

Exchange / refresh token

Client-authenticated (no bearer). Exchange the auth code for an access token, or refresh an existing one.

ParameterDescription
grant_type*authorization_code | refresh_token
client_id*Your platform app id.
client_secret*Your platform secret.
codeFor authorization_code.
redirect_uriMust match the authorize request.
code_verifierFor PKCE.
refresh_tokenFor refresh_token.
Request
curl -X POST https://api.twelveai.app/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
  "grant_type": "authorization_code",
  "client_id": "twc_...",
  "client_secret": "twcs_...",
  "code": "tw_ac_...",
  "redirect_uri": "https://yourapp.com/twelveai/callback"
}'
Response
{
  "success": true,
  "access_token": "tw_at_...",
  "refresh_token": "tw_rt_...",
  "token_type": "Bearer",
  "scope": "payments:read payments:write",
  "mode": "live"
}
Then call any /api/business/v1/* endpoint with Authorization: Bearer tw_at_…, limited to the granted scopes.
POST/oauth/sandbox/connect

Sandbox connect

Client-authenticated. Returns an instant TEST access token connected to a sandbox account, so you can test your on-behalf integration without the interactive consent flow. Test mode only.

ParameterDescription
client_id*Your platform app id.
client_secret*Your platform secret.
scopeOptional subset of your app scopes. Defaults to all.
Request
curl -X POST https://api.twelveai.app/oauth/sandbox/connect \
  -H "Content-Type: application/json" \
  -d '{
  "client_id": "twc_...",
  "client_secret": "twcs_..."
}'
Response
{
  "success": true,
  "access_token": "tw_at_...",
  "refresh_token": "tw_rt_...",
  "token_type": "Bearer",
  "scope": "payments:read payments:write",
  "mode": "test",
  "connected_account_id": 2
}

Connect — act on behalf of other businesses

TwelveAI Connect lets your platform accept payments, read data, and initiate payouts on behalf of other TwelveAI businesses that authorize you. It is the standard OAuth 2.0 authorization-code flow. You never see the connected business's own API keys — you receive a scoped, revocable access token per connected account.

  1. 1. Register a platform app in your dashboard (Developers → Register app). You get a client_id and client_secret (viewable any time) and pick the scopes your app may request. Test mode works immediately; live requires approval.
  2. 2. Send the business to the consent screen with your client_id. They approve, and we redirect back to your redirect_uri with a one-time code.
  3. 3. Exchange the code for tokens from your server — the code alone is nothing; you must POST it to /oauth/token with your client_secret. See How the flow works below.
  4. 4. Call the API on their behalf — send the access token as the bearer on any /api/business/v1/* call, limited to the granted scopes. There is no separate on-behalf endpoint: the token identifies the connected business, so the same /payments/initialize a first-party merchant uses now charges on their behalf.
Charge on behalf of a connected business
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \
  -H "Authorization: Bearer tw_at_..." \   # the connected business's access token
  -H "Content-Type: application/json" \
  -d '{
    "email": "customer@example.com",
    "amount": 50000,
    "reference": "order-12345",
    "application_fee_bps": 250        // optional — your platform cut (250 = 2.5%)
  }'

Application fees. Take a cut of on-behalf charges by setting a default on your app (Developers → Default fee) or per charge with application_fee (kobo) or application_fee_bps. At settlement the connected account is credited net-of-fee and your platform wallet is credited the fee.

How Connect works end-to-end

Connect is the standard OAuth 2.0 authorization-code flow. It is a three-step handoff between the browser and your server, and it is not complete until step 3. If your redirect_uri receives a code but your server never exchanges it, no connection is saved — the code silently expires after 60 seconds.

Step 1 — Browser
Redirect the business to GET /oauth/authorize?client_id=…&redirect_uri=…. They approve on our consent screen.
Step 2 — Browser
We redirect back to your redirect_uri with ?code=tw_ac_…. The code is single-use and expires in 60 seconds.
Step 3 — Your server (REQUIRED)
Your backend POSTs POST /oauth/token with the code + your client_secret. You get back an access_token. The connection is created here, not on approve.The browser cannot do this step — the client secret must never touch it.

Step 3 in code

Minimal server-side exchange — this is what turns the approved code into a saved connection and a working access token. Run it on your backend, never in the browser.

Node.js
// POST /api/twelveai/exchange — called by your redirect_uri handler
app.post("/api/twelveai/exchange", async (req, res) => {
  const { code } = req.body

  const r = await fetch("https://dashboard.twelveai.app/oauth/token", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      grant_type:    "authorization_code",
      client_id:     process.env.TWELVEAI_CLIENT_ID,
      client_secret: process.env.TWELVEAI_CLIENT_SECRET, // never in the browser
      code,
      redirect_uri:  "https://yourapp.com/twelveai/callback", // must match step 1
    }),
  })
  const tokens = await r.json()
  // tokens.access_token = tw_at_…  — store it against the connected merchant.
  res.json({ ok: true, connected_account_id: tokens.connected_account_id })
})
Python (Flask)
@app.post("/api/twelveai/exchange")
def exchange():
    code = request.json["code"]
    r = requests.post("https://dashboard.twelveai.app/oauth/token", json={
        "grant_type":    "authorization_code",
        "client_id":     os.environ["TWELVEAI_CLIENT_ID"],
        "client_secret": os.environ["TWELVEAI_CLIENT_SECRET"],  # server-side only
        "code":          code,
        "redirect_uri":  "https://yourapp.com/twelveai/callback",
    })
    tokens = r.json()  # {access_token: "tw_at_…", refresh_token, scope, mode, connected_account_id}
    # Store tokens.access_token against the connected merchant.
    return jsonify(ok=True, connected_account_id=tokens["connected_account_id"])
Testing without a server? Use the Test connection button on your dashboard's Developers page — it walks the whole flow and calls the exchange for you. Or hit POST /oauth/sandbox/connect to skip the browser entirely and get a test access token in one call.

Connecting a business: popup & mobile

Three ways to run step 1 — pick what fits your app. All of them end the same way: the browser lands on your redirect_uri with a one-time code, and your server must do step 3 (POST /oauth/token) or nothing is saved.

Web — one-click button (drop-in)

The fastest path: include connect.js and add a button with data-twelveai-connect. It’s auto-wired on load — no JS to write. Handle the result with a global data-on-success function or a twelveai:success DOM event.

Drop-in button (HTML only)
<script src="https://dashboard.twelveai.app/connect.js"></script>

<button
  data-twelveai-connect
  data-client-id="twc_..."
  data-redirect-uri="https://yourapp.com/twelveai/callback"
  data-mode="live"
  data-on-success="onTwelveConnected">
  Connect with TwelveAI
</button>

<script>
  // Called with the one-time code — exchange it on YOUR server.
  function onTwelveConnected({ code }) {
    fetch("/api/twelveai/exchange", { method: "POST", body: JSON.stringify({ code }) })
  }
  // …or listen on the element: btn.addEventListener("twelveai:success", e => e.detail.code)
</script>

Style it your way. The button gets a default .twelveai-connect-btn class you can override in your own CSS. Add classes with data-class="my-btn" (kept alongside the default), set custom text as the button’s content, or drop the default look entirely with data-unstyled.

React or a framework? Wire an element you render, or create one:

Programmatic (button / mount)
// Wire an existing element:
TwelveConnect.button("#connect", {
  clientId: "twc_...",
  redirectUri: "https://yourapp.com/twelveai/callback",
  // scope is optional — defaults to the app's registered scopes
  className: "my-btn",                    // appended alongside the default
  unstyled: false,                        // true = your styles only
  onSuccess: ({ code }) => { /* exchange server-side */ },
})

// …or create + insert a button into a container:
TwelveConnect.mount("#slot", { clientId: "twc_...", redirectUri: "...", label: "Connect" })

React, Vue, Svelte & Next

The SDK is plain browser JavaScript on window.TwelveConnect — framework-agnostic and SSR-safe (it no-ops on the server, so it’s safe in Next / SvelteKit / Nuxt). The drop-in data-twelveai-connect button even auto-wires when your framework renders it after load (a MutationObserver re-scans on DOM changes). Or call the programmatic API from a client-only mount hook.

React / Next
import Script from "next/script" // or a plain <script> tag

export function ConnectButton() {
  useEffect(() => {
    window.TwelveConnect?.button("#connect", {
      clientId: "twc_...", redirectUri: "https://yourapp.com/twelveai/callback",
      onSuccess: ({ code }) => exchangeOnServer(code),
    })
  }, [])
  return (
    <>
      <Script src="https://dashboard.twelveai.app/connect.js" strategy="afterInteractive" />
      <button id="connect" className="my-btn">Connect with TwelveAI</button>
    </>
  )
}
Vue
<script setup>
import { onMounted } from "vue"
onMounted(() =>
  window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "...", onSuccess })
)
</script>
<template>
  <button id="connect" class="my-btn">Connect with TwelveAI</button>
</template>
Svelte
<script>
  import { onMount } from "svelte"
  onMount(() =>
    window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "...", onSuccess })
  )
</script>
<button id="connect" class="my-btn">Connect with TwelveAI</button>

Load connect.js once (a script tag in your document head, or Next’s <Script>). In SSR apps, call the API from a client-only hook (useEffect / onMounted / onMount) — never during render.

Web — popup call (full control)

Drop in connect.js and call TwelveConnect.open(). It opens a popup and hands the code back via postMessage — your page never navigates away.

Web popup
<script src="https://dashboard.twelveai.app/connect.js"></script>
<script>
  TwelveConnect.open({
    clientId: "twc_...",
    redirectUri: "https://yourapp.com/twelveai/callback", // must be registered
    mode: "live", // scope is optional — defaults to the app's registered scopes
    onSuccess: async ({ code }) => {
      // Send code to YOUR server, then exchange it with your client_secret.
      await fetch("/api/twelveai/exchange", { method: "POST", body: JSON.stringify({ code }) })
    },
    onError: (err) => console.error(err),
    onClose: () => {}, // user closed the popup
  })
</script>

Note: this is a popup window, not an iframe modal — an OAuth consent screen can’t be safely iframed (and we send X-Frame-Options).

Mobile app — in-app browser + deep link

Register a custom-scheme redirect URI (e.g. myapp://twelveai/callback), open the same authorize URL in an in-app browser, and capture the code from the deep link the consent screen redirects to. Then exchange it from your server.

React Native / Expo
import * as WebBrowser from "expo-web-browser"

const redirectUri = "myapp://twelveai/callback"   // registered on your app
const authorizeUrl =
  "https://dashboard.twelveai.app/oauth/authorize" +
  "?client_id=twc_..." +
  "&redirect_uri=" + encodeURIComponent(redirectUri) +
  "&mode=live" // scope optional — defaults to the app's registered scopes

const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, redirectUri)
if (result.type === "success") {
  const code = new URL(result.url).searchParams.get("code")
  // POST code to your server → exchange at /oauth/token with your client_secret
}

Use PKCE (code_challenge) for mobile, since public clients can’t hold a secret. Universal/App Links (an https redirect that opens your app) work too.

Connect — testing your integration

Use the sandbox to exercise scopes and on-behalf calls without the consent flow. Every call here is test mode.

1. Get a sandbox test token

Sandbox connect
curl -X POST https://api.twelveai.app/oauth/sandbox/connect \
  -H "Content-Type: application/json" \
  -d '{ "client_id": "twc_...", "client_secret": "twcs_..." }'

# → { "access_token": "tw_at_...", "mode": "test",
#     "scope": "payments:read payments:write ...", "connected_account_id": 2 }

To test scope enforcement, request a subset — the token is limited to it:

Restrict scopes
-d '{ "client_id":"twc_...", "client_secret":"twcs_...", "scope":"payments:read" }'

2. Charge on behalf (payments:write)

Initialize a payment as the platform
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \
  -H "Authorization: Bearer tw_at_..." \
  -H "Content-Type: application/json" \
  -d '{ "email":"buyer@test.com", "amount":50000, "application_fee_bps":250 }'
# → open the returned checkout_url; pay with the test card:
#   4084 0840 8408 4081 · any CVV · any future expiry · OTP 123456

3. Read scopes

Exercise each read scope
curl https://api.twelveai.app/api/business/v1/payments      -H "Authorization: Bearer tw_at_..."  # payments:read
curl https://api.twelveai.app/api/business/v1/balance      -H "Authorization: Bearer tw_at_..."  # balance:read
curl https://api.twelveai.app/api/business/v1/transactions -H "Authorization: Bearer tw_at_..."  # transactions:read
curl https://api.twelveai.app/api/business/v1/saved-cards  -H "Authorization: Bearer tw_at_..."  # customers:read

4. Prove enforcement

A call outside the token's granted scopes is rejected with 403 insufficient_scope:

Insufficient scope
# token issued with scope=payments:read, then attempt a charge:
# → 403 { "error": "insufficient_scope",
#         "message": "This token is missing the required scope: payments:write" }
Two sandbox limits to know. Payouts are live-only — a test token calling /transfers returns "Payouts are only available in live mode." You can still verify the scope gate, but a successful payout needs a live connection. Application fees settle at T+1 (live only) — in test the fee is recorded on the payment but the net-to-platform split isn't moved. Test verifies scopes + plumbing; live verifies settled money movement.

Connect scopes

A connection’s granted scopes are the ceiling — enforced on every request. Out-of-scope calls return 403 insufficient_scope.

payments:readGET /payments · GET /payments/verify/:reference
payments:writePOST /payments/initialize · POST /payments/charge-authorization
transactions:readGET /transactions
balance:readGET /balance
customers:readGET /customers · GET /saved-cards
payouts:writePOST /transfers · GET /transfers (live only)

Webhooks

We POST a JSON event to your configured URL whenever money moves — a payment succeeds or fails, a refund clears, or a bank transfer lands in your wallet. Configure the endpoint at POST /api/business/v1/webhooks (per mode).

Signing is optional and bring-your-own. Send your own signing_secret and we’ll sign every delivery’s x-twelveai-signature with it (verify against the raw body). Or set generate_secret: true to have us mint one. If you set neither, deliveries are sent unsigned — in that case restrict your endpoint another way (a secret path, an IP allowlist, or mutual TLS).

Events

charge.successBusiness + platformA card/Paystack payment succeeded.
charge.failedBusiness + platformA payment attempt failed.
refund.processedBusiness + platformA refund completed.
transfer.receivedBusinessAn incoming bank transfer credited the wallet (direct NUBAN deposit).
connection.createdPlatformA business connected to your Connect app.
connection.updatedPlatformA connection’s granted scopes changed.
connection.revokedPlatformA business disconnected from your app.

“Business + platform” = the connected business always receives it; the Connect platform also receives it when the charge was made on its behalf.

Headers on every delivery

  • x-twelveai-signature — HMAC-SHA512 of the raw body, hex. Only present when you’ve set a signing secret — unsigned deliveries omit it.
  • x-twelveai-timestamp — Unix seconds. Reject if > 5 minutes old.
  • x-twelveai-event — event name.
  • x-twelveai-event-id — unique delivery id for dedup.

Connect: platform webhooks

If you’re a platform and a charge was made on behalf of a connected account, we also POST that payment event to your app’s webhook_url — signed with your app client secret (not the connected account’s signing secret). It carries the same fields plus app_id, connected_account_id, and the application_fee (kobo) you earned. Your app also receives connection.created, connection.updated, and connection.revoked lifecycle events. Direct bank transfers (transfer.received) aren’t on-behalf charges, so they’re delivered to the business only.

Scope gating. Payment events reach your app only while the connection is active and still grants a payments scope — if the business disconnects or drops payments access, delivery stops. The event names follow the processor’s convention, mapped to scopes like this:

payments:read or payments:writecharge.success · charge.failed · refund.processed
(connection lifecycle)connection.created · connection.updated · connection.revoked
(account only — not sent to platforms)transfer.received

Example payloads

charge.success
{
  "event": "charge.success",
  "data": {
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "successful",
    "paid_at": "2026-06-12T12:34:56Z",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "provider": "paystack",
    "provider_reference": "1234567890",
    "metadata": { "order_id": "12345" }
  }
}
charge.failed
{
  "event": "charge.failed",
  "data": {
    "reference": "order-12346",
    "amount": 50000,
    "fee": 0,
    "net": 0,
    "currency": "NGN",
    "status": "failed",
    "paid_at": null,
    "customer": { "email": "customer@example.com", "name": null, "phone": null },
    "provider": "paystack",
    "provider_reference": null,
    "metadata": {},
    "failure_reason": "insufficient_funds"
  }
}
refund.processed
{
  "event": "refund.processed",
  "data": {
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "refunded",
    "paid_at": "2026-06-12T12:34:56Z",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "provider": "paystack",
    "provider_reference": "1234567890",
    "metadata": { "order_id": "12345" },
    "refund": {
      "provider_reference": "RFND_abc",
      "amount": 50000,
      "currency": "NGN",
      "reason": "requested_by_customer"
    }
  }
}
transfer.received
{
  "event": "transfer.received",
  "data": {
    "reference": "HOSTFI_TXN_987",
    "amount": 500000,
    "currency": "NGN",
    "channel": "bank_transfer",
    "status": "success",
    "account_number": "0123456789",
    "balance_after": 4925000,
    "received_at": "2026-06-12T12:34:56Z",
    "metadata": {}
  }
}
charge.success — delivered to a Connect platform
{
  "event": "charge.success",
  "data": {
    "app_id": 42,
    "connected_account_id": 108,
    "application_fee": 1250,
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "successful",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "metadata": { "order_id": "12345" }
  },
  "created_at": "2026-06-12T12:34:56Z"
}
connection.created — Connect platform
{
  "event": "connection.created",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "scopes": ["payments:read", "payments:write"],
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}
connection.updated — Connect platform
{
  "event": "connection.updated",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "scopes": ["payments:read", "payments:write", "payouts:write"],
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}
connection.revoked — Connect platform
{
  "event": "connection.revoked",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}

Verify the signature

Only if you set a signing secret. Recompute the HMAC over the raw bytes and compare in constant time (reject a missing signature too). Same scheme for every event. If you didn’t set a secret, deliveries are unsigned — there’s no x-twelveai-signature to check, so guard the endpoint another way.

Node (Express)
import crypto from "node:crypto"

// Only set this if you configured a signing secret. If you didn't, deliveries
// arrive UNSIGNED (no x-twelveai-signature header) — there's nothing to verify.
const SECRET = process.env.TWELVE_SIGNING_SECRET // may be undefined

// Use the RAW request body — not the parsed JSON object.
app.post("/webhooks/twelve", express.raw({ type: "*/*" }), (req, res) => {
  const raw = req.body.toString("utf8")

  if (SECRET) {
    // You opted into signing → require AND verify the signature.
    const got = req.header("x-twelveai-signature") || ""
    const expected = crypto.createHmac("sha512", SECRET).update(raw).digest("hex")
    if (got.length !== expected.length ||
        !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
      return res.sendStatus(401) // missing or wrong signature
    }
    // Reject replays: timestamp must be within 5 minutes.
    const ts = Number(req.header("x-twelveai-timestamp") || 0)
    if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401)
  }
  // No secret → unsigned delivery; secure the endpoint another way
  // (secret path, IP allowlist, mTLS).

  const { event, data } = JSON.parse(raw)
  // Ack fast (2xx within 10s), then do your work asynchronously.
  res.sendStatus(200)
})

Retry policy: 1m → 5m → 30m → 2h → 12h → 24h → 48h (~3.5 days). After 20 consecutive non-2xx responses we auto-disable the endpoint; re-save the URL to re-enable. Acknowledge with a 2xx within 10 seconds and do your work asynchronously. Deliveries can retry, so dedupe on x-twelveai-event-id.

Errors & rate limits

Errors return a non-2xx status with a JSON body. Common statuses:

400Bad request — a required field is missing or invalid.
401Unauthenticated — bad, missing, or revoked key/token.
403Forbidden — verification required, or a Connect token missing a scope.
404Not found — no such resource on this account.
429Too many requests — you are being rate limited; back off and retry.
Error shape
{ "success": false, "message": "A valid customer email is required." }

Get your API keys

Create an account, grab your test keys, and make your first call in minutes.