12TwelveAI Business
Docs/API Reference

API reference

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

Getting started

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

Authentication header
Authorization: Bearer tw_live_xxxxxxxxxxxxxxxxxxxx

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

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.

Step 1
Backend creates the payment
POST /payments/initialize with Authorization: Bearer tw_test_... (secret key, server only). Send email, amount in kobo, your own reference, callback_url and metadata. You get data.checkout_url and data.reference.
Step 2
Frontend opens the checkout
Web: window.location.href = checkout_url (or a new tab). Mobile: an in-app browser or WebView that watches for navigation to callback_url.
Step 3
Confirm
The customer lands on callback_url?reference=... Call GET /payments/verify/:reference to render the result page. Fulfil the order from the charge.success webhook, applied once per reference.

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

Step 1 - Your backend creates the payment
// 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 }
}
Step 2 - Open the checkout
// 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>
}
Step 3 - Confirm
// 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.
Step 1 - Your backend creates the payment
// 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 })
}
Step 2 - Open the checkout
// 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>
}
Step 3 - Confirm
// 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
Step 1 - Your backend creates the payment
// 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 }
})
Step 2 - Open the checkout
<!-- 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>
Step 3 - Confirm
<!-- 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
Step 1 - Your backend creates the payment
// 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 })
}
Step 2 - Open the checkout
<!-- 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>
Step 3 - Confirm
// 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 platforms

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

Step 1 - Your backend creates the payment
// 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.
Step 2 - Open the checkout
// 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'))} />
Step 3 - Confirm
// 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.

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

Step 1 - Your backend creates the payment
// 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);
}
Step 2 - Open the checkout
// 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=...
Step 3 - Confirm
// 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
Step 1 - Your backend creates the payment
// 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)
Step 2 - Open the checkout
<!-- 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>
Step 3 - Confirm
// 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
Step 1 - Your backend creates the payment
# 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"]}
Step 2 - Open the checkout
<!-- 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>
Step 3 - Confirm
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
Step 1 - Your backend creates the payment
// 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'],
    ]);
});
Step 2 - Open the checkout
{{-- 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>
Step 3 - Confirm
// 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
});
Step 1 - Your backend creates the payment
// 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)
}
Step 2 - Open the checkout
<!-- 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>
Step 3 - Confirm
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)
Test keys, live keys. A 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

POST/api/business/v1/payments/initialize

Initialize a payment

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

ParameterDescription
email*Customer email.
amount*Amount in minor units of currency: kobo for NGN, cents for USD. Min 100.
currencyOptional. "NGN" (default) or "USD". A USD price is paid in dollars by card, or in naira at the live rate plus 1.5%; for USD, authorization_url is the hosted checkout (where the customer chooses) and access_code is empty. split, split_group and application fees need NGN. See Currencies.
referenceYour order id. Auto-generated if omitted.
callback_urlWhere the customer is redirected after payment. Optional: falls back to the default callback URL in Settings > Payments, then to your dashboard payment page.
channelsOptional. Limit this checkout to some payment methods: an array (or comma list) of card, bank, bank_transfer, ussd, qr, mobile_money. It can only narrow the methods switched on under Settings > Payments; a list that leaves none is refused with a 400. Omit it to use the business setting.
customer_nameStored on the payment.
customer_phoneStored on the payment.
metadataEchoed back on verify + webhook.
splitOptional. Send part of this payment to vendors at settlement: { shares: [{ party_id, type: "percentage" | "flat", value }], fee_bearer?: "merchant" | "party" }. Flat values in kobo; shares are computed on the net. See Split payments.
split_groupOptional. The id of a saved split group, instead of split. Send one or neither.
application_fee_percentConnect only: your platform cut as a percentage of the charge (e.g. 2.5). Defaults to your app’s configured %. Capped at net.
application_fee_bpsConnect only: the same cut in basis points (250 = 2.5%). Ignored if application_fee_percent is set.
application_fee_capConnect only: cap the % fee at this max amount in kobo (e.g. 10000 = ₦100, so "2% capped at ₦100"). Defaults to your app’s configured cap. 0 = uncapped.
Values for channels
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).

