Documentation

TwelveAI Business API

Accept payments on your existing website. We handle Paystack under the hood, settle T+1 to your TwelveAI wallet, and POST webhooks to your server.

Base URL https://api.twelveai.app - one host for test and live; the API key you send decides the mode.

Overview

The Business API mirrors how Paystack works. The full surface:

  • POST /api/business/v1/payments/initialize - start a payment; get a branded checkout_url to redirect your customer to.
  • GET /api/business/v1/payments/verify/:reference - re-check a payment's status.
  • GET /api/business/v1/payments - list payments, for reconciliation.
  • GET /api/business/v1/saved-cards - list reusable card authorizations.
  • POST /api/business/v1/payments/charge-authorization - re-charge a saved card.
  • GET /api/business/v1/balance - wallet balances + this month's usage.
  • POST /api/business/v1/webhooks - configure the URL we POST events to.

Full request/response detail for each is in the API Reference tab above.

Quick start (5 minutes)

  1. Sign in, create your business. We auto-provision test + live API keypairs. Save the secrets - they're shown once.
  2. Add your webhook URL (Webhooks tab) and store the signing_secret.
  3. Initialize a test payment from your backend, then redirect the customer to the returned checkout_url.
  4. Verify the webhook when it lands, mark your order paid, ship.

Authentication

Every merchant-API request takes a Bearer token:

Authorization: Bearer tw_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Use tw_test_… in development, tw_live_… in production. Never expose secret keys in your frontend or commit them to git. The public key (tw_pub_…) is safe in client-side code.

Lost a secret? Open API Keys in your dashboard and click Rotate - the old one stops working immediately and a fresh one is revealed once.

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 a wallet. Configure your endpoint on the Webhooks tab (per mode).

Signing is optional and bring-your-own. Provide your own signing_secret and we sign every delivery’s x-twelveai-signature with it; or ask us to generate one; or leave it blank to receive unsigned deliveries. Updating the URL no longer rotates the secret.

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; a Connect platform also receives it when the charge was made on its behalf (see below).

Headers we send:

  • x-twelveai-signature - HMAC-SHA512 of the raw body, hex. Only sent 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 on your end.

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.

Respond fast. Acknowledge with a 2xx within 10 seconds and do your business logic asynchronously. Deliveries can retry, so dedupe on x-twelveai-event-id.

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 go to the business only.

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",
    "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"
}

Signature verification

Only applies when you set a signing secret. If you did, compute HMAC-SHA512 of the raw request body (NOT the JSON-parsed object) using that secret and compare against the x-twelveai-signature header — and reject the request if the header is missing. If you didn't set a secret, deliveries arrive unsigned (no x-twelveai-signature): there's nothing to verify, so guard the endpoint another way (a secret path, an IP allowlist, or mTLS).

Provide your own secret, or ask us to generate one, on the Webhooks tab. The examples below assume you configured one.

Node.js

import crypto from 'node:crypto'

app.post('/webhooks/twelve', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8')
  const expected = crypto
    .createHmac('sha512', process.env.TWELVE_WEBHOOK_SECRET)
    .update(raw)
    .digest('hex')
  const ok = crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(req.header('x-twelveai-signature') || '')
  )
  if (!ok) return res.status(401).end()

  const { event, data } = JSON.parse(raw)
  // ... mark order paid ...
  res.status(200).end()
})

Python (Flask)

import hmac, hashlib, os
from flask import request, abort

@app.post("/webhooks/twelve")
def twelve_webhook():
    raw = request.get_data()  # raw bytes BEFORE parsing
    expected = hmac.new(
        os.environ["TWELVE_WEBHOOK_SECRET"].encode(),
        raw,
        hashlib.sha512,
    ).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("x-twelveai-signature", "")):
        abort(401)
    # ... mark order paid ...
    return "", 200

PHP

