Overview
The Business API mirrors how Paystack works. The full surface:
POST /api/business/v1/payments/initialize- start a payment; get a brandedcheckout_urlto 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)
- Sign in, create your business. We auto-provision test + live API keypairs. Save the secrets - they're shown once.
- Add your webhook URL (Webhooks tab) and store the
signing_secret. - Initialize a test payment from your backend, then redirect the customer to the returned
checkout_url. - Verify the webhook when it lands, mark your order paid, ship.
Authentication
Every merchant-API request takes a Bearer token:
Authorization: Bearer tw_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxUse 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
{
"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" }
}
}{
"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"
}
}{
"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"
}
}
}{
"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": {}
}
}{
"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"
}{
"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"
}{
"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"
}{
"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 "", 200PHP
$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);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 appThey 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.
?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:
payments:writewritepayments:readreadtransactions:readreadbalance:readreadcustomers:readreadpayouts:writewriteapplication_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.
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.
GET /oauth/authorize?client_id=…&redirect_uri=…. They approve on our consent screen.redirect_uri with ?code=tw_ac_…. The code is single-use and expires in 60 seconds.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"])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"
}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 1234563. 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:read4. 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" }/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)
}@twelveai/js, @twelveai/react, and @twelveai/vue are in active development. Until they ship, the snippet above is the canonical shape.