Request
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \
  -H "Authorization: Bearer tw_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "email": "customer@example.com",
  "amount": 50000,
  "reference": "order-12345"
}'
Response
{
  "success": true,
  "data": {
    "reference": "order-12345",
    "checkout_url": "https://checkout.twelveai.app/c/order-12345",
    "authorization_url": "https://checkout.twelveai.app/c/order-12345",
    "access_code": "",
    "amount": 50000,
    "currency": "NGN",
    "provider": "flutterwave"
  }
}
Redirect the customer to checkout_url (our branded page), or to authorization_url. provider says which processor takes the payment: "paystack" or "flutterwave". When it is "flutterwave" (or the price is in USD), authorization_url is our checkout and access_code is empty, so do not open a Paystack popup; send the customer to the URL. Webhooks carry the same provider value.
GET/api/business/v1/payments/verify/:reference

Verify a payment

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

Request
curl "https://api.twelveai.app/api/business/v1/payments/verify/order-12345" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "success": true,
  "data": {
    "reference": "order-12345",
    "status": "successful",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "paid_at": "2026-06-12T12:34:56Z",
    "customer": { "email": "customer@example.com", "name": null },
    "metadata": { "order_id": "12345", "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
    }
  }
}
For a successful card payment, data.card is the reusable card it was made with (null otherwise). Keep its authorization_code on your server and pass it to Charge a saved card to bill that customer again with no checkout. Treat it like a secret.
GET/api/business/v1/payments

List payments

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

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

List saved cards

Reusable 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).

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

Charge a saved card

Synchronous re-charge against a saved authorization_code. Live mode only. 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.

ParameterDescription
authorization_code*From GET /saved-cards.
email*The card owner’s email.
amount*Amount in minor units of currency: kobo for NGN (minimum 100), cents for USD (minimum 100, USD 1.00).
currencyOptional. NGN (default) or USD. Must match the card: a card saved from a USD card payment is charged in USD only, a naira card in NGN only. USD charges cannot carry a Connect application fee.
referenceYour reference for this charge.
metadataEchoed back on verify + webhook.
Request
curl -X POST https://api.twelveai.app/api/business/v1/payments/charge-authorization \
  -H "Authorization: Bearer tw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
  "authorization_code": "AUTH_xxxxxxxx",
  "email": "customer@example.com",
  "amount": 50000
}'
Response
{
  "success": true,
  "message": "Charged.",
  "data": {
    "reference": "subscription-may-2026",
    "status": "successful",
    "amount": 50000,
    "net": 49250,
    "paid_at": "2026-05-01T08:00:00Z"
  }
}
Live mode only - saved-card re-charges always move real money. There is no test-mode charge-authorization. A USD charge Flutterwave is still processing answers status pending; charge.success (or GET /payments/verify/:reference) follows when it completes. The fee is the usual 2%, capped at the USD value of NGN 2,500.

Balance & money

GET/api/business/v1/balance

Get balance

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

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

List transactions

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

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

List customers

Your customers, aggregated by email across payments. 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.

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

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

ParameterDescription
amount*Amount in NGN (major units), min 100. A flat NGN 55 fee is debited with it.
memoOptional note on the payout.
Request
curl -X POST https://api.twelveai.app/api/business/v1/transfers \
  -H "Authorization: Bearer tw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 25000
}'
Response
{
  "success": true,
  "message": "Payout submitted. Status will update when the bank confirms.",
  "data": {
    "payout": {
      "reference": "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"
    }
  }
}
Live only, and always to the saved settlement account - a Connect platform can never route funds to an arbitrary destination. Min ₦100 plus a flat ₦55 fee per transfer (returned if it fails); the unverified monthly cap and the business's own transfer limits (Settings > Transfers) still apply. Naira payouts are automatic: there is no approval step, the payout goes to the bank as processing and transfer.succeeded or transfer.failed follows. Only USD withdrawals (converted to naira) are reviewed before they are sent.
POST/api/business/v1/disbursements

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

ParameterDescription
amount*Amount in NGN (major units), min 100. A flat NGN 55 fee is debited with it.
bank_code*A code from GET /banks/paystack.
account_number*10-digit NUBAN of the payee.
reasonOptional narration shown on the transfer.
customer_referenceOptional pointer to your own customer/order id.
idempotency_keyRecommended. Also accepted as the Idempotency-Key header. A retry with the same key returns the original transfer instead of sending twice.
Request
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"
}'
Response
{
  "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"
    }
  }
}
Debits your business's available balance under a row lock, so a transfer can never overdraw it. Verified (KYB + BVN) businesses only - unverified businesses can only withdraw to their own settlement account. Straight-through: no manual review. Always send an idempotency key so a retry never double-sends.

Banks

GET/api/business/v1/banks

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