$raw = file_get_contents('php://input');
$expected = hash_hmac('sha512', $raw, getenv('TWELVE_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_TWELVEAI_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
// ... mark order paid ...
http_response_code(200);
Gotcha. Middleware that parses JSON before your handler sees the body will break verification. Use Express's express.raw() or read php://input directly. Re-serializing the parsed body and hashing that will NOT match.

Balance & settlement (T+1)

Your business wallet has two balances:

  • Available balance - withdrawable now.
  • Pending balance - collected via Paystack but not yet settled. After T+1 (≥ 29 hours after the payment) we move it to available automatically.

Fetch both from GET /api/business/v1/balance (see API Reference).

Limits

  • Unverified businesses: NGN 500,000 in successful live collections per calendar month (test mode is uncapped). Verify to remove the cap.
  • Minimum payment: NGN 1.00 (100 kobo).
  • Currency: NGN only for v1.
  • Take rate: 1.5% of gross, capped at NGN 2,000 per transaction.

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 a 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. 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. Send the business to the consent screen with your client_id:

https://dashboard.twelveai.app/oauth/authorize
  ?client_id=twc_...
  &redirect_uri=https://yourapp.com/twelveai/callback
  &metadata=<opaque, echoed back — may be a JSON object>
  &mode=live
  # scope is optional — defaults to the scopes you registered for the app

They approve, and we redirect the browser back to your redirect_uri with a one-time code (and your metadata). If the business has no TwelveAI account, they can sign up in a couple of fields first — then land back on consent.

Step 3 is required — approval alone does not create a connection. The ?code=… your redirect_uri receives is single-use and expires in 60 seconds. A connection is only saved once your server POSTs it to /oauth/token with your client_secret. If you never call /oauth/token, the code silently expires and nothing shows up in Connected apps — this is the most common "why didn't it save?" gotcha. The browser can't do this call; the client secret must stay server-side.

Just testing? Use Developers → Test connection (the dashboard walks the whole flow and calls the exchange for you), or POST /oauth/sandbox/connect to skip the browser entirely.

3. Exchange the code for tokens — your server posts the code + client secret to /oauth/token. Store the returned access_token against the business.

# Node — inside your redirect_uri handler
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,                                               // from ?code=... on the redirect
    redirect_uri:  "https://yourapp.com/twelveai/callback", // must match step 2
  }),
})
const { access_token, refresh_token, scope, mode, connected_account_id } = await r.json()

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": "cantant-order-123",
    "application_fee_bps": 250          // optional; your cut
  }'

# 200 — settles to the connected business's wallet, your fee to yours
{
  "success": true,
  "data": {
    "reference": "cantant-order-123",
    "checkout_url": "https://checkout.twelveai.app/c/cantant-order-123",
    "amount": 50000,
    "currency": "NGN"
  }
}

Redirect the customer to checkout_url. The payment settles to the connected business's wallet (minus fees). Use the access token on every call; the refresh_token is only for renewing it later via /oauth/token.

A call missing the required scope returns 403 insufficient_scope. The connected business can change granted scopes or disconnect at any time from their dashboard. Every scope and the endpoints it unlocks:

Scopes → endpoints
payments:writewrite
POST /payments/initialize · POST /payments/charge-authorization
payments:readread
GET /payments · GET /payments/verify/:reference
transactions:readread
GET /transactions
balance:readread
GET /balance
customers:readread
GET /customers · GET /saved-cards
payouts:writewrite
POST /transfers · GET /transfers (live only)
Application fees. Take a cut of on-behalf charges either by setting a default fee on your app (Developers → your app → Default fee), or per charge by sending application_fee (kobo) or application_fee_bps on /payments/initialize (per-request overrides the default). At settlement the connected account receives its net minus your fee, and your platform wallet is credited the fee. The fee is capped at the connected account's net.

