API reference
Every endpoint, in the open — with request samples in cURL, Node, Python, PHP, Go, Ruby and Java. REST over HTTPS, JSON in and out, amounts in kobo. Base URL: https://api.twelveai.app
Getting started
Each key is a public/secret pair, mode-scoped. Your secret key is tw_test_… (test) or tw_live_… (live); the matching public key is tw_pub_test_… / tw_pub_live_…. Authenticate every request with the secret key as a bearer token — never expose it in client-side code. Connect platforms use an OAuth access token (tw_at_…) on the same endpoints, limited to the granted scopes.
Authorization: Bearer tw_live_xxxxxxxxxxxxxxxxxxxxEvery endpoint below includes a ready-to-run request in your language — switch tabs on the Request panel.
Payments
/api/business/v1/payments/initializeInitialize a payment
Starts a payment and returns a branded checkout_url to redirect your customer to.
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \ -H "Authorization: Bearer tw_test_..." \ -H "Content-Type: application/json" \ -d '{ "email": "customer@example.com", "amount": 50000, "reference": "order-12345" }'
{ "success": true, "data": { "reference": "order-12345", "checkout_url": "https://checkout.twelveai.app/c/order-12345", "authorization_url": "https://checkout.paystack.com/…", "amount": 50000, "currency": "NGN" } }
/api/business/v1/payments/verify/:referenceVerify a payment
Re-checks a payment. Use when a webhook was missed. Idempotent.
curl "https://api.twelveai.app/api/business/v1/payments/verify/order-12345" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "reference": "order-12345", "status": "successful", "amount": 50000, "fee": 750, "net": 49250, "currency": "NGN", "paid_at": "2026-06-12T12:34:56Z", "customer": { "email": "customer@example.com", "name": null }, "metadata": { "order_id": "12345" } } }
/api/business/v1/paymentsList payments
Cursor-paginated, newest first, scoped to the mode of the key you send.
curl "https://api.twelveai.app/api/business/v1/payments?status=successful&limit=50" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "payments": [ { "reference": "order-12345", "amount": 50000, "fee": 750, "net": 49250, "currency": "NGN", "status": "successful", "settlement_status": "settled", "customer_email": "customer@example.com", "paid_at": "2026-06-12T12:34:56Z", "created_at": "2026-06-12T12:30:00Z" } ], "next_cursor": null } }
/api/business/v1/saved-cardsList saved cards
Reusable card authorizations captured on successful payments. Use the authorization_code to re-charge.
curl "https://api.twelveai.app/api/business/v1/saved-cards?email=customer@example.com" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "saved_cards": [ { "authorization_code": "AUTH_xxxxxxxx", "customer_email": "customer@example.com", "brand": "visa", "last4": "4081", "exp_month": "08", "exp_year": "2029", "bank": "GTBank", "reusable": true } ], "next_cursor": null } }
/api/business/v1/payments/charge-authorizationCharge a saved card
Synchronous re-charge against a saved authorization_code. Live mode only.
curl -X POST https://api.twelveai.app/api/business/v1/payments/charge-authorization \ -H "Authorization: Bearer tw_live_..." \ -H "Content-Type: application/json" \ -d '{ "authorization_code": "AUTH_xxxxxxxx", "email": "customer@example.com", "amount": 50000 }'
{ "success": true, "message": "Charged.", "data": { "reference": "subscription-may-2026", "status": "successful", "amount": 50000, "net": 49250, "paid_at": "2026-05-01T08:00:00Z" } }
Balance & money
/api/business/v1/balanceGet balance
Wallet balances (kobo), verification status, and this month's live usage.
curl "https://api.twelveai.app/api/business/v1/balance" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "available_balance": 4925000, "pending_balance": 150000, "currency": "NGN", "verification_status": "verified", "monthly_cap": null, "monthly_used": 12500000 } }
/api/business/v1/transactionsList transactions
Unified money-movement ledger (live): card payments + incoming bank transfers + payouts, newest first. Connect scope: transactions:read.
curl "https://api.twelveai.app/api/business/v1/transactions?limit=50" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "transactions": [ { "kind": "payment", "direction": "credit", "reference": "order-12345", "amount_minor": 50000, "fee_minor": 750, "currency": "NGN", "status": "successful", "counterparty": "customer@example.com", "created_at": "2026-06-12T12:34:56Z" } ], "next_offset": 50 } }
/api/business/v1/customersList customers
Your customers, aggregated by email across payments. Connect scope: customers:read.
curl "https://api.twelveai.app/api/business/v1/customers?limit=50" \ -H "Authorization: Bearer tw_live_..."
{ "success": true, "data": { "customers": [ { "email": "customer@example.com", "payments": 3, "total_minor": 150000, "last_seen": "2026-06-12T12:34:56Z" } ] } }
/api/business/v1/transfersCreate a payout
Withdraw available balance to the account's saved settlement account. Live only. Connect scope: payouts:write. GET the same path lists payouts.
curl -X POST https://api.twelveai.app/api/business/v1/transfers \ -H "Authorization: Bearer tw_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 25000 }'
{ "success": true, "message": "Payout submitted. Status will update when the bank confirms.", "data": { "payout": { "reference": "TWAI_PO_…", "status": "processing", "amount": 2500000, "currency": "NGN", "destination": { "bank_name": "GTBank", "account_number": "0123456789", "account_name": "Greyark Technologies LLC" }, "created_at": "2026-06-12T12:34:56Z" } } }
Banks
/api/business/v1/banksList banks
Supported payout banks with their codes. Use a returned code as bank_code when resolving an account or creating a payout. No scope required.
curl "https://api.twelveai.app/api/business/v1/banks?country=NG" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "banks": [ { "code": "NG::000013", "name": "Guaranty Trust Bank" }, { "code": "NG::000014", "name": "Access Bank" }, { "code": "NG::000015", "name": "Zenith Bank" } ] } }
/api/business/v1/banks/resolveResolve an account
Verify a 10-digit NUBAN and return the account holder name. Use this to confirm a destination before a payout. No scope required; rate-limited to 30/min.
curl "https://api.twelveai.app/api/business/v1/banks/resolve?account_number=0123456789&bank_code=NG::000013" \ -H "Authorization: Bearer tw_test_..."
{ "success": true, "data": { "account_number": "0123456789", "account_name": "JOHN A ADEYEMI", "bank_code": "NG::000013", "bank_name": "Guaranty Trust Bank" } }
Webhooks
/api/business/v1/webhooksConfigure webhooks
Sets (or replaces) the URL we POST events to for this mode. Signing is optional and bring-your-own.
curl -X POST https://api.twelveai.app/api/business/v1/webhooks \ -H "Authorization: Bearer tw_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourdomain.com/webhooks/twelve", "signing_secret": "my_own_secret_123" }'
{ "success": true, "message": "Webhook saved. Deliveries are signed — store your signing_secret to verify them.", "data": { "id": 12, "mode": "live", "url": "https://yourdomain.com/webhooks/twelve", "signing_secret": "my_own_secret_123", "secret_set": true, "enabled": true } }
Connect (OAuth)
/oauth/tokenExchange / refresh token
Client-authenticated (no bearer). Exchange the auth code for an access token, or refresh an existing one.
curl -X POST https://api.twelveai.app/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "authorization_code", "client_id": "twc_...", "client_secret": "twcs_...", "code": "tw_ac_...", "redirect_uri": "https://yourapp.com/twelveai/callback" }'
{ "success": true, "access_token": "tw_at_...", "refresh_token": "tw_rt_...", "token_type": "Bearer", "scope": "payments:read payments:write", "mode": "live" }
/oauth/sandbox/connectSandbox connect
Client-authenticated. Returns an instant TEST access token connected to a sandbox account, so you can test your on-behalf integration without the interactive consent flow. Test mode only.
curl -X POST https://api.twelveai.app/oauth/sandbox/connect \ -H "Content-Type: application/json" \ -d '{ "client_id": "twc_...", "client_secret": "twcs_..." }'
{ "success": true, "access_token": "tw_at_...", "refresh_token": "tw_rt_...", "token_type": "Bearer", "scope": "payments:read payments:write", "mode": "test", "connected_account_id": 2 }
Connect — act on behalf of other businesses
TwelveAI Connect lets your platform accept payments, read data, and initiate payouts on behalf of other TwelveAI businesses that authorize you. It is the standard OAuth 2.0 authorization-code flow. You never see the connected business's own API keys — you receive a scoped, revocable access token per connected account.
- 1. Register a platform app in your dashboard (Developers → Register app). You get a
client_idandclient_secret(viewable any time) and pick the scopes your app may request. Test mode works immediately; live requires approval. - 2. Send the business to the consent screen with your
client_id. They approve, and we redirect back to yourredirect_uriwith a one-timecode. - 3. Exchange the code for tokens from your server — the code alone is nothing; you must POST it to
/oauth/tokenwith yourclient_secret. See How the flow works below. - 4. Call the API on their behalf — send the access token as the bearer on any
/api/business/v1/*call, limited to the granted scopes. There is no separate on-behalf endpoint: the token identifies the connected business, so the same/payments/initializea first-party merchant uses now charges on their behalf.
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \ -H "Authorization: Bearer tw_at_..." \ # the connected business's access token -H "Content-Type: application/json" \ -d '{ "email": "customer@example.com", "amount": 50000, "reference": "order-12345", "application_fee_bps": 250 // optional — your platform cut (250 = 2.5%) }'
Application fees. Take a cut of on-behalf charges by setting a default on your app (Developers → Default fee) or per charge with application_fee (kobo) or application_fee_bps. At settlement the connected account is credited net-of-fee and your platform wallet is credited the fee.
How Connect works end-to-end
Connect is the standard OAuth 2.0 authorization-code flow. It is a three-step handoff between the browser and your server, and it is not complete until step 3. If your redirect_uri receives a code but your server never exchanges it, no connection is saved — the code silently expires after 60 seconds.
GET /oauth/authorize?client_id=…&redirect_uri=…. They approve on our consent screen.redirect_uri with ?code=tw_ac_…. The code is single-use and expires in 60 seconds.POST /oauth/token with the code + your client_secret. You get back an access_token. The connection is created here, not on approve.The browser cannot do this step — the client secret must never touch it.Step 3 in code
Minimal server-side exchange — this is what turns the approved code into a saved connection and a working access token. Run it on your backend, never in the browser.
// POST /api/twelveai/exchange — called by your redirect_uri handler app.post("/api/twelveai/exchange", async (req, res) => { const { code } = req.body const r = await fetch("https://dashboard.twelveai.app/oauth/token", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: process.env.TWELVEAI_CLIENT_ID, client_secret: process.env.TWELVEAI_CLIENT_SECRET, // never in the browser code, redirect_uri: "https://yourapp.com/twelveai/callback", // must match step 1 }), }) const tokens = await r.json() // tokens.access_token = tw_at_… — store it against the connected merchant. res.json({ ok: true, connected_account_id: tokens.connected_account_id }) })
@app.post("/api/twelveai/exchange") def exchange(): code = request.json["code"] r = requests.post("https://dashboard.twelveai.app/oauth/token", json={ "grant_type": "authorization_code", "client_id": os.environ["TWELVEAI_CLIENT_ID"], "client_secret": os.environ["TWELVEAI_CLIENT_SECRET"], # server-side only "code": code, "redirect_uri": "https://yourapp.com/twelveai/callback", }) tokens = r.json() # {access_token: "tw_at_…", refresh_token, scope, mode, connected_account_id} # Store tokens.access_token against the connected merchant. return jsonify(ok=True, connected_account_id=tokens["connected_account_id"])
POST /oauth/sandbox/connect to skip the browser entirely and get a test access token in one call.Connecting a business: popup & mobile
Three ways to run step 1 — pick what fits your app. All of them end the same way: the browser lands on your redirect_uri with a one-time code, and your server must do step 3 (POST /oauth/token) or nothing is saved.
Web — one-click button (drop-in)
The fastest path: include connect.js and add a button with data-twelveai-connect. It’s auto-wired on load — no JS to write. Handle the result with a global data-on-success function or a twelveai:success DOM event.
<script src="https://dashboard.twelveai.app/connect.js"></script> <button data-twelveai-connect data-client-id="twc_..." data-redirect-uri="https://yourapp.com/twelveai/callback" data-mode="live" data-on-success="onTwelveConnected"> Connect with TwelveAI </button> <script> // Called with the one-time code — exchange it on YOUR server. function onTwelveConnected({ code }) { fetch("/api/twelveai/exchange", { method: "POST", body: JSON.stringify({ code }) }) } // …or listen on the element: btn.addEventListener("twelveai:success", e => e.detail.code) </script>
Style it your way. The button gets a default .twelveai-connect-btn class you can override in your own CSS. Add classes with data-class="my-btn" (kept alongside the default), set custom text as the button’s content, or drop the default look entirely with data-unstyled.
React or a framework? Wire an element you render, or create one:
// Wire an existing element: TwelveConnect.button("#connect", { clientId: "twc_...", redirectUri: "https://yourapp.com/twelveai/callback", // scope is optional — defaults to the app's registered scopes className: "my-btn", // appended alongside the default unstyled: false, // true = your styles only onSuccess: ({ code }) => { /* exchange server-side */ }, }) // …or create + insert a button into a container: TwelveConnect.mount("#slot", { clientId: "twc_...", redirectUri: "...", label: "Connect" })
React, Vue, Svelte & Next
The SDK is plain browser JavaScript on window.TwelveConnect — framework-agnostic and SSR-safe (it no-ops on the server, so it’s safe in Next / SvelteKit / Nuxt). The drop-in data-twelveai-connect button even auto-wires when your framework renders it after load (a MutationObserver re-scans on DOM changes). Or call the programmatic API from a client-only mount hook.
import Script from "next/script" // or a plain <script> tag export function ConnectButton() { useEffect(() => { window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "https://yourapp.com/twelveai/callback", onSuccess: ({ code }) => exchangeOnServer(code), }) }, []) return ( <> <Script src="https://dashboard.twelveai.app/connect.js" strategy="afterInteractive" /> <button id="connect" className="my-btn">Connect with TwelveAI</button> </> ) }
<script setup> import { onMounted } from "vue" onMounted(() => window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "...", onSuccess }) ) </script> <template> <button id="connect" class="my-btn">Connect with TwelveAI</button> </template>
<script> import { onMount } from "svelte" onMount(() => window.TwelveConnect?.button("#connect", { clientId: "twc_...", redirectUri: "...", onSuccess }) ) </script> <button id="connect" class="my-btn">Connect with TwelveAI</button>
Load connect.js once (a script tag in your document head, or Next’s <Script>). In SSR apps, call the API from a client-only hook (useEffect / onMounted / onMount) — never during render.
Web — popup call (full control)
Drop in connect.js and call TwelveConnect.open(). It opens a popup and hands the code back via postMessage — your page never navigates away.
<script src="https://dashboard.twelveai.app/connect.js"></script> <script> TwelveConnect.open({ clientId: "twc_...", redirectUri: "https://yourapp.com/twelveai/callback", // must be registered mode: "live", // scope is optional — defaults to the app's registered scopes onSuccess: async ({ code }) => { // Send code to YOUR server, then exchange it with your client_secret. await fetch("/api/twelveai/exchange", { method: "POST", body: JSON.stringify({ code }) }) }, onError: (err) => console.error(err), onClose: () => {}, // user closed the popup }) </script>
Note: this is a popup window, not an iframe modal — an OAuth consent screen can’t be safely iframed (and we send X-Frame-Options).
Mobile app — in-app browser + deep link
Register a custom-scheme redirect URI (e.g. myapp://twelveai/callback), open the same authorize URL in an in-app browser, and capture the code from the deep link the consent screen redirects to. Then exchange it from your server.
import * as WebBrowser from "expo-web-browser" const redirectUri = "myapp://twelveai/callback" // registered on your app const authorizeUrl = "https://dashboard.twelveai.app/oauth/authorize" + "?client_id=twc_..." + "&redirect_uri=" + encodeURIComponent(redirectUri) + "&mode=live" // scope optional — defaults to the app's registered scopes const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, redirectUri) if (result.type === "success") { const code = new URL(result.url).searchParams.get("code") // POST code to your server → exchange at /oauth/token with your client_secret }
Use PKCE (code_challenge) for mobile, since public clients can’t hold a secret. Universal/App Links (an https redirect that opens your app) work too.
Connect — testing your integration
Use the sandbox to exercise scopes and on-behalf calls without the consent flow. Every call here is test mode.
1. Get a sandbox test token
curl -X POST https://api.twelveai.app/oauth/sandbox/connect \ -H "Content-Type: application/json" \ -d '{ "client_id": "twc_...", "client_secret": "twcs_..." }' # → { "access_token": "tw_at_...", "mode": "test", # "scope": "payments:read payments:write ...", "connected_account_id": 2 }
To test scope enforcement, request a subset — the token is limited to it:
-d '{ "client_id":"twc_...", "client_secret":"twcs_...", "scope":"payments:read" }'
2. Charge on behalf (payments:write)
curl -X POST https://api.twelveai.app/api/business/v1/payments/initialize \ -H "Authorization: Bearer tw_at_..." \ -H "Content-Type: application/json" \ -d '{ "email":"buyer@test.com", "amount":50000, "application_fee_bps":250 }' # → open the returned checkout_url; pay with the test card: # 4084 0840 8408 4081 · any CVV · any future expiry · OTP 123456
3. Read scopes
curl https://api.twelveai.app/api/business/v1/payments -H "Authorization: Bearer tw_at_..." # payments:read curl https://api.twelveai.app/api/business/v1/balance -H "Authorization: Bearer tw_at_..." # balance:read curl https://api.twelveai.app/api/business/v1/transactions -H "Authorization: Bearer tw_at_..." # transactions:read curl https://api.twelveai.app/api/business/v1/saved-cards -H "Authorization: Bearer tw_at_..." # customers:read
4. Prove enforcement
A call outside the token's granted scopes is rejected with 403 insufficient_scope:
# token issued with scope=payments:read, then attempt a charge: # → 403 { "error": "insufficient_scope", # "message": "This token is missing the required scope: payments:write" }
/transfers returns "Payouts are only available in live mode." You can still verify the scope gate, but a successful payout needs a live connection. Application fees settle at T+1 (live only) — in test the fee is recorded on the payment but the net-to-platform split isn't moved. Test verifies scopes + plumbing; live verifies settled money movement.Connect scopes
A connection’s granted scopes are the ceiling — enforced on every request. Out-of-scope calls return 403 insufficient_scope.
payments:readGET /payments · GET /payments/verify/:referencepayments:writePOST /payments/initialize · POST /payments/charge-authorizationtransactions:readGET /transactionsbalance:readGET /balancecustomers:readGET /customers · GET /saved-cardspayouts:writePOST /transfers · GET /transfers (live only)Webhooks
We POST a JSON event to your configured URL whenever money moves — a payment succeeds or fails, a refund clears, or a bank transfer lands in your wallet. Configure the endpoint at POST /api/business/v1/webhooks (per mode).
Signing is optional and bring-your-own. Send your own signing_secret and we’ll sign every delivery’s x-twelveai-signature with it (verify against the raw body). Or set generate_secret: true to have us mint one. If you set neither, deliveries are sent unsigned — in that case restrict your endpoint another way (a secret path, an IP allowlist, or mutual TLS).
Events
charge.successBusiness + platformA card/Paystack payment succeeded.charge.failedBusiness + platformA payment attempt failed.refund.processedBusiness + platformA refund completed.transfer.receivedBusinessAn incoming bank transfer credited the wallet (direct NUBAN deposit).connection.createdPlatformA business connected to your Connect app.connection.updatedPlatformA connection’s granted scopes changed.connection.revokedPlatformA business disconnected from your app.“Business + platform” = the connected business always receives it; the Connect platform also receives it when the charge was made on its behalf.
Headers on every delivery
x-twelveai-signature— HMAC-SHA512 of the raw body, hex. Only present when you’ve set a signing secret — unsigned deliveries omit it.x-twelveai-timestamp— Unix seconds. Reject if > 5 minutes old.x-twelveai-event— event name.x-twelveai-event-id— unique delivery id for dedup.
Connect: platform webhooks
If you’re a platform and a charge was made on behalf of a connected account, we also POST that payment event to your app’s webhook_url — signed with your app client secret (not the connected account’s signing secret). It carries the same fields plus app_id, connected_account_id, and the application_fee (kobo) you earned. Your app also receives connection.created, connection.updated, and connection.revoked lifecycle events. Direct bank transfers (transfer.received) aren’t on-behalf charges, so they’re delivered to the business only.
Scope gating. Payment events reach your app only while the connection is active and still grants a payments scope — if the business disconnects or drops payments access, delivery stops. The event names follow the processor’s convention, mapped to scopes like this:
payments:read or payments:writecharge.success · charge.failed · refund.processed(connection lifecycle)connection.created · connection.updated · connection.revoked(account only — not sent to platforms)transfer.receivedExample payloads
{ "event": "charge.success", "data": { "reference": "order-12345", "amount": 50000, "fee": 750, "net": 49250, "currency": "NGN", "status": "successful", "paid_at": "2026-06-12T12:34:56Z", "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null }, "provider": "paystack", "provider_reference": "1234567890", "metadata": { "order_id": "12345" } } }
{ "event": "charge.failed", "data": { "reference": "order-12346", "amount": 50000, "fee": 0, "net": 0, "currency": "NGN", "status": "failed", "paid_at": null, "customer": { "email": "customer@example.com", "name": null, "phone": null }, "provider": "paystack", "provider_reference": null, "metadata": {}, "failure_reason": "insufficient_funds" } }
{ "event": "refund.processed", "data": { "reference": "order-12345", "amount": 50000, "fee": 750, "net": 49250, "currency": "NGN", "status": "refunded", "paid_at": "2026-06-12T12:34:56Z", "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null }, "provider": "paystack", "provider_reference": "1234567890", "metadata": { "order_id": "12345" }, "refund": { "provider_reference": "RFND_abc", "amount": 50000, "currency": "NGN", "reason": "requested_by_customer" } } }
{ "event": "transfer.received", "data": { "reference": "HOSTFI_TXN_987", "amount": 500000, "currency": "NGN", "channel": "bank_transfer", "status": "success", "account_number": "0123456789", "balance_after": 4925000, "received_at": "2026-06-12T12:34:56Z", "metadata": {} } }
{ "event": "charge.success", "data": { "app_id": 42, "connected_account_id": 108, "application_fee": 1250, "reference": "order-12345", "amount": 50000, "fee": 750, "net": 49250, "currency": "NGN", "status": "successful", "customer": { "email": "customer@example.com", "name": "Jane Doe", "phone": null }, "metadata": { "order_id": "12345" } }, "created_at": "2026-06-12T12:34:56Z" }
{ "event": "connection.created", "data": { "app_id": 42, "connection_id": 17, "business_id": 108, "scopes": ["payments:read", "payments:write"], "mode": "live" }, "created_at": "2026-06-12T12:34:56Z" }
{ "event": "connection.updated", "data": { "app_id": 42, "connection_id": 17, "business_id": 108, "scopes": ["payments:read", "payments:write", "payouts:write"], "mode": "live" }, "created_at": "2026-06-12T12:34:56Z" }
{ "event": "connection.revoked", "data": { "app_id": 42, "connection_id": 17, "business_id": 108, "mode": "live" }, "created_at": "2026-06-12T12:34:56Z" }
Verify the signature
Only if you set a signing secret. Recompute the HMAC over the raw bytes and compare in constant time (reject a missing signature too). Same scheme for every event. If you didn’t set a secret, deliveries are unsigned — there’s no x-twelveai-signature to check, so guard the endpoint another way.
import crypto from "node:crypto" // Only set this if you configured a signing secret. If you didn't, deliveries // arrive UNSIGNED (no x-twelveai-signature header) — there's nothing to verify. const SECRET = process.env.TWELVE_SIGNING_SECRET // may be undefined // Use the RAW request body — not the parsed JSON object. app.post("/webhooks/twelve", express.raw({ type: "*/*" }), (req, res) => { const raw = req.body.toString("utf8") if (SECRET) { // You opted into signing → require AND verify the signature. const got = req.header("x-twelveai-signature") || "" const expected = crypto.createHmac("sha512", SECRET).update(raw).digest("hex") if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) { return res.sendStatus(401) // missing or wrong signature } // Reject replays: timestamp must be within 5 minutes. const ts = Number(req.header("x-twelveai-timestamp") || 0) if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401) } // No secret → unsigned delivery; secure the endpoint another way // (secret path, IP allowlist, mTLS). const { event, data } = JSON.parse(raw) // Ack fast (2xx within 10s), then do your work asynchronously. res.sendStatus(200) })
Retry policy: 1m → 5m → 30m → 2h → 12h → 24h → 48h (~3.5 days). After 20 consecutive non-2xx responses we auto-disable the endpoint; re-save the URL to re-enable. Acknowledge with a 2xx within 10 seconds and do your work asynchronously. Deliveries can retry, so dedupe on x-twelveai-event-id.
Errors & rate limits
Errors return a non-2xx status with a JSON body. Common statuses:
400Bad request — a required field is missing or invalid.401Unauthenticated — bad, missing, or revoked key/token.403Forbidden — verification required, or a Connect token missing a scope.404Not found — no such resource on this account.429Too many requests — you are being rate limited; back off and retry.{ "success": false, "message": "A valid customer email is required." }
Get your API keys
Create an account, grab your test keys, and make your first call in minutes.