ParameterDescription
countryISO country code. Default 'NG'.
Request
curl "https://api.twelveai.app/api/business/v1/banks?country=NG" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "success": true,
  "data": {
    "banks": [
      { "code": "NG::000013", "name": "Guaranty Trust Bank" },
      { "code": "NG::000014", "name": "Access Bank" },
      { "code": "NG::000015", "name": "Zenith Bank" }
    ]
  }
}
Legacy. This list comes from the old rail and its codes can no longer be used for transfers. Kept only so existing integrations keep resolving; new integrations should use GET /banks/paystack for both payouts and disbursements.
GET/api/business/v1/banks/resolve

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

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

List banks

Supported banks with their Paystack NUBAN codes. Use a returned code as bank_code when sending a customer disbursement. No scope required.

ParameterDescription
Request
curl "https://api.twelveai.app/api/business/v1/banks/paystack" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "success": true,
  "data": {
    "banks": [
      { "code": "044", "name": "Access Bank" },
      { "code": "058", "name": "GTBank" },
      { "code": "057", "name": "Zenith Bank" }
    ]
  }
}
Codes are Paystack NUBAN codes (short numerics like "058"). This is the pair to use for everything: a code here works in /banks/paystack/resolve, in POST /transfers and in POST /disbursements. /banks and /banks/resolve are legacy.
GET/api/business/v1/banks/paystack/resolve

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

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

Webhooks

POST/api/business/v1/webhooks

Configure webhooks

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

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

Connect (OAuth)

GET/oauth/authorize

Authorize (browser redirect)

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

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

Exchange / refresh token

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

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

Sandbox connect

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

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

Subscriptions

POST/api/business/v1/plans

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

ParameterDescription
name*Shown to the customer, up to 120 characters.
amount*Per period, in minor units of currency: kobo for NGN (minimum 5000, NGN 50), cents for USD (minimum 100, USD 1; maximum 10000000, USD 100,000).
currencyNGN (default) or USD. Fixed once the plan exists. A USD plan is paid by card in US dollars at the first checkout (naira is not offered), and that card is charged in dollars at every renewal; renewals settle to your USD balance with the usual fee.
interval*weekly, monthly, quarterly or yearly.
interval_count1 to 12, default 1. monthly with interval_count 2 bills every two months.
descriptionOptional, shown on the subscribe page.
redirect_urlWhere the customer goes after the first payment.
metadataYour own key-values, up to 8 KB.
Request
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"
}'
Response
{
  "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"
  }
}
GET/api/business/v1/plans

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

Request
curl "https://api.twelveai.app/api/business/v1/plans?status=active" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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" }
    ]
  }
}
POST/api/business/v1/subscriptions

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

ParameterDescription
plan*The plan id (PLN_...), active and in the same mode as your key.
email*The customer's email. Reminders and the manage link go here (live mode).
nameThe customer's name.
callback_urlWhere the customer goes after the first payment. Defaults to the plan's redirect_url, then your default callback URL.
metadataStored on the subscription and returned in every subscription event.
Request
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"
}'
Response
{
  "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"
  }
}
GET/api/business/v1/subscriptions/:id

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

Request
curl "https://api.twelveai.app/api/business/v1/subscriptions/SUB_k3m9q2x7v8w4r6t2" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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..."
  }
}
POST/api/business/v1/subscriptions/:id/cancel

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

ParameterDescription
at_period_endtrue keeps an active subscription running until current_period_end, then ends it (subscription.cancelled fires then). Default false: cancel now. A past-due or unpaid subscription always cancels now.
Request
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
}'
Response
{
  "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

GET/api/business/v1/disputes

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

ParameterDescription
statusFilter: open, needs_response, under_review, won, lost or accepted.
limitDefault 50, max 200.
cursorPrevious response's next_cursor.
Request
curl "https://api.twelveai.app/api/business/v1/disputes?status=open" \
  -H "Authorization: Bearer tw_live_..."
Response
{
  "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
  }
}
GET/api/business/v1/disputes/:id

Fetch a dispute

One dispute by its id (from the list or the dispute.created event). Same fields as the list. Connect scope: payments:read.

Request
curl "https://api.twelveai.app/api/business/v1/disputes/42" \
  -H "Authorization: Bearer tw_live_..."
