Documentation

TwelveAI Business API

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

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

Overview

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

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

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

Quick start (5 minutes)

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

Integrate on your platform

Jump to your stack from the menu. Every platform follows the same three steps with copy-paste code: your backend creates the payment with your secret key, your frontend sends the customer to the branded checkout, and you confirm with verify for the screen and the charge.success webhook for the ledger.

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.

Authentication

Every merchant-API request takes a Bearer token:

Authorization: Bearer tw_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

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

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

Webhooks

We POST a JSON event to your configured URL whenever money moves - a payment succeeds or fails, a refund clears, or a bank transfer lands in a wallet. Configure your endpoint on the Webhooks tab (per mode).

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

Events

charge.successBusiness + platformA card/Paystack payment succeeded.
charge.failedBusiness + platformA payment attempt failed.
refund.processedBusiness + platformA refund completed.
dispute.createdBusiness + platformA customer disputed a payment (chargeback). The disputed amount is held from your balance; respond before due_at.
dispute.resolvedBusiness + platformA dispute closed. outcome is won (the hold is back in your balance), lost or accepted (the held amount went to the customer).
transfer.processingBusinessA transfer out was handed to the bank rail. Money has left your balance; wait for the outcome event.
transfer.succeededBusinessA transfer out (payout or customer disbursement) reached the bank.
transfer.failedBusinessA transfer out failed; the amount and the fee are back in your balance.
transfer.reversedBusinessA transfer that had succeeded was returned by the receiving bank; the amount and the fee are back in your balance.
transfer.receivedBusinessAn incoming bank transfer credited the wallet. Paused: business account numbers are not available at the moment, so this event does not fire.
subscription.createdBusiness + platformA subscription's first payment succeeded and it is now active.
subscription.renewedBusiness + platformA renewal (or an overdue period paid with a new card) succeeded; the subscription moved to its next period.
subscription.payment_failedBusiness + platformA renewal charge failed. Carries the attempt number, the reason and the next retry time.
subscription.cancelledBusiness + platformA subscription ended: cancelled by you or the customer, at the end of its period, or after the final failed retry.
invoice.sentBusiness + platformAn invoice was issued and emailed to the customer for the first time.
invoice.partially_paidBusiness + platformA payment reduced an invoice balance without clearing it. Carries the payment block.
invoice.paidBusiness + platformAn invoice balance reached zero (online, recorded offline, or both). Fires once per invoice.
invoice.overdueBusiness + platformAn unpaid invoice passed the end of its due date (Lagos time). Fires once per invoice.
store.order.paidBusiness + platformA storefront order was paid. status is paid, or paid_needs_attention when stock ran short (stock_issues lists the lines).
store.order.fulfilledBusiness + platformYou marked a storefront order fulfilled (fulfillment carries your note and tracking).
instalment_plan.createdBusiness + platformPart 1 of an instalment plan was paid and the plan started. Carries the payment.
instalment_plan.part_paidBusiness + platformA payment was applied to an instalment plan (part 1, a card charge, or the customer paying early or in full). Carries the payment and the parts it paid.
instalment_plan.completedBusiness + platformEvery part is paid (or waived). Release the goods.
instalment_plan.part_failedBusiness + platformAn automatic card charge for a part failed. Carries the attempt number, the reason and the next retry time.
instalment_plan.defaultedBusiness + platformA part stayed unpaid for the number of days set in your instalment settings (7 by default). No more charges are made.
instalment_plan.cancelledBusiness + platformYou cancelled the plan. No more charges are made; money already paid stays with you.
connection.authorizedPlatformOwner approved consent - payload carries the one-time OAuth code + redirect_uri so your backend can exchange without waiting for the browser round-trip. Codes are single-use.
connection.createdPlatformA business connected to your Connect app (fires when the code is exchanged for a token).
connection.updatedPlatformA connection’s granted scopes changed.
connection.revokedPlatformA business disconnected from your app.

“Business + platform” = the connected business always receives it; a Connect platform also receives it when the charge was made on its behalf (see below).

Headers we send:

  • x-twelveai-signature - HMAC-SHA512 of the raw body, hex. Only sent when you've set a signing secret - unsigned deliveries omit it.
  • x-twelveai-signature-v2 - HMAC-SHA512 of <timestamp>.<raw body>, hex. Optional, stronger: it binds the timestamp, so a captured delivery cannot be replayed later with a fresh one. Verify either header.
  • x-twelveai-timestamp - Unix seconds. Reject if > 5 minutes old.
  • x-twelveai-event - event name.
  • x-twelveai-event-id - the delivery id. Stable across retries and replays; dedupe on it.
  • User-Agent - TwelveAI-Webhook/1.0.

Respond fast. Acknowledge with any 2xx within 10 seconds and do your business logic asynchronously. Delivery is at-least-once with retries over about 3.3 days, so dedupe on x-twelveai-event-id. The full schedule, endpoint health rules and the handler checklist are in Webhook reliability.

Connect: platform webhooks

If you’re a platform and a charge was made on behalf of a connected account, we also POST that payment event to your app’s webhook_url - signed with your app client secret (not the connected account’s signing secret). It carries the same fields plus app_id, connected_account_id, and the application_fee (kobo) you earned. Your app also receives connection.created, connection.updated, and connection.revoked lifecycle events. Direct bank transfers (transfer.received) aren’t on-behalf charges, so they go to the business only. Platform deliveries go through the same queue as merchant webhooks: the same retries over about 3.3 days, the same x-twelveai-timestamp, x-twelveai-event-id and v2 signature headers, so a platform that is briefly down does not lose an event.

Example payloads

