Onboarding connected accounts
A connected account takes payments once it’s verified. Collect what’s needed on Tuppence’s hosted page in your branding, or on your own screens through the API. Either way, the requirement keys are the same.
Create a connected account
Section titled “Create a connected account”curl https://api.tuppence.ai/v1/accounts \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"email": "owner@bellasbarks.example", "business_type": "individual", "business_profile": {"name": "Bella'\''s Barks", "mcc": "0742"}, "controller": {"fees": {"payer": "account"}}}'const account = await tuppence.accounts.create({ email: "owner@bellasbarks.example", business_type: "individual", business_profile: { name: "Bella's Barks", mcc: "0742" }, controller: { fees: { payer: "account" } },});account = tuppence.accounts.create( email="owner@bellasbarks.example", business_type="individual", business_profile={"name": "Bella's Barks", "mcc": "0742"}, controller={"fees": {"payer": "account"}},){ "id": "acct_1…", "object": "account", "platform": "acct_your_platform", "charges_enabled": false, "payouts_enabled": false, "controller": { "fees": { "payer": "account" }, "losses": { "payments": "application" }, "requirement_collection": "application", "dashboard": { "type": "none" } }, "requirements": { "currently_due": ["individual.first_name", "individual.dob.day", "tos_acceptance.date", "external_account"], "errors": [], "disabled_reason": "requirements.past_due" }, "onboarding_status": "in_progress", "status": "active"}Idempotency-Keyis required: a retry returns the same account, never a second one.- You collect requirements and bear losses (
controller.losses.payments: application). Your only controller choice is who pays Tuppence’s card rate: the account (default) or you (controller.fees.payer: application). GET /v1/accountslists yours (filter bystatus,email,charges_enabled).POST /v1/accounts/{id}updatesemail,business_profile,settingsandmetadata.- An account returns nothing personal: its people and bank account appear by id only.
Onboard it
Section titled “Onboard it”Hosted, in your branding
Section titled “Hosted, in your branding”Create an account link and redirect the business to its url. The page uses the name, logo
and colour from your account’s branding.
curl https://api.tuppence.ai/v1/account_links \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"account": "acct_1…", "type": "account_onboarding", "refresh_url": "https://example.com/onboarding/refresh", "return_url": "https://example.com/onboarding/done"}'const link = await tuppence.accountLinks.create({ account: "acct_1…", type: "account_onboarding", refresh_url: "https://example.com/onboarding/refresh", return_url: "https://example.com/onboarding/done",});// redirect the business to link.urllink = tuppence.account_links.create( account="acct_1…", type="account_onboarding", refresh_url="https://example.com/onboarding/refresh", return_url="https://example.com/onboarding/done",)# redirect the business to link.url{ "object": "account_link", "created": 1790600000, "expires_at": 1790600300, "url": "https://pay.tuppence.ai/onboarding/…" }| Link life | Opens once, within five minutes. If spent or expired, the browser goes to refresh_url: mint a fresh link there and redirect again |
| Finished, or left | The browser returns to return_url. Your Connect endpoint hears account.updated as verification goes on |
| Progress | Saved as they go. A fresh link resumes it |
collection_options.fields | currently_due (default) or eventually_due to ask for everything up front |
type: "account_update" | Opens on what’s on file, to change it. Changing the bank account needs the page opened from a link within the last 15 minutes |
| Replacing a bank account | Any link adds the first bank account. Replacing one needs a link minted with a secret key (a restricted key’s link can’t), or by an owner or admin in the dashboard, who confirms it’s them with a code first |
The page asks for:
- the business (from Companies House for a UK company);
- its people;
- a bank account, which Tuppence keeps encrypted;
- Stripe’s Connected Account Agreement and Tuppence’s terms;
- Cards, only if you requested
card_issuing: the business accepts Stripe’s Issuing terms itself (Capabilities); - an identity check (run by Stripe Identity): a photo of their ID and a selfie.
When the forms can’t. Occasionally verification needs something no form collects, such as a
manual review (an interv_… requirement). The hosted page says so and opens Stripe’s own page for
that step, which then returns the business to your return_url (or refresh_url). If you onboard through the API,
send the business to an account link for that step.
Or through the API
Section titled “Or through the API”Send the same details from your own screens on POST /v1/accounts/{id} (or the create):
| Field | |
|---|---|
individual | An individual’s own details, as a person. Like company, needs a secret key (persons:write) |
company | Name, phone, registered address, tax_id, owners_provided / directors_provided / executives_provided |
business_type | While nobody is on the account yet |
tos_acceptance | When, and from which IP and browser, the person accepted Stripe’s Connected Account Agreement on your screen. Under the Platform Agreement, you vouch for it |
external_account | The bank details (Its bank account). A token is refused. Needs a secret key: connected_accounts:write alone can’t change where payouts go |
capabilities | |
settings.payments.statement_descriptor | The line on its non-card payments, 5 to 22 characters, kept as its own (a rename doesn’t change it). It follows the prefix: BELLAS BARKS shows as TUPPENCE BELLAS BARKS. Its prefix, card statement name and payout line: Statement descriptors |
curl https://api.tuppence.ai/v1/accounts/acct_1… \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"individual": {"first_name": "Bella", "last_name": "Barks", "email": "bella@example.com", "phone": "07700 900123", "dob": {"day": 1, "month": 9, "year": 1990}, "address": {"line1": "1 High St", "city": "London", "postal_code": "SW1A 1AA", "country": "GB"}}, "tos_acceptance": {"date": 1790600000, "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 …"}, "external_account": {"object": "bank_account", "country": "GB", "currency": "gbp", "account_holder_name": "Bella Barks", "account_holder_type": "individual", "routing_number": "108800", "account_number": "00012345"}}'await tuppence.accounts.update("acct_1…", { individual: { first_name: "Bella", last_name: "Barks", email: "bella@example.com", phone: "07700 900123", dob: { day: 1, month: 9, year: 1990 }, address: { line1: "1 High St", city: "London", postal_code: "SW1A 1AA", country: "GB" }, }, tos_acceptance: { date: 1790600000, ip: "203.0.113.7", user_agent: "Mozilla/5.0 …" }, external_account: { object: "bank_account", country: "GB", currency: "gbp", account_holder_name: "Bella Barks", account_holder_type: "individual", routing_number: "108800", account_number: "00012345", },});tuppence.accounts.update( "acct_1…", individual={ "first_name": "Bella", "last_name": "Barks", "email": "bella@example.com", "phone": "07700 900123", "dob": {"day": 1, "month": 9, "year": 1990}, "address": {"line1": "1 High St", "city": "London", "postal_code": "SW1A 1AA", "country": "GB"}, }, tos_acceptance={"date": 1790600000, "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 …"}, external_account={ "object": "bank_account", "country": "GB", "currency": "gbp", "account_holder_name": "Bella Barks", "account_holder_type": "individual", "routing_number": "108800", "account_number": "00012345", },)curl https://api.tuppence.ai/v1/accounts/acct_1… \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"individual": {"first_name": "Bella", "last_name": "Barks", "email": "bella@example.com", "phone": "+1 201 555 0123", "ssn_last_4": "0000", "dob": {"day": 1, "month": 9, "year": 1990}, "address": {"line1": "1 Main St", "city": "San Francisco", "state": "CA", "postal_code": "94111", "country": "US"}}, "tos_acceptance": {"date": 1790600000, "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 …"}, "external_account": {"object": "bank_account", "country": "US", "currency": "usd", "account_holder_name": "Bella Barks", "account_holder_type": "individual", "routing_number": "110000000", "account_number": "000123456789"}}'await tuppence.accounts.update("acct_1…", { individual: { first_name: "Bella", last_name: "Barks", email: "bella@example.com", phone: "+1 201 555 0123", ssn_last_4: "0000", dob: { day: 1, month: 9, year: 1990 }, address: { line1: "1 Main St", city: "San Francisco", state: "CA", postal_code: "94111", country: "US" }, }, tos_acceptance: { date: 1790600000, ip: "203.0.113.7", user_agent: "Mozilla/5.0 …" }, external_account: { object: "bank_account", country: "US", currency: "usd", account_holder_name: "Bella Barks", account_holder_type: "individual", routing_number: "110000000", account_number: "000123456789", },});tuppence.accounts.update( "acct_1…", individual={ "first_name": "Bella", "last_name": "Barks", "email": "bella@example.com", "phone": "+1 201 555 0123", "ssn_last_4": "0000", "dob": {"day": 1, "month": 9, "year": 1990}, "address": {"line1": "1 Main St", "city": "San Francisco", "state": "CA", "postal_code": "94111", "country": "US"}, }, tos_acceptance={"date": 1790600000, "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 …"}, external_account={ "object": "bank_account", "country": "US", "currency": "usd", "account_holder_name": "Bella Barks", "account_holder_type": "individual", "routing_number": "110000000", "account_number": "000123456789", },)- Everything personal is sent on for verification. Tuppence keeps none of it except in the account’s encrypted draft, which the hosted page opens on. The response is the Account object.
- An impossible detail is refused before anything changes (a date of birth on 30 February:
param: individual.dob.day). Refusals from verification itself name our fields too. GET /v1/country_specs/GB(or/USfor a US platform, or/DE,/FR… for a European one; each lists only its own region’s countries) returnsverification_fields: the minimum for an individual or a company, and what may be asked later. Tuppence’s forms are built from the same list.
Keep the business posted
Section titled “Keep the business posted”Tell your accounts what verification needs in your own product, or turn on
settings.notifications.email_connected_accounts (POST /v1/account) and Tuppence emails each
account’s email in your name. It’s off by default. Outside production, every email goes to
Tuppence’s test inbox.
| When | |
|---|---|
| More information needed | Once per set of things asked, in plain words, linking to your refresh_url for a fresh link |
| A verification check didn’t go through | With why |
| Verified |
Its people
Section titled “Its people”A company’s representative, owners, directors and executives are persons. Use them when you onboard on your own screens.
curl https://api.tuppence.ai/v1/accounts/acct_1…/persons \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"first_name": "Jenny", "last_name": "Rosen", "email": "jenny@example.com", "dob": {"day": 1, "month": 9, "year": 1989}, "address": {"line1": "1 High St", "city": "London", "postal_code": "SW1A 1AA", "country": "GB"}, "relationship": {"representative": true, "director": true, "title": "Director"}}'const person = await tuppence.accounts.persons.create("acct_1…", { first_name: "Jenny", last_name: "Rosen", email: "jenny@example.com", dob: { day: 1, month: 9, year: 1989 }, address: { line1: "1 High St", city: "London", postal_code: "SW1A 1AA", country: "GB" }, relationship: { representative: true, director: true, title: "Director" },});person = tuppence.accounts.persons.create( "acct_1…", first_name="Jenny", last_name="Rosen", email="jenny@example.com", dob={"day": 1, "month": 9, "year": 1989}, address={"line1": "1 High St", "city": "London", "postal_code": "SW1A 1AA", "country": "GB"}, relationship={"representative": True, "director": True, "title": "Director"},)curl https://api.tuppence.ai/v1/accounts/acct_1…/persons \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"first_name": "Jenny", "last_name": "Rosen", "email": "jenny@example.com", "dob": {"day": 1, "month": 9, "year": 1989}, "address": {"line1": "1 Main St", "city": "San Francisco", "state": "CA", "postal_code": "94111", "country": "US"}, "relationship": {"representative": true, "director": true, "title": "Director"}}'const person = await tuppence.accounts.persons.create("acct_1…", { first_name: "Jenny", last_name: "Rosen", email: "jenny@example.com", dob: { day: 1, month: 9, year: 1989 }, address: { line1: "1 Main St", city: "San Francisco", state: "CA", postal_code: "94111", country: "US" }, relationship: { representative: true, director: true, title: "Director" },});person = tuppence.accounts.persons.create( "acct_1…", first_name="Jenny", last_name="Rosen", email="jenny@example.com", dob={"day": 1, "month": 9, "year": 1989}, address={"line1": "1 Main St", "city": "San Francisco", "state": "CA", "postal_code": "94111", "country": "US"}, relationship={"representative": True, "director": True, "title": "Director"},){ "id": "per_…", "object": "person", "account": "acct_1…", "first_name": "Jenny", "last_name": "Rosen", "relationship": { "representative": true, "director": true, "owner": false, "executive": false, "title": "Director", "percent_ownership": null }, "requirements": { "currently_due": ["phone"], "errors": [] }, "verification": { "status": "unverified" }, "dob_provided": true, "address_provided": true, "email_provided": true, "phone_provided": false, "id_number_provided": false}| What comes back | Name, roles, verification and what’s still due; for every other detail only whether it was given (*_provided). Tuppence keeps none of it |
| Other calls | GET …/persons (filter relationship[owner]=true); GET, POST, DELETE …/persons/{per} |
| Keys | A secret key. A restricted key can’t reach people |
| Identity check on your page | POST …/persons/{per}/identity_sessions returns a client_secret for Stripe.js verifyIdentity (Stripe Identity runs the check) |
| Events | person.created, .updated, .deleted to your Connect endpoint, by id and roles, never a name. Includes people added on the hosted page |
Documents
Section titled “Documents”When verification asks a person for an ID document or proof of address, upload it as the account, then set the file’s id on the person.
curl https://api.tuppence.ai/v1/files \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Tuppence-Account: acct_1…" \ -H "Idempotency-Key: $(uuidgen)" \ -F purpose=identity_document \ -F file=@passport.png
curl https://api.tuppence.ai/v1/accounts/acct_1…/persons/per_… \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"verification": {"document": {"front": "file_…"}}}'import { readFile } from "node:fs/promises";
const file = await tuppence.files.create( { purpose: "identity_document", file: await readFile("passport.png"), filename: "passport.png" }, { tuppenceAccount: "acct_1…" },);await tuppence.accounts.persons.update("acct_1…", "per_…", { verification: { document: { front: file.id } },});with open("passport.png", "rb") as f: file = tuppence.files.create(purpose="identity_document", file=f, tuppence_account="acct_1…")tuppence.accounts.persons.update("acct_1…", "per_…", verification={"document": {"front": file.id}})- JPEG, PNG or (except an ID document) PDF, up to 10 MB.
- Tuppence keeps an encrypted copy for its KYC records, then sends it for verification. The API returns only id, purpose, size and type.
- A file belongs to the account it was uploaded for. Another account’s
file_id is not found. - For an ID card or driving licence, upload the back separately as
verification.document.back.
Its bank account
Section titled “Its bank account”Payouts go to the account’s default bank account for its currency. Collect the details on
your page, check them with @tuppence/js, and send them from your server.
// Browserconst tuppence = await loadTuppence("pk_test_…");const { externalAccount, error } = tuppence.bankAccountDetails({ country: "GB", currency: "gbp", routingNumber: "10-88-00", accountNumber: "00012345", accountHolderName: "Bella Barks", accountHolderType: "individual",});// error names the field to fix; otherwise post externalAccount to your server.curl https://api.tuppence.ai/v1/accounts/acct_1…/external_accounts \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"external_account": {"object": "bank_account", "country": "GB", "currency": "gbp", "account_holder_name": "Bella Barks", "account_holder_type": "individual", "routing_number": "108800", "account_number": "00012345"}}'const bank = await tuppence.accounts.externalAccounts.create("acct_1…", { external_account: externalAccount, // from bankAccountDetails() in the browser});bank = tuppence.accounts.external_accounts.create( "acct_1…", external_account=external_account, # posted from bankAccountDetails() in the browser){ "id": "ba_…", "object": "bank_account", "account": "acct_1…", "bank_name": "…", "last4": "2345", "currency": "gbp", "country": "GB", "default_for_currency": true, "status": "new" }// Browserconst tuppence = await loadTuppence("pk_test_…");const { externalAccount, error } = tuppence.bankAccountDetails({ country: "US", currency: "usd", routingNumber: "110000000", accountNumber: "000123456789", accountHolderName: "Bella Barks", accountHolderType: "individual",});// error names the field to fix; otherwise post externalAccount to your server.curl https://api.tuppence.ai/v1/accounts/acct_1…/external_accounts \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"external_account": {"object": "bank_account", "country": "US", "currency": "usd", "account_holder_name": "Bella Barks", "account_holder_type": "individual", "routing_number": "110000000", "account_number": "000123456789"}}'const bank = await tuppence.accounts.externalAccounts.create("acct_1…", { external_account: externalAccount, // from bankAccountDetails() in the browser});bank = tuppence.accounts.external_accounts.create( "acct_1…", external_account=external_account, # posted from bankAccountDetails() in the browser){ "id": "ba_…", "object": "bank_account", "account": "acct_1…", "bank_name": "…", "last4": "6789", "currency": "usd", "country": "US", "default_for_currency": true, "status": "new" }In Europe the bank account is an IBAN in the account’s country: send it as account_number,
with no routing_number. A platform in Europe onboards accounts in any of its 20 countries, each
paid out in euros. bankAccountDetails checks the IBAN’s length and check digits first, and
Tuppence checks them again before anything is sent on (parameter_invalid on
external_account[account_number]).
// Browserconst tuppence = await loadTuppence("pk_test_…");const { externalAccount, error } = tuppence.bankAccountDetails({ country: "FR", currency: "eur", accountNumber: "FR14 2004 1010 0505 0001 3M02 606", accountHolderName: "Atelier Lyon", accountHolderType: "company",});// error names the field to fix; otherwise post externalAccount to your server.curl https://api.tuppence.ai/v1/accounts/acct_1…/external_accounts \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"external_account": {"object": "bank_account", "country": "FR", "currency": "eur", "account_holder_name": "Atelier Lyon", "account_holder_type": "company", "account_number": "FR1420041010050500013M02606"}}'| Rule | |
|---|---|
| Storage | Kept encrypted, like all verification details, so the business is never asked again. Never returned or logged: last four digits only |
| Tokens | A bank token (btok_…) is 400, naming external_account. The hosted page works the same way: no token is created on the business’s device |
| Default | The first for a currency is its default. POST …/external_accounts/{ba} with default_for_currency: true moves it |
| Keys | A secret key. A restricted key can’t reach bank accounts. In the dashboard, adding one beside another or moving the default asks for a code first |
| After a change | A new bank account, or a new default, pauses the account’s payouts for 72 hours, and its owners get an email |
| List, remove | GET …/external_accounts (never with numbers). DELETE removes one, but never the default (409: make another the default first) |
| Events | account.external_account.created, .updated, .deleted to your Connect endpoint |
Capabilities
Section titled “Capabilities”GET /v1/accounts/{id}/capabilities lists the account’s three. Each moves from pending to
active as the account is verified, with a capability.updated event per change.
| Capability | |
|---|---|
card_payments, transfers | Requested together on every account; neither can be declined |
card_issuing | Cards the account’s agents spend with. Companies and sole traders only |
Request card_issuing when you create the account, or later. Either way the business accepts
Stripe’s Issuing terms itself.
curl https://api.tuppence.ai/v1/accounts \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"business_type": "company", "business_profile": {"name": "Bella'\''s Barks Ltd"}, "capabilities": {"card_issuing": {"requested": true}}}'// On create…await tuppence.accounts.create({ business_type: "company", business_profile: { name: "Bella's Barks Ltd" }, capabilities: { card_issuing: { requested: true } },});// …or on an existing account.await tuppence.accounts.capabilities.update("acct_1…", "card_issuing", { requested: true });# On create…tuppence.accounts.create( business_type="company", business_profile={"name": "Bella's Barks Ltd"}, capabilities={"card_issuing": {"requested": True}},)# …or on an existing account.tuppence.accounts.capabilities.update("acct_1…", "card_issuing", requested=True)- Until the business accepts,
card_issuingis requested andinactive, andrequirements.currently_due(the account’s and the capability’s) includescard_issuing.tos_acceptance. - Send the business an account link. The hosted page shows a Cards step with Stripe’s Issuing
terms. On acceptance, Tuppence records the time, IP and browser, and requests the
capability. To collect the terms in your own app instead, send
{"requested": true, "tos_acceptance": {"date": …, "ip": "…", "user_agent": "…"}}: the record of the account’s representative accepting Stripe’s Issuing terms on your platform. - Once
active, issue cards as the business withTuppence-Account: acct_…, using the same calls as for your own cards. Approvals its agents request come to your team, with a link that opens them as the business.
Your team can’t accept the terms for a business in the Tuppence dashboard.
Where an account stands
Section titled “Where an account stands”Read it from the Account object (GET /v1/accounts/{id}) or each account.updated event.
onboarding_status | Means |
|---|---|
not_started | Nothing submitted yet |
in_progress | Details are still due and haven’t been submitted |
pending_verification | Submitted; being checked |
action_required | Something is due, before or after it was switched on (see current_deadline) |
restricted | Switched off for another disabled_reason |
rejected | disabled_reason starts rejected. |
enabled | charges_enabled and payouts_enabled, nothing due |
| Field | |
|---|---|
charges_enabled, payouts_enabled | What it can do now. A payment before charges_enabled is 403 onboarding_incomplete |
requirements.currently_due, past_due, eventually_due | The requirement keys still to collect |
requirements.pending_verification, errors, disabled_reason, current_deadline | Where verification stands |
status | active, rejected or closed |
- Money for platforms
- Webhooks for platforms
- Testing Connect: verify an account in one call