Response
{
  "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

POST/api/business/v1/invoices

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

ParameterDescription
currencyOptional. "NGN" (default) or "USD". Line prices, totals, part payments and recorded offline payments are all in this currency.
customer*{ email (required), name?, phone?, address? }. The phone is used for the WhatsApp share link.
line_items*1 to 100 of { description, quantity? (default 1, decimals allowed), unit_price (kobo or cents), tax_percent? }.
tax_percentInvoice tax rate (0 to 100) for lines without their own tax_percent.
discount_percentPercent off the subtotal, before tax. Or send discount instead.
discountFixed discount in kobo or cents (capped at the subtotal). Not with discount_percent.
issue_dateYYYY-MM-DD or ISO 8601. Default today.
due_dateYYYY-MM-DD (the end of that day, Lagos time) or ISO 8601. Needed for reminders and overdue.
allow_partialLet the customer pay part of the balance (min NGN 100). Default false.
remindersPer-invoice reminder switch. Omit to follow the business setting (on by default).
notesShown on the invoice.
termsShown on the invoice.
metadataYour own key-values, returned on the invoice and its events.
sendtrue issues and emails it straight away (adds email_sent to the response).
Request
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"
  }
}'
Response
{
  "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"
  }
}
GET/api/business/v1/invoices

List invoices

Invoices in the key's mode, newest first. Returns data.invoices and data.meta. Connect scope: payments:read.

ParameterDescription
statusFilter: draft, sent, partially_paid, overdue, paid or void.
customer_emailFilter to one customer.
pagePage number, default 1.
per_pageMax 100.
Request
curl "https://api.twelveai.app/api/business/v1/invoices?status=overdue" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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 }
  }
}
GET/api/business/v1/invoices/:id

Get an invoice

One invoice by its numeric id or its invoice number (INV-0042), with the payments applied to it. Connect scope: payments:read.

Request
curl "https://api.twelveai.app/api/business/v1/invoices/INV-0042" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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" }
    ]
  }
}
The response is the full invoice object (as under Create an invoice) plus payments. kind is online for TwelveAI payments and manual for payments recorded from the dashboard (money received outside TwelveAI).
POST/api/business/v1/invoices/:id/send

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

Request
curl -X POST https://api.twelveai.app/api/business/v1/invoices/412/send \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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..."
  }
}
POST/api/business/v1/invoices/:id/void

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

Request
curl -X POST https://api.twelveai.app/api/business/v1/invoices/412/void \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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

POST/api/business/v1/instalment-plans

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

ParameterDescription
email*The customer's email. Reminders and the pay link go here (live mode).
amount*The purchase total in minor units of currency (kobo or cents). Every part must be at least NGN 100 (10000 kobo) for a naira plan.
currencyOptional. "NGN" (default) or "USD". Every part is in this currency.
parts*How many parts, 2 to 6. Equal parts; any rounding kobo goes on the last one.
frequencyweekly, biweekly (every 2 weeks) or monthly. Default monthly.
nameThe customer's name.
phoneThe customer's phone, used for the WhatsApp share in the dashboard.
descriptionWhat is being bought, shown to the customer (max 300 characters).
callback_urlWhere the customer goes after part 1. Defaults to your default callback URL, then the customer's pay page.
metadataYour own key-values, stored on the plan and returned in every instalment event.
Request
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"
  }
}'
Response
{
  "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"
  }
}
The plan object is shown in full under Get an instalment plan. Due dates are moved to follow the moment part 1 is actually paid. If part 1 is paid by card, collection becomes card and later parts are charged automatically; otherwise it stays pay_link and the customer pays each part from pay_url.
GET/api/business/v1/instalment-plans/:id

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

Request
curl "https://api.twelveai.app/api/business/v1/instalment-plans/INS_q8m2k7x4v9w3r6t2p5n1" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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
  }
}
status is one of pending, active, overdue, completed, defaulted, cancelled or expired; each part is scheduled, paid, failed or waived. pay_url is the customer's private page; our emails already include it, so only share it with that customer.
POST/api/business/v1/instalment-plans/:id/cancel

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

Request
curl -X POST https://api.twelveai.app/api/business/v1/instalment-plans/INS_q8m2k7x4v9w3r6t2p5n1/cancel \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "success": true,
  "data": {
    "id": "INS_q8m2k7x4v9w3r6t2p5n1",
    "status": "cancelled",
    "amount_paid": 10000000,
    "cancelled_at": "2026-11-12T14:03:22.000Z",
    "changed": true
  }
}
Cancelling never refunds: refund any part from its payment with the normal refund flow if you need to.