charge.success
{
  "event": "charge.success",
  "data": {
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "successful",
    "paid_at": "2026-06-12T12:34:56Z",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "provider": "paystack",
    "provider_reference": "1234567890",
    "metadata": { "order_id": "12345" }
  }
}
charge.failed
{
  "event": "charge.failed",
  "data": {
    "reference": "order-12346",
    "amount": 50000,
    "fee": 0,
    "net": 0,
    "currency": "NGN",
    "status": "failed",
    "paid_at": null,
    "customer": { "email": "customer@example.com", "name": null, "phone": null },
    "provider": "paystack",
    "provider_reference": null,
    "metadata": {},
    "failure_reason": "insufficient_funds"
  }
}
refund.processed
{
  "event": "refund.processed",
  "data": {
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "refunded",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "provider": "paystack",
    "provider_reference": "1234567890",
    "metadata": { "order_id": "12345" },
    "refund": {
      "provider_reference": "RFND_abc",
      "amount": 50000,
      "currency": "NGN",
      "reason": "requested_by_customer"
    }
  }
}
refund.processed (USD price paid in naira)
{
  "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_124",
    "idempotency_key": "disb_order_124",
    "provider": "paystack",
    "provider_reference": null,
    "failure_reason": "Account number is invalid",
    "succeeded_at": null,
    "failed_at": "2026-06-12T12:36:10Z",
    "created_at": "2026-06-12T12:34:56Z",
    "metadata": {}
  }
}
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" }
  }
}

Webhook reliability

What you can rely on from our side, and what your handler has to do so that a retry, a replay or an out-of-order delivery never double-fulfils an order.

Delivery is at-least-once

Every event carries a stable x-twelveai-event-id header (the delivery id). Store it and ignore repeats. Processing must be idempotent, and charge.success should be applied once per reference.

Acknowledge within 10 seconds

Respond with any 2xx within 10 seconds. Do the work after acknowledging (queue it). A non-2xx response or a timeout counts as a failed attempt.

Retries

After a failed attempt we retry at 30s, 1m, 2m, 5m, 15m, 1h, 6h, 24h, 48h: 10 attempts over about 3.3 days. A network-level failure (DNS, refused connection, TLS) gets one immediate extra try 2 seconds later before that schedule starts. After the last attempt the delivery is marked failed and you are notified in the dashboard and by email.

Ordering is not guaranteed

A retried transfer.processing can arrive after transfer.succeeded. Use the timestamps and status fields in the payload, not arrival order.

Headers

  • x-twelveai-signature - HMAC-SHA512 hex of the raw body, keyed by your signing secret. Only present when a secret is set.
  • x-twelveai-signature-v2 - HMAC-SHA512 hex of <timestamp>.<raw body>; also binds the timestamp. Only present when a secret is set.
  • x-twelveai-timestamp - unix seconds. Reject if older than 5 minutes.
  • x-twelveai-event - the event name.
  • x-twelveai-event-id - the delivery id; the same on every retry and replay.
  • User-Agent - TwelveAI-Webhook/1.0.

Endpoint health

After 20 consecutive permanently failed deliveries the endpoint is switched off automatically and you are notified. While an endpoint is off, whether you disabled it or we did, events are not lost: they are queued and parked for up to 7 days and delivered in order as soon as you enable the endpoint again from the dashboard.

Safety net

An independent reconciliation sweep runs every 10 minutes and queues any successful payment or transfer outcome from the last 48 hours that somehow has no delivery, so an internal fault on our side cannot silently drop an event. If you ever see a gap, replay any delivery from the Webhooks page in the dashboard; replays carry the same event id.

Redirects are not followed

Give us the handler's final URL. A 301, 302, 307 or 308 reply counts as a failed attempt: we never re-send a signed body to wherever a redirect points, and a 200 after a redirect is not a receipt.

Where we can deliver

Public internet addresses only. A URL that points at localhost, a private or link-local address, or an internal name is refused when you save it, and the address a hostname resolves to is checked again before every send.

Switching an endpoint back on

Parked events go out on the next worker pass, within about a minute, in the order they were queued.

Test sends

A test send from the dashboard carries "sample": true in the payload. Verify it like any other delivery, then skip fulfilment.

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.

Signature verification

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

Provide your own secret, or ask us to generate one, on the Webhooks tab. The examples below assume you configured one. A valid signature is step one; also reject a x-twelveai-timestamp older than 5 minutes, dedupe on x-twelveai-event-id, and return 200 before you do the work (see Webhook reliability).

Node.js

import crypto from 'node:crypto'

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

  const ts = Number(req.header('x-twelveai-timestamp') || 0)
  if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end()

  const eventId = req.header('x-twelveai-event-id')
  const { event, data } = JSON.parse(raw)
  res.status(200).end()
  // ... queue { eventId, event, data }; skip if eventId was seen; mark order paid once per data.reference ...
})

Python (Flask)

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

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

PHP

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

Balance & settlement (T+1)

Your business wallet has two balances:

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

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

What happens to the available balance is your choice under Settings, Settlement: Automatic sweeps it to your settlement account once a day, and On request keeps it until you withdraw it from the dashboard or with POST /api/business/v1/transfers.

Naira withdrawals and transfers are automatic: they go to the bank as soon as you confirm, with no review or approval on our side, and usually arrive within minutes. Only USD withdrawals are reviewed before they are sent.

Disputes and chargebacks

A dispute (chargeback) is a customer asking their bank to reverse a card payment. The processor that took the payment (Paystack or Flutterwave) tells us, we tell you, and you have until the deadline to show the payment was genuine. A dispute is in the currency the customer paid, and its hold comes out of the balance that payment credited.

What happens to the money

  • When a dispute opens, the disputed amount (the net we credited you for that part of the payment) is held: moved out of your available balance (or out of pending, if the payment has not settled yet) into a held bucket. Held money cannot be withdrawn. If your balance is too low, we hold what is there and record the rest as owed.
  • A payment with an open dispute does not settle until the dispute closes.
  • Won: exactly the held amount goes back to where it came from.
  • Lost or accepted: the held amount goes to the customer. If a refund for the same payment also comes through, it is counted against the dispute, so you are never charged twice.
  • Test-mode disputes move no money.

Deadlines. Each dispute has a due_at. We notify the account owner (in-app and by email) when it opens and again when Paystack sends a reminder. If you do not respond in time, the bank usually decides for the customer.

Responding. In the dashboard, open Disputes, pick the dispute and either send evidence (customer details, what you delivered and when, and a receipt, delivery proof or chat log as a PDF or image) or Accept it, which refunds the customer from the held funds. Responding needs the "Approve transfers" permission. Evidence without a document is saved; attaching a document submits your response to the bank.

API. Read-only on the merchant API: GET /api/business/v1/disputes and GET /api/business/v1/disputes/:id (scope payments:read). Responding is dashboard-only.

Events. dispute.created when one opens and dispute.resolved when it closes, with outcome set to won, lost or accepted. Same signing, retries and replay as every other event.