Webhooks (optional). Set a webhook_url on your app to be notified when accounts connect or change. We POST connection.created, connection.updated, and connection.revoked events, each signed with your client secret in the x-twelveai-signature header (HMAC-SHA512 of the raw body). Verify it the same way as merchant webhooks.

Sandbox — test without the consent flow. To test your integration end to end, call POST /oauth/sandbox/connect with your client_id + client_secret. It returns an instant test-mode access token connected to a sandbox account — use it exactly like a real one to exercise on-behalf charges, reads, and payouts in test mode. No redirect or approval needed.

Connect — how the flow 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 — inside your redirect_uri handler
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,                                               // from ?code=... on the redirect
    redirect_uri:  "https://yourapp.com/twelveai/callback", // must match step 1
  }),
})
const { access_token, refresh_token, scope, mode, connected_account_id } = await r.json()
# 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 Developers → Test connection — the dashboard 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.

Connect — button & mobile SDKs

You don't have to full-page redirect. The authorize step (step 1) works several ways — drop-in HTML button, popup, mobile deep link. All of them end the same way: your redirect_uri receives a code, and your server must still do step 3 (POST /oauth/token) or nothing is saved.

Web — one-click button (drop-in)

Include connect.js and add a button with data-twelveai-connect. It's auto-wired on load — no JS to write.

<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 }) })
  }
</script>

Web — popup (no full redirect)

TwelveConnect.open() opens a popup and hands the code back via postMessage, so your page never navigates away.

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

Programmatic (button / mount)

// Wire an existing element:
TwelveConnect.button("#connect", {
  clientId: "twc_...",
  redirectUri: "https://yourapp.com/twelveai/callback",
  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 / Next

import Script from "next/script"

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>

Mobile — in-app browser + deep link

Register a custom-scheme redirect URI (e.g. myapp://twelveai/callback), open the authorize URL in an in-app browser, and capture the code from the deep link. 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 — platform webhook events

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). Your app also receives connection.* lifecycle events.

charge.successA card/Paystack payment succeeded on the connected account.
charge.failedA payment attempt failed on the connected account.
refund.processedA refund completed on the connected account.
connection.createdA business connected to your Connect app.
connection.updatedA connection’s granted scopes changed.
connection.revokedA business disconnected from your app.

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.

Payload — charge.success (on-behalf)

{
  "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"
}

Payload — connection.created

{
  "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"
}

Payload — connection.updated

{
  "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"
}

Payload — connection.revoked

{
  "event": "connection.revoked",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}
Verifying the signature. Same shape as merchant webhooks — HMAC-SHA512 of the raw body, hex, in x-twelveai-signature. The secret is your app client secret (from Developers → Reveal secret), not the connected account's webhook signing secret. See Guides → Signature verification for the full pattern.

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

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:

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

2. Charge on behalf (payments:write)

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

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:

# 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 (a token without payouts:write is 403'd), 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.

Client SDKs (drop-in checkout)

Thin wrappers around the API. Each calls /payments/initialize on your server, then opens the returned checkout_url in a popup. Never call initialize from the browser - that exposes your secret key.

// Server (Node / Bun / Deno)
const res = await fetch('https://api.twelveai.app/api/business/v1/payments/initialize', {
  method: 'POST',
  headers: { Authorization: 'Bearer tw_live_…', 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'customer@example.com', amount: 50000, reference: 'order-12345' }),
})
const { data } = await res.json()
return data.checkout_url // hand back to your frontend
// Browser - open the branded checkout in a popup
function payWithTwelveAI({ checkoutUrl, onDone }) {
  const popup = window.open(checkoutUrl, 'twelveai_checkout', 'width=520,height=720')
  const i = setInterval(() => {
    if (popup?.closed) { clearInterval(i); onDone?.() }
  }, 500)
}
Official npm packages. @twelveai/js, @twelveai/react, and @twelveai/vue are in active development. Until they ship, the snippet above is the canonical shape.

Stuck?

We'll get back to you within a few hours.

dev@twelveai.app →