Storefront

GET/api/business/v1/store/orders

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

ParameterDescription
statusFilter: pending_payment, paid, paid_needs_attention, fulfilled or cancelled.
emailFilter to one customer.
pagePage number, default 1.
per_pageMax 100.
Request
curl "https://api.twelveai.app/api/business/v1/store/orders?status=paid" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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 }
  }
}
GET/api/business/v1/store/orders/:id

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

Request
curl "https://api.twelveai.app/api/business/v1/store/orders/ORD_K7Q2M9X4TP" \
  -H "Authorization: Bearer tw_test_..."
Response
{
  "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"
  }
}
Amounts are kobo. stock_issues is set when the order is paid_needs_attention (each line has requested and available); fulfillment is set once you mark the order fulfilled.

Connect - act on behalf of other businesses

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

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

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

How Connect works end-to-end

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

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

Step 3 in code

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

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

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

Connecting a business: popup & mobile

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

Web - one-click button (drop-in)

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

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

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

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

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

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

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

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

React, Vue, Svelte & Next

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

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

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

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

Web - popup call (full control)

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

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

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

Mobile app - in-app browser + deep link

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

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

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

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

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

Connect - testing your integration

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

1. Get a sandbox test token

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

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

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

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

2. Charge on behalf (payments:write)

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

3. Read scopes

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

4. Prove enforcement

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

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

Connect scopes

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

payments:readGET /payments · GET /payments/verify/:reference
payments:writePOST /payments/initialize · POST /payments/charge-authorization
transactions:readGET /transactions
balance:readGET /balance
customers:readGET /customers · GET /saved-cards
payouts:writePOST /transfers · 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.processed
payouts: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.received

Example payloads

charge.success
{
  "event": "charge.success",
  "data": {
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "successful",
    "paid_at": "2026-06-12T12:34:56Z",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "provider": "paystack",
    "provider_reference": "1234567890",
    "metadata": { "order_id": "12345" }
  }
}
charge.failed
{
  "event": "charge.failed",
  "data": {
    "reference": "order-12346",
    "amount": 50000,
    "fee": 0,
    "net": 0,
    "currency": "NGN",
    "status": "failed",
    "paid_at": null,
    "customer": { "email": "customer@example.com", "name": null, "phone": null },
    "provider": "paystack",
    "provider_reference": null,
    "metadata": {},
    "failure_reason": "insufficient_funds"
  }
}
refund.processed
{
  "event": "refund.processed",
  "data": {
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "refunded",
    "paid_at": "2026-06-12T12:34:56Z",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "provider": "paystack",
    "provider_reference": "1234567890",
    "metadata": { "order_id": "12345" },
    "refund": {
      "provider_reference": "RFND_abc",
      "amount": 50000,
      "currency": "NGN",
      "reason": "requested_by_customer"
    }
  }
}
refund.processed (USD price paid in naira, partial)
{
  "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
    }
  }
}
dispute.created
{
  "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" }
    }
  }
}
dispute.resolved
{
  "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" } }
  }
}
transfer.processing
{
  "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": {}
  }
}
transfer.succeeded
{
  "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": {}
  }
}
transfer.failed
{
  "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": {}
  }
}
transfer.received
{
  "event": "transfer.received",
  "data": {
    "reference": "HOSTFI_TXN_987",
    "amount": 500000,
    "currency": "NGN",
    "channel": "bank_transfer",
    "status": "success",
    "account_number": "0123456789",
    "balance_after": 4925000,
    "received_at": "2026-06-12T12:34:56Z",
    "metadata": {}
  }
}
charge.success - delivered to a Connect platform
{
  "event": "charge.success",
  "data": {
    "app_id": 42,
    "connected_account_id": 108,
    "application_fee": 1250,
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "successful",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "metadata": { "order_id": "12345" }
  },
  "created_at": "2026-06-12T12:34:56Z"
}
connection.authorized - Connect platform
{
  "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"
}
connection.created - Connect platform
{
  "event": "connection.created",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "scopes": ["payments:read", "payments:write"],
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}
connection.updated - Connect platform
{
  "event": "connection.updated",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "scopes": ["payments:read", "payments:write", "payouts:write"],
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}
connection.revoked - Connect platform
{
  "event": "connection.revoked",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}
invoice.partially_paid
{
  "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" }
  }
}
instalment_plan.part_paid
{
  "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
  }
}
split.paid
{
  "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"
  }
}
store.order.paid
{
  "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.

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

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

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

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

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

Delivery is at-least-onceEvery event carries a stable x-twelveai-event-id header (the delivery id). Store it and ignore repeats. Processing must be idempotent, and charge.success should be applied once per reference.
Acknowledge within 10 secondsRespond with any 2xx within 10 seconds. Do the work after acknowledging (queue it). A non-2xx response or a timeout counts as a failed attempt.
RetriesAfter a failed attempt we retry at 30s, 1m, 2m, 5m, 15m, 1h, 6h, 24h, 48h: 10 attempts over about 3.3 days. A network-level failure (DNS, refused connection, TLS) gets one immediate extra try 2 seconds later before that schedule starts. After the last attempt the delivery is marked failed and you are notified in the dashboard and by email.
Ordering is not guaranteedA retried transfer.processing can arrive after transfer.succeeded. Use the timestamps and status fields in the payload, not arrival order.
Endpoint healthAfter 20 consecutive permanently failed deliveries the endpoint is switched off automatically and you are notified. While an endpoint is off, whether you disabled it or we did, events are not lost: they are queued and parked for up to 7 days and delivered in order as soon as you enable the endpoint again from the dashboard.
Safety netAn independent reconciliation sweep runs every 10 minutes and queues any successful payment or transfer outcome from the last 48 hours that somehow has no delivery, so an internal fault on our side cannot silently drop an event. If you ever see a gap, replay any delivery from the Webhooks page in the dashboard; replays carry the same event id.
Redirects are not followedGive us the final URL of your handler. A 301, 302, 307 or 308 reply counts as a failed attempt: we never re-send a signed body to wherever a redirect points, and a 200 after a redirect is not a receipt.
Where we can deliverPublic internet addresses only. A URL that points at localhost, a private or link-local address, or an internal name is refused when you save it, and the address a hostname resolves to is checked again before every send.
Switching an endpoint back onParked events go out on the next worker pass, within about a minute, in the order they were queued.
Test sendsA test send from the dashboard carries "sample": true in the payload. Verify it like any other delivery, then skip fulfilment. A test send gets one attempt only and never counts toward switching an endpoint off.

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.
Recommended handler checklist.
  1. Verify x-twelveai-signature over the raw body (if you set a secret).
  2. Reject if x-twelveai-timestamp is older than 5 minutes.
  3. Dedupe on x-twelveai-event-id; skip anything you have already seen.
  4. Return 200 right away.
  5. Process asynchronously; apply charge.success once per reference.
  6. If you need to be sure, fetch the latest state with GET /payments/verify/:reference or GET /transfers rather 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.

Payment channels
Settings > Payments
Which of card, bank account, bank transfer, USSD, QR and mobile money your checkout offers. Every payment you initialize, from the API, a payment link or the dashboard, is limited to the channels you keep on. All six are on by default. A single payment can narrow them further with the channels field on initialize, and a payment link can set its own subset.
Default callback URL
Settings > Payments
Where customers land after paying when your initialize call sends no callback_url. A callback_url in the request always wins.
Customer receipts
Settings > Payments
Off by default. When on, we email your customer a branded receipt (amount, reference, date) for every successful live payment, with replies going to your business email. Test payments never send one.
Who pays the fee
Settings > Payments
When on, the processing fee is added on top so you receive the full amount you asked for; the payment amount on the API is what the customer paid.
Settlement schedule
Settings > Settlement
Automatic (daily sweep of your available balance to your settlement account) or On request (the balance stays until you withdraw it, from the dashboard or POST /transfers). Either way a naira withdrawal is sent to the bank as soon as it is made, with no approval step.
Transfer limits
Settings > Transfers
Largest single transfer and a daily total (Lagos time). They apply to dashboard transfers, bulk transfers, POST /transfers and POST /disbursements; the automatic daily sweep is not affected. A transfer over a limit is refused with a 400 before any money moves.
Transfer over a limit (400, nothing debited)
{ "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.

Where
Settings, Developers in the dashboard. Only the owner or an admin can change it, and every change is recorded in the audit log.
Entries
An IPv4 or IPv6 address (203.0.113.7, 2001:db8::1) or a CIDR range of either (203.0.113.0/24, 2001:db8:abcd::/48). Up to 50 entries.
Live and test keys
Live keys are always checked once the list has an entry. Test keys are checked only when you turn on Also restrict test keys, so your sandbox keeps working from laptops and CI by default.
Which address
The one your request reaches us from. If your servers sit behind a NAT gateway or a proxy, list its public address. Recent callers on the settings card shows the addresses we saw in the last 7 days.
Connect
Connect access tokens (tw_at_...) are not checked: a platform calls on your behalf from its own servers. The list covers your own secret keys only.
Webhooks
Calls from TwelveAI to you, such as webhooks, are not affected. They go out from us to your endpoint.

A call from an address outside the list is refused with a 403 before anything runs. 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.

Call from an address not on the list (403)
{
  "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.
Initialize with channels
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"]
}
Refusals
// 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 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.

EmailWe email the customer a request in your branding with a Pay button. Replies go to your business email.
WhatsAppYou also get a Send on WhatsApp button that opens WhatsApp on your phone with the message and pay link typed in. Nigerian numbers like 0803... are converted to 234803...; with no number, WhatsApp asks you to pick the contact.
ReminderOne reminder email if it is still unpaid on the due date, or 24 hours after the request when there is no due date. You can turn it off per request.
StatusRequested, paid, cancelled (you cancelled it; the pay link stops working) or expired (7 days after the due date, or 30 days after the request).

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.

WhenAfter the delay you choose (15 minutes to 24 hours, default 1 hour), for payments still pending and started in the last 48 hours. Test and live payments both qualify.
Once onlyAt most one reminder per payment. Payment requests are skipped; they have their own reminder.
No false remindersBefore emailing we check the payment with the provider. If it was in fact paid (the webhook had not arrived yet), it is recorded as paid and no email goes out.
Your messageAn optional line of your own, shown in the email.
ResultsThe dashboard card shows reminders sent and payments recovered in the last 30 days. Payments that got a reminder carry a Reminder sent badge.

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.

Where to set itSend currency ("NGN" default, or "USD") when you create a payment (amount in kobo or cents), a payment link, an invoice (line prices, totals and recorded offline payments all follow it), an inventory item or store product (its options follow it), a payment request or an instalment plan. A payment link that offers instalments uses the link currency for the plan.
USD price paid in USDThe customer pays the dollar price by card through Flutterwave. No conversion and no margin. The net goes to your USD balance.
USD price paid in nairaThe customer can pay in naira instead: converted at Flutterwave's live rate plus a 1.5% conversion margin and charged as naira through Paystack (card, transfer and the other naira methods). Each quote shows its rate and is valid for 10 minutes. The net goes to your naira balance.
NGN price paid in USDWhere you take USD, the customer can pay a naira price in dollars at Flutterwave's live rate plus 1.5%, rounded up to the cent. The net goes to your USD balance. Payment link, invoice, store and instalment pages show the approximate price in the other currency.
Collection feeThe usual 2%, capped at NGN 2,500. A charge in dollars is capped at the dollar value of NGN 2,500. With fee pass-through on, the amount the customer pays covers the fee too.
USD balanceDollar charges credit your USD balance as pending, then settle T+1 into available, just like naira.
Withdraw to nairaWithdraw any amount of your available USD as naira: you get a quote at Flutterwave's live rate less a 1% conversion margin (valid 5 minutes), confirm it, and, once approved, the naira is paid to your settlement account by a transfer from our Flutterwave account (where your dollars are held), with the NGN 55 transfer fee. If the transfer fails or is reversed, the dollars go back to your USD balance. Each withdrawal in GET /api/v1/business/usd also has a provider field (flutterwave or paystack, null while it awaits approval).
Naira onlySplit payments (a payment or link with a split must be in NGN: "Split payments are naira-only. Price this payment in NGN to split it.") and Connect payments with an application fee ("Payments with an application fee are naira-only. Price this payment in NGN."). Their money is shared out at settlement, which for dollars would need a conversion margin, so it stays naira.
RefundsRefunds work on every payment. The refund is in the price currency (refund.amount, refund.currency); when the customer paid in the other currency, refund.paid_currency and refund.paid_amount say what went back to them, worked out by the same proportion as the payment (a partial refund of a USD price paid in naira returns that share of the naira paid). You are debited the net share of the refund from the balance the payment credited: your USD balance for a payment paid in dollars, your naira balance otherwise. A refund never exceeds what was paid.
DisputesDisputes and chargebacks work on every payment. A dispute is in the currency the customer paid (amount and currency in kobo or cents) and its hold comes out of the balance that payment credited. When that differs from the price, price_currency and price_amount carry the price share. Respond in the dashboard as for any dispute.
Saved cardsA USD card payment saves the card (a Flutterwave card token) like a naira card. GET /saved-cards shows each card with its currency; charge it with POST /payments/charge-authorization and currency USD (amount in cents).
SubscriptionsPlans can be priced in USD (currency on POST /plans). The first payment is by card in dollars; renewals charge that card in dollars and settle to your USD balance.
InvoicesA catalogue line (item_id) must be priced in the invoice currency; a line from the other currency is refused with a message naming the item.
StorefrontA store sells in one currency (store settings, defaulting to the business currency). Only products priced in it are listed and can be bought; the others stay in your inventory and the dashboard marks them as not sold in the store.
Turn dollars offDashboard, Settings, Payments, Pay in USD. With it off, a naira price is paid in naira only; a dollar price can still be paid in naira.
AvailabilityTest mode works now. Live dollar card payments are enabled for the platform once approved; until then a live checkout for a dollar price offers naira only.

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.

charge.success (USD price paid in naira)
{
  "event": "charge.success",
  "data": {
    "reference": "order-777",
    "amount": 10000,
    "currency": "USD",
    "status": "successful",
    "paid_currency": "NGN",
    "paid_amount": 15225000,
    "fx_rate": 1522.5
  }
}
charge.success (NGN price paid in USD)
{
  "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.

POST /api/business/v1/payments/initialize
{ "email": "customer@example.com", "amount": 10000, "currency": "USD", "reference": "order-777" }

{
  "success": true,
  "data": {
    "reference": "order-777",
    "checkout_url": "https://checkout.twelveai.app/c/order-777",
    "authorization_url": "https://checkout.twelveai.app/c/order-777",
    "access_code": "",
    "amount": 10000,
    "currency": "USD"
  }
}

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.

subscription.payment_failed
{
  "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:

instalment_plan.part_failed 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:

Preview the schedule, then start part 1
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

  1. 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.
  2. Shares are computed on what is left, the net. Percentages round down to the kobo; every kobo of rounding stays with you.
  3. 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.
  4. You keep net minus everything taken for vendors. Vendors plus what you keep always equals the net exactly.
50% to vendor A, you pay feesNGN 10,000 payment: fee NGN 200, net NGN 9,800. Vendor A receives NGN 4,900; your balance pays NGN 4,955; you keep NGN 4,845.
50% to vendor A, vendor pays feesSame payment: vendor A receives NGN 4,845 (4,900 minus 55); your balance pays NGN 4,900; you keep NGN 4,900.
NGN 3,000 flat to A, 10% to BNet NGN 9,800: A receives NGN 3,000, B receives NGN 980, transfer fees NGN 110, you keep NGN 5,710.

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.

Initialize with a split
{
  "email": "customer@example.com",
  "amount": 1000000,
  "reference": "order-12345",
  "split": {
    "fee_bearer": "merchant",
    "shares": [
      { "party_id": 14, "type": "percentage", "value": 50 },
      { "party_id": 15, "type": "flat", "value": 300000 }
    ]
  }
}
A split that cannot work (400)
{ "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.

Pay button

Take payments on any website without writing backend code: create a payment link in the dashboard, add one script tag, and mark any button with data-twelveai-pay. The button opens the hosted checkout in a popup (or a new tab on mobile). It never needs a secret key.

Add to your page
<!-- 1. Once per page -->
<script src="https://payment.twelveai.app/js/twelveai-pay-v1.min.js" async></script>

<!-- 2. Any button: opens your payment link in a popup -->
<button data-twelveai-pay data-link="https://checkout.twelveai.app/l/rybejd97ta">Pay now</button>

<!-- Optional: prefill the customer and amount (amount in kobo) -->
<button data-twelveai-pay data-link="https://checkout.twelveai.app/l/rybejd97ta"
        data-email="customer@example.com" data-amount="500000" data-color="#0F766E">
  Pay NGN 5,000
</button>
  • With data-email, the customer skips straight to payment using the public Pay endpoint above; fixed-amount links ignore data-amount, and links with required questions open the link page instead.
  • Other options: data-target (popup, tab or redirect), data-color, and data-style="none" to keep your own button styling.
  • Events on the button element: twelveai:open, twelveai:closed and twelveai:error. Always confirm payment on your server with the charge.success webhook or verify, never from the browser.
  • The file is versioned (v1) so it never changes under you; new behaviour ships as a new version.

Errors & rate limits

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

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

Get your API keys

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