API reference
Every endpoint, in the open - with request samples in cURL, Node, Python, PHP, Go, Ruby and Java. REST over HTTPS, JSON in and out, amounts in kobo. Base URL: https://api.twelveai.app
Getting started
Each key is a public/secret pair, mode-scoped. Your secret key is tw_test_… (test) or tw_live_… (live); the matching public key is tw_pub_test_… / tw_pub_live_…. Authenticate every request with the secret key as a bearer token - never expose it in client-side code. Connect platforms use an OAuth access token (tw_at_…) on the same endpoints, limited to the granted scopes.
Authorization: Bearer tw_live_xxxxxxxxxxxxxxxxxxxxEvery endpoint below includes a ready-to-run request in your language - switch tabs on the Request panel.
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.
Payments
/api/business/v1/payments/initializeInitialize a payment
Starts a payment and returns a branded checkout_url to redirect your customer to.
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.Send any subset, as an array or a comma-separated string. It can only narrow what the business accepts (Settings, Payments). See Payment methods (channels).
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \ -H "Authorization: Bearer tw_test_..." \ -H "Content-Type: application/json" \ -d '{ "email": "customer@example.com", "amount": 50000, "reference": "order-12345" }'
{ "success": true, "data": { "reference": "order-12345", "checkout_url": "https://checkout.twelveai.app/c/order-12345", "authorization_url": "https://checkout.twelveai.app/c/order-12345", "access_code": "", "amount": 50000, "currency": "NGN", "provider": "flutterwave" } }
/api/business/v1/payments/verify/:referenceVerify a payment
Re-checks a payment. Use when a webhook was missed. Idempotent.
curl "https://api.twelveai.app/api/business/v1/payments/verify/order-12345" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "reference": "order-12345", "status": "successful", "amount": 50000, "fee": 750, "net": 49250, "currency": "NGN", "paid_at": "2026-06-12T12:34:56Z", "customer": { "email": "customer@example.com", "name": null }, "metadata": { "order_id": "12345", "saved_card_id": 42 }, "card": { "authorization_code": "AUTH_8dfhjjdt", "signature": "SIG_yEXu7dLBeqG0kU7g95Ke", "customer_email": "customer@example.com", "last4": "4081", "brand": "visa", "bank": "TEST BANK", "exp_month": "12", "exp_year": "2030", "reusable": true } } }
/api/business/v1/paymentsList payments
Cursor-paginated, newest first, scoped to the mode of the key you send.
curl "https://api.twelveai.app/api/business/v1/payments?status=successful&limit=50" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "payments": [ { "reference": "order-12345", "amount": 50000, "fee": 750, "net": 49250, "currency": "NGN", "status": "successful", "settlement_status": "settled", "customer_email": "customer@example.com", "paid_at": "2026-06-12T12:34:56Z", "created_at": "2026-06-12T12:30:00Z" } ], "next_cursor": null } }
/api/business/v1/saved-cardsList saved cards
Reusable cards captured on successful card payments. Use the authorization_code to re-charge. currency is the only currency the card can be charged in: NGN for a card saved from a naira payment, USD for a card saved from a USD card payment (a Flutterwave card token).
curl "https://api.twelveai.app/api/business/v1/saved-cards?email=customer@example.com" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "saved_cards": [ { "authorization_code": "AUTH_xxxxxxxx", "customer_email": "customer@example.com", "brand": "visa", "last4": "4081", "exp_month": "08", "exp_year": "2029", "bank": "GTBank", "reusable": true, "currency": "NGN" }, { "authorization_code": "flw-t1nf-5b0f12a1c3e4d5f6-m03k", "customer_email": "buyer@example.com", "brand": "mastercard", "last4": "2950", "exp_month": "09", "exp_year": "2032", "bank": "MASTERCARD CREDIT", "reusable": true, "currency": "USD" } ], "next_cursor": null } }
/api/business/v1/payments/charge-authorizationCharge a saved card
Synchronous re-charge against a saved authorization_code. Live mode only. A naira card is charged through Paystack; a USD card (saved from a USD card payment) through Flutterwave, and the net goes to your USD balance.
curl -X POST https://api.twelveai.app/api/business/v1/payments/charge-authorization \ -H "Authorization: Bearer tw_live_..." \ -H "Content-Type: application/json" \ -d '{ "authorization_code": "AUTH_xxxxxxxx", "email": "customer@example.com", "amount": 50000 }'
{ "success": true, "message": "Charged.", "data": { "reference": "subscription-may-2026", "status": "successful", "amount": 50000, "net": 49250, "paid_at": "2026-05-01T08:00:00Z" } }
Balance & money
/api/business/v1/balanceGet balance
Wallet balances (kobo), verification status, and this month's live usage.
curl "https://api.twelveai.app/api/business/v1/balance" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "available_balance": 4925000, "pending_balance": 150000, "currency": "NGN", "verification_status": "verified", "monthly_cap": null, "monthly_used": 12500000 } }
/api/business/v1/transactionsList transactions
Unified money-movement ledger (live): card payments + incoming bank transfers + payouts, newest first. Connect scope: transactions:read.
curl "https://api.twelveai.app/api/business/v1/transactions?limit=50" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "transactions": [ { "kind": "payment", "direction": "credit", "reference": "order-12345", "amount_minor": 50000, "fee_minor": 750, "currency": "NGN", "status": "successful", "counterparty": "customer@example.com", "created_at": "2026-06-12T12:34:56Z" } ], "next_offset": 50 } }
/api/business/v1/customersList customers
Your customers, aggregated by email across payments. total_minor is naira-priced payments only (kobo); a customer with USD-priced payments also has total_usd_minor (cents). Naira and dollars are never added together. Connect scope: customers:read.
curl "https://api.twelveai.app/api/business/v1/customers?limit=50" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "customers": [ { "email": "customer@example.com", "payments": 3, "total_minor": 150000, "last_seen": "2026-06-12T12:34:56Z" }, { "email": "buyer@example.com", "payments": 2, "total_minor": 0, "last_seen": "2026-06-14T09:10:00Z", "total_usd_minor": 12000 } ] } }
/api/business/v1/transfersCreate a payout
Withdraw available balance to the account's saved settlement account. Sent to the bank straight away, with no review on our side, and usually received within minutes. Live only. Connect scope: payouts:write. GET the same path lists payouts.
curl -X POST https://api.twelveai.app/api/business/v1/transfers \ -H "Authorization: Bearer tw_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 25000 }'
{ "success": true, "message": "Payout submitted. Status will update when the bank confirms.", "data": { "payout": { "reference": "bpo_…", "status": "processing", "amount": 2500000, "currency": "NGN", "destination": { "bank_name": "GTBank", "account_number": "0123456789", "account_name": "Greyark Technologies LLC" }, "created_at": "2026-06-12T12:34:56Z" } } }
/api/business/v1/disbursementsSend a transfer to a customer
Send money to any bank account on behalf of your customer. The debit draws against your business's own available balance (there is no separate per-customer wallet). Live only, verified businesses only. Connect scope: payouts:write.
curl -X POST https://api.twelveai.app/api/business/v1/disbursements \ -H "Authorization: Bearer tw_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 15000, "bank_code": "058", "account_number": "0123456789", "reason": "Payout to customer", "customer_reference": "order_123", "idempotency_key": "disb_order_123" }'
{ "success": true, "message": "Transfer submitted. Status will update when the bank confirms.", "data": { "payout": { "reference": "bpo_…", "status": "processing", "amount": 1500000, "currency": "NGN", "destination": { "bank_name": "GTBank", "account_number": "0123456789", "account_name": "JOHN A ADEYEMI" }, "created_at": "2026-06-12T12:34:56Z" } } }
Banks
/api/business/v1/banksList banks (legacy)
Supported payout banks with their codes. Use a returned code as bank_code when resolving an account or creating a payout. No scope required.
curl "https://api.twelveai.app/api/business/v1/banks?country=NG" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "banks": [ { "code": "NG::000013", "name": "Guaranty Trust Bank" }, { "code": "NG::000014", "name": "Access Bank" }, { "code": "NG::000015", "name": "Zenith Bank" } ] } }
/api/business/v1/banks/resolveResolve an account (legacy)
Verify a 10-digit NUBAN and return the account holder name. Use this to confirm a destination before a payout. No scope required; rate-limited to 30/min.
curl "https://api.twelveai.app/api/business/v1/banks/resolve?account_number=0123456789&bank_code=NG::000013" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "account_number": "0123456789", "account_name": "JOHN A ADEYEMI", "bank_code": "NG::000013", "bank_name": "Guaranty Trust Bank" } }
/api/business/v1/banks/paystackList banks
Supported banks with their Paystack NUBAN codes. Use a returned code as bank_code when sending a customer disbursement. No scope required.
curl "https://api.twelveai.app/api/business/v1/banks/paystack" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "banks": [ { "code": "044", "name": "Access Bank" }, { "code": "058", "name": "GTBank" }, { "code": "057", "name": "Zenith Bank" } ] } }
/api/business/v1/banks/paystack/resolveResolve an account
Verify a 10-digit NUBAN against the Paystack rail and return the account holder name. Use this to confirm a destination before a disbursement. No scope required; rate-limited to 30/min.
curl "https://api.twelveai.app/api/business/v1/banks/paystack/resolve?account_number=0123456789&bank_code=058" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "account_number": "0123456789", "account_name": "JOHN A ADEYEMI", "bank_code": "058", "bank_name": "GTBank" } }
Webhooks
/api/business/v1/webhooksConfigure webhooks
Sets (or replaces) the URL we POST events to for this mode. Signing is optional and bring-your-own.
curl -X POST https://api.twelveai.app/api/business/v1/webhooks \ -H "Authorization: Bearer tw_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourdomain.com/webhooks/twelve", "signing_secret": "my_own_secret_123" }'
{ "success": true, "message": "Webhook saved. Deliveries are signed - store your signing_secret to verify them.", "data": { "id": 12, "mode": "live", "url": "https://yourdomain.com/webhooks/twelve", "signing_secret": "my_own_secret_123", "secret_set": true, "enabled": true } }
Connect (OAuth)
/oauth/tokenExchange / refresh token
Client-authenticated (no bearer). Exchange the auth code for an access token, or refresh an existing one.
curl -X POST https://api.twelveai.app/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "authorization_code", "client_id": "twc_...", "client_secret": "twcs_...", "code": "tw_ac_...", "redirect_uri": "https://yourapp.com/twelveai/callback" }'
{ "success": true, "access_token": "tw_at_...", "refresh_token": "tw_rt_...", "token_type": "Bearer", "scope": "payments:read payments:write", "mode": "live" }
/oauth/sandbox/connectSandbox connect
Client-authenticated. Returns an instant TEST access token connected to a sandbox account, so you can test your on-behalf integration without the interactive consent flow. Test mode only.
curl -X POST https://api.twelveai.app/oauth/sandbox/connect \ -H "Content-Type: application/json" \ -d '{ "client_id": "twc_...", "client_secret": "twcs_..." }'
{ "success": true, "access_token": "tw_at_...", "refresh_token": "tw_rt_...", "token_type": "Bearer", "scope": "payments:read payments:write", "mode": "test", "connected_account_id": 2 }
Subscriptions
/api/business/v1/plansCreate a plan
Something you sell on repeat: an amount charged every interval. PATCH /plans/:id changes name, description, amount (new subscribers only), redirect_url or metadata; the interval cannot change. POST /plans/:id/archive stops new subscriptions while existing ones keep renewing. Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/plans \ -H "Authorization: Bearer tw_test_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Gold", "amount": 500000, "interval": "monthly" }'
{ "success": true, "data": { "id": "PLN_a7c2e9k4m3p8", "mode": "test", "name": "Gold", "description": null, "amount": 500000, "currency": "NGN", "interval": "monthly", "interval_count": 1, "interval_label": "monthly", "status": "active", "hosted_url": "https://checkout.twelveai.app/s/PLN_a7c2e9k4m3p8", "redirect_url": null, "metadata": {}, "created_at": "2026-10-05T10:00:00.000Z", "updated_at": "2026-10-05T10:00:00.000Z" } }
/api/business/v1/plansList plans
Plans in the key's mode, newest first (up to 200). Filter with ?status=active|archived. GET /plans/:id returns one plan with its subscriber counts. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/plans?status=active" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "plans": [ { "id": "PLN_a7c2e9k4m3p8", "name": "Gold", "amount": 500000, "interval": "monthly", "interval_count": 1, "status": "active", "hosted_url": "https://checkout.twelveai.app/s/PLN_a7c2e9k4m3p8" } ] } }
/api/business/v1/subscriptionsCreate a subscription
Starts a subscription (status incomplete) and its first checkout, cards only. Send the customer to checkout_url; when that payment succeeds the subscription becomes active and subscription.created fires. Unpaid subscriptions expire after 7 days. Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/subscriptions \ -H "Authorization: Bearer tw_test_..." \ -H "Content-Type: application/json" \ -d '{ "plan": "PLN_a7c2e9k4m3p8", "email": "ada@example.com", "name": "Ada Okafor" }'
{ "success": true, "data": { "subscription": { "id": "SUB_k3m9q2x7v8w4r6t2", "status": "incomplete", "next_charge_at": null, "card": null, "manage_url": "https://checkout.twelveai.app/s/manage/3f9c..." }, "checkout_url": "https://checkout.twelveai.app/c/TWAI_15_92a4565952f1f586", "authorization_url": "https://checkout.paystack.com/abc123", "reference": "TWAI_15_92a4565952f1f586" } }
/api/business/v1/subscriptions/:idGet a subscription
One subscription by id (SUB_...). GET /subscriptions lists them newest first with ?status=, ?plan=, ?email=, ?page= and ?per_page= (max 100), returning data.subscriptions and data.meta. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/subscriptions/SUB_k3m9q2x7v8w4r6t2" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "id": "SUB_k3m9q2x7v8w4r6t2", "mode": "test", "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-10-31T09:12:00.000Z", "current_period_end": "2026-11-30T09:12:00.000Z", "next_charge_at": "2026-11-30T09: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", "manage_url": "https://checkout.twelveai.app/s/manage/3f9c..." } }
/api/business/v1/subscriptions/:id/cancelCancel a subscription
Stops future charges; never refunds money already collected. The response is the subscription plus changed (false when there was nothing to do). Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/subscriptions/SUB_k3m9q2x7v8w4r6t2/cancel \ -H "Authorization: Bearer tw_test_..." \ -H "Content-Type: application/json" \ -d '{ "at_period_end": true }'
{ "success": true, "data": { "id": "SUB_k3m9q2x7v8w4r6t2", "status": "active", "cancel_at_period_end": true, "cancel_reason": "merchant", "current_period_end": "2026-11-30T09:12:00.000Z", "changed": true } }
Disputes
/api/business/v1/disputesList disputes
Disputes (chargebacks) in the key's mode, newest first. Read-only: responding happens in the dashboard. Amounts are in minor units of the dispute's currency (the currency the customer paid: kobo for NGN, cents for USD); held is what is set aside from that balance right now. A dispute on a payment paid in another currency than its price also has price_currency and price_amount. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/disputes?status=open" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "disputes": [ { "id": 42, "provider_dispute_id": "2867", "status": "needs_response", "category": "chargeback", "reason": "Customer says the item never arrived", "currency": "NGN", "amount": 50000, "held": 49250, "due_at": "2026-06-15T12:00:00.000Z", "resolution": null, "resolved_at": null, "evidence_submitted_at": null, "payment_reference": "order-12345", "created_at": "2026-06-13T09:10:00.000Z", "updated_at": "2026-06-13T09:10:00.000Z" } ], "next_cursor": null } }
/api/business/v1/disputes/:idFetch a dispute
One dispute by its id (from the list or the dispute.created event). Same fields as the list. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/disputes/42" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "id": 42, "provider_dispute_id": "2867", "status": "won", "category": "chargeback", "reason": "Customer says the item never arrived", "currency": "NGN", "amount": 50000, "held": 0, "due_at": "2026-06-15T12:00:00.000Z", "resolution": "declined", "resolved_at": "2026-06-20T10:00:00.000Z", "evidence_submitted_at": "2026-06-14T08:00:00.000Z", "payment_reference": "order-12345", "created_at": "2026-06-13T09:10:00.000Z", "updated_at": "2026-06-20T10:00:00.000Z" } }
Invoices
/api/business/v1/invoicesCreate an invoice
Creates a draft invoice in the key's mode (send: true also issues and emails it). Amounts in minor units of the invoice currency (kobo or cents). Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/invoices \ -H "Authorization: Bearer tw_test_..." \ -H "Content-Type: application/json" \ -d '{ "customer": { "email": "ada@example.com", "name": "Ada Okafor" }, "line_items": [ { "description": "Logo design", "quantity": 1, "unit_price": 15000000 } ], "tax_percent": 7.5, "due_date": "2026-10-19", "allow_partial": true, "metadata": { "order_id": "A-1001" } }'
{ "success": true, "data": { "id": 412, "invoice_number": "INV-0042", "mode": "test", "status": "draft", "currency": "NGN", "customer": { "email": "ada@example.com", "name": "Ada Okafor", "phone": null, "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": 0, "amount_due": 16125000, "overpaid": 0, "allow_partial": true, "notes": null, "terms": null, "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": null, "paid_at": null, "voided_at": null, "metadata": { "order_id": "A-1001" }, "created_at": "2026-10-05T09:00:00.000+01:00" } }
/api/business/v1/invoicesList invoices
Invoices in the key's mode, newest first. Returns data.invoices and data.meta. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/invoices?status=overdue" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "invoices": [ { "id": 412, "invoice_number": "INV-0042", "status": "overdue", "total": 16125000, "amount_paid": 5000000, "amount_due": 11125000, "hosted_url": "https://checkout.twelveai.app/i/cm1x9..." } ], "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 } } }
/api/business/v1/invoices/:idGet an invoice
One invoice by its numeric id or its invoice number (INV-0042), with the payments applied to it. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/invoices/INV-0042" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "id": 412, "invoice_number": "INV-0042", "status": "partially_paid", "total": 16125000, "amount_paid": 5000000, "amount_due": 11125000, "payments": [ { "kind": "online", "reference": "INV_412_9c1e2a7b40f3", "amount": 5000000, "applied": 5000000, "overpaid": 0, "method": "card", "paid_at": "2026-10-07T14:22:10.000+01:00" } ] } }
/api/business/v1/invoices/:id/sendSend an invoice
Issues a draft (status sent) and emails the customer the invoice with a Pay invoice button. Calling it again re-emails an open invoice. invoice.sent fires on the first send only. Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/invoices/412/send \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "id": 412, "invoice_number": "INV-0042", "status": "sent", "sent_at": "2026-10-05T09:01:12.000+01:00", "hosted_url": "https://checkout.twelveai.app/i/cm1x9...", "email_sent": true, "whatsapp_url": "https://wa.me/2348031234567?text=Hi%20Ada%2C%20here%20is%20invoice%20INV-0042..." } }
/api/business/v1/invoices/:id/voidVoid an invoice
Cancels an invoice nothing has been paid on. The link then shows it as void and refuses payment. Returns 409 when payments exist (refund them first) or it is already paid or void. Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/invoices/412/void \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "id": 412, "invoice_number": "INV-0042", "status": "void", "amount_due": 0, "voided_at": "2026-10-06T10:00:00.000+01:00" } }
Pay in instalments
/api/business/v1/instalment-plansCreate an instalment plan
Splits a purchase into 2 to 6 parts and starts the checkout for part 1. Send the customer to checkout_url; when part 1 is paid the plan becomes active and instalment_plan.created fires. An unpaid plan expires after 7 days. Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/instalment-plans \ -H "Authorization: Bearer tw_test_..." \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "name": "Ada Okafor", "amount": 30000000, "parts": 3, "frequency": "monthly", "description": "3-seater sofa", "metadata": { "order_id": "SO-1042" } }'
{ "success": true, "data": { "plan": { "id": "INS_q8m2k7x4v9w3r6t2p5n1", "status": "pending", "amount": 30000000, "amount_paid": 0, "amount_remaining": 30000000, "parts_count": 3, "frequency": "monthly", "collection": "pay_link", "parts": [ { "number": 1, "amount": 10000000, "due_at": "2026-10-31T09:10:41.000Z", "status": "scheduled" }, { "number": 2, "amount": 10000000, "due_at": "2026-11-30T09:10:41.000Z", "status": "scheduled" }, { "number": 3, "amount": 10000000, "due_at": "2026-12-31T09:10:41.000Z", "status": "scheduled" } ], "pay_url": "https://checkout.twelveai.app/p/7c1e..." }, "checkout_url": "https://checkout.twelveai.app/c/TWAI_15_92a4565952f1f586", "authorization_url": "https://checkout.paystack.com/abc123", "reference": "TWAI_15_92a4565952f1f586" } }
/api/business/v1/instalment-plans/:idGet an instalment plan
One plan by id (INS_...). GET /api/business/v1/instalment-plans lists them newest first, with ?status=, ?email=, ?page= and ?per_page= (max 100); the list returns data.plans and data.meta. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/instalment-plans/INS_q8m2k7x4v9w3r6t2p5n1" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "id": "INS_q8m2k7x4v9w3r6t2p5n1", "mode": "test", "status": "active", "customer": { "email": "ada@example.com", "name": "Ada Okafor", "phone": null }, "description": "3-seater sofa", "amount": 30000000, "amount_paid": 10000000, "amount_waived": 0, "amount_remaining": 20000000, "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": 2, "amount_due": 10000000, "due_at": "2026-11-30T09: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": 0, "amount_remaining": 10000000, "due_at": "2026-11-30T09:12:00.000Z", "status": "scheduled", "paid_at": null, "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 } }
/api/business/v1/instalment-plans/:id/cancelCancel an instalment plan
Stops future charges and reminders. Money already paid stays with you. The response is the full plan object plus changed (false when the plan had already ended). Connect scope: payments:write.
curl -X POST https://api.twelveai.app/api/business/v1/instalment-plans/INS_q8m2k7x4v9w3r6t2p5n1/cancel \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "id": "INS_q8m2k7x4v9w3r6t2p5n1", "status": "cancelled", "amount_paid": 10000000, "cancelled_at": "2026-11-12T14:03:22.000Z", "changed": true } }
Storefront
/api/business/v1/store/ordersList store orders
Storefront orders, newest first, for the key's mode. Read-only: fulfil and cancel from the dashboard. Returns data.orders and data.meta. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/store/orders?status=paid" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "orders": [ { "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" } ], "meta": { "page": 1, "per_page": 50, "total": 1 } } }
/api/business/v1/store/orders/:idGet a store order
One storefront order by its id (ORD_...). The same object store.order.paid and store.order.fulfilled carry. Connect scope: payments:read.
curl "https://api.twelveai.app/api/business/v1/store/orders/ORD_K7Q2M9X4TP" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "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" } }
Connect - act on behalf of other businesses
TwelveAI Connect lets your platform accept payments, read data, and initiate payouts on behalf of other TwelveAI businesses that authorize you. It is the standard OAuth 2.0 authorization-code flow. You never see the connected business's own API keys - you receive a scoped, revocable access token per connected account.
- 1. Register a platform app in your dashboard (Developers → Register app). You get a
client_idandclient_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. They approve, and we redirect back to yourredirect_uriwith a one-timecode. - 3. Exchange the code for tokens from your server - the code alone is nothing; you must POST it to
/oauth/tokenwith yourclient_secret. See How the flow works below. - 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/initializea first-party merchant uses now charges on their behalf.
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \ -H "Authorization: Bearer tw_at_..." \ # the connected business's access token -H "Content-Type: application/json" \ -d '{ "email": "customer@example.com", "amount": 50000, "reference": "order-12345", "application_fee_bps": 250 // optional - your platform cut (250 = 2.5%) }'
Application fees. Take a cut of on-behalf charges by setting a default on your app (Developers → Default fee) or per charge with application_fee (kobo) or application_fee_bps. At settlement the connected account is credited net-of-fee and your platform wallet is credited the fee.
How Connect works end-to-end
Connect is the standard OAuth 2.0 authorization-code flow. It is a three-step handoff between the browser and your server, and it is not complete until step 3. If your redirect_uri receives a code but your server never exchanges it, no connection is saved - the code silently expires after 60 seconds.
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.
// POST /api/twelveai/exchange - called by your redirect_uri handler app.post("/api/twelveai/exchange", async (req, res) => { const { code } = req.body const r = await fetch("https://dashboard.twelveai.app/oauth/token", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: process.env.TWELVEAI_CLIENT_ID, client_secret: process.env.TWELVEAI_CLIENT_SECRET, // never in the browser code, redirect_uri: "https://yourapp.com/twelveai/callback", // must match step 1 }), }) const tokens = await r.json() // tokens.access_token = tw_at_… - store it against the connected merchant. res.json({ ok: true, connected_account_id: tokens.connected_account_id }) })
@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.Connecting a business: popup & mobile
Three ways to run step 1 - pick what fits your app. All of them end the same way: the browser lands on your redirect_uri with a one-time code, and your server must do step 3 (POST /oauth/token) or nothing is saved.
Web - one-click button (drop-in)
The fastest path: include connect.js and add a button with data-twelveai-connect. It’s auto-wired on load - no JS to write. Handle the result with a global data-on-success function or a twelveai:success DOM event.
<script src="https://dashboard.twelveai.app/connect.js"></script> <button data-twelveai-connect data-client-id="twc_..." data-redirect-uri="https://yourapp.com/twelveai/callback" data-mode="live" data-on-success="onTwelveConnected"> Connect with TwelveAI </button> <script> // Called with the one-time code - exchange it on YOUR server. function onTwelveConnected({ code }) { fetch("/api/twelveai/exchange", { method: "POST", body: JSON.stringify({ code }) }) } // …or listen on the element: btn.addEventListener("twelveai:success", e => e.detail.code) </script>
Style it your way. The button gets a default .twelveai-connect-btn class you can override in your own CSS. Add classes with data-class="my-btn" (kept alongside the default), set custom text as the button’s content, or drop the default look entirely with data-unstyled.
React or a framework? Wire an element you render, or create one:
// Wire an existing element: TwelveConnect.button("#connect", { clientId: "twc_...", redirectUri: "https://yourapp.com/twelveai/callback", // scope is optional - defaults to the app's registered scopes className: "my-btn", // appended alongside the default unstyled: false, // true = your styles only onSuccess: ({ code }) => { /* exchange server-side */ }, }) // …or create + insert a button into a container: TwelveConnect.mount("#slot", { clientId: "twc_...", redirectUri: "...", label: "Connect" })
React, Vue, Svelte & Next
The SDK is plain browser JavaScript on window.TwelveConnect - framework-agnostic and SSR-safe (it no-ops on the server, so it’s safe in Next / SvelteKit / Nuxt). The drop-in data-twelveai-connect button even auto-wires when your framework renders it after load (a MutationObserver re-scans on DOM changes). Or call the programmatic API from a client-only mount hook.
import Script from "next/script" // or a plain <script> tag export function ConnectButton() { useEffect(() => { window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "https://yourapp.com/twelveai/callback", onSuccess: ({ code }) => exchangeOnServer(code), }) }, []) return ( <> <Script src="https://dashboard.twelveai.app/connect.js" strategy="afterInteractive" /> <button id="connect" className="my-btn">Connect with TwelveAI</button> </> ) }
<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>
<script> import { onMount } from "svelte" onMount(() => window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "...", onSuccess }) ) </script> <button id="connect" class="my-btn">Connect with TwelveAI</button>
Load connect.js once (a script tag in your document head, or Next’s <Script>). In SSR apps, call the API from a client-only hook (useEffect / onMounted / onMount) - never during render.
Web - popup call (full control)
Drop in connect.js and call TwelveConnect.open(). It opens a popup and hands the code back via postMessage - your page never navigates away.
<script src="https://dashboard.twelveai.app/connect.js"></script> <script> TwelveConnect.open({ clientId: "twc_...", redirectUri: "https://yourapp.com/twelveai/callback", // must be registered mode: "live", // scope is optional - defaults to the app's registered scopes onSuccess: async ({ code }) => { // Send code to YOUR server, then exchange it with your client_secret. await fetch("/api/twelveai/exchange", { method: "POST", body: JSON.stringify({ code }) }) }, onError: (err) => console.error(err), onClose: () => {}, // user closed the popup }) </script>
Note: this is a popup window, not an iframe modal - an OAuth consent screen can’t be safely iframed (and we send X-Frame-Options).
Mobile app - in-app browser + deep link
Register a custom-scheme redirect URI (e.g. myapp://twelveai/callback), open the same authorize URL in an in-app browser, and capture the code from the deep link the consent screen redirects to. Then exchange it from your server.
import * as WebBrowser from "expo-web-browser" const redirectUri = "myapp://twelveai/callback" // registered on your app const authorizeUrl = "https://dashboard.twelveai.app/oauth/authorize" + "?client_id=twc_..." + "&redirect_uri=" + encodeURIComponent(redirectUri) + "&mode=live" // scope optional - defaults to the app's registered scopes const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, redirectUri) if (result.type === "success") { const code = new URL(result.url).searchParams.get("code") // POST code to your server → exchange at /oauth/token with your client_secret }
Use PKCE (code_challenge) for mobile, since public clients can’t hold a secret. Universal/App Links (an https redirect that opens your app) work too.
Connect - testing your integration
Use the sandbox to exercise scopes and on-behalf calls without the consent flow. Every call here is test mode.
1. Get a sandbox test token
curl -X POST https://api.twelveai.app/oauth/sandbox/connect \ -H "Content-Type: application/json" \ -d '{ "client_id": "twc_...", "client_secret": "twcs_..." }' # → { "access_token": "tw_at_...", "mode": "test", # "scope": "payments:read payments:write ...", "connected_account_id": 2 }
To test scope enforcement, request a subset - the token is limited to it:
-d '{ "client_id":"twc_...", "client_secret":"twcs_...", "scope":"payments:read" }'
2. Charge on behalf (payments:write)
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \ -H "Authorization: Bearer tw_at_..." \ -H "Content-Type: application/json" \ -d '{ "email":"buyer@test.com", "amount":50000, "application_fee_bps":250 }' # → open the returned checkout_url; pay with the test card: # 4084 0840 8408 4081 · any CVV · any future expiry · OTP 123456
3. Read scopes
curl https://api.twelveai.app/api/business/v1/payments -H "Authorization: Bearer tw_at_..." # payments:read curl https://api.twelveai.app/api/business/v1/balance -H "Authorization: Bearer tw_at_..." # balance:read curl https://api.twelveai.app/api/business/v1/transactions -H "Authorization: Bearer tw_at_..." # transactions:read curl https://api.twelveai.app/api/business/v1/saved-cards -H "Authorization: Bearer tw_at_..." # customers:read
4. Prove enforcement
A call outside the token's granted scopes is rejected with 403 insufficient_scope:
# token issued with scope=payments:read, then attempt a charge: # → 403 { "error": "insufficient_scope", # "message": "This token is missing the required scope: payments:write" }
/transfers returns "Payouts are only available in live mode." You can still verify the scope gate, but a successful payout needs a live connection. Application fees settle at T+1 (live only) - in test the fee is recorded on the payment but the net-to-platform split isn't moved. Test verifies scopes + plumbing; live verifies settled money movement.Connect scopes
A connection’s granted scopes are the ceiling - enforced on every request. Out-of-scope calls return 403 insufficient_scope.
payments:readGET /payments · GET /payments/verify/:referencepayments:writePOST /payments/initialize · POST /payments/charge-authorizationtransactions:readGET /transactionsbalance:readGET /balancecustomers:readGET /customers · GET /saved-cardspayouts:writePOST /transfers · POST /disbursements · GET /transfers (live only)Webhooks
We POST a JSON event to your configured URL whenever money moves - a payment succeeds or fails, a refund clears, or a bank transfer lands in your wallet. Configure the endpoint at POST /api/business/v1/webhooks (per mode).
Signing is optional and bring-your-own. Send your own signing_secret and we’ll sign every delivery’s x-twelveai-signature with it (verify against the raw body). Or set generate_secret: true to have us mint one. If you set neither, deliveries are sent unsigned - in that case restrict your endpoint another way (a secret path, an IP allowlist, or mutual TLS).
Events
charge.successBusiness + platformA card/Paystack payment succeeded.charge.failedBusiness + platformA payment attempt failed.refund.processedBusiness + platformA refund completed.dispute.createdBusiness + platformA customer disputed a payment (chargeback). The disputed amount is held from your balance; respond in the dashboard 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 (payout or customer disbursement) was handed to the bank rail. Money has left your balance; wait for the outcome event.transfer.succeededBusinessA transfer out 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.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.split.paidBusiness + platformA vendor's share of a split payment reached the vendor's bank account.split.failedBusiness + platformA vendor's split payout failed or was reversed; the share and its fee are back in your balance.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).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 - dedupe if you also handle the redirect.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; the Connect platform also receives it when the charge was made on its behalf.
Headers on every delivery
x-twelveai-signature- HMAC-SHA512 of the raw body, hex. Only present when you’ve set a signing secret - unsigned deliveries omit it.x-twelveai-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.
Connect: platform webhooks
If you’re a platform and a charge was made on behalf of a connected account, we also POST that payment event to your app’s webhook_url - signed with your app client secret (not the connected account’s signing secret). It carries the same fields plus app_id, connected_account_id, and the application_fee (kobo) you earned. Your app also receives connection.created, connection.updated, and connection.revoked lifecycle events. Direct bank transfers (transfer.received) aren’t on-behalf charges, so they’re delivered to the business only. Platform deliveries go through the same queue as merchant webhooks: the same retries over about 3.3 days and 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.
Scope gating. Payment events reach your app only while the connection is active and still grants a payments scope - if the business disconnects or drops payments access, delivery stops. The event names follow the processor’s convention, mapped to scopes like this:
payments:read or payments:writecharge.success · charge.failed · refund.processedpayouts:writetransfer.processing · transfer.succeeded · transfer.failed · transfer.reversed(connection lifecycle)connection.authorized · connection.created · connection.updated · connection.revoked(account only - not sent to platforms)transfer.receivedExample 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", "paid_at": "2026-06-12T12:34:56Z", "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null }, "provider": "paystack", "provider_reference": "1234567890", "metadata": { "order_id": "12345" }, "refund": { "provider_reference": "RFND_abc", "amount": 50000, "currency": "NGN", "reason": "requested_by_customer" } } }
{ "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_123", "idempotency_key": "disb_order_123", "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" } } }
{ "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 } }
{ "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" } }
{ "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" } }
Verify the signature
Only if you set a signing secret. Recompute the HMAC over the raw bytes and compare in constant time (reject a missing signature too). Same scheme for every event. If you didn’t set a secret, deliveries are unsigned - there’s no x-twelveai-signature to check, so guard the endpoint another way.
import crypto from "node:crypto" // Only set this if you configured a signing secret. If you didn't, deliveries // arrive UNSIGNED (no x-twelveai-signature header) - there's nothing to verify. const SECRET = process.env.TWELVE_SIGNING_SECRET // may be undefined // Use the RAW request body - not the parsed JSON object. app.post("/webhooks/twelve", express.raw({ type: "*/*" }), (req, res) => { const raw = req.body.toString("utf8") if (SECRET) { // You opted into signing → require AND verify the signature. const got = req.header("x-twelveai-signature") || "" const expected = crypto.createHmac("sha512", SECRET).update(raw).digest("hex") if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) { return res.sendStatus(401) // missing or wrong signature } // Reject replays: timestamp must be within 5 minutes. const ts = Number(req.header("x-twelveai-timestamp") || 0) if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401) } // No secret → unsigned delivery; secure the endpoint another way // (secret path, IP allowlist, mTLS). const eventId = req.header("x-twelveai-event-id") // stable across retries: dedupe on it const { event, data } = JSON.parse(raw) // Ack fast (2xx within 10s), then do your work asynchronously. res.sendStatus(200) // queue { eventId, event, data }; skip if eventId was seen; apply charge.success once per data.reference })
A valid signature is step one. Acknowledge with a 2xx within 10 seconds and do your work 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.
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.
Headers on every delivery
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.
- 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.
Settings that affect the API
A few dashboard settings change how the API behaves for your business. Anyone on your team who can manage settings can change them, and every change is recorded in the audit log.
{ "success": false, "message": "This transfer would pass your daily transfer limit of NGN 50,000. NGN 12,000 is left for today. Change it under Settings > Transfers." }
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.
A call from an address outside the list is refused with a 403 before anything runs. Add every server that calls the API, including workers and cron hosts, before saving a list. Blocked attempts show in the audit log and as a dashboard notification, at most once per address every 10 minutes.
{ "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." }
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: the business setting (Settings, Payments; all six on by default), a payment link (the business setting or its own subset, chosen in the link editor), and one payment, through the channels field on initialize (an array or a comma-separated string; leave it out to use the business setting).
A request can never switch a method back on: asking for card and 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 one 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. A link is priced in naira or US dollars (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 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.Errors use the usual shape: { "success": false, "message": "..." }.
Payment requests
Ask one customer for a set amount without copying links around. In the dashboard, on Payments (or a customer's page), choose Request payment, enter the customer's email, the amount (in naira or US dollars) 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, pays the normal collection fee (2%, capped at NGN 2,500), 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. There is no API for this setting and no extra fee.
Currencies: NGN and USD
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 (dashboard, Settings, Payments, Currency) pre-fills every form; 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.
Webhook fields
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 } }
{ "event": "charge.success", "data": { "reference": "TWAI_12_9f3c...", "amount": 5000000, "currency": "NGN", "status": "successful", "provider": "flutterwave", "paid_currency": "USD", "paid_amount": 3384, "fx_rate": 1477.541371 } }
A dollar payment from your backend
Send currency USD and the amount in cents. authorization_url is the hosted checkout, where the customer chooses dollars or naira, and access_code is empty, so open the checkout URL rather than a Paystack popup.
{ "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" } }
Public endpoints
Used by the hosted checkout and pay pages. No API key: the payment reference is the authorization, and each is rate-limited per IP.
POST /api/v1/public/payments/:reference/usd-quoteQuote a pending payment in dollars. Returns quote_id, usd_amount, fx_rate, margin_bps and expires_at (10 minutes). For a dollar price usd_amount is the price itself, with no rate and no margin.POST /api/v1/public/payments/:reference/pay-usdBody { quote_id }. Starts the Flutterwave card payment and returns checkout_url to send the customer to.POST /api/v1/public/payments/:reference/ngn-quoteA dollar price paid in naira. Returns quote_id, ngn_amount, ngn_amount_minor, usd_amount, fx_rate (naira per dollar, margin included), margin_bps (150) and expires_at.POST /api/v1/public/payments/:reference/restartBody { quote_id } for a dollar price paid in naira (no body for a naira price). Returns access_code and authorization_url for Paystack. An expired quote answers 422 QUOTE_EXPIRED with a fresh quote in data for the customer to confirm.GET /api/v1/public/usd-rateQuery link, store, invoice or instalment (the page token or slug). Returns available and fx_rate (a naira price paid in dollars), ngn_fx_rate (a dollar price paid in naira) and pay_usd_direct, for showing an approximate price in the other currency.Subscriptions
Charge a customer automatically every week, month, quarter or year. Create a plan, the customer pays the first period on the hosted checkout (cards only, so the card can be charged again), and we charge that saved card at the start of every period. Every charge is an ordinary payment with the normal collection fee and the usual charge.success or charge.failed webhook; there is no extra subscription fee. Customers subscribe from your backend (POST /subscriptions returns a checkout_url) or on the plan's hosted page https://checkout.twelveai.app/s/PLN_..., which you can share as a link or QR code from the dashboard.
incompleteCreated; the first payment has not been made. Expires after 7 days unpaid.activePaid for the current period. next_charge_at is when the card is charged again.past_dueA renewal failed. The card is retried 1, 3 and 5 days after the first failure, and the customer is emailed a link to pay with another card.cancelledEnded by you, by the customer (at period end), or after the final failed retry (cancel_reason payment_failed).expiredThe first payment was never made.Billing dates follow the moment of the first payment on the Lagos calendar: a subscription started on 31 January renews on 28 February (29 in a leap year), then 31 March, 30 April, and never drifts. Renewals run every 10 minutes. After a failed renewal we retry on day 1, 3 and 5; if the day 5 retry also fails, the subscription is cancelled. A successful retry, or the customer paying with a new card from our email, makes it active again. Each attempt is recorded before the card is charged, and only one successful charge can exist per period, so a period is never charged twice.
In live mode we email the customer in your business name: a reminder 3 days before each renewal, a notice when a charge fails, and a confirmation when the subscription is cancelled, each with a link to a manage page (pay an overdue period with a new card, or stop renewing). Free trials are not available yet: the first period is always paid at checkout, and a non-zero trial_days is rejected.
Webhook events, signed and retried like payment events: subscription.created (first payment succeeded), subscription.renewed (a renewal or an overdue period paid), subscription.payment_failed (with an attempt block: number, reference, failure_reason, next_retry_at, will_cancel) and subscription.cancelled. data is the subscription object from Get a subscription plus a stable event_id you can de-duplicate on; created and renewed also carry the payment.
{ "event": "subscription.payment_failed", "data": { "event_id": "SUB_k3m9q2x7v8w4r6t2:payment_failed:912", "id": "SUB_k3m9q2x7v8w4r6t2", "status": "past_due", "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, "next_charge_at": "2026-12-01T09:20:00.000Z", "failure_count": 1, "last_failure_reason": "Insufficient Funds", "attempt": { "number": 1, "reference": "TWAI_SUB_41_20261130T081200_1", "failure_reason": "Insufficient Funds", "next_retry_at": "2026-12-01T09:20:00.000Z", "will_cancel": false } } }
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 (a draft; send: true also issues and emails it). 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): line prices, totals, part payments and recorded offline payments all follow it.
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.
draftCreated, not sent. Editable; the link cannot be paid yet.sentIssued and waiting for payment.partially_paidSome of the balance is paid (part payments allowed).overduePast the end of its due date (Lagos time) with a balance left, part paid or not.paidThe balance is zero. Locked: a paid invoice is a receipt.voidCancelled before anything was paid. The link shows it as void and cannot be paid.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. There is no extra invoice fee.
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. Money you received outside TwelveAI (cash, or a transfer to another account) can be recorded from the dashboard: no money moves and nothing is charged, it only lowers the balance, and it fires the same invoice 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. See the invoice.partially_paid example under Webhooks.
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; on a payment link it follows the link 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.
- Payment links: turn on Allow instalments in the link editor in the dashboard (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-plans returns the plan and a checkout_url for part 1. Amounts are in kobo.
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.
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.
pendingCreated; part 1 has not been paid. Expires after 7 days unpaid.activePart 1 is paid and nothing is overdue.overdueA card charge for a part failed, or a part is still unpaid a day after its due date.completedEvery part is paid or waived. Release the goods.defaultedA part stayed unpaid for the days set in your settings (7 by default). No more charges or reminders.cancelledYou cancelled it. No more charges. Money already paid stays with you; refund it from each payment if you need to.expiredPart 1 was never paid.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. instalment_plan.created carries payment (part 1); part_paid carries payment, applied, parts_paid and excess_amount (above zero only if two payments landed at the same moment and together paid more than was owed; refund the difference). See the part_paid example under Webhooks. 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 }
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.
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.
- 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 }: type is percentage (value is a percent of the net, up to 2 decimals, e.g. 12.5) or flat (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, managed in the dashboard. Use its id as split_group on 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.
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 } ] } }
{ "success": false, "code": "SPLIT_INVALID", "message": "A split can have at most 10 parties." }
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.paid is 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 in the dashboard (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. See the split.paid example under Webhooks.
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, and lists only the products priced in it.
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 (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.source "store" and metadata.store_order_number. There is no extra storefront fee.
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. 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.
pending_paymentPlaced; the payment has not succeeded yet. Nothing is reserved.paidPaid and stock drawn down. Ready to fulfil.paid_needs_attentionPaid, but stock ran short (or the order had been cancelled before the payment landed). Restock or refund.fulfilledYou marked it sent or picked up. The customer is emailed.cancelledCancelled by you. Cancelling a paid order can put its stock back, but never refunds the customer automatically.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.
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. Webhook events, 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 a store order returns; event_id is stable per occurrence, so you can de-duplicate on it. See the store.order.paid example under Webhooks.
Disputes and chargebacks
A dispute (chargeback) is a customer asking their bank to reverse a card payment. Paystack (naira payments) or Flutterwave (payments made in US dollars) tells us, we tell you, and you have until the deadline to show the payment was genuine. There is no dispute fee. A dispute is in the currency the customer paid, and its hold comes out of the balance that payment credited: your USD balance for a dollar payment, your naira balance otherwise.
- 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 happens 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.
needs_responseOpen and waiting for your evidence or acceptance before due_at.under_reviewYou responded; the bank is deciding.wonClosed in your favour. The hold is back in your balance.lostClosed for the customer. The held amount went to the customer.acceptedYou accepted it. The held amount went to the customer.The merchant API is read-only: GET /api/business/v1/disputes (filter with status=open for everything still undecided) and GET /api/business/v1/disputes/:id (scope payments:read). Webhook 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; examples under Webhooks.
Errors & rate limits
Errors return a non-2xx status with a JSON body. Common statuses:
400Bad request - a required field is missing or invalid.401Unauthenticated - bad, missing, or revoked key/token.403Forbidden - verification required, or a Connect token missing a scope.404Not found - no such resource on this account.429Too many requests - you are being rate limited; back off and retry.{ "success": false, "message": "A valid customer email is required." }
Get your API keys
Create an account, grab your test keys, and make your first call in minutes.