Statuses: needs_response, under_review (you responded, the bank is deciding), won, lost, accepted.

Limits

  • Unverified businesses: NGN 500,000 in successful live collections per calendar month (test mode is uncapped). Verify to remove the cap.
  • Minimum payment: NGN 1.00 (100 kobo).
  • Currency: NGN only for v1.
  • Take rate: 2% of gross, capped at NGN 2,500 per transaction.
  • Transfers out: minimum NGN 100 per transfer, plus a flat NGN 55 fee debited with the amount (payouts to your settlement account and transfers to your customers alike). The fee is returned with the amount if a transfer fails.
  • Business account numbers: not available at the moment. Money comes in through card payments, payment links and checkout; it goes out through transfers.

Settings that affect the API

A few dashboard settings change how the API behaves for your business. They are set by anyone on your team who can manage settings, and every change is recorded in the audit log.

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).
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 limit errors. A transfer over your limits answers 400 with a message such as This transfer is above your single-transfer limit of NGN 1,000. or This transfer would pass your daily transfer limit of NGN 50,000. NGN 12,000 is left for today. Nothing is debited. Show the message to the person who started the transfer, or raise the limit in Settings.

IP allowlist

An optional extra lock on your secret keys: list the addresses your servers call from, and API calls made with your keys from anywhere else are refused. It is off by default. While the list is empty, every call with a valid key is accepted exactly as before.

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

A call from an address outside the list is refused with a 403 before anything runs:

{
  "success": false,
  "code": "ip_not_allowed",
  "message": "This request came from 203.0.113.50, which is not on this business's IP allowlist. Add it in the dashboard under Settings, Developers."
}
Before you save. Add every server that calls the API, including workers and cron hosts, before saving a list. A missing address starts getting 403 within a minute. Blocked attempts show in the audit log and as a notification, at most once per address every 10 minutes.

Payment methods (channels)

Which payment methods a customer sees at checkout is decided at three levels, each one only able to narrow the one above it:

  1. Business setting (Settings, Payments): the methods your business accepts at all. All six are on by default.
  2. Payment link: each link can use the business setting or its own subset, for example card only. Set it under Payment methods when you create or edit the link.
  3. One payment: send channels on POST /api/business/v1/payments/initialize as an array or a comma-separated string. Leave it out to use the business setting.

A request can never switch a method back on: ["card", "ussd"] while USSD is off for the business simply becomes card. If nothing is left, or a name is unknown, the call is refused with a 400 before any checkout is created.

cardCardDebit and credit cards. The only method that can be saved for later charges.
bankBank accountPay directly from a bank account.
bank_transferBank transferA one-time account number to transfer into.
ussdUSSDDial a bank code on the phone.
qrQRScan with a banking app.
mobile_moneyMobile moneyMobile wallets where supported.
POST /api/business/v1/payments/initialize
Authorization: Bearer tw_test_...
Content-Type: application/json

{
  "email": "customer@example.com",
  "amount": 150000,
  "reference": "order-12345",
  "channels": ["card", "bank_transfer"]
}
// A method the business has switched off (400)
{ "success": false, "message": "None of the requested channels (ussd) is enabled for this business. Enabled: card, bank_transfer." }

// An unknown method name (400)
{ "success": false, "message": "Unknown payment channel: paypal. Use any of card, bank, bank_transfer, ussd, qr, mobile_money." }

The payment provider may still hide a method it cannot offer for a particular payment, for example a method not available for the amount or currency.

Payment requests

