Skip to content

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.

Your serverBusinessTuppencePOST /v1/accountsPOST /v1/account_linksurl (once, 5 min)redirect to urlcompletes hosted pageidentity checkback to return_urlverifiedaccount.updated
Terminal window
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"}}}'
{
"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-Key is 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/accounts lists yours (filter by status, email, charges_enabled). POST /v1/accounts/{id} updates email, business_profile, settings and metadata.
  • An account returns nothing personal: its people and bank account appear by id only.

Create an account link and redirect the business to its url. The page uses the name, logo and colour from your account’s branding.

Terminal window
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"}'
{ "object": "account_link", "created": 1790600000, "expires_at": 1790600300, "url": "https://pay.tuppence.ai/onboarding/…" }
Link lifeOpens once, within five minutes. If spent or expired, the browser goes to refresh_url: mint a fresh link there and redirect again
Finished, or leftThe browser returns to return_url. Your Connect endpoint hears account.updated as verification goes on
ProgressSaved as they go. A fresh link resumes it
collection_options.fieldscurrently_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 accountAny 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:

  1. the business (from Companies House for a UK company);
  2. its people;
  3. a bank account, which Tuppence keeps encrypted;
  4. Stripe’s Connected Account Agreement and Tuppence’s terms;
  5. Cards, only if you requested card_issuing: the business accepts Stripe’s Issuing terms itself (Capabilities);
  6. 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.

Send the same details from your own screens on POST /v1/accounts/{id} (or the create):

Field
individualAn individual’s own details, as a person. Like company, needs a secret key (persons:write)
companyName, phone, registered address, tax_id, owners_provided / directors_provided / executives_provided
business_typeWhile nobody is on the account yet
tos_acceptanceWhen, 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_accountThe 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_descriptorThe 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
Terminal window
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"}}'
Terminal window
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"}}'
  • 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 /US for a US platform, or /DE, /FR… for a European one; each lists only its own region’s countries) returns verification_fields: the minimum for an individual or a company, and what may be asked later. Tuppence’s forms are built from the same list.

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.

EmailWhen
More information neededOnce per set of things asked, in plain words, linking to your refresh_url for a fresh link
A verification check didn’t go throughWith why
Verified

A company’s representative, owners, directors and executives are persons. Use them when you onboard on your own screens.

Terminal window
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"}}'
Terminal window
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"}}'
{
"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 backName, roles, verification and what’s still due; for every other detail only whether it was given (*_provided). Tuppence keeps none of it
Other callsGET …/persons (filter relationship[owner]=true); GET, POST, DELETE …/persons/{per}
KeysA secret key. A restricted key can’t reach people
Identity check on your pagePOST …/persons/{per}/identity_sessions returns a client_secret for Stripe.js verifyIdentity (Stripe Identity runs the check)
Eventsperson.created, .updated, .deleted to your Connect endpoint, by id and roles, never a name. Includes people added on the hosted page

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.

Terminal window
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_…"}}}'
  • 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.

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.

// Browser
const 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.
Terminal window
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"}}'
{ "id": "ba_…", "object": "bank_account", "account": "acct_1…", "bank_name": "…", "last4": "2345", "currency": "gbp", "country": "GB", "default_for_currency": true, "status": "new" }
// Browser
const 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.
Terminal window
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"}}'
{ "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]).

// Browser
const 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.
Terminal window
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
StorageKept encrypted, like all verification details, so the business is never asked again. Never returned or logged: last four digits only
TokensA bank token (btok_…) is 400, naming external_account. The hosted page works the same way: no token is created on the business’s device
DefaultThe first for a currency is its default. POST …/external_accounts/{ba} with default_for_currency: true moves it
KeysA 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 changeA new bank account, or a new default, pauses the account’s payouts for 72 hours, and its owners get an email
List, removeGET …/external_accounts (never with numbers). DELETE removes one, but never the default (409: make another the default first)
Eventsaccount.external_account.created, .updated, .deleted to your Connect endpoint

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, transfersRequested together on every account; neither can be declined
card_issuingCards 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.

Terminal window
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}}}'
  1. Until the business accepts, card_issuing is requested and inactive, and requirements.currently_due (the account’s and the capability’s) includes card_issuing.tos_acceptance.
  2. 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.
  3. Once active, issue cards as the business with Tuppence-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.

Read it from the Account object (GET /v1/accounts/{id}) or each account.updated event.

onboarding_statusMeans
not_startedNothing submitted yet
in_progressDetails are still due and haven’t been submitted
pending_verificationSubmitted; being checked
action_requiredSomething is due, before or after it was switched on (see current_deadline)
restrictedSwitched off for another disabled_reason
rejecteddisabled_reason starts rejected.
enabledcharges_enabled and payouts_enabled, nothing due
Field
charges_enabled, payouts_enabledWhat it can do now. A payment before charges_enabled is 403 onboarding_incomplete
requirements.currently_due, past_due, eventually_dueThe requirement keys still to collect
requirements.pending_verification, errors, disabled_reason, current_deadlineWhere verification stands
statusactive, rejected or closed

Docs assistant

Open as a page
Loading the assistant…