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. Pay with the test card4084 0840 8408 4081(any CVV, any future expiry, OTP123456). - Verify the webhook when it lands, mark your order paid, ship.
Integrate on your platform
Jump to your stack from the menu. Every platform follows the same three steps with copy-paste code: your backend creates the payment with your secret key, your frontend sends the customer to the branded checkout, and you confirm with verify for the screen and the charge.success webhook for the ledger.
React
All platformsThis framework runs in the browser or on a device, so it never holds your secret key. Step 1 calls an endpoint on your own backend; the server side of that endpoint (and the webhook handler) is in the Node, Python, PHP or Go tab.
// lib/checkout.ts - talks to YOUR backend, never to TwelveAI directly.
// The server side of POST /api/checkout is in the Node, Python, PHP or Go tab.
export async function createCheckout(input: { email: string; amount: number; orderId: string }) {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input), // amount in kobo: 50000 = NGN 500.00
})
if (!r.ok) throw new Error('Could not start checkout')
return (await r.json()) as { checkout_url: string; reference: string }
}// PayButton.tsx
import { useState } from 'react'
import { createCheckout } from './lib/checkout'
export function PayButton({ email, amount, orderId }: { email: string; amount: number; orderId: string }) {
const [busy, setBusy] = useState(false)
async function pay() {
setBusy(true)
try {
const { checkout_url } = await createCheckout({ email, amount, orderId })
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
} finally {
setBusy(false)
}
}
return <button onClick={pay} disabled={busy}>{busy ? 'Starting...' : 'Pay NGN 500'}</button>
}// PaymentResult.tsx - rendered at your callback_url, e.g. /pay/done?reference=order-12345
import { useEffect, useState } from 'react'
export function PaymentResult() {
const [status, setStatus] = useState('checking')
useEffect(() => {
const reference = new URLSearchParams(window.location.search).get('reference')
fetch('/api/checkout/' + reference) // your backend calls GET /payments/verify/:reference
.then((r) => r.json())
.then((d) => setStatus(d.status))
}, [])
if (status === 'checking') return <p>Confirming your payment...</p>
if (status === 'successful') return <p>Payment received. Thank you!</p>
return <p>Payment {status}. If you were charged, it will reflect shortly.</p>
}
// This screen is for the customer. Fulfil the order from the charge.success
// webhook on your backend (Node, Python, PHP or Go tab), never from here.Next.js
All platforms// app/api/checkout/route.ts - a Route Handler runs on the server, so the
// secret key never reaches the browser. Keep it in .env as TWELVEAI_SECRET_KEY.
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const { email, amount, orderId } = await req.json()
const r = await fetch('https://api.twelveai.app/api/business/v1/payments/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.TWELVEAI_SECRET_KEY, // tw_test_... or tw_live_...
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
amount, // kobo: 50000 = NGN 500.00
reference: 'order-' + orderId, // your own id, unique per payment
callback_url: process.env.NEXT_PUBLIC_APP_URL + '/pay/done',
metadata: { order_id: orderId },
}),
})
const { data } = await r.json()
return NextResponse.json({ checkout_url: data.checkout_url, reference: data.reference })
}// app/checkout/PayButton.tsx
'use client'
import { useState } from 'react'
export function PayButton({ email, amount, orderId }: { email: string; amount: number; orderId: string }) {
const [busy, setBusy] = useState(false)
async function pay() {
setBusy(true)
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount, orderId }),
})
const { checkout_url } = await r.json()
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
}
return <button onClick={pay} disabled={busy}>Pay NGN 500</button>
}// app/pay/done/page.tsx - your callback_url. Verify is for the UI only.
export default async function PayDone({ searchParams }: { searchParams: Promise<{ reference?: string }> }) {
const { reference } = await searchParams
const r = await fetch('https://api.twelveai.app/api/business/v1/payments/verify/' + reference, {
headers: { Authorization: 'Bearer ' + process.env.TWELVEAI_SECRET_KEY },
cache: 'no-store',
})
const { data } = await r.json()
return <h1>{data.status === 'successful' ? 'Payment received' : 'Payment ' + data.status}</h1>
}
// app/api/webhooks/twelve/route.ts - the source of truth for fulfilling the order
import crypto from 'node:crypto'
export async function POST(req: Request) {
const raw = await req.text() // raw body, before JSON.parse
const expected = crypto.createHmac('sha512', process.env.TWELVEAI_WEBHOOK_SECRET!).update(raw).digest('hex')
const got = req.headers.get('x-twelveai-signature') || ''
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return new Response('bad signature', { status: 401 })
}
const eventId = req.headers.get('x-twelveai-event-id')!
const { event, data } = JSON.parse(raw)
void enqueue(eventId, event, data) // hand to a queue, do not await it; dedupe by eventId
return new Response('ok', { status: 200 }) // ack within 10 seconds
}Vue / Nuxt
All platforms// server/api/checkout.post.ts (Nuxt) - runs on the server, so the secret key never reaches the browser.
// Plain Vue without Nuxt? Put this endpoint on your backend: see the Node, Python, PHP or Go tab.
export default defineEventHandler(async (event) => {
const { email, amount, orderId } = await readBody(event)
const { data } = await $fetch<{ data: { checkout_url: string; reference: string } }>(
'https://api.twelveai.app/api/business/v1/payments/initialize',
{
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.TWELVEAI_SECRET_KEY }, // tw_test_... or tw_live_...
body: {
email,
amount, // kobo: 50000 = NGN 500.00
reference: 'order-' + orderId, // your own id, unique per payment
callback_url: 'https://yourapp.com/pay/done',
metadata: { order_id: orderId },
},
},
)
return { checkout_url: data.checkout_url, reference: data.reference }
})<!-- components/PayButton.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ email: string; amount: number; orderId: string }>()
const busy = ref(false)
async function pay() {
busy.value = true
try {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: props.email, amount: props.amount, orderId: props.orderId }),
})
const { checkout_url } = await r.json()
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
} finally {
busy.value = false
}
}
</script>
<template>
<button :disabled="busy" @click="pay">Pay NGN 500</button>
</template><!-- pages/pay/done.vue - your callback_url. Verify is for the UI only. -->
<script setup lang="ts">
const route = useRoute()
// server/api/checkout/[reference].get.ts calls GET /payments/verify/:reference with your secret key
const { data } = await useFetch('/api/checkout/' + route.query.reference)
</script>
<template>
<h1>{{ data?.status === 'successful' ? 'Payment received' : 'Payment ' + data?.status }}</h1>
</template>
// server/api/webhooks/twelve.post.ts - the source of truth for fulfilling the order
import { createHmac, timingSafeEqual } from 'node:crypto'
export default defineEventHandler(async (event) => {
const raw = (await readRawBody(event, 'utf8')) || '' // raw body, before JSON.parse
const expected = createHmac('sha512', process.env.TWELVEAI_WEBHOOK_SECRET!).update(raw).digest('hex')
const got = getHeader(event, 'x-twelveai-signature') || ''
if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
throw createError({ statusCode: 401 })
}
const eventId = getHeader(event, 'x-twelveai-event-id')!
const { event: name, data } = JSON.parse(raw)
void enqueue(eventId, name, data) // hand to a queue; dedupe by eventId, fulfil once per data.reference
return { ok: true } // 200 within 10 seconds
})Svelte / SvelteKit
All platforms// src/routes/api/checkout/+server.ts (SvelteKit) - server only; the secret key never reaches the browser.
// Plain Svelte without Kit? Put this endpoint on your backend: see the Node, Python, PHP or Go tab.
import { json } from '@sveltejs/kit'
import { TWELVEAI_SECRET_KEY } from '$env/static/private' // tw_test_... or tw_live_...
export async function POST({ request }) {
const { email, amount, orderId } = await request.json()
const r = await fetch('https://api.twelveai.app/api/business/v1/payments/initialize', {
method: 'POST',
headers: { Authorization: 'Bearer ' + TWELVEAI_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
amount, // kobo: 50000 = NGN 500.00
reference: 'order-' + orderId, // your own id, unique per payment
callback_url: 'https://yourapp.com/pay/done',
metadata: { order_id: orderId },
}),
})
const { data } = await r.json()
return json({ checkout_url: data.checkout_url, reference: data.reference })
}<!-- PayButton.svelte -->
<script lang="ts">
export let email: string
export let amount: number // kobo: 50000 = NGN 500.00
export let orderId: string
let busy = false
async function pay() {
busy = true
try {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount, orderId }),
})
const { checkout_url } = await r.json()
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
} finally {
busy = false
}
}
</script>
<button on:click={pay} disabled={busy}>Pay NGN 500</button>// src/routes/pay/done/+page.server.ts - your callback_url. Verify is for the UI only.
import { TWELVEAI_SECRET_KEY } from '$env/static/private'
export async function load({ url, fetch }) {
const reference = url.searchParams.get('reference')
const r = await fetch('https://api.twelveai.app/api/business/v1/payments/verify/' + reference, {
headers: { Authorization: 'Bearer ' + TWELVEAI_SECRET_KEY },
})
const { data } = await r.json()
return { status: data.status } // +page.svelte shows "Payment received" when it is 'successful'
}
// src/routes/webhooks/twelve/+server.ts - the source of truth for fulfilling the order
import { createHmac, timingSafeEqual } from 'node:crypto'
import { TWELVEAI_WEBHOOK_SECRET } from '$env/static/private'
export async function POST({ request }) {
const raw = await request.text() // raw body, before JSON.parse
const expected = createHmac('sha512', TWELVEAI_WEBHOOK_SECRET).update(raw).digest('hex')
const got = request.headers.get('x-twelveai-signature') || ''
if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return new Response('bad signature', { status: 401 })
}
const eventId = request.headers.get('x-twelveai-event-id')!
const { event, data } = JSON.parse(raw)
void enqueue(eventId, event, data) // queue it; dedupe by eventId, fulfil once per data.reference
return new Response('ok', { status: 200 }) // ack within 10 seconds
}React Native / Expo
All platformsThis framework runs in the browser or on a device, so it never holds your secret key. Step 1 calls an endpoint on your own backend; the server side of that endpoint (and the webhook handler) is in the Node, Python, PHP or Go tab.
// lib/checkout.ts - talks to YOUR backend, never to TwelveAI directly.
// Secret keys must never ship inside a mobile app. The server side of
// POST /api/checkout is in the Node, Python, PHP or Go tab.
const BACKEND = 'https://yourapp.com'
export async function createCheckout(input: { email: string; amount: number; orderId: string }) {
const r = await fetch(BACKEND + '/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input), // amount in kobo: 50000 = NGN 500.00
})
if (!r.ok) throw new Error('Could not start checkout')
return (await r.json()) as { checkout_url: string; reference: string }
}
// On the server, send callback_url: 'https://yourapp.com/pay/done'. Make that URL a
// universal / app link so the in-app browser closes and returns to your app.// PayButton.tsx
import * as WebBrowser from 'expo-web-browser'
import { Button } from 'react-native'
import { router } from 'expo-router'
import { createCheckout } from './lib/checkout'
const CALLBACK_URL = 'https://yourapp.com/pay/done' // the same callback_url your backend sends
export function PayButton(props: { email: string; amount: number; orderId: string }) {
async function pay() {
const { checkout_url, reference } = await createCheckout(props)
// Opens the branded checkout in an in-app browser and closes it once the
// customer lands on CALLBACK_URL?reference=...
const result = await WebBrowser.openAuthSessionAsync(checkout_url, CALLBACK_URL)
if (result.type === 'success') {
router.push({ pathname: '/pay/done', params: { reference } })
}
// 'cancel' or 'dismiss': the customer closed the sheet. A payment may still have
// completed, so let them re-check the order; the webhook will have fulfilled it.
}
return <Button title="Pay NGN 500" onPress={pay} />
}
// Prefer a WebView (react-native-webview)? Watch navigation for the callback:
// <WebView source={{ uri: checkout_url }}
// onNavigationStateChange={(s) => s.url.startsWith(CALLBACK_URL) &&
// onDone(new URL(s.url).searchParams.get('reference'))} />// app/pay/done.tsx - shows the result. Verify is for the UI only.
import { useEffect, useState } from 'react'
import { Text } from 'react-native'
import { useLocalSearchParams } from 'expo-router'
const BACKEND = 'https://yourapp.com'
export default function PayDone() {
const { reference } = useLocalSearchParams<{ reference: string }>()
const [status, setStatus] = useState('checking')
useEffect(() => {
fetch(BACKEND + '/api/checkout/' + reference) // your backend calls GET /payments/verify/:reference
.then((r) => r.json())
.then((d) => setStatus(d.status))
}, [reference])
return <Text>{status === 'successful' ? 'Payment received' : 'Payment ' + status}</Text>
}
// Fulfil the order from the charge.success webhook on your backend, never from this screen.Flutter
All platformsThis framework runs in the browser or on a device, so it never holds your secret key. Step 1 calls an endpoint on your own backend; the server side of that endpoint (and the webhook handler) is in the Node, Python, PHP or Go tab.
// lib/checkout.dart - talks to YOUR backend, never to TwelveAI directly.
// Secret keys must never ship inside a mobile app. The server side of
// POST /api/checkout is in the Node, Python, PHP or Go tab.
import 'dart:convert';
import 'package:http/http.dart' as http;
const backend = 'https://yourapp.com';
Future<({String checkoutUrl, String reference})> createCheckout({
required String email,
required int amount, // kobo: 50000 = NGN 500.00
required String orderId,
}) async {
final r = await http.post(
Uri.parse('$backend/api/checkout'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'amount': amount, 'orderId': orderId}),
);
if (r.statusCode != 200) throw Exception('Could not start checkout');
final body = jsonDecode(r.body) as Map<String, dynamic>;
return (checkoutUrl: body['checkout_url'] as String, reference: body['reference'] as String);
}// checkout_page.dart - webview_flutter; closes itself when the customer lands on callback_url
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
const callbackUrl = 'https://yourapp.com/pay/done'; // the same callback_url your backend sends
/// Resolves with the reference once the customer reaches callback_url, or null if they close it.
Future<String?> openCheckout(BuildContext context, String checkoutUrl) {
return Navigator.push<String>(context, MaterialPageRoute(builder: (ctx) {
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(onNavigationRequest: (req) {
if (req.url.startsWith(callbackUrl)) {
Navigator.pop(ctx, Uri.parse(req.url).queryParameters['reference']);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}))
..loadRequest(Uri.parse(checkoutUrl));
return Scaffold(
appBar: AppBar(title: const Text('Checkout')),
body: WebViewWidget(controller: controller),
);
}));
}
// Prefer the system browser? url_launcher:
// launchUrl(Uri.parse(checkoutUrl), mode: LaunchMode.externalApplication);
// and make callback_url an app link that reopens your app with ?reference=...// pay_result.dart - shows the result. Verify is for the UI only.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
Future<void> showResult(BuildContext context, String reference) async {
// Your backend calls GET /payments/verify/:reference with the secret key and returns { status }
final r = await http.get(Uri.parse('$backend/api/checkout/$reference'));
final status = (jsonDecode(r.body) as Map<String, dynamic>)['status'] as String;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(status == 'successful' ? 'Payment received' : 'Payment $status'),
));
}
// Putting it together in your pay button:
// final c = await createCheckout(email: email, amount: 50000, orderId: '12345');
// final reference = await openCheckout(context, c.checkoutUrl);
// if (reference != null) await showResult(context, reference);
//
// Fulfil the order from the charge.success webhook on your backend, never from the app.Node (Express)
All platforms// server.js - the secret key lives in .env as TWELVEAI_SECRET_KEY and never leaves this process
import express from 'express'
const app = express()
const API = 'https://api.twelveai.app/api/business/v1'
const AUTH = { Authorization: 'Bearer ' + process.env.TWELVEAI_SECRET_KEY } // tw_test_... or tw_live_...
app.post('/api/checkout', express.json(), async (req, res) => {
const { email, amount, orderId } = req.body
const r = await fetch(API + '/payments/initialize', {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
amount, // kobo: 50000 = NGN 500.00
reference: 'order-' + orderId, // your own id, unique per payment
callback_url: 'https://yourapp.com/pay/done',
metadata: { order_id: orderId },
}),
})
const { data } = await r.json()
res.json({ checkout_url: data.checkout_url, reference: data.reference })
})
app.listen(3000)<!-- any page on your site: ask YOUR backend for a checkout_url, then send the customer there -->
<button id="pay">Pay NGN 500</button>
<script>
document.getElementById('pay').onclick = async () => {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'customer@example.com', amount: 50000, orderId: '12345' }),
})
const { checkout_url } = await r.json()
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
}
</script>// Result page data: verify is for the UI only (your /pay/done page calls this)
app.get('/api/checkout/:reference', async (req, res) => {
const r = await fetch(API + '/payments/verify/' + req.params.reference, { headers: AUTH })
const { data } = await r.json()
res.json({ status: data.status, paid: data.status === 'successful' })
})
// Webhook: the source of truth for fulfilling the order
import crypto from 'node:crypto'
app.post('/webhooks/twelve', express.raw({ type: '*/*' }), (req, res) => {
const raw = req.body.toString('utf8') // raw body, before any JSON parsing
const expected = crypto.createHmac('sha512', process.env.TWELVEAI_WEBHOOK_SECRET).update(raw).digest('hex')
const got = req.header('x-twelveai-signature') || ''
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(401)
}
const ts = Number(req.header('x-twelveai-timestamp') || 0)
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401)
const eventId = req.header('x-twelveai-event-id')
const { event, data } = JSON.parse(raw)
res.sendStatus(200) // ack first, within 10 seconds
queue.add('twelve-event', { eventId, event, data }) // then dedupe by eventId, fulfil once per data.reference
})Python (FastAPI)
All platforms# main.py - the secret key lives in the environment as TWELVEAI_SECRET_KEY and never leaves this process
import os, httpx
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from pydantic import BaseModel
app = FastAPI()
API = "https://api.twelveai.app/api/business/v1"
AUTH = {"Authorization": "Bearer " + os.environ["TWELVEAI_SECRET_KEY"]} # tw_test_... or tw_live_...
class Checkout(BaseModel):
email: str
amount: int # kobo: 50000 = NGN 500.00
order_id: str
@app.post("/api/checkout")
async def checkout(body: Checkout):
async with httpx.AsyncClient() as client:
r = await client.post(f"{API}/payments/initialize", headers=AUTH, json={
"email": body.email,
"amount": body.amount,
"reference": f"order-{body.order_id}", # your own id, unique per payment
"callback_url": "https://yourapp.com/pay/done",
"metadata": {"order_id": body.order_id},
})
data = r.json()["data"]
return {"checkout_url": data["checkout_url"], "reference": data["reference"]}<!-- any page on your site: ask YOUR backend for a checkout_url, then send the customer there -->
<button id="pay">Pay NGN 500</button>
<script>
document.getElementById('pay').onclick = async () => {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'customer@example.com', amount: 50000, orderId: '12345' }),
})
const { checkout_url } = await r.json()
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
}
</script>import hmac, hashlib, time
# Result page data: verify is for the UI only (your /pay/done page calls this)
@app.get("/api/checkout/{reference}")
async def checkout_status(reference: str):
async with httpx.AsyncClient() as client:
r = await client.get(f"{API}/payments/verify/{reference}", headers=AUTH)
status = r.json()["data"]["status"]
return {"status": status, "paid": status == "successful"}
# Webhook: the source of truth for fulfilling the order
@app.post("/webhooks/twelve")
async def twelve_webhook(request: Request, tasks: BackgroundTasks):
raw = await request.body() # raw bytes, before any parsing
expected = hmac.new(os.environ["TWELVEAI_WEBHOOK_SECRET"].encode(), raw, hashlib.sha512).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("x-twelveai-signature", "")):
raise HTTPException(401)
if abs(time.time() - int(request.headers.get("x-twelveai-timestamp", "0"))) > 300:
raise HTTPException(401)
event_id = request.headers["x-twelveai-event-id"]
tasks.add_task(process_event, event_id, await request.json()) # runs after the 200 is sent
return {"ok": True} # ack within 10 seconds
def process_event(event_id: str, payload: dict):
# 1. skip if event_id was already processed
# 2. on charge.success, fulfil the order once per payload["data"]["reference"]
...PHP (Laravel)
All platforms// routes/web.php - the secret key lives in .env (TWELVEAI_SECRET_KEY) via config/services.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
Route::post('/api/checkout', function (Request $request) {
$orderId = $request->input('order_id');
$data = Http::withToken(config('services.twelveai.secret')) // tw_test_... or tw_live_...
->post('https://api.twelveai.app/api/business/v1/payments/initialize', [
'email' => $request->input('email'),
'amount' => (int) $request->input('amount'), // kobo: 50000 = NGN 500.00
'reference' => "order-{$orderId}", // your own id, unique per payment
'callback_url' => url('/pay/done'),
'metadata' => ['order_id' => $orderId],
])
->json('data');
return response()->json([
'checkout_url' => $data['checkout_url'],
'reference' => $data['reference'],
]);
});{{-- resources/views/pay.blade.php --}}
<button id="pay">Pay NGN 500</button>
<script>
document.getElementById('pay').onclick = async () => {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ email: 'customer@example.com', amount: 50000, order_id: '12345' }),
})
const { checkout_url } = await r.json()
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
}
</script>// Result page: verify is for the UI only
Route::get('/pay/done', function (Request $request) {
$data = Http::withToken(config('services.twelveai.secret'))
->get('https://api.twelveai.app/api/business/v1/payments/verify/' . $request->query('reference'))
->json('data');
return view('pay-result', ['paid' => $data['status'] === 'successful', 'status' => $data['status']]);
});
// Webhook: the source of truth for fulfilling the order.
// Exclude this route from CSRF (VerifyCsrfToken::$except or ->withoutMiddleware()).
Route::post('/webhooks/twelve', function (Request $request) {
$raw = $request->getContent(); // raw body, not $request->all()
$expected = hash_hmac('sha512', $raw, config('services.twelveai.webhook_secret'));
if (! hash_equals($expected, $request->header('x-twelveai-signature', ''))) {
abort(401);
}
if (abs(time() - (int) $request->header('x-twelveai-timestamp', 0)) > 300) {
abort(401);
}
ProcessTwelveEvent::dispatch($request->header('x-twelveai-event-id'), json_decode($raw, true));
return response()->noContent(200); // ack within 10 seconds; the job dedupes by event id
});// main.go - net/http only; the secret key comes from the TWELVEAI_SECRET_KEY env var
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
const api = "https://api.twelveai.app/api/business/v1"
func checkout(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Amount int `json:"amount"` // kobo: 50000 = NGN 500.00
OrderID string `json:"order_id"`
}
json.NewDecoder(r.Body).Decode(&in)
body, _ := json.Marshal(map[string]any{
"email": in.Email,
"amount": in.Amount,
"reference": "order-" + in.OrderID, // your own id, unique per payment
"callback_url": "https://yourapp.com/pay/done",
"metadata": map[string]any{"order_id": in.OrderID},
})
req, _ := http.NewRequest("POST", api+"/payments/initialize", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TWELVEAI_SECRET_KEY")) // tw_test_... or tw_live_...
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer res.Body.Close()
var out struct {
Data struct {
CheckoutURL string `json:"checkout_url"`
Reference string `json:"reference"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
json.NewEncoder(w).Encode(map[string]string{"checkout_url": out.Data.CheckoutURL, "reference": out.Data.Reference})
}
func main() {
http.HandleFunc("/api/checkout", checkout)
http.ListenAndServe(":3000", nil)
}<!-- any page on your site: ask YOUR backend for a checkout_url, then send the customer there -->
<button id="pay">Pay NGN 500</button>
<script>
document.getElementById('pay').onclick = async () => {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'customer@example.com', amount: 50000, orderId: '12345' }),
})
const { checkout_url } = await r.json()
window.location.href = checkout_url // or window.open(checkout_url, '_blank')
}
</script>import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"io"
"strconv"
"strings"
"time"
)
// Result page data: verify is for the UI only (your /pay/done page calls this)
func checkoutStatus(w http.ResponseWriter, r *http.Request) {
ref := strings.TrimPrefix(r.URL.Path, "/api/checkout/")
req, _ := http.NewRequest("GET", api+"/payments/verify/"+ref, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("TWELVEAI_SECRET_KEY"))
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var out struct {
Data struct {
Status string `json:"status"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
json.NewEncoder(w).Encode(map[string]any{"status": out.Data.Status, "paid": out.Data.Status == "successful"})
}
// Webhook: the source of truth for fulfilling the order
func twelveWebhook(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body) // raw bytes, before any parsing
mac := hmac.New(sha512.New, []byte(os.Getenv("TWELVEAI_WEBHOOK_SECRET")))
mac.Write(raw)
if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(r.Header.Get("x-twelveai-signature"))) {
w.WriteHeader(http.StatusUnauthorized)
return
}
ts, _ := strconv.ParseInt(r.Header.Get("x-twelveai-timestamp"), 10, 64)
if d := time.Now().Unix() - ts; d > 300 || d < -300 {
w.WriteHeader(http.StatusUnauthorized)
return
}
eventID := r.Header.Get("x-twelveai-event-id")
w.WriteHeader(http.StatusOK) // ack within 10 seconds
go processEvent(eventID, raw) // then dedupe by eventID and fulfil once per data.reference
}
// main: http.HandleFunc("/api/checkout/", checkoutStatus); http.HandleFunc("/webhooks/twelve", twelveWebhook)tw_test_… secret key produces simulated payments: open the checkout and pay with the test card 4084 0840 8408 4081 (any CVV, any future expiry, OTP 123456); no real money moves and charge.success still fires. A tw_live_… key charges real cards. Both are secret: they belong on your server, never in a browser bundle or a mobile app. Legacy sk_test_… / sk_live_… keys still work the same way.The webhook handlers above are the minimum. Read Webhook reliability for retries, ordering and the handler checklist before you go live.
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.dispute.createdBusiness + platformA customer disputed a payment (chargeback). The disputed amount is held from your balance; respond before due_at.dispute.resolvedBusiness + platformA dispute closed. outcome is won (the hold is back in your balance), lost or accepted (the held amount went to the customer).transfer.processingBusinessA transfer out was handed to the bank rail. Money has left your balance; wait for the outcome event.transfer.succeededBusinessA transfer out (payout or customer disbursement) reached the bank.transfer.failedBusinessA transfer out failed; the amount and the fee are back in your balance.transfer.reversedBusinessA transfer that had succeeded was returned by the receiving bank; the amount and the fee are back in your balance.transfer.receivedBusinessAn incoming bank transfer credited the wallet. Paused: business account numbers are not available at the moment, so this event does not fire.subscription.createdBusiness + platformA subscription's first payment succeeded and it is now active.subscription.renewedBusiness + platformA renewal (or an overdue period paid with a new card) succeeded; the subscription moved to its next period.subscription.payment_failedBusiness + platformA renewal charge failed. Carries the attempt number, the reason and the next retry time.subscription.cancelledBusiness + platformA subscription ended: cancelled by you or the customer, at the end of its period, or after the final failed retry.invoice.sentBusiness + platformAn invoice was issued and emailed to the customer for the first time.invoice.partially_paidBusiness + platformA payment reduced an invoice balance without clearing it. Carries the payment block.invoice.paidBusiness + platformAn invoice balance reached zero (online, recorded offline, or both). Fires once per invoice.invoice.overdueBusiness + platformAn unpaid invoice passed the end of its due date (Lagos time). Fires once per invoice.store.order.paidBusiness + platformA storefront order was paid. status is paid, or paid_needs_attention when stock ran short (stock_issues lists the lines).store.order.fulfilledBusiness + platformYou marked a storefront order fulfilled (fulfillment carries your note and tracking).instalment_plan.createdBusiness + platformPart 1 of an instalment plan was paid and the plan started. Carries the payment.instalment_plan.part_paidBusiness + platformA payment was applied to an instalment plan (part 1, a card charge, or the customer paying early or in full). Carries the payment and the parts it paid.instalment_plan.completedBusiness + platformEvery part is paid (or waived). Release the goods.instalment_plan.part_failedBusiness + platformAn automatic card charge for a part failed. Carries the attempt number, the reason and the next retry time.instalment_plan.defaultedBusiness + platformA part stayed unpaid for the number of days set in your instalment settings (7 by default). No more charges are made.instalment_plan.cancelledBusiness + platformYou cancelled the plan. No more charges are made; money already paid stays with you.connection.authorizedPlatformOwner approved consent - payload carries the one-time OAuth code + redirect_uri so your backend can exchange without waiting for the browser round-trip. Codes are single-use.connection.createdPlatformA business connected to your Connect app (fires when the code is exchanged for a token).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-signature-v2- HMAC-SHA512 of<timestamp>.<raw body>, hex. Optional, stronger: it binds the timestamp, so a captured delivery cannot be replayed later with a fresh one. Verify either header.x-twelveai-timestamp- Unix seconds. Reject if > 5 minutes old.x-twelveai-event- event name.x-twelveai-event-id- the delivery id. Stable across retries and replays; dedupe on it.User-Agent-TwelveAI-Webhook/1.0.
Respond fast. Acknowledge with any 2xx within 10 seconds and do your business logic asynchronously. Delivery is at-least-once with retries over about 3.3 days, so dedupe on x-twelveai-event-id. The full schedule, endpoint health rules and the handler checklist are in Webhook reliability.
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. Platform deliveries go through the same queue as merchant webhooks: the same retries over about 3.3 days, the same x-twelveai-timestamp, x-twelveai-event-id and v2 signature headers, so a platform that is briefly down does not lose an event.
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": "refund.processed",
"data": {
"reference": "order-777",
"amount": 10000,
"currency": "USD",
"status": "partially_refunded",
"paid_currency": "NGN",
"paid_amount": 15225000,
"fx_rate": 1522.5,
"refund": {
"provider_reference": "1048832",
"amount": 2500,
"currency": "USD",
"reason": null,
"paid_currency": "NGN",
"paid_amount": 3806250
}
}
}{
"event": "dispute.created",
"data": {
"id": 42,
"provider": "paystack",
"provider_dispute_id": "2867",
"status": "needs_response",
"outcome": null,
"category": "chargeback",
"reason": "Customer says the item never arrived",
"amount": 50000,
"held": 49250,
"currency": "NGN",
"mode": "live",
"due_at": "2026-06-15T12:00:00.000Z",
"resolution": null,
"resolved_at": null,
"created_at": "2026-06-13T09:10:00.000Z",
"payment": {
"reference": "order-12345",
"amount": 50000,
"currency": "NGN",
"status": "successful",
"paid_at": "2026-06-12T12:34:56Z",
"customer_email": "customer@example.com",
"metadata": { "order_id": "12345" }
}
}
}{
"event": "dispute.resolved",
"data": {
"id": 42,
"provider": "paystack",
"provider_dispute_id": "2867",
"status": "won",
"outcome": "won",
"category": "chargeback",
"reason": "Customer says the item never arrived",
"amount": 50000,
"held": 0,
"currency": "NGN",
"mode": "live",
"due_at": "2026-06-15T12:00:00.000Z",
"resolution": "declined",
"resolved_at": "2026-06-20T10:00:00.000Z",
"created_at": "2026-06-13T09:10:00.000Z",
"payment": { "reference": "order-12345", "amount": 50000, "currency": "NGN", "status": "successful", "paid_at": "2026-06-12T12:34:56Z", "customer_email": "customer@example.com", "metadata": { "order_id": "12345" } }
}
}{
"event": "transfer.processing",
"data": {
"reference": "bpo_7Q2K9M",
"status": "processing",
"amount": 1500000,
"fee": 5500,
"currency": "NGN",
"mode": "live",
"source": "disbursement",
"destination": { "bank_code": "058", "bank_name": "Guaranty Trust Bank", "account_number": "0123456789", "account_name": "ADA OKAFOR" },
"customer_reference": "order_123",
"idempotency_key": "disb_order_123",
"provider": "paystack",
"provider_reference": null,
"failure_reason": null,
"succeeded_at": null,
"failed_at": null,
"created_at": "2026-06-12T12:34:56Z",
"metadata": {}
}
}{
"event": "transfer.succeeded",
"data": {
"reference": "bpo_7Q2K9M",
"status": "succeeded",
"amount": 1500000,
"fee": 5500,
"currency": "NGN",
"mode": "live",
"source": "disbursement",
"destination": { "bank_code": "058", "bank_name": "Guaranty Trust Bank", "account_number": "0123456789", "account_name": "ADA OKAFOR" },
"customer_reference": "order_123",
"idempotency_key": "disb_order_123",
"provider": "paystack",
"provider_reference": "TRF_x1y2z3",
"failure_reason": null,
"succeeded_at": "2026-06-12T12:36:10Z",
"failed_at": null,
"created_at": "2026-06-12T12:34:56Z",
"metadata": {}
}
}{
"event": "transfer.failed",
"data": {
"reference": "bpo_7Q2K9N",
"status": "failed",
"amount": 1500000,
"fee": 5500,
"currency": "NGN",
"mode": "live",
"source": "disbursement",
"destination": { "bank_code": "058", "bank_name": "Guaranty Trust Bank", "account_number": "0123456789", "account_name": "ADA OKAFOR" },
"customer_reference": "order_124",
"idempotency_key": "disb_order_124",
"provider": "paystack",
"provider_reference": null,
"failure_reason": "Account number is invalid",
"succeeded_at": null,
"failed_at": "2026-06-12T12:36:10Z",
"created_at": "2026-06-12T12:34:56Z",
"metadata": {}
}
}{
"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.authorized",
"data": {
"app_id": 42,
"code": "tw_ac_...",
"redirect_uri": "https://yourapp.com/twelveai/callback",
"business_id": 108,
"scopes": ["payments:read", "payments:write"],
"mode": "live",
"expires_in": 60,
"metadata": "order_42"
},
"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"
}{
"event": "invoice.partially_paid",
"data": {
"event_id": "412:partially_paid:9051",
"id": 412,
"invoice_number": "INV-0042",
"mode": "live",
"status": "partially_paid",
"currency": "NGN",
"customer": { "email": "ada@example.com", "name": "Ada Okafor", "phone": "08031234567", "address": null },
"line_items": [
{ "description": "Logo design", "quantity": 1, "unit_price": 15000000, "amount": 15000000, "tax_percent": null, "tax": 1125000, "discount": 0 }
],
"subtotal": 15000000,
"discount_percent": 0,
"discount": 0,
"tax_percent": 7.5,
"tax": 1125000,
"total": 16125000,
"amount_paid": 5000000,
"amount_due": 11125000,
"overpaid": 0,
"allow_partial": true,
"notes": null,
"terms": "Payment due within 14 days.",
"hosted_url": "https://checkout.twelveai.app/i/cm1x9...",
"pdf_url": "https://checkout.twelveai.app/i/cm1x9.../pdf",
"issued_at": "2026-10-05T09:00:00.000+01:00",
"due_at": "2026-10-19T23:59:59.000+01:00",
"sent_at": "2026-10-05T09:01:12.000+01:00",
"paid_at": null,
"voided_at": null,
"metadata": { "order_id": "A-1001" },
"created_at": "2026-10-05T09:00:00.000+01:00",
"payment": { "kind": "online", "reference": "INV_412_9c1e2a7b40f3", "amount": 5000000, "applied": 5000000, "overpaid": 0, "paid_at": "2026-10-07T14:22:10.000+01:00" }
}
}Webhook reliability
What you can rely on from our side, and what your handler has to do so that a retry, a replay or an out-of-order delivery never double-fulfils an order.
Delivery is at-least-once
Every event carries a stable x-twelveai-event-id header (the delivery id). Store it and ignore repeats. Processing must be idempotent, and charge.success should be applied once per reference.
Acknowledge within 10 seconds
Respond with any 2xx within 10 seconds. Do the work after acknowledging (queue it). A non-2xx response or a timeout counts as a failed attempt.
Retries
After a failed attempt we retry at 30s, 1m, 2m, 5m, 15m, 1h, 6h, 24h, 48h: 10 attempts over about 3.3 days. A network-level failure (DNS, refused connection, TLS) gets one immediate extra try 2 seconds later before that schedule starts. After the last attempt the delivery is marked failed and you are notified in the dashboard and by email.
Ordering is not guaranteed
A retried transfer.processing can arrive after transfer.succeeded. Use the timestamps and status fields in the payload, not arrival order.
Headers
x-twelveai-signature- HMAC-SHA512 hex of the raw body, keyed by your signing secret. Only present when a secret is set.x-twelveai-signature-v2- HMAC-SHA512 hex of<timestamp>.<raw body>; also binds the timestamp. Only present when a secret is set.x-twelveai-timestamp- unix seconds. Reject if older than 5 minutes.x-twelveai-event- the event name.x-twelveai-event-id- the delivery id; the same on every retry and replay.User-Agent-TwelveAI-Webhook/1.0.
Endpoint health
After 20 consecutive permanently failed deliveries the endpoint is switched off automatically and you are notified. While an endpoint is off, whether you disabled it or we did, events are not lost: they are queued and parked for up to 7 days and delivered in order as soon as you enable the endpoint again from the dashboard.
Safety net
An independent reconciliation sweep runs every 10 minutes and queues any successful payment or transfer outcome from the last 48 hours that somehow has no delivery, so an internal fault on our side cannot silently drop an event. If you ever see a gap, replay any delivery from the Webhooks page in the dashboard; replays carry the same event id.
Redirects are not followed
Give us the handler's final URL. A 301, 302, 307 or 308 reply counts as a failed attempt: we never re-send a signed body to wherever a redirect points, and a 200 after a redirect is not a receipt.
Where we can deliver
Public internet addresses only. A URL that points at localhost, a private or link-local address, or an internal name is refused when you save it, and the address a hostname resolves to is checked again before every send.
Switching an endpoint back on
Parked events go out on the next worker pass, within about a minute, in the order they were queued.
Test sends
A test send from the dashboard carries "sample": true in the payload. Verify it like any other delivery, then skip fulfilment.
- Verify
x-twelveai-signatureover the raw body (if you set a secret). - Reject if
x-twelveai-timestampis older than 5 minutes. - Dedupe on
x-twelveai-event-id; skip anything you have already seen. - Return 200 right away.
- Process asynchronously; apply
charge.successonce perreference. - If you need to be sure, fetch the latest state with
GET /payments/verify/:referenceorGET /transfersrather than trusting arrival order.
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. A valid signature is step one; also reject a x-twelveai-timestamp older than 5 minutes, dedupe on x-twelveai-event-id, and return 200 before you do the work (see Webhook reliability).
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 got = req.header('x-twelveai-signature') || ''
const ok = got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got))
if (!ok) return res.status(401).end()
const ts = Number(req.header('x-twelveai-timestamp') || 0)
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end()
const eventId = req.header('x-twelveai-event-id')
const { event, data } = JSON.parse(raw)
res.status(200).end()
// ... queue { eventId, event, data }; skip if eventId was seen; mark order paid once per data.reference ...
})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 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).
What happens to the available balance is your choice under Settings, Settlement: Automatic sweeps it to your settlement account once a day, and On request keeps it until you withdraw it from the dashboard or with POST /api/business/v1/transfers.
Naira withdrawals and transfers are automatic: they go to the bank as soon as you confirm, with no review or approval on our side, and usually arrive within minutes. Only USD withdrawals are reviewed before they are sent.
Disputes and chargebacks
A dispute (chargeback) is a customer asking their bank to reverse a card payment. The processor that took the payment (Paystack or Flutterwave) tells us, we tell you, and you have until the deadline to show the payment was genuine. A dispute is in the currency the customer paid, and its hold comes out of the balance that payment credited.
What happens to the money
- When a dispute opens, the disputed amount (the net we credited you for that part of the payment) is held: moved out of your available balance (or out of pending, if the payment has not settled yet) into a held bucket. Held money cannot be withdrawn. If your balance is too low, we hold what is there and record the rest as owed.
- A payment with an open dispute does not settle until the dispute closes.
- Won: exactly the held amount goes back to where it came from.
- Lost or accepted: the held amount goes to the customer. If a refund for the same payment also comes through, it is counted against the dispute, so you are never charged twice.
- Test-mode disputes move no money.
Deadlines. Each dispute has a due_at. We notify the account owner (in-app and by email) when it opens and again when Paystack sends a reminder. If you do not respond in time, the bank usually decides for the customer.
Responding. In the dashboard, open Disputes, pick the dispute and either send evidence (customer details, what you delivered and when, and a receipt, delivery proof or chat log as a PDF or image) or Accept it, which refunds the customer from the held funds. Responding needs the "Approve transfers" permission. Evidence without a document is saved; attaching a document submits your response to the bank.
API. Read-only on the merchant API: GET /api/business/v1/disputes and GET /api/business/v1/disputes/:id (scope payments:read). Responding is dashboard-only.
Events. dispute.created when one opens and dispute.resolved when it closes, with outcome set to won, lost or accepted. Same signing, retries and replay as every other event.
Statuses: needs_response, under_review (you responded, the bank is deciding), won, lost, accepted.
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: 2% of gross, capped at NGN 2,500 per transaction.
- Transfers out: minimum NGN 100 per transfer, plus a flat NGN 55 fee debited with the amount (payouts to your settlement account and transfers to your customers alike). The fee is returned with the amount if a transfer fails.
- Business account numbers: not available at the moment. Money comes in through card payments, payment links and checkout; it goes out through transfers.
Settings that affect the API
A few dashboard settings change how the API behaves for your business. They are set by anyone on your team who can manage settings, and every change is recorded in the audit log.
channels field on initialize, and a payment link can set its own subset.callback_url. A callback_url in the request always wins.POST /transfers).POST /transfers and POST /disbursements; the automatic daily sweep is not affected. A transfer over a limit is refused with a 400 before any money moves.400 with a message such as This transfer is above your single-transfer limit of NGN 1,000. or This transfer would pass your daily transfer limit of NGN 50,000. NGN 12,000 is left for today. Nothing is debited. Show the message to the person who started the transfer, or raise the limit in Settings.IP allowlist
An optional extra lock on your secret keys: list the addresses your servers call from, and API calls made with your keys from anywhere else are refused. It is off by default. While the list is empty, every call with a valid key is accepted exactly as before.
- Where: Settings, Developers in the dashboard. Only the owner or an admin can change it, and every change is recorded in the audit log.
- Entries: an IPv4 or IPv6 address (
203.0.113.7,2001:db8::1) or a CIDR range of either (203.0.113.0/24,2001:db8:abcd::/48). Up to 50 entries. - Live keys are always checked once the list has an entry. Test keys are checked only when you turn on Also restrict test keys, so your sandbox keeps working from laptops and CI by default.
- The address checked is the one your request reaches us from. If your servers sit behind a NAT gateway or a proxy, list its public address. Recent callers on the settings card shows the addresses we saw in the last 7 days.
- Connect access tokens (
tw_at_...) are not checked: a platform calls on your behalf from its own servers. The list covers your own secret keys only. - Calls from TwelveAI to you, such as webhooks, are not affected. They go out from us to your endpoint.
A call from an address outside the list is refused with a 403 before anything runs:
{
"success": false,
"code": "ip_not_allowed",
"message": "This request came from 203.0.113.50, which is not on this business's IP allowlist. Add it in the dashboard under Settings, Developers."
}403 within a minute. Blocked attempts show in the audit log and as a notification, at most once per address every 10 minutes.Payment methods (channels)
Which payment methods a customer sees at checkout is decided at three levels, each one only able to narrow the one above it:
- Business setting (Settings, Payments): the methods your business accepts at all. All six are on by default.
- Payment link: each link can use the business setting or its own subset, for example card only. Set it under Payment methods when you create or edit the link.
- One payment: send
channelsonPOST /api/business/v1/payments/initializeas an array or a comma-separated string. Leave it out to use the business setting.
A request can never switch a method back on: ["card", "ussd"] while USSD is off for the business simply becomes card. If nothing is left, or a name is unknown, the call is refused with a 400 before any checkout is created.
cardCardDebit and credit cards. The only method that can be saved for later charges.bankBank accountPay directly from a bank account.bank_transferBank transferA one-time account number to transfer into.ussdUSSDDial a bank code on the phone.qrQRScan with a banking app.mobile_moneyMobile moneyMobile wallets where supported.POST /api/business/v1/payments/initialize
Authorization: Bearer tw_test_...
Content-Type: application/json
{
"email": "customer@example.com",
"amount": 150000,
"reference": "order-12345",
"channels": ["card", "bank_transfer"]
}// A method the business has switched off (400)
{ "success": false, "message": "None of the requested channels (ussd) is enabled for this business. Enabled: card, bank_transfer." }
// An unknown method name (400)
{ "success": false, "message": "Unknown payment channel: paypal. Use any of card, bank, bank_transfer, ussd, qr, mobile_money." }The payment provider may still hide a method it cannot offer for a particular payment, for example a method not available for the amount or currency.
Payment links
A payment link is a hosted, branded page you can share anywhere (WhatsApp, Instagram, email, a QR code on a counter) without writing code. Create and manage links in the dashboard under Payment links; each one has a URL like https://checkout.twelveai.app/l/rybejd97ta and a QR code you can download.
currency; amounts in that currency). A link with a split must be in naira.A payment made through a link is an ordinary payment: it shows under Payments, settles like any other, and fires the same charge.success webhook. Its metadata tells you which link it came from and carries the customer's answers:
{
"event": "charge.success",
"data": {
"reference": "TWAI_15_92a4565952f1f586",
"amount": 150000,
"fee": 3000,
"net": 147000,
"currency": "NGN",
"status": "successful",
"customer": { "email": "customer@example.com", "name": "Ada Okafor", "phone": null },
"metadata": {
"payment_link": "rybejd97ta",
"payment_link_title": "Workshop ticket",
"link_answers": { "company": "Acme Ltd", "ticket_type": "VIP" }
}
}
}The dashboard shows each link's payments, amount collected, and for links with a known total, what is still outstanding. Switch a link off at any time; a single-use link closes itself once paid. Links are managed in the dashboard only; to create checkouts from your own code, use POST /api/business/v1/payments/initialize.
Public payment link endpoints
Two endpoints let your own page, app or the Pay button use a payment link without sending the customer to the link page first: describe the link, then start a payment with the customer's details filled in.
- No API key. The slug is the only credential, the same as sharing the link, so these endpoints are safe to call from a browser, a mobile app or the TwelveAI Pay button.
- amount is in NAIRA here (5000 means NGN 5,000), unlike the rest of the API, which uses kobo. It is ignored when the link has a fixed amount. GET returns amounts in kobo.
- fields carries answers to the link's custom questions, keyed by each question's key from GET. Required questions must be answered.
- Pay creates an ordinary pending payment in the link's mode and returns its hosted checkout_url; send the customer there. It is confirmed by the usual charge.success webhook and by verify, with metadata.payment_link set.
- A payment link's use limit counts successful payments, so a customer who opens checkout and leaves does not use it up.
- Links that offer instalments also return an instalments object and accept POST /api/v1/public/links/:slug/pay-instalments; see Pay in instalments.
GET /api/v1/public/links/rybejd97ta
{
"success": true,
"data": {
"slug": "rybejd97ta",
"mode": "live",
"title": "Workshop ticket",
"description": "One seat, lunch included",
"amount": null,
"min_amount": 500000,
"max_amount": null,
"currency": "NGN",
"open": true,
"closed_reason": null,
"fields": [
{ "key": "company", "label": "Company", "type": "text", "required": true }
],
"merchant": { "name": "Acme Events", "slug": "acme-events", "logo_url": null, "branding": { "color": "#0F766E" } }
}
}POST /api/v1/public/links/rybejd97ta/pay
Content-Type: application/json
{
"email": "customer@example.com",
"name": "Ada Okafor",
"phone": "08012345678",
"amount": 5000,
"fields": { "company": "Acme Ltd" }
}
200
{ "success": true, "data": { "reference": "TWAI_15_92a4565952f1f586", "checkout_url": "https://checkout.twelveai.app/c/TWAI_15_92a4565952f1f586" } }400A missing or invalid email, an amount outside the link's limits, or a missing required answer. The message is written for the customer.404No link with that slug.409The merchant cannot accept this payment right now (for example the monthly limit for unverified businesses).410The link is closed: switched off, expired, single use and already paid, or at its payment limit.429More than 20 Pay calls a minute from the same IP address.Payment requests
Ask one customer for a set amount without copying links around. On Payments (or a customer's page) choose Request payment, enter the customer's email, the amount (in naira or US dollars, currency) and what it is for, and optionally their WhatsApp number and a due date.
Each request uses its own single-use payment link, so the payment is an ordinary payment: it shows under Payments, settles like any other and fires the usual charge.success webhook, with metadata.payment_request_id set. Requests are a dashboard feature; there is no separate API endpoint or webhook event.
Checkout recovery
Many customers open checkout and leave before paying. Turn on Checkout recovery in Settings, Payments, and we email each of them once with a button back to the same checkout. It is off until you switch it on.
The customer resumes on the same checkout page, which checks with the provider again before starting a new attempt, so nobody is charged twice. A recovered payment fires the usual charge.success webhook.
Currencies: NGN and USD
You price each thing a customer pays in naira (NGN) or US dollars (USD): payments from /payments/initialize, payment links, invoices, products, payment requests and instalment plans. The business default currency (Settings, Payments, Currency) pre-fills every form in the dashboard; each one can still be priced in the other currency. Every object with a price returns its currency, and every amount in a response is in minor units of that currency: kobo for NGN, cents for USD.
currency ("NGN" default, or "USD") when you create a payment (POST /payments/initialize, amount in kobo or cents), a payment link, an invoice (line prices, totals and recorded offline payments are all in the invoice currency), an inventory item or store product (its options follow it), a payment request or an instalment plan. A payment link that offers instalments uses the link currency for the plan.refund.paid_currency and refund.paid_amount say what went back to them, by the same proportion as the payment. You are debited the net share from the balance the payment credited (USD for a dollar payment, naira otherwise). A refund never exceeds what was paid.price_currency and price_amount carry the price share.currency; charge a USD card with POST /payments/charge-authorization and currency USD (amount in cents).item_id) must be priced in the invoice currency.currency, defaulting to the business currency). Only products priced in that currency are listed and can be bought; the others stay in your inventory and show in the dashboard as not sold in the store, with the reason.Webhooks: amount and currency are always the price. When the customer paid in the other currency, three fields are added: paid_currency, paid_amount (minor units of the paid currency: cents for USD, kobo for NGN) and fx_rate (naira per US dollar). A payment paid in its own currency carries none of them.
{
"event": "charge.success",
"data": {
"reference": "order-777",
"amount": 10000,
"currency": "USD",
"status": "successful",
"paid_currency": "NGN",
"paid_amount": 15225000,
"fx_rate": 1522.5
}
}A dollar payment from /payments/initialize: amount is in cents and the response's authorization_url is the hosted checkout, where the customer chooses dollars or naira; access_code is empty, so open the checkout URL rather than a Paystack popup.
POST /api/business/v1/payments/initialize
{ "email": "customer@example.com", "amount": 10000, "currency": "USD", "reference": "order-777" }
{
"success": true,
"data": {
"reference": "order-777",
"checkout_url": "https://checkout.twelveai.app/c/order-777",
"authorization_url": "https://checkout.twelveai.app/c/order-777",
"access_code": "",
"amount": 10000,
"currency": "USD"
}
}Hosted checkout endpoints (used by the checkout page, limited per IP): POST /api/v1/public/payments/:reference/usd-quote and POST /api/v1/public/payments/:reference/pay-usd with { quote_id } to pay in dollars; POST /api/v1/public/payments/:reference/ngn-quote and POST /api/v1/public/payments/:reference/restart with { quote_id } to pay a dollar price in naira (an expired quote answers 422 QUOTE_EXPIRED with a fresh quote to confirm).
Subscriptions
Charge a customer automatically every week, month, quarter or year. You create a plan (an amount and an interval), the customer pays the first period on the normal hosted checkout (cards only, so the card can be charged again), and from then on we charge that saved card at the start of every period. Every charge is an ordinary payment: it shows under Payments, pays the normal collection fee (2%, capped at NGN 2,500, or passed to the customer if you turned that on), settles T+1, and fires charge.success or charge.failed like any other. There is no extra subscription fee. A plan is priced in NGN or USD (fixed once created): a USD plan is paid by card in dollars and renews on that card, settling to your USD balance.
Customers subscribe in one of two ways: from your backend with POST /api/business/v1/subscriptions (you get a checkout_url to send them to), or on the plan's hosted page https://checkout.twelveai.app/s/PLN_… (share the link or its QR code from the dashboard). Manage plans and subscribers in the dashboard under Subscriptions.
Lifecycle
Renewals and retries
Billing dates follow the moment the first payment was made, on the Lagos calendar. A subscription that started on 31 January renews on 28 February (29 in a leap year), then 31 March, 30 April, and so on; it never drifts to the 28th. Renewals are processed every 10 minutes, so a charge lands within about 10 minutes of next_charge_at.
When a renewal fails the subscription becomes past_due and we retry the same card 1, 3 and 5 days after the first failure (four attempts in all). If the retry on day 5 also fails, the subscription is cancelled with cancel_reason: "payment_failed". A successful retry, or the customer paying with a new card from the link in our email, makes it active again and links the new card; the paid period starts from the date that was due. A customer can also restart a subscription cancelled for failed payments by paying from that link.
Each renewal attempt is recorded before the card is charged, under a key unique to the subscription, the period and the attempt, and only one successful charge can exist per period. If a retry and a card update ever both succeed, the second payment covers the following period instead of paying the same one twice.
Customer emails (live mode)
Sent in your business name, with replies going to your business email: a reminder 3 days before each renewal, a notice when a charge fails (with a link to pay with another card), and a confirmation when the subscription is cancelled. Every email links to a manage page where the customer can see the next charge, pay an overdue period with a new card, or stop renewing at the end of the paid period. Test-mode subscriptions send no emails; copy the manage link from the dashboard to try the page.
trial_days.Webhook events
Delivered to the same endpoint, signed and retried exactly like payment events: subscription.created, subscription.renewed, subscription.payment_failed and subscription.cancelled. The payment itself also fires charge.success / charge.failed, with metadata.subscription_code set. data is the subscription object; event_id is stable per occurrence, so you can de-duplicate on it.
{
"event": "subscription.renewed",
"data": {
"event_id": "SUB_k3m9q2x7v8w4r6t2:subscription.renewed:5121",
"id": "SUB_k3m9q2x7v8w4r6t2",
"mode": "live",
"status": "active",
"plan": { "id": "PLN_a7c2e9k4m3p8", "name": "Gold", "amount": 500000, "currency": "NGN", "interval": "monthly", "interval_count": 1 },
"customer": { "email": "ada@example.com", "name": "Ada Okafor" },
"amount": 500000,
"currency": "NGN",
"interval": "monthly",
"interval_count": 1,
"current_period_start": "2026-11-30T09:12:00.000Z",
"current_period_end": "2026-12-31T09:12:00.000Z",
"next_charge_at": "2026-12-31T09:12:00.000Z",
"cancel_at_period_end": false,
"cancelled_at": null,
"cancel_reason": null,
"failure_count": 0,
"last_failure_reason": null,
"card": { "id": 88, "brand": "visa", "last4": "4081", "exp_month": "12", "exp_year": "2030" },
"metadata": {},
"created_at": "2026-10-31T09:10:41.000Z",
"payment": { "reference": "TWAI_SUB_41_20261130T081200_1", "amount": 500000, "currency": "NGN", "paid_at": "2026-11-30T09:12:07.000Z" }
}
}subscription.payment_failed carries an attempt block instead of payment:
"attempt": {
"number": 2,
"reference": "TWAI_SUB_41_20261130T081200_2",
"failure_reason": "Insufficient Funds",
"next_retry_at": "2026-12-03T09:20:00.000Z",
"will_cancel": false
}subscription.created also carries payment (the first payment), and subscription.renewed has reactivated: true when a customer restarted a subscription that had been cancelled for failed payments.
Invoices
Bill a customer with line items, tax and a discount, and let them pay from a branded invoice page. Create invoices in the dashboard under Invoices, or from your backend with POST /api/business/v1/invoices. Each invoice gets a number from your own sequence (INV-0001, INV-0002, ...) and a private link, https://checkout.twelveai.app/i/…, that shows the invoice, what has been paid, the balance and a Pay button. The page prints cleanly and offers a PDF download. An invoice is in naira or US dollars (currency): its line prices, totals, part payments and recorded offline payments are all in that currency.
Sending emails the customer the invoice with a Pay invoice button, in your business name with replies going to your business email. The dashboard also gives you the link to copy and a WhatsApp share link with the message typed in. Test-mode invoices are emailed too, marked [Test], so you can try the flow end to end.
Totals
Every line is quantity x unit price, rounded to the kobo. A discount (a percent of the subtotal, or a fixed amount) comes off before tax. Tax is the invoice rate unless a line has its own tax_percent. total = subtotal - discount + tax. All amounts on the merchant API are in kobo.
Statuses
Payments and part payments
The Pay button starts an ordinary payment for the balance on the hosted checkout, so the normal collection fee applies (2%, capped at NGN 2,500, or passed to the customer if you turned that on), it settles T+1, and charge.success fires for it with metadata.source: "invoice", metadata.invoice_id and metadata.invoice_number. With allow_partial: true the customer can instead pay any amount from NGN 100 up to the balance, as many times as needed.
Each successful payment is applied to its invoice exactly once (it is recorded against the payment before the balance moves, so a retried webhook or a repeated verify changes nothing). amount_paid never goes past the total: if two checkouts are paid at once, the extra is kept in overpaid and flagged in the dashboard for you to refund.
invoice.partially_paid / invoice.paid events with payment.kind: "manual".Reminders and receipts
Unless you turn them off (for the business in the dashboard, or per invoice with reminders: false), a sent invoice with a due date reminds the customer by email 3 days before the due date, on the due date and 7 days after it, at 09:00 Lagos time, until it is paid or voided. Each reminder goes out at most once. When the balance reaches zero the customer gets a paid receipt.
Webhook events
invoice.sent, invoice.partially_paid, invoice.paid and invoice.overdue, delivered to the same endpoint, signed and retried like payment events, and to connected platforms holding a payments scope. data is the invoice object; payment events add a payment block. event_id is stable per occurrence, so you can de-duplicate on it.
{
"event": "invoice.partially_paid",
"data": {
"event_id": "412:partially_paid:9051",
"id": 412,
"invoice_number": "INV-0042",
"mode": "live",
"status": "partially_paid",
"currency": "NGN",
"customer": { "email": "ada@example.com", "name": "Ada Okafor", "phone": "08031234567", "address": null },
"line_items": [
{ "description": "Logo design", "quantity": 1, "unit_price": 15000000, "amount": 15000000, "tax_percent": null, "tax": 1125000, "discount": 0 }
],
"subtotal": 15000000,
"discount_percent": 0,
"discount": 0,
"tax_percent": 7.5,
"tax": 1125000,
"total": 16125000,
"amount_paid": 5000000,
"amount_due": 11125000,
"overpaid": 0,
"allow_partial": true,
"notes": null,
"terms": "Payment due within 14 days.",
"hosted_url": "https://checkout.twelveai.app/i/cm1x9...",
"pdf_url": "https://checkout.twelveai.app/i/cm1x9.../pdf",
"issued_at": "2026-10-05T09:00:00.000+01:00",
"due_at": "2026-10-19T23:59:59.000+01:00",
"sent_at": "2026-10-05T09:01:12.000+01:00",
"paid_at": null,
"voided_at": null,
"metadata": { "order_id": "A-1001" },
"created_at": "2026-10-05T09:00:00.000+01:00",
"payment": { "kind": "online", "reference": "INV_412_9c1e2a7b40f3", "amount": 5000000, "applied": 5000000, "overpaid": 0, "paid_at": "2026-10-07T14:22:10.000+01:00" }
}
}Split payments
Send part of a payment to other people's bank accounts: vendors on a marketplace, a partner's commission, a supplier's cost. Each share is paid to a plain Nigerian bank account by bank transfer when the payment settles (T+1), at the standard NGN 55 per transfer. You are responsible for your vendors: they are your payees, paid from your balance. Split payments are naira-only: a payment or link with a split must be in NGN.
The model
- Vendors (split parties) are bank accounts you add in the dashboard under Split payments. Each one is checked with the bank first; we pay the account holder name the bank returns. Test and live vendors are separate.
- A share is
{ party_id, type, value }:typeispercentage(value is a percent of the net, up to 2 decimals, e.g.12.5) orflat(value in kobo, like every amount in the API). You keep the rest. - A split group is a saved set of shares with a name. Use its id as
split_groupon initialize, or pick it on a payment link so every payment through the link is split.
Fee order
- The TwelveAI collection fee (2%, capped at NGN 2,500) comes off the whole payment first, exactly as without a split (or is paid by the customer if you pass fees on). A Connect application fee, if any, also comes off first.
- Shares are computed on what is left, the net. Percentages round down to the kobo; every kobo of rounding stays with you.
- Each vendor costs one NGN 55 transfer.
fee_bearer: "merchant"(the default): the vendor receives the full share and your balance pays share + NGN 55.fee_bearer: "party": the vendor receives share minus NGN 55 and your balance pays the share. - You keep net minus everything taken for vendors. Vendors plus what you keep always equals the net exactly.
Examples
On initialize
Add either split (inline) or split_group (a saved group id) to POST /api/business/v1/payments/initialize. Both are optional: a request without them behaves exactly as before. The split is checked against the amount before the payment is created and stored with the payment, so later changes to a group never change a payment already started.
{
"email": "customer@example.com",
"amount": 1000000,
"reference": "order-12345",
"split": {
"fee_bearer": "merchant",
"shares": [
{ "party_id": 14, "type": "percentage", "value": 50 },
{ "party_id": 15, "type": "flat", "value": 300000 }
]
}
}A split that cannot work is refused with HTTP 400, code: "SPLIT_INVALID" and a message saying why.
Rules
- At most 10 vendors per split, each once.
- Percentages must add up to less than 100%, so you always keep part of the payment.
- Shares plus transfer fees must fit inside the net.
- Each vendor must receive at least NGN 100 (after the fee when the vendor pays it).
- Live splits need a verified business (KYB) and a verified director BVN, like any payout to another account. Vendors must be active and in the same mode as the payment.
- Test mode: test payments never settle, so their vendor payouts are simulated as soon as the payment succeeds and
split.paidis sent to your test endpoint. No money moves.
At settlement
When the payment settles, the same step that credits your available balance sets each vendor's share aside (share + fee, or the share when the vendor pays the fee) and starts one transfer per vendor. Each vendor is paid at most once per payment. If payouts to other accounts are blocked on your business at that moment (for example verification lapsed), the money stays set aside, the payout waits on hold, you are notified, and it is sent automatically once the block clears. Split payouts do not count toward your own transfer limits under Settings, and appear under Transactions as payouts.
Refunds, disputes and failures
- Refund before settlement: a full refund cancels the split. A partial refund shrinks every unpaid share in proportion to what is left of the net; a share that drops under NGN 100, or no longer fits with its fee, is cancelled and stays with you.
- Dispute before settlement: an open dispute holds the payment, and its split, until it is resolved. Won: the split is paid as normal. Lost or accepted: the split is cancelled.
- After vendors are paid: refunds and chargebacks are yours. We do not claw money back from vendors; recover it from them directly if you need to.
- Failed or reversed transfer: the share and its fee go back to your available balance, the split is marked failed, you are notified, and you can retry it from Split payments, Activity (to the vendor's current verified account).
Webhook events
split.paid (the transfer reached the vendor) and split.failed (it failed or was reversed; the money is back in your balance). Signed and retried like every other event; event_id is stable per occurrence. Split payouts do not send transfer.* events.
{
"event": "split.paid",
"data": {
"event_id": "split_301:split.paid:9921",
"id": 301,
"status": "paid",
"mode": "live",
"currency": "NGN",
"share_type": "percentage",
"share_value": 50,
"amount": 490000,
"fee": 5500,
"fee_bearer": "merchant",
"transfer_amount": 490000,
"party": { "id": 14, "name": "Ada Foods", "account_name": "ADA OKAFOR", "bank_name": "Access Bank", "account_number": "0123456789" },
"payment": { "reference": "order-12345", "amount": 1000000, "net": 980000, "currency": "NGN", "paid_at": "2026-10-05T10:02:11.000Z", "metadata": {} },
"payout": { "reference": "bpo_41_9f2c1a7b3d5e8f60", "status": "succeeded", "provider_reference": "TRF_1a2b3c" },
"attempts": 1,
"failure_reason": null,
"paid_at": "2026-10-06T16:05:40.000Z",
"failed_at": null,
"created_at": "2026-10-05T10:01:58.000Z"
}
}Storefront
A shop page for sellers who take orders on WhatsApp and Instagram: https://checkout.twelveai.app/store/your-store. Customers browse your published products, pick options and quantities, choose pickup or delivery, and pay on the normal hosted checkout. You get the order in the dashboard (Store), an in-app notification and an email, and the customer gets an order confirmation email. Set it up under Store in the dashboard: publish products, set delivery and your WhatsApp number, then open the store and share its link or QR code.
A store sells in one currency, naira or US dollars (store settings, currency). Only products priced in it are listed; the dashboard marks the others as not sold in the store. Public store responses carry currency, and delivery fees are in it too.
Products are your inventory items (the same catalogue as the TwelveAI Business app), including their options, prices and stock. Each order is paid by one ordinary payment, so it shows under Payments, pays the normal collection fee (or passes it to the customer if you turned that on), settles T+1 and fires charge.success with metadata.source: "store" and metadata.store_order_number. There is no extra storefront fee.
Products, options and photos
Manage the whole catalogue from the dashboard (Store, Products): name, description, price, SKU (never shown to customers), stock with a low-stock alert, status, and up to 9 photos with a cover. A product can come in options (sizes, colours), each with its own price, stock and photo; the store offers them as a list to pick from and shows "Only N left" or "Sold out" per option. An optional compare-at price is shown struck through next to the selling price, and each product can carry its own page title and description for search engines and link previews. Products appear in the order you set; move them up or down.
Collections
Group products into collections ("Dresses", "New in") under Store, Products, Collections. Published collections show as tabs at the top of the store, with "All products" first; a collection has its own shareable address, https://checkout.twelveai.app/store/your-store?c=dresses. A product can sit in several collections, and each collection keeps its own product order. Customers can also search the catalogue and sort by price, name or newest.
In the order object below, every line carries item_id and, when the customer picked an option, variant_id and variant_name (null for a product without options). Stock is drawn down per option, and stock_issues names the option that ran short. Option ids stay the same when you edit a product, so you can key your own stock on them.
Prices and stock
The browser only sends product ids, options and quantities. Every price, the delivery fee and the total are worked out on our server from your inventory at checkout. If a price changed since the customer opened the page, the checkout is refused and the page shows the new prices before the customer pays.
Stock is not held while a customer is paying. It is checked at checkout and drawn down once, when the payment succeeds, with a sale entry in the item's stock history. If two customers pay for the last unit, the later order is still paid (the money is real) and is marked paid_needs_attention with the short lines in stock_issues: restock and fulfil it, or refund the difference from the payment.
Order statuses
Delivery
Offer pickup, delivery, both, or neither (the customer is told you will arrange it). Delivery is a flat fee, optionally free when the order subtotal is at or above an amount you set. The fee is added to the order total the customer pays.
Webhook events
Delivered to the same endpoint, signed and retried exactly like payment events: store.order.paid and store.order.fulfilled. data is the order object (amounts in kobo), the same one GET /api/business/v1/store/orders/:id returns; event_id is stable per occurrence, so you can de-duplicate on it.
{
"event": "store.order.paid",
"data": {
"event_id": "ORD_K7Q2M9X4TP:store.order.paid",
"id": "ORD_K7Q2M9X4TP",
"mode": "live",
"status": "paid",
"store": "ada-fabrics",
"customer": { "name": "Chioma Obi", "email": "chioma@example.com", "phone": "+2348031234567" },
"delivery": { "method": "delivery", "address": "12 Allen Avenue, Ikeja", "note": null, "fee": 150000 },
"items": [
{ "item_id": 41, "variant_id": 7, "name": "Ankara wrap dress", "variant_name": "Size 12", "image_url": "https://res.cloudinary.com/demo/dress.jpg", "unit_price": 1500000, "quantity": 1, "line_total": 1500000 }
],
"subtotal": 1500000,
"delivery_fee": 150000,
"total": 1650000,
"currency": "NGN",
"payment": { "reference": "TWAI_12_9f3c1a7b2d4e6f80", "paid_at": "2026-10-05T10:02:11.000Z" },
"stock_issues": null,
"fulfillment": null,
"cancelled_at": null,
"cancel_reason": null,
"paid_at": "2026-10-05T10:02:11.000Z",
"created_at": "2026-10-05T10:00:48.000Z"
}
}Store orders are read-only on the API for now: list them with GET /api/business/v1/store/orders and fetch one with GET /api/business/v1/store/orders/:id (scope payments:read). Fulfil and cancel from the dashboard.
Storefront design and AI assistant
Your store page has a look of its own: a template, colours, fonts, a header style, an announcement bar, a hero, the order and copy of the page sections, the product card style, the footer and the page title search engines show. Set it under Store, Design in the dashboard. Nothing here changes your products, prices or stock; it only changes how the page is drawn.
Templates
Try a template on the live preview, customise anything by hand (every colour, font, section and line of copy), then Publish. Shoppers only ever see the last published look; the preview shows your working copy. Every publish is kept in the version history (the last 30), so you can restore an earlier look in one click.
The design assistant
Describe a change in plain words ("make it feel like a premium boutique with serif headings", "add an FAQ about delivery and returns", "put a sale announcement at the top") and the assistant proposes a new look. You see a one-line summary and the result on the preview; apply it, change more, or discard it. Nothing is published until you press Publish.
What it can change: the template, colours, fonts, corners and spacing, header layout, the announcement bar, the hero, which sections appear and in what order, the copy in About, Testimonials, FAQ and Contact, product card style, footer text and links, and the SEO title and description.
What it cannot do, by design: touch products, prices, stock, orders, payments, customers, settings, API keys, other pages or other websites, or write any code. The assistant can only produce the same validated design document a manual save produces (fixed lists of allowed values, hex colours, bounded plain text, https links only); anything else is refused before it reaches your store. Requests outside storefront design get a short explanation instead of a change.
AI credits and prices
The assistant is paid for with prepaid AI credits in naira, at the same rates as the Twelve Engine console: NGN 12 per 1,000 input tokens and NGN 72 per 1,000 output tokens. A typical design request uses about 2,000 to 3,000 input tokens and 700 to 1,000 output tokens, so it costs roughly NGN 60 to 120. Every request shows its exact token count and cost, and your balance after it. A request is charged for the tokens actually used, including when the assistant declines or proposes something that fails validation, because the model was still run; a request that never reaches the model is not charged.
Every business receives NGN 5,000 of welcome credits once, the first time it opens the Design tab or asks the assistant. A request is refused when the balance is below NGN 20; buy credits to continue.
Top-ups
Buy credits from the Design tab or under Settings, AI credits: pick NGN 2,000, 5,000, 20,000 or any whole amount from NGN 1,000 to NGN 500,000. You pay TwelveAI on the normal hosted checkout (card, bank transfer or USSD); the payment is a payment to TwelveAI, not to your business, so it does not appear in your Payments and the standard collection fee is TwelveAI's to bear. Credits are added the moment the payment is confirmed (the dashboard verifies it when you return, and a successful payment credits you even if you close the tab). Credits do not expire and are not refundable to cash.
Pay in instalments
Let a customer split a purchase into 2 to 6 parts, paid weekly, every 2 weeks or monthly. The customer pays part 1 now; the rest follow on a schedule. You release the goods (or start the service) when the plan is paid in full, so there is no lending and no credit risk: the parts always add up to exactly the purchase total, with any rounding kobo on the last part. A plan is in naira or US dollars (currency on create; on a payment link it follows the link currency), and every part is in the plan currency.
Every part is an ordinary payment: it shows under Payments, pays the normal collection fee (per part, 2% capped at NGN 2,500, or passed to the customer if you turned that on), settles T+1 and fires charge.success with metadata.instalment_plan_code set. There is no extra instalment fee.
Where customers get it
- Payment links: turn on Allow instalments in the link editor (part counts, how often, and the smallest order). The link page then offers "Pay in N parts" and shows the dates and amounts before the customer pays.
- Your backend:
POST /api/business/v1/instalment-plansreturns the plan and acheckout_urlfor part 1.
How later parts are collected
If part 1 is paid by card, the card is saved and each later part is charged to it automatically on its due date (within about 10 minutes). If the charge fails we retry on day 1 and day 3 after the first failure (3 attempts by default; change it in Settings, Payments) and email the customer a link to pay another way. If part 1 was paid by transfer, USSD or another method that cannot be charged again, the customer is emailed 2 days before each part and on the due date with a link to their pay page (https://checkout.twelveai.app/p/…), which shows the schedule with buttons to pay the next part or everything that is left. Paying early is always allowed; extra money goes to the next part. Customer emails are sent in live mode only (the dashboard can still send the pay link for a test plan).
Dates follow the day part 1 was paid, on the Lagos calendar: a plan started on 31 January is due 28 February (29 in a leap year), 31 March, 30 April. Each automatic charge is recorded under a key unique to the plan, the part and the attempt before the card is charged, and no second attempt starts while one is in flight or unconfirmed, so a part is never charged twice. While the customer is at checkout on the pay page, the automatic charge waits.
Lifecycle
Parts are scheduled, paid, failed (a card charge failed) or waived. In the dashboard under Instalments you can see every plan's progress, cancel a plan, waive a part (forgive what is left on it; the plan completes when nothing is owed), and send the pay link by email, copy it, or share it on WhatsApp. Only you can cancel a plan; customers cannot.
Webhook events
Delivered to the same endpoint, signed and retried exactly like payment events, and fanned out to connected apps like subscription events: instalment_plan.created, instalment_plan.part_paid, instalment_plan.completed, instalment_plan.part_failed, instalment_plan.defaulted and instalment_plan.cancelled. data is the plan object; event_id is stable per occurrence, so you can de-duplicate on it.
{
"event": "instalment_plan.part_paid",
"data": {
"event_id": "INS_q8m2k7x4v9w3r6t2p5n1:part_paid:6120",
"id": "INS_q8m2k7x4v9w3r6t2p5n1",
"mode": "live",
"status": "active",
"customer": { "email": "ada@example.com", "name": "Ada Okafor", "phone": "08012345678" },
"description": "3-seater sofa",
"amount": 30000000,
"amount_paid": 20000000,
"amount_waived": 0,
"amount_remaining": 10000000,
"currency": "NGN",
"parts_count": 3,
"frequency": "monthly",
"collection": "card",
"card": { "id": 88, "brand": "visa", "last4": "4081", "exp_month": "12", "exp_year": "2030" },
"next_part": { "number": 3, "amount_due": 10000000, "due_at": "2026-12-31T09:12:00.000Z" },
"parts": [
{ "number": 1, "amount": 10000000, "amount_paid": 10000000, "amount_remaining": 0, "due_at": "2026-10-31T09:12:00.000Z", "status": "paid", "paid_at": "2026-10-31T09:12:00.000Z", "failure_count": 0, "last_failure_reason": null },
{ "number": 2, "amount": 10000000, "amount_paid": 10000000, "amount_remaining": 0, "due_at": "2026-11-30T09:12:00.000Z", "status": "paid", "paid_at": "2026-11-30T09:14:02.000Z", "failure_count": 0, "last_failure_reason": null },
{ "number": 3, "amount": 10000000, "amount_paid": 0, "amount_remaining": 10000000, "due_at": "2026-12-31T09:12:00.000Z", "status": "scheduled", "paid_at": null, "failure_count": 0, "last_failure_reason": null }
],
"pay_url": "https://checkout.twelveai.app/p/7c1e...",
"metadata": { "order_id": "SO-1042" },
"created_at": "2026-10-31T09:10:41.000Z",
"activated_at": "2026-10-31T09:12:00.000Z",
"completed_at": null,
"cancelled_at": null,
"defaulted_at": null,
"payment": { "reference": "TWAI_INS_41_2_1", "amount": 10000000, "currency": "NGN", "paid_at": "2026-11-30T09:14:02.000Z" },
"applied": [{ "part": 2, "amount": 10000000, "part_paid_in_full": true }],
"parts_paid": [2],
"excess_amount": 0
}
}instalment_plan.created carries payment (part 1). instalment_plan.part_failed carries an attempt block:
"attempt": {
"part": 2,
"number": 1,
"reference": "TWAI_INS_41_2_1",
"failure_reason": "Insufficient Funds",
"next_retry_at": "2026-12-01T09:20:00.000Z",
"will_retry": true
}excess_amount is above zero only if two payments landed at the same moment and together paid more than was owed; refund the difference from that payment.
From a payment link (public endpoints)
GET /api/v1/public/links/:slug now includes instalments (null when the link does not offer them): { enabled, allowed_parts, frequency, min_amount }, with min_amount in kobo. Two more endpoints, with no API key (the slug is the credential) and amount in naira as for /pay:
GET /api/v1/public/links/rybejd97ta/instalment-schedule?parts=3&amount=300000
{ "success": true, "data": { "amount": 30000000, "parts": 3, "frequency": "monthly", "fee_passed_to_customer": false,
"schedule": [ { "number": 1, "amount": 10000000, "charge_amount": 10000000, "due_at": "2026-10-31T09:12:00.000Z" }, ... ] } }
POST /api/v1/public/links/rybejd97ta/pay-instalments
{ "email": "ada@example.com", "name": "Ada Okafor", "amount": 300000, "parts": 3, "fields": { "company": "Acme Ltd" } }
{ "success": true, "data": { "reference": "TWAI_15_...", "checkout_url": "https://checkout.twelveai.app/c/TWAI_15_...", "pay_url": "https://checkout.twelveai.app/p/7c1e..." } }POST /api/v1/public/links/:slug/pay is unchanged and still takes a full payment.
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.authorizedOwner approved consent - payload carries the one-time OAuth code + redirect_uri so your backend can exchange without waiting for the browser round-trip. Codes are single-use.connection.createdA business connected to your Connect app (fires when the code is exchanged for a token).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.authorized
{
"event": "connection.authorized",
"data": {
"app_id": 42,
"code": "tw_ac_...",
"redirect_uri": "https://yourapp.com/twelveai/callback",
"business_id": 108,
"scopes": ["payments:read", "payments:write"],
"mode": "live",
"expires_in": 60,
"metadata": "order_42"
},
"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.