Ask one customer for a set amount without copying links around. On Payments (or a customer's page) choose Request payment, enter the customer's email, the amount (in naira or US dollars, currency) and what it is for, and optionally their WhatsApp number and a due date.

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

Currencies: NGN and USD

You price each thing a customer pays in naira (NGN) or US dollars (USD): payments from /payments/initialize, payment links, invoices, products, payment requests and instalment plans. The business default currency (Settings, Payments, Currency) pre-fills every form in the dashboard; each one can still be priced in the other currency. Every object with a price returns its currency, and every amount in a response is in minor units of that currency: kobo for NGN, cents for USD.

Where to set itSend currency ("NGN" default, or "USD") when you create a payment (POST /payments/initialize, amount in kobo or cents), a payment link, an invoice (line prices, totals and recorded offline payments are all in the invoice currency), an inventory item or store product (its options follow it), a payment request or an instalment plan. A payment link that offers instalments uses the link currency for the plan.
USD price paid in USDThe customer pays the dollar price by card through Flutterwave. No conversion and no margin. The net (after our fee) goes to your USD balance.
USD price paid in nairaThe customer can pay in naira instead: we convert at Flutterwave's live rate plus a 1.5% conversion margin and charge the naira through Paystack (card, transfer and the other naira methods). The quote is shown with its rate and is good for 10 minutes. The net goes to your naira balance.
NGN price paid in USDAs before: where you take USD (Settings, Payments, Pay in 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.
Fees2% of each payment, capped at NGN 2,500. A charge in dollars is capped at the dollar value of NGN 2,500 at the rate of the day. If you pass fees to customers, the amount they pay covers the fee too.
Your USD balanceDollar charges credit your USD balance (pending), settling T+1 into available like naira. See it on Overview and Transfers. Withdraw USD as naira from there: a quote at Flutterwave's live rate less a 1% conversion margin (valid 5 minutes), reviewed by TwelveAI, then paid to your settlement account by a transfer from our Flutterwave account (where your dollars are held), with the usual NGN 55 transfer fee, minimum payout and limits. If the transfer fails or is reversed, the dollars return to your USD balance.
Naira onlySplit payments (a payment link or payment with a split must be in NGN) and Connect payments with an application fee. Their money is shared out at settlement, which for dollars would need a conversion margin, so they stay naira.
RefundsRefunds work on every payment. The refund is in the price currency; when the customer paid in the other currency, refund.paid_currency and refund.paid_amount say what went back to them, by the same proportion as the payment. You are debited the net share from the balance the payment credited (USD for a dollar payment, naira otherwise). A refund never exceeds what was paid.
DisputesA dispute is in the currency the customer paid 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.
Saved cardsA USD card payment saves the card like a naira card. Saved cards show their currency; charge a USD card with POST /payments/charge-authorization and currency USD (amount in cents).
SubscriptionsPlans can be priced in USD. 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.
StorefrontA store sells in one currency (store settings, currency, defaulting to the business currency). Only products priced in that currency are listed and can be bought; the others stay in your inventory and show in the dashboard as not sold in the store, with the reason.
Turn dollars offSettings, 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 today. Live dollar card payments are switched on for the platform once approved; until then a live checkout for a dollar price offers naira only.

Webhooks: amount and currency are always the price. When the customer paid in the other currency, three fields are added: paid_currency, paid_amount (minor units of the paid currency: cents for USD, kobo for NGN) and fx_rate (naira per US dollar). A payment paid in its own currency carries none of them.

{
  "event": "charge.success",
  "data": {
    "reference": "order-777",
    "amount": 10000,
    "currency": "USD",
    "status": "successful",
    "paid_currency": "NGN",
    "paid_amount": 15225000,
    "fx_rate": 1522.5
  }
}

A dollar payment from /payments/initialize: amount is in cents and the response's authorization_url is the hosted checkout, where the customer chooses dollars or naira; access_code is empty, so open the checkout URL rather than a Paystack popup.

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

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

Hosted checkout endpoints (used by the checkout page, limited per IP): POST /api/v1/public/payments/:reference/usd-quote and POST /api/v1/public/payments/:reference/pay-usd with { quote_id } to pay in dollars; POST /api/v1/public/payments/:reference/ngn-quote and POST /api/v1/public/payments/:reference/restart with { quote_id } to pay a dollar price in naira (an expired quote answers 422 QUOTE_EXPIRED with a fresh quote to confirm).

Subscriptions

Charge a customer automatically every week, month, quarter or year. You create a plan (an amount and an interval), the customer pays the first period on the normal hosted checkout (cards only, so the card can be charged again), and from then on we charge that saved card at the start of every period. Every charge is an ordinary payment: it shows under Payments, pays the normal collection fee (2%, capped at NGN 2,500, or passed to the customer if you turned that on), settles T+1, and fires charge.success or charge.failed like any other. There is no extra subscription fee. A plan is priced in NGN or USD (fixed once created): a USD plan is paid by card in dollars and renews on that card, settling to your USD balance.

Customers subscribe in one of two ways: from your backend with POST /api/business/v1/subscriptions (you get a checkout_url to send them to), or on the plan's hosted page https://checkout.twelveai.app/s/PLN_… (share the link or its QR code from the dashboard). Manage plans and subscribers in the dashboard under Subscriptions.

Lifecycle

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. We retry the card on a schedule (below) and email the customer 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.

Renewals and retries

Billing dates follow the moment the first payment was made, on the Lagos calendar. A subscription that started on 31 January renews on 28 February (29 in a leap year), then 31 March, 30 April, and so on; it never drifts to the 28th. Renewals are processed every 10 minutes, so a charge lands within about 10 minutes of next_charge_at.

When a renewal fails the subscription becomes past_due and we retry the same card 1, 3 and 5 days after the first failure (four attempts in all). If the retry on day 5 also fails, the subscription is cancelled with cancel_reason: "payment_failed". A successful retry, or the customer paying with a new card from the link in our email, makes it active again and links the new card; the paid period starts from the date that was due. A customer can also restart a subscription cancelled for failed payments by paying from that link.

Each renewal attempt is recorded before the card is charged, under a key unique to the subscription, the period and the attempt, and only one successful charge can exist per period. If a retry and a card update ever both succeed, the second payment covers the following period instead of paying the same one twice.

Customer emails (live mode)

Sent in your business name, with replies going to your business email: a reminder 3 days before each renewal, a notice when a charge fails (with a link to pay with another card), and a confirmation when the subscription is cancelled. Every email links to a manage page where the customer can see the next charge, pay an overdue period with a new card, or stop renewing at the end of the paid period. Test-mode subscriptions send no emails; copy the manage link from the dashboard to try the page.

Free trials are not available yet. The first period is always paid at checkout. Paystack cannot save a card without charging it, so a trial would need a separate card check we do not offer yet. Plans and subscriptions reject a non-zero trial_days.

Webhook events

Delivered to the same endpoint, signed and retried exactly like payment events: subscription.created, subscription.renewed, subscription.payment_failed and subscription.cancelled. The payment itself also fires charge.success / charge.failed, with metadata.subscription_code set. data is the subscription object; event_id is stable per occurrence, so you can de-duplicate on it.

{
  "event": "subscription.renewed",
  "data": {
    "event_id": "SUB_k3m9q2x7v8w4r6t2:subscription.renewed:5121",
    "id": "SUB_k3m9q2x7v8w4r6t2",
    "mode": "live",
    "status": "active",
    "plan": { "id": "PLN_a7c2e9k4m3p8", "name": "Gold", "amount": 500000, "currency": "NGN", "interval": "monthly", "interval_count": 1 },
    "customer": { "email": "ada@example.com", "name": "Ada Okafor" },
    "amount": 500000,
    "currency": "NGN",
    "interval": "monthly",
    "interval_count": 1,
    "current_period_start": "2026-11-30T09:12:00.000Z",
    "current_period_end": "2026-12-31T09:12:00.000Z",
    "next_charge_at": "2026-12-31T09:12:00.000Z",
    "cancel_at_period_end": false,
    "cancelled_at": null,
    "cancel_reason": null,
    "failure_count": 0,
    "last_failure_reason": null,
    "card": { "id": 88, "brand": "visa", "last4": "4081", "exp_month": "12", "exp_year": "2030" },
    "metadata": {},
    "created_at": "2026-10-31T09:10:41.000Z",
    "payment": { "reference": "TWAI_SUB_41_20261130T081200_1", "amount": 500000, "currency": "NGN", "paid_at": "2026-11-30T09:12:07.000Z" }
  }
}

subscription.payment_failed carries an attempt block instead of payment:

"attempt": {
  "number": 2,
  "reference": "TWAI_SUB_41_20261130T081200_2",
  "failure_reason": "Insufficient Funds",
  "next_retry_at": "2026-12-03T09:20:00.000Z",
  "will_cancel": false
}

subscription.created also carries payment (the first payment), and subscription.renewed has reactivated: true when a customer restarted a subscription that had been cancelled for failed payments.

Invoices

Bill a customer with line items, tax and a discount, and let them pay from a branded invoice page. Create invoices in the dashboard under Invoices, or from your backend with POST /api/business/v1/invoices. Each invoice gets a number from your own sequence (INV-0001, INV-0002, ...) and a private link, https://checkout.twelveai.app/i/…, that shows the invoice, what has been paid, the balance and a Pay button. The page prints cleanly and offers a PDF download. An invoice is in naira or US dollars (currency): its line prices, totals, part payments and recorded offline payments are all in that currency.

Sending emails the customer the invoice with a Pay invoice button, in your business name with replies going to your business email. The dashboard also gives you the link to copy and a WhatsApp share link with the message typed in. Test-mode invoices are emailed too, marked [Test], so you can try the flow end to end.

Totals

Every line is quantity x unit price, rounded to the kobo. A discount (a percent of the subtotal, or a fixed amount) comes off before tax. Tax is the invoice rate unless a line has its own tax_percent. total = subtotal - discount + tax. All amounts on the merchant API are in kobo.

Statuses

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.

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.

Offline payments. 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 fires the same invoice.partially_paid / invoice.paid events with payment.kind: "manual".

Reminders and receipts

Unless you turn them off (for the business in the dashboard, or per invoice with reminders: false), a sent invoice with a due date reminds the customer by email 3 days before the due date, on the due date and 7 days after it, at 09:00 Lagos time, until it is paid or voided. Each reminder goes out at most once. When the balance reaches zero the customer gets a paid receipt.

Webhook events

invoice.sent, invoice.partially_paid, invoice.paid and invoice.overdue, delivered to the same endpoint, signed and retried like payment events, and to connected platforms holding a payments scope. data is the invoice object; payment events add a payment block. event_id is stable per occurrence, so you can de-duplicate on it.

{
  "event": "invoice.partially_paid",
  "data": {
    "event_id": "412:partially_paid:9051",
    "id": 412,
    "invoice_number": "INV-0042",
    "mode": "live",
    "status": "partially_paid",
    "currency": "NGN",
    "customer": { "email": "ada@example.com", "name": "Ada Okafor", "phone": "08031234567", "address": null },
    "line_items": [
      { "description": "Logo design", "quantity": 1, "unit_price": 15000000, "amount": 15000000, "tax_percent": null, "tax": 1125000, "discount": 0 }
    ],
    "subtotal": 15000000,
    "discount_percent": 0,
    "discount": 0,
    "tax_percent": 7.5,
    "tax": 1125000,
    "total": 16125000,
    "amount_paid": 5000000,
    "amount_due": 11125000,
    "overpaid": 0,
    "allow_partial": true,
    "notes": null,
    "terms": "Payment due within 14 days.",
    "hosted_url": "https://checkout.twelveai.app/i/cm1x9...",
    "pdf_url": "https://checkout.twelveai.app/i/cm1x9.../pdf",
    "issued_at": "2026-10-05T09:00:00.000+01:00",
    "due_at": "2026-10-19T23:59:59.000+01:00",
    "sent_at": "2026-10-05T09:01:12.000+01:00",
    "paid_at": null,
    "voided_at": null,
    "metadata": { "order_id": "A-1001" },
    "created_at": "2026-10-05T09:00:00.000+01:00",
    "payment": { "kind": "online", "reference": "INV_412_9c1e2a7b40f3", "amount": 5000000, "applied": 5000000, "overpaid": 0, "paid_at": "2026-10-07T14:22:10.000+01:00" }
  }
}

Split payments

Send part of a payment to other people's bank accounts: vendors on a marketplace, a partner's commission, a supplier's cost. Each share is paid to a plain Nigerian bank account by bank transfer when the payment settles (T+1), at the standard NGN 55 per transfer. You are responsible for your vendors: they are your payees, paid from your balance. Split payments are naira-only: a payment or link with a split must be in NGN.

The model

  • Vendors (split parties) are bank accounts you add in the dashboard under Split payments. Each one is checked with the bank first; we pay the account holder name the bank returns. Test and live vendors are separate.
  • A share is { party_id, type, value }: 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. 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.

Examples

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.

{
  "email": "customer@example.com",
  "amount": 1000000,
  "reference": "order-12345",
  "split": {
    "fee_bearer": "merchant",
    "shares": [
      { "party_id": 14, "type": "percentage", "value": 50 },
      { "party_id": 15, "type": "flat", "value": 300000 }
    ]
  }
}

A split that cannot work is refused with HTTP 400, code: "SPLIT_INVALID" and a message saying why.

Rules

  • At most 10 vendors per split, each once.
  • Percentages must add up to less than 100%, so you always keep part of the payment.
  • Shares plus transfer fees must fit inside the net.
  • Each vendor must receive at least NGN 100 (after the fee when the vendor pays it).
  • Live splits need a verified business (KYB) and a verified director BVN, like any payout to another account. Vendors must be active and in the same mode as the payment.
  • Test mode: test payments never settle, so their vendor payouts are simulated as soon as the payment succeeds and split.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 (to the vendor's current verified account).

Webhook events

split.paid (the transfer reached the vendor) and split.failed (it failed or was reversed; the money is back in your balance). Signed and retried like every other event; event_id is stable per occurrence. Split payouts do not send transfer.* events.

{
  "event": "split.paid",
  "data": {
    "event_id": "split_301:split.paid:9921",
    "id": 301,
    "status": "paid",
    "mode": "live",
    "currency": "NGN",
    "share_type": "percentage",
    "share_value": 50,
    "amount": 490000,
    "fee": 5500,
    "fee_bearer": "merchant",
    "transfer_amount": 490000,
    "party": { "id": 14, "name": "Ada Foods", "account_name": "ADA OKAFOR", "bank_name": "Access Bank", "account_number": "0123456789" },
    "payment": { "reference": "order-12345", "amount": 1000000, "net": 980000, "currency": "NGN", "paid_at": "2026-10-05T10:02:11.000Z", "metadata": {} },
    "payout": { "reference": "bpo_41_9f2c1a7b3d5e8f60", "status": "succeeded", "provider_reference": "TRF_1a2b3c" },
    "attempts": 1,
    "failure_reason": null,
    "paid_at": "2026-10-06T16:05:40.000Z",
    "failed_at": null,
    "created_at": "2026-10-05T10:01:58.000Z"
  }
}

Storefront

A shop page for sellers who take orders on WhatsApp and Instagram: https://checkout.twelveai.app/store/your-store. Customers browse your published products, pick options and quantities, choose pickup or delivery, and pay on the normal hosted checkout. You get the order in the dashboard (Store), an in-app notification and an email, and the customer gets an order confirmation email. Set it up under Store in the dashboard: publish products, set delivery and your WhatsApp number, then open the store and share its link or QR code.

A store sells in one currency, naira or US dollars (store settings, currency). Only products priced in it are listed; the dashboard marks the others as not sold in the store. Public store responses carry currency, and delivery fees are in it too.

Products are your inventory items (the same catalogue as the TwelveAI Business app), including their options, prices and stock. Each order is paid by one ordinary payment, so it shows under Payments, pays the normal collection fee (or passes it to the customer if you turned that on), settles T+1 and fires charge.success with metadata.source: "store" and metadata.store_order_number. There is no extra storefront fee.

Products, options and photos

Manage the whole catalogue from the dashboard (Store, Products): name, description, price, SKU (never shown to customers), stock with a low-stock alert, status, and up to 9 photos with a cover. A product can come in options (sizes, colours), each with its own price, stock and photo; the store offers them as a list to pick from and shows "Only N left" or "Sold out" per option. An optional compare-at price is shown struck through next to the selling price, and each product can carry its own page title and description for search engines and link previews. Products appear in the order you set; move them up or down.

Collections

Group products into collections ("Dresses", "New in") under Store, Products, Collections. Published collections show as tabs at the top of the store, with "All products" first; a collection has its own shareable address, https://checkout.twelveai.app/store/your-store?c=dresses. A product can sit in several collections, and each collection keeps its own product order. Customers can also search the catalogue and sort by price, name or newest.

In the order object below, every line carries item_id and, when the customer picked an option, variant_id and variant_name (null for a product without options). Stock is drawn down per option, and stock_issues names the option that ran short. Option ids stay the same when you edit a product, so you can key your own stock on them.

Prices and stock

The browser only sends product ids, options and quantities. Every price, the delivery fee and the total are worked out on our server from your inventory at checkout. If a price changed since the customer opened the page, the checkout is refused and the page shows the new prices before the customer pays.

Stock is not held while a customer is paying. It is checked at checkout and drawn down once, when the payment succeeds, with a sale entry in the item's stock history. If two customers pay for the last unit, the later order is still paid (the money is real) and is marked paid_needs_attention with the short lines in stock_issues: restock and fulfil it, or refund the difference from the payment.

Order statuses

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.

Webhook events

Delivered to the same endpoint, signed and retried exactly like payment events: store.order.paid and store.order.fulfilled. data is the order object (amounts in kobo), the same one GET /api/business/v1/store/orders/:id returns; event_id is stable per occurrence, so you can de-duplicate on it.

{
  "event": "store.order.paid",
  "data": {
    "event_id": "ORD_K7Q2M9X4TP:store.order.paid",
    "id": "ORD_K7Q2M9X4TP",
    "mode": "live",
    "status": "paid",
    "store": "ada-fabrics",
    "customer": { "name": "Chioma Obi", "email": "chioma@example.com", "phone": "+2348031234567" },
    "delivery": { "method": "delivery", "address": "12 Allen Avenue, Ikeja", "note": null, "fee": 150000 },
    "items": [
      { "item_id": 41, "variant_id": 7, "name": "Ankara wrap dress", "variant_name": "Size 12", "image_url": "https://res.cloudinary.com/demo/dress.jpg", "unit_price": 1500000, "quantity": 1, "line_total": 1500000 }
    ],
    "subtotal": 1500000,
    "delivery_fee": 150000,
    "total": 1650000,
    "currency": "NGN",
    "payment": { "reference": "TWAI_12_9f3c1a7b2d4e6f80", "paid_at": "2026-10-05T10:02:11.000Z" },
    "stock_issues": null,
    "fulfillment": null,
    "cancelled_at": null,
    "cancel_reason": null,
    "paid_at": "2026-10-05T10:02:11.000Z",
    "created_at": "2026-10-05T10:00:48.000Z"
  }
}

Store orders are read-only on the API for now: list them with GET /api/business/v1/store/orders and fetch one with GET /api/business/v1/store/orders/:id (scope payments:read). Fulfil and cancel from the dashboard.

Storefront design and AI assistant

Your store page has a look of its own: a template, colours, fonts, a header style, an announcement bar, a hero, the order and copy of the page sections, the product card style, the footer and the page title search engines show. Set it under Store, Design in the dashboard. Nothing here changes your products, prices or stock; it only changes how the page is drawn.

Templates

MinimalClean and quiet; the look every store starts with.
BoutiqueSerif headings, tall product photos, soft neutrals. Fashion, beauty, accessories.
MarketAnnouncement bar, compact cards, lots on one screen. General stores, gadgets, home.
BoldHigh contrast, big type, a strong accent. Streetwear, drops, brands with attitude.
StudioSpacious, with room for your story, testimonials and questions. Services, creators, courses.
GrocerFresh greens, list-style cards with stock badges. Food, groceries, farm produce.

Try a template on the live preview, customise anything by hand (every colour, font, section and line of copy), then Publish. Shoppers only ever see the last published look; the preview shows your working copy. Every publish is kept in the version history (the last 30), so you can restore an earlier look in one click.

The design assistant

Describe a change in plain words ("make it feel like a premium boutique with serif headings", "add an FAQ about delivery and returns", "put a sale announcement at the top") and the assistant proposes a new look. You see a one-line summary and the result on the preview; apply it, change more, or discard it. Nothing is published until you press Publish.

What it can change: the template, colours, fonts, corners and spacing, header layout, the announcement bar, the hero, which sections appear and in what order, the copy in About, Testimonials, FAQ and Contact, product card style, footer text and links, and the SEO title and description.

What it cannot do, by design: touch products, prices, stock, orders, payments, customers, settings, API keys, other pages or other websites, or write any code. The assistant can only produce the same validated design document a manual save produces (fixed lists of allowed values, hex colours, bounded plain text, https links only); anything else is refused before it reaches your store. Requests outside storefront design get a short explanation instead of a change.

AI credits and prices

The assistant is paid for with prepaid AI credits in naira, at the same rates as the Twelve Engine console: NGN 12 per 1,000 input tokens and NGN 72 per 1,000 output tokens. A typical design request uses about 2,000 to 3,000 input tokens and 700 to 1,000 output tokens, so it costs roughly NGN 60 to 120. Every request shows its exact token count and cost, and your balance after it. A request is charged for the tokens actually used, including when the assistant declines or proposes something that fails validation, because the model was still run; a request that never reaches the model is not charged.

Every business receives NGN 5,000 of welcome credits once, the first time it opens the Design tab or asks the assistant. A request is refused when the balance is below NGN 20; buy credits to continue.

Top-ups

Buy credits from the Design tab or under Settings, AI credits: pick NGN 2,000, 5,000, 20,000 or any whole amount from NGN 1,000 to NGN 500,000. You pay TwelveAI on the normal hosted checkout (card, bank transfer or USSD); the payment is a payment to TwelveAI, not to your business, so it does not appear in your Payments and the standard collection fee is TwelveAI's to bear. Credits are added the moment the payment is confirmed (the dashboard verifies it when you return, and a successful payment credits you even if you close the tab). Credits do not expire and are not refundable to cash.

Pay in instalments

Let a customer split a purchase into 2 to 6 parts, paid weekly, every 2 weeks or monthly. The customer pays part 1 now; the rest follow on a schedule. You release the goods (or start the service) when the plan is paid in full, so there is no lending and no credit risk: the parts always add up to exactly the purchase total, with any rounding kobo on the last part. A plan is in naira or US dollars (currency on create; on a payment link it follows the link currency), and every part is in the plan currency.

Every part is an ordinary payment: it shows under Payments, pays the normal collection fee (per part, 2% capped at NGN 2,500, or passed to the customer if you turned that on), settles T+1 and fires charge.success with metadata.instalment_plan_code set. There is no extra instalment fee.

Where customers get it

  • Payment links: turn on Allow instalments in the link editor (part counts, how often, and the smallest order). The link page then offers "Pay in N parts" and shows the dates and amounts before the customer pays.
  • Your backend: POST /api/business/v1/instalment-plans returns the plan and a checkout_url for part 1.

How later parts are collected

If part 1 is paid by card, the card is saved and each later part is charged to it automatically on its due date (within about 10 minutes). If the charge fails we retry on day 1 and day 3 after the first failure (3 attempts by default; change it in Settings, Payments) and email the customer a link to pay another way. If part 1 was paid by transfer, USSD or another method that cannot be charged again, the customer is emailed 2 days before each part and on the due date with a link to their pay page (https://checkout.twelveai.app/p/…), which shows the schedule with buttons to pay the next part or everything that is left. Paying early is always allowed; extra money goes to the next part. Customer emails are sent in live mode only (the dashboard can still send the pay link for a test plan).

Dates follow the day part 1 was paid, on the Lagos calendar: a plan started on 31 January is due 28 February (29 in a leap year), 31 March, 30 April. Each automatic charge is recorded under a key unique to the plan, the part and the attempt before the card is charged, and no second attempt starts while one is in flight or unconfirmed, so a part is never charged twice. While the customer is at checkout on the pay page, the automatic charge waits.

Lifecycle

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.

{
  "event": "instalment_plan.part_paid",
  "data": {
    "event_id": "INS_q8m2k7x4v9w3r6t2p5n1:part_paid:6120",
    "id": "INS_q8m2k7x4v9w3r6t2p5n1",
    "mode": "live",
    "status": "active",
    "customer": { "email": "ada@example.com", "name": "Ada Okafor", "phone": "08012345678" },
    "description": "3-seater sofa",
    "amount": 30000000,
    "amount_paid": 20000000,
    "amount_waived": 0,
    "amount_remaining": 10000000,
    "currency": "NGN",
    "parts_count": 3,
    "frequency": "monthly",
    "collection": "card",
    "card": { "id": 88, "brand": "visa", "last4": "4081", "exp_month": "12", "exp_year": "2030" },
    "next_part": { "number": 3, "amount_due": 10000000, "due_at": "2026-12-31T09:12:00.000Z" },
    "parts": [
      { "number": 1, "amount": 10000000, "amount_paid": 10000000, "amount_remaining": 0, "due_at": "2026-10-31T09:12:00.000Z", "status": "paid", "paid_at": "2026-10-31T09:12:00.000Z", "failure_count": 0, "last_failure_reason": null },
      { "number": 2, "amount": 10000000, "amount_paid": 10000000, "amount_remaining": 0, "due_at": "2026-11-30T09:12:00.000Z", "status": "paid", "paid_at": "2026-11-30T09:14:02.000Z", "failure_count": 0, "last_failure_reason": null },
      { "number": 3, "amount": 10000000, "amount_paid": 0, "amount_remaining": 10000000, "due_at": "2026-12-31T09:12:00.000Z", "status": "scheduled", "paid_at": null, "failure_count": 0, "last_failure_reason": null }
    ],
    "pay_url": "https://checkout.twelveai.app/p/7c1e...",
    "metadata": { "order_id": "SO-1042" },
    "created_at": "2026-10-31T09:10:41.000Z",
    "activated_at": "2026-10-31T09:12:00.000Z",
    "completed_at": null,
    "cancelled_at": null,
    "defaulted_at": null,
    "payment": { "reference": "TWAI_INS_41_2_1", "amount": 10000000, "currency": "NGN", "paid_at": "2026-11-30T09:14:02.000Z" },
    "applied": [{ "part": 2, "amount": 10000000, "part_paid_in_full": true }],
    "parts_paid": [2],
    "excess_amount": 0
  }
}

instalment_plan.created carries payment (part 1). instalment_plan.part_failed carries an attempt block:

"attempt": {
  "part": 2,
  "number": 1,
  "reference": "TWAI_INS_41_2_1",
  "failure_reason": "Insufficient Funds",
  "next_retry_at": "2026-12-01T09:20:00.000Z",
  "will_retry": true
}

excess_amount is above zero only if two payments landed at the same moment and together paid more than was owed; refund the difference from that payment.

From a payment link (public endpoints)

GET /api/v1/public/links/:slug now includes instalments (null when the link does not offer them): { enabled, allowed_parts, frequency, min_amount }, with min_amount in kobo. Two more endpoints, with no API key (the slug is the credential) and amount in naira as for /pay:

GET /api/v1/public/links/rybejd97ta/instalment-schedule?parts=3&amount=300000
{ "success": true, "data": { "amount": 30000000, "parts": 3, "frequency": "monthly", "fee_passed_to_customer": false,
  "schedule": [ { "number": 1, "amount": 10000000, "charge_amount": 10000000, "due_at": "2026-10-31T09:12:00.000Z" }, ... ] } }

POST /api/v1/public/links/rybejd97ta/pay-instalments
{ "email": "ada@example.com", "name": "Ada Okafor", "amount": 300000, "parts": 3, "fields": { "company": "Acme Ltd" } }
{ "success": true, "data": { "reference": "TWAI_15_...", "checkout_url": "https://checkout.twelveai.app/c/TWAI_15_...", "pay_url": "https://checkout.twelveai.app/p/7c1e..." } }

POST /api/v1/public/links/:slug/pay is unchanged and still takes a full payment.

Connect - act on behalf of other businesses

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

1. Register a platform app in your dashboard (Developers → Register app). You get a client_id and client_secret (viewable any time) and pick the scopes your app may request. Test mode works immediately; live requires approval.

2. Send the business to the consent screen with your client_id:

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

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

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

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

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

# Node - inside your redirect_uri handler
const r = await fetch("https://dashboard.twelveai.app/oauth/token", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    grant_type:    "authorization_code",
    client_id:     process.env.TWELVEAI_CLIENT_ID,
    client_secret: process.env.TWELVEAI_CLIENT_SECRET,  // NEVER in the browser
    code,                                               // from ?code=... on the redirect
    redirect_uri:  "https://yourapp.com/twelveai/callback", // must match step 2
  }),
})
const { access_token, refresh_token, scope, mode, connected_account_id } = await r.json()

4. Call the API on their behalf - send the access token as the bearer on any /api/business/v1/* call, limited to the granted scopes. There is no separate on-behalf endpoint: the token identifies the connected business, so the same /payments/initialize a first-party merchant uses now charges on their behalf.

# Charge on behalf of a connected business
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \
  -H "Authorization: Bearer tw_at_..." \   # the connected business's access token
  -H "Content-Type: application/json" \
  -d '{
    "email": "customer@example.com",
    "amount": 50000,
    "reference": "cantant-order-123",
    "application_fee_bps": 250          // optional; your cut
  }'

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

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

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

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

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

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

Connect - how the flow works end-to-end

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

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

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

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

Connect - button & mobile SDKs

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

Web - one-click button (drop-in)

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

<script src="https://dashboard.twelveai.app/connect.js"></script>

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

<script>
  // Called with the one-time code - exchange it on YOUR server.
  function onTwelveConnected({ code }) {
    fetch("/api/twelveai/exchange", { method: "POST", body: JSON.stringify({ code }) })
  }
</script>

Web - popup (no full redirect)

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

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

Programmatic (button / mount)

// Wire an existing element:
TwelveConnect.button("#connect", {
  clientId: "twc_...",
  redirectUri: "https://yourapp.com/twelveai/callback",
  className: "my-btn",           // appended alongside the default
  unstyled: false,               // true = your styles only
  onSuccess: ({ code }) => { /* exchange server-side */ },
})

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

React / Next

import Script from "next/script"

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

Vue

<script setup>
import { onMounted } from "vue"
onMounted(() =>
  window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "...", onSuccess })
)
</script>
<template>
  <button id="connect" class="my-btn">Connect with TwelveAI</button>
</template>

Svelte

<script>
  import { onMount } from "svelte"
  onMount(() =>
    window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "...", onSuccess })
  )
</script>
<button id="connect" class="my-btn">Connect with TwelveAI</button>

Mobile - in-app browser + deep link

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

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

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

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

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

Connect - platform webhook events

If you're a platform and a charge was made on behalf of a connected account, we also POST that payment event to your app's webhook_url - signed with your app client secret (not the connected account's signing secret). Your app also receives connection.* lifecycle events.

charge.successA card/Paystack payment succeeded on the connected account.
charge.failedA payment attempt failed on the connected account.
refund.processedA refund completed on the connected account.
connection.authorizedOwner approved consent - payload carries the one-time OAuth code + redirect_uri so your backend can exchange without waiting for the browser round-trip. Codes are single-use.
connection.createdA business connected to your Connect app (fires when the code is exchanged for a token).
connection.updatedA connection’s granted scopes changed.
connection.revokedA business disconnected from your app.

Payment events reach your app only while the connection is active and still grants a payments scope. If the business disconnects or drops payments access, delivery stops.

Payload - charge.success (on-behalf)

{
  "event": "charge.success",
  "data": {
    "app_id": 42,
    "connected_account_id": 108,
    "application_fee": 1250,
    "reference": "order-12345",
    "amount": 50000,
    "fee": 750,
    "net": 49250,
    "currency": "NGN",
    "status": "successful",
    "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null },
    "metadata": { "order_id": "12345" }
  },
  "created_at": "2026-06-12T12:34:56Z"
}

Payload - connection.authorized

{
  "event": "connection.authorized",
  "data": {
    "app_id": 42,
    "code": "tw_ac_...",
    "redirect_uri": "https://yourapp.com/twelveai/callback",
    "business_id": 108,
    "scopes": ["payments:read", "payments:write"],
    "mode": "live",
    "expires_in": 60,
    "metadata": "order_42"
  },
  "created_at": "2026-06-12T12:34:56Z"
}

Payload - connection.created

{
  "event": "connection.created",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "scopes": ["payments:read", "payments:write"],
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}

Payload - connection.updated

{
  "event": "connection.updated",
  "data": {
    "app_id": 42,
    "connection_id": 17,
    "business_id": 108,
    "scopes": ["payments:read", "payments:write", "payouts:write"],
    "mode": "live"
  },
  "created_at": "2026-06-12T12:34:56Z"
}

Payload - connection.revoked

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

Connect - testing your integration

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

1. Get a sandbox test token

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

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

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

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

2. Charge on behalf (payments:write)

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

3. Read scopes

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

4. Prove enforcement

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

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

Client SDKs (drop-in checkout)

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

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

Stuck?

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

dev@twelveai.app →