Connect
Tuppence Connect is for platforms such as marketplaces, booking apps and vertical software. Each business you serve is a connected account: a full Tuppence account that you create, onboard and act as.
How it works
Section titled “How it works”| The account | Its own payments, customers, bookings, balance and payouts. No dashboard and no keys: you run it. |
| Onboarding | Tuppence’s hosted page in your branding, or your own screens via the API. Onboarding → |
| Acting as it | Your own key plus Tuppence-Account: acct_… on any request. Below ↓ |
| Your fee | application_fee_amount on its payments, held in your Connect balance and swept into your own balance daily. Money → |
| Region | A UK platform makes UK accounts (GBP), a US platform US ones (USD). Each region is separate: nothing is created, charged or paid across regions. |
| Losses | Yours: controller.losses.payments: application. |
| Existing accounts | Can’t join your platform. Your platform owns its accounts: it runs them, bears their losses and collects their details. For a business already on Tuppence, create a new connected account. |
| Events | A Connect endpoint hears every account’s events, each with account. Webhooks → |
Test helpers: Testing Connect.
The whole flow
Section titled “The whole flow”One script in test mode, from a new account to money moved. CI runs it against the real API on every change.
import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const testHelper = (path) => tuppence.request("POST", `/v1/test_helpers/${path}`); // test mode onlyconst wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Hear your accounts' events: a Connect endpoint (a test inbox here, your server in real life).const inbox = await testHelper("webhook_inboxes");await tuppence.webhookEndpoints.create({ url: inbox.url, connect: true, enabled_events: ["account.updated"] });
// 1. A business on your platform: a connected account, with its verification requirements to collect.const bella = await tuppence.accounts.create({ email: "owner@bellasbarks.example", business_type: "individual", business_profile: { name: "Bella's Barks" },});console.log(bella.id, "is due", bella.requirements.currently_due.length, "details");
// 2. Its onboarding: a link to the hosted page in your branding, good once, for five minutes.const link = await tuppence.accountLinks.create({ account: bella.id, type: "account_onboarding", refresh_url: "https://example.com/onboarding/refresh", return_url: "https://example.com/onboarding/done",});console.log("send the business to", link.url);await testHelper(`accounts/${bella.id}/verify`); // test mode: the forms, filled in with the test values// They're checked before payments turn on (up to a minute or two): wait for it.for (let i = 0; !(await tuppence.accounts.retrieve(bella.id)).charges_enabled; i += 1) { if (i === 120) throw new Error("Bella's Barks was not verified"); await wait(1000);}
// 3. A £45 payment, as Bella, with your £1 fee. (A payer confirms it; test mode confirms it here,// with the test card whose money is available at once rather than in a few days.)const asBella = tuppence.as(bella.id);const payment = await asBella.payments.create({ amount: 4500, currency: "gbp", application_fee_amount: 100 });await asBella.request("POST", `/v1/payments/${payment.id}/confirm`, { body: { payment_method: "pm_card_bypassPending" },});
// 4. Your fee, recorded as the payment settles, and your Connect balance.let fees = await tuppence.applicationFees.list({ payment: payment.id });for (let i = 0; fees.data.length === 0 && i < 20; i += 1) { await wait(500); fees = await tuppence.applicationFees.list({ payment: payment.id });}console.log("your fee:", fees.data[0].amount, "on", fees.data[0].account);console.log("Connect balance available:", (await tuppence.balance.retrieve()).connect.available);
// 5. Send some of it to one of your accounts. (The daily sweep moves the rest into your own balance.)// Money leaves a new account only seven days after its first payment, review or going live, so// test mode moves your clock past that first.await tuppence.request("POST", "/v1/test_helpers/clock/advance", { body: { days: 7, hours: 1 } });const transfer = await tuppence.transfers.create({ amount: 50, currency: "gbp", destination: bella.id });console.log("sent", transfer.amount, "to", transfer.destination);
// 6. And your Connect endpoint heard Bella's account change, with `account` naming her.let heard;for (let i = 0; !heard && i < 40; i += 1) { const { requests } = await tuppence.request("GET", `/v1/test_helpers/webhook_inboxes/${inbox.id}`); heard = requests.map((r) => JSON.parse(r.body)).find((e) => e.account === bella.id); if (!heard) await wait(500);}if (!heard) throw new Error("the Connect endpoint heard nothing of the account");console.log("heard", heard.type, "for", heard.account);import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const testHelper = (path) => tuppence.request("POST", `/v1/test_helpers/${path}`); // test mode onlyconst wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Hear your accounts' events: a Connect endpoint (a test inbox here, your server in real life).const inbox = await testHelper("webhook_inboxes");await tuppence.webhookEndpoints.create({ url: inbox.url, connect: true, enabled_events: ["account.updated"] });
// 1. A business on your platform: a connected account, with its verification requirements to collect.const bella = await tuppence.accounts.create({ email: "owner@bellasbarks.example", business_type: "individual", business_profile: { name: "Bella's Barks" },});console.log(bella.id, "is due", bella.requirements.currently_due.length, "details");
// 2. Its onboarding: a link to the hosted page in your branding, good once, for five minutes.const link = await tuppence.accountLinks.create({ account: bella.id, type: "account_onboarding", refresh_url: "https://example.com/onboarding/refresh", return_url: "https://example.com/onboarding/done",});console.log("send the business to", link.url);await testHelper(`accounts/${bella.id}/verify`); // test mode: the forms, filled in with the test values// They're checked before payments turn on (up to a minute or two): wait for it.for (let i = 0; !(await tuppence.accounts.retrieve(bella.id)).charges_enabled; i += 1) { if (i === 120) throw new Error("Bella's Barks was not verified"); await wait(1000);}
// 3. A $45 payment, as Bella, with your $1 fee. (A payer confirms it; test mode confirms it here,// with the test card whose money is available at once rather than in a few days.)const asBella = tuppence.as(bella.id);const payment = await asBella.payments.create({ amount: 4500, currency: "usd", application_fee_amount: 100 });await asBella.request("POST", `/v1/payments/${payment.id}/confirm`, { body: { payment_method: "pm_card_bypassPending" },});
// 4. Your fee, recorded as the payment settles, and your Connect balance.let fees = await tuppence.applicationFees.list({ payment: payment.id });for (let i = 0; fees.data.length === 0 && i < 20; i += 1) { await wait(500); fees = await tuppence.applicationFees.list({ payment: payment.id });}console.log("your fee:", fees.data[0].amount, "on", fees.data[0].account);console.log("Connect balance available:", (await tuppence.balance.retrieve()).connect.available);
// 5. Send some of it to one of your accounts. (The daily sweep moves the rest into your own balance.)// Money leaves a new account only seven days after its first payment, review or going live, so// test mode moves your clock past that first.await tuppence.request("POST", "/v1/test_helpers/clock/advance", { body: { days: 7, hours: 1 } });const transfer = await tuppence.transfers.create({ amount: 50, currency: "usd", destination: bella.id });console.log("sent", transfer.amount, "to", transfer.destination);
// 6. And your Connect endpoint heard Bella's account change, with `account` naming her.let heard;for (let i = 0; !heard && i < 40; i += 1) { const { requests } = await tuppence.request("GET", `/v1/test_helpers/webhook_inboxes/${inbox.id}`); heard = requests.map((r) => JSON.parse(r.body)).find((e) => e.account === bella.id); if (!heard) await wait(500);}if (!heard) throw new Error("the Connect endpoint heard nothing of the account");console.log("heard", heard.type, "for", heard.account);import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const testHelper = (path) => tuppence.request("POST", `/v1/test_helpers/${path}`); // test mode onlyconst wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Hear your accounts' events: a Connect endpoint (a test inbox here, your server in real life).const inbox = await testHelper("webhook_inboxes");await tuppence.webhookEndpoints.create({ url: inbox.url, connect: true, enabled_events: ["account.updated"] });
// 1. A business on your platform: a connected account, with its verification requirements to collect.const bella = await tuppence.accounts.create({ email: "owner@bellasbarks.example", business_type: "individual", business_profile: { name: "Bella's Barks" },});console.log(bella.id, "is due", bella.requirements.currently_due.length, "details");
// 2. Its onboarding: a link to the hosted page in your branding, good once, for five minutes.const link = await tuppence.accountLinks.create({ account: bella.id, type: "account_onboarding", refresh_url: "https://example.com/onboarding/refresh", return_url: "https://example.com/onboarding/done",});console.log("send the business to", link.url);await testHelper(`accounts/${bella.id}/verify`); // test mode: the forms, filled in with the test values// They're checked before payments turn on (up to a minute or two): wait for it.for (let i = 0; !(await tuppence.accounts.retrieve(bella.id)).charges_enabled; i += 1) { if (i === 120) throw new Error("Bella's Barks was not verified"); await wait(1000);}
// 3. A €45 payment, as Bella, with your €1 fee. (A payer confirms it; test mode confirms it here,// with the test card whose money is available at once rather than in a few days.)const asBella = tuppence.as(bella.id);const payment = await asBella.payments.create({ amount: 4500, currency: "eur", application_fee_amount: 100 });await asBella.request("POST", `/v1/payments/${payment.id}/confirm`, { body: { payment_method: "pm_card_bypassPending" },});
// 4. Your fee, recorded as the payment settles, and your Connect balance.let fees = await tuppence.applicationFees.list({ payment: payment.id });for (let i = 0; fees.data.length === 0 && i < 20; i += 1) { await wait(500); fees = await tuppence.applicationFees.list({ payment: payment.id });}console.log("your fee:", fees.data[0].amount, "on", fees.data[0].account);console.log("Connect balance available:", (await tuppence.balance.retrieve()).connect.available);
// 5. Send some of it to one of your accounts. (The daily sweep moves the rest into your own balance.)// Money leaves a new account only seven days after its first payment, review or going live, so// test mode moves your clock past that first.await tuppence.request("POST", "/v1/test_helpers/clock/advance", { body: { days: 7, hours: 1 } });const transfer = await tuppence.transfers.create({ amount: 50, currency: "eur", destination: bella.id });console.log("sent", transfer.amount, "to", transfer.destination);
// 6. And your Connect endpoint heard Bella's account change, with `account` naming her.let heard;for (let i = 0; !heard && i < 40; i += 1) { const { requests } = await tuppence.request("GET", `/v1/test_helpers/webhook_inboxes/${inbox.id}`); heard = requests.map((r) => JSON.parse(r.body)).find((e) => e.account === bella.id); if (!heard) await wait(500);}if (!heard) throw new Error("the Connect endpoint heard nothing of the account");console.log("heard", heard.type, "for", heard.account);The Python SDK has the same calls in snake_case (accounts.create, account_links.create,
as_account, application_fees.list, transfers.create).
Act as it
Section titled “Act as it”Add Tuppence-Account to any call you can make for your own account. Whatever it creates
belongs to the connected account, and only that account sees it.
curl https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Tuppence-Account: acct_1…" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"amount": 4500, "currency": "gbp"}'curl https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Tuppence-Account: acct_1…" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"amount": 4500, "currency": "usd"}'curl https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Tuppence-Account: acct_1…" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"amount": 4500, "currency": "eur"}'// Per request…await tuppence.payments.create({ amount: 4500, currency: "gbp" }, { tuppenceAccount: "acct_1…" });// …or a client bound to one account (`tuppence` is unchanged).const bella = tuppence.as("acct_1…");// Per request…await tuppence.payments.create({ amount: 4500, currency: "usd" }, { tuppenceAccount: "acct_1…" });// …or a client bound to one account (`tuppence` is unchanged).const bella = tuppence.as("acct_1…");// Per request…await tuppence.payments.create({ amount: 4500, currency: "eur" }, { tuppenceAccount: "acct_1…" });// …or a client bound to one account (`tuppence` is unchanged).const bella = tuppence.as("acct_1…");# Per request…tuppence.payments.create(amount=4500, currency="gbp", tuppence_account="acct_1…")# …or a client bound to one account.bella = tuppence.as_account("acct_1…")# Per request…tuppence.payments.create(amount=4500, currency="usd", tuppence_account="acct_1…")# …or a client bound to one account.bella = tuppence.as_account("acct_1…")# Per request…tuppence.payments.create(amount=4500, currency="eur", tuppence_account="acct_1…")# …or a client bound to one account.bella = tuppence.as_account("acct_1…")| Rule | |
|---|---|
| Not your account (another platform’s, closed, nonexistent) | 403 account_invalid, the same whatever the reason |
| Your key’s limits, IP allowlist and scopes | Still apply |
| Test clock and kill switches | The account’s own |
| Minting keys, running a team, going live on the dashboard’s forms | Not as a connected account: 403 connected_account_not_allowed |
Agents do the same with the account argument on any MCP tool.
Your platform’s settings
Section titled “Your platform’s settings”settings.connect on POST /v1/account, or the dashboard’s Settings → Platform.
| Field | What it sets |
|---|---|
terms_url, privacy_url | Your terms and privacy notice, linked from your hosted onboarding beside ours |
defaults.fees_payer | account or application: who pays Tuppence’s fee. Fixed once the account is made |
defaults.application_fees | percent, fixed_amount. percent can’t exceed your fee cap: 20% unless we’ve raised it (Take a fee) |
defaults.payout_schedule | interval, weekly_anchor, monthly_anchor, delay_days |
defaults.cashback_share | How much cashback on your accounts’ card spend you pass on, as a percentage of spend ("0" until set). Read at each monthly statement; accounts without their own follow it (Cashback) |
defaults.statement_descriptor_prefix, defaults.payout_statement_descriptor, defaults.statement_descriptors_locked | Your accounts’ card prefix and payout line, and whether only you can change their lines (Statement descriptors) |
card_topups | Your accounts’ card balance top-ups: enabled, methods, your platform_fee on card top-ups (at most 5%), lower limits, auto_topup_allowed (Top up your accounts’ cards) |
defaults fill in only what a create request leaves out.
curl https://api.tuppence.ai/v1/account \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"settings": {"connect": {"terms_url": "https://groomly.example.com/terms", "defaults": {"application_fees": {"percent": "2.5"}}}}}'await tuppence.request("POST", "/v1/account", { body: { settings: { connect: { terms_url: "https://groomly.example.com/terms", defaults: { application_fees: { percent: "2.5" } }, }, }, },});tuppence.request("POST", "/v1/account", body={ "settings": {"connect": { "terms_url": "https://groomly.example.com/terms", "defaults": {"application_fees": {"percent": "2.5"}}, }},})Reject or close an account
Section titled “Reject or close an account”| Call | Effect |
|---|---|
POST /v1/accounts/{id}/reject with reason (fraud, terms_of_service, other) | Charges and payouts stop for good (requirements.disabled_reason: rejected.fraud). Reads and refunds of past payments still work. |
DELETE /v1/accounts/{id} | Closes it. Test mode: any time. Live: once its balance is zero and nothing is in dispute, else 409 balance_not_zero. |
Closing doesn’t remove the account: it stays retrievable with status: closed, and
disputes on its past payments are still recorded against it. You can’t act as it or pay out
from it. Closing can’t be undone.
Screening and holds
Section titled “Screening and holds”Tuppence screens a live connected account before it goes live, as it screens any business. It
looks for a bank account, person or device shared with a rejected business, a blocklisted
identifier, or a sign-up from Tor or a hosting network. An account that fails reads
charges_enabled: false, payouts_enabled: false, requirements.disabled_reason: under_review,
and you get account.updated. We review it, usually within one business day, and switch it on if
approved.
We also check every connected account, and the people who own, control or represent it, against sanctions lists and lists of politically exposed people: when their details are given, whenever they change, and every week. This never holds up going live. A person at Tuppence reviews any possible match. Nothing changes on the account unless we act on it, and then it reads as under review, like any other review.
You bear your accounts’ losses, so their risk is yours:
- While your payouts are on hold, so are your accounts’ scheduled, manual and instant payouts
(
403 payouts_on_hold, andpayout.heldon the scheduled run). - An early fraud warning, a fraud dispute, or one card used across several new businesses on one of your accounts holds your platform’s payouts for review as well as the account’s.
Limits
Section titled “Limits”| Limit | Value | Refusal |
|---|---|---|
| New connected accounts | 100 per mode in any 30 days, closed ones included; ask for more | 409 max_connected_accounts with available_from and fix_url |
| Country | Your own | None |
| Live connected accounts | Earned: your payouts approved after review, your platform live for 30 days, and the current Platform Agreement accepted by an owner or admin in Connect → Platform settings. Then Tuppence switches them on | 403 connect_live_not_enabled with missing, available_from, fix_url |
Test mode works from the start. GET /v1/account/live_connect shows where you stand on the three
live conditions.