Skip to content

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.

Your platformsk_test_…Its payerscard, link, bookingConnected accountacct_…Tuppence feefeeIts bankpayout of netYour Connect balanceapplication feesYour balance→ your bankTuppence-Accountpays 4500payoutapplication feetransfersdaily sweep
The accountIts own payments, customers, bookings, balance and payouts. No dashboard and no keys: you run it.
OnboardingTuppence’s hosted page in your branding, or your own screens via the API. Onboarding →
Acting as itYour own key plus Tuppence-Account: acct_… on any request. Below ↓
Your feeapplication_fee_amount on its payments, held in your Connect balance and swept into your own balance daily. Money →
RegionA UK platform makes UK accounts (GBP), a US platform US ones (USD). Each region is separate: nothing is created, charged or paid across regions.
LossesYours: controller.losses.payments: application.
Existing accountsCan’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.
EventsA Connect endpoint hears every account’s events, each with account. Webhooks →

Test helpers: Testing Connect.

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

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.

Terminal window
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"}'
Terminal window
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"}'
Terminal window
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"}'
Rule
Not your account (another platform’s, closed, nonexistent)403 account_invalid, the same whatever the reason
Your key’s limits, IP allowlist and scopesStill apply
Test clock and kill switchesThe account’s own
Minting keys, running a team, going live on the dashboard’s formsNot as a connected account: 403 connected_account_not_allowed

Agents do the same with the account argument on any MCP tool.

settings.connect on POST /v1/account, or the dashboard’s Settings → Platform.

FieldWhat it sets
terms_url, privacy_urlYour terms and privacy notice, linked from your hosted onboarding beside ours
defaults.fees_payeraccount or application: who pays Tuppence’s fee. Fixed once the account is made
defaults.application_feespercent, fixed_amount. percent can’t exceed your fee cap: 20% unless we’ve raised it (Take a fee)
defaults.payout_scheduleinterval, weekly_anchor, monthly_anchor, delay_days
defaults.cashback_shareHow 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_lockedYour accounts’ card prefix and payout line, and whether only you can change their lines (Statement descriptors)
card_topupsYour 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.

Terminal window
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"}}}}}'
CallEffect
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.

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, and payout.held on 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.
LimitValueRefusal
New connected accounts100 per mode in any 30 days, closed ones included; ask for more409 max_connected_accounts with available_from and fix_url
CountryYour ownNone
Live connected accountsEarned: 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 on403 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.

Docs assistant

Open as a page
Loading the assistant…