Take a payment
A payment is an amount you want paid. Your server creates it, then a card pays it on our hosted page, in your own checkout, or as a saved card.
- Create
POST /v1/payments - A card payshosted page or your checkout
- Hear it
payment.succeeded - Settled
feeandnetfixed
| Where the card is presented | You use | You build |
|---|---|---|
| Our hosted page | approval_url | Nothing |
| Your own checkout | client_secret + @tuppence/js | A card field on your page |
| A card saved earlier, nobody there | POST /v1/payments/charge_saved | Nothing |
1. Create it
Section titled “1. Create it”curl https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: order-1042" \ -H "Content-Type: application/json" \ -d '{"amount": 2000, "currency": "gbp", "description": "Order 1042"}'curl https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: order-1042" \ -H "Content-Type: application/json" \ -d '{"amount": 2000, "currency": "usd", "description": "Order 1042"}'curl https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: order-1042" \ -H "Content-Type: application/json" \ -d '{"amount": 2000, "currency": "eur", "description": "Order 1042"}'import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const payment = await tuppence.payments.create({ amount: 2000, // £20.00 currency: "gbp", description: "Order 1042",});console.log(payment.status, payment.approval_url); // requires_payment_method https://…import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const payment = await tuppence.payments.create({ amount: 2000, // $20.00 currency: "usd", description: "Order 1042",});console.log(payment.status, payment.approval_url); // requires_payment_method https://…import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const payment = await tuppence.payments.create({ amount: 2000, // €20.00 currency: "eur", description: "Order 1042",});console.log(payment.status, payment.approval_url); // requires_payment_method https://…import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])payment = tuppence.payments.create(amount=2000, currency="gbp", description="Order 1042")print(payment.status, payment.approval_url) # requires_payment_method https://…import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])payment = tuppence.payments.create(amount=2000, currency="usd", description="Order 1042")print(payment.status, payment.approval_url) # requires_payment_method https://…import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])payment = tuppence.payments.create(amount=2000, currency="eur", description="Order 1042")print(payment.status, payment.approval_url) # requires_payment_method https://…Nothing is charged until a card is presented. The response includes two ways to pay it. Use one:
| Field | What it is |
|---|---|
approval_url | The hosted page for this payment. On every payment |
client_secret | The browser’s key to pay it in your checkout. Returned in this response only. Pass it to the payer’s browser; never log or store it |
Optional fields: customer, metadata, receipt_email, statement_descriptor_suffix
(below), capture_method (below),
save_card and mandate (saved cards).
On the payer’s statement
Section titled “On the payer’s statement”Card statements show TUPPENCE* and then your business name, so payers recognise the payment and
are less likely to dispute it. Tuppence sets this on every payment, with no setup:
| You | The payer’s card statement |
|---|---|
| Nothing set (business name “Bella’s Barks”) | TUPPENCE* BELLAS BARKS |
| A card statement name under Settings, Statements | TUPPENCE* <YOUR NAME> |
statement_descriptor_suffix: "SPRING SALE" on the payment | TUPPENCE* SPRING SALE |
The part after TUPPENCE* is at most 12 characters: letters, digits, spaces and . , - & / #,
with at least one letter, in upper case. Tuppence shortens a longer business name at a word where
it can. A payment’s own statement_descriptor_suffix is used first, then your card statement name,
then your branding display name or business name. A business with its own prefix, or a platform’s
account, has a different prefix and room: see Statement descriptors. Where a card suffix doesn’t apply, statements
show TUPPENCE and your business name, for example TUPPENCE BELLAS BARKS.
2. A card pays it
Section titled “2. A card pays it”A payment accepts a card, Apple Pay or Google Pay (each a card underneath), and
Bacs Direct Debit for UK businesses approved for it. Any other method is
refused with 400 payment_method_unavailable before anything is charged.
In Europe, a payment also accepts the methods the business switches on in Settings → Payment
methods, each shown with its price: iDEAL | Wero (Netherlands),
Bancontact (Belgium), EPS (Austria), MB WAY (Portugal), Revolut Pay and Klarna, offered on the
hosted page and in the Payment Element to payers where each works. SEPA Direct Debit is
by application. payment_method_details.type names the method used.
On our hosted page
Section titled “On our hosted page”Send the payer to the approval_url by redirect, email or a message from your agent.
The page runs the bank’s check if it asks (3-D Secure). Fulfil on the webhook, not the redirect.
In your own checkout
Section titled “In your own checkout”Keep the payer on your page. The browser pays with your publishable key and the payment’s
client_secret through Tuppence’s secure card fields, so the card never touches your code or our servers.
import { loadTuppence } from "@tuppence/js";
const tuppence = await loadTuppence("pk_test_…"); // publishable: a secret key is refusedconst sheet = tuppence.paymentSheet({ clientSecret }).mount("#card"); // the secure card fields
// When the payer presses Pay:const { error, payment } = await sheet.confirm();confirm()runs the bank’s check in a frame over your page, then reads the payment back until it settles.- A refused card returns
error, with a message written for the payer. If a response is lost in transit,confirm()reads back what happened rather than trying the card again. - The fields use your business’s branding unless you style them yourself.
- Your Content-Security-Policy must allow the card fields and Tuppence’s API. The browser SDK gives the policy, and what to do on the simulated gateway.
In test mode, from your server
Section titled “In test mode, from your server”Confirm with one of the test payment methods below. No browser needed:
id=$(curl -sS https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"amount": 2000, "currency": "gbp"}' | jq -r .id)curl -sS https://api.tuppence.ai/v1/payments/$id/confirm \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"payment_method": "pm_card_gb"}' | jq -r .statusid=$(curl -sS https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"amount": 2000, "currency": "usd"}' | jq -r .id)curl -sS https://api.tuppence.ai/v1/payments/$id/confirm \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"payment_method": "pm_card_us"}' | jq -r .statusid=$(curl -sS https://api.tuppence.ai/v1/payments \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"amount": 2000, "currency": "eur"}' | jq -r .id)curl -sS https://api.tuppence.ai/v1/payments/$id/confirm \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"payment_method": "pm_card_de"}' | jq -r .statusimport { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const payment = await tuppence.payments.create({ amount: 2000, currency: "gbp" });const paid = await tuppence.payments.confirm(payment.id, { payment_method: "pm_card_gb" }); // test mode onlyconsole.log(paid.status); // succeededimport { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const payment = await tuppence.payments.create({ amount: 2000, currency: "usd" });const paid = await tuppence.payments.confirm(payment.id, { payment_method: "pm_card_us" }); // test mode onlyconsole.log(paid.status); // succeededimport { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const payment = await tuppence.payments.create({ amount: 2000, currency: "eur" });const paid = await tuppence.payments.confirm(payment.id, { payment_method: "pm_card_de" }); // test mode onlyconsole.log(paid.status); // succeededimport osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
payment = tuppence.payments.create(amount=2000, currency="gbp")paid = tuppence.payments.confirm(payment.id, payment_method="pm_card_gb") # test mode onlyprint(paid.status) # succeededimport osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
payment = tuppence.payments.create(amount=2000, currency="usd")paid = tuppence.payments.confirm(payment.id, payment_method="pm_card_us") # test mode onlyprint(paid.status) # succeededimport osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
payment = tuppence.payments.create(amount=2000, currency="eur")paid = tuppence.payments.confirm(payment.id, payment_method="pm_card_de") # test mode onlyprint(paid.status) # succeededTest payment_method | What happens |
|---|---|
pm_card_gb | UK Visa: succeeds |
pm_card_us, pm_card_visa | US Visa: succeeds |
pm_card_amex | Amex: succeeds, with the Amex surcharge, with the Amex surcharge |
pm_card_bypassPending | US Visa with funds available immediately. Use it before a test payout |
pm_card_authenticationRequired | The bank checks the cardholder. Finish in a browser on the test challenge |
pm_card_chargeDeclinedInsufficientFunds | Declined: insufficient funds |
pm_card_chargeDeclined | Declined |
pm_card_createDispute | Succeeds, then the cardholder disputes it |
A card issued outside your account’s country pays the foreign-card surcharge (pricing).
Statuses
Section titled “Statuses”status | Means | Event |
|---|---|---|
requires_payment_method | Created; waiting for a card. A declined card leaves it here, payable again, with last_error saying why | payment.created; payment.failed on a decline |
requires_action | The payer’s bank wants 3-D Secure | payment.requires_action |
requires_capture | Authorised, not yet taken (manual capture) | payment.requires_capture |
processing | On its way: a Bacs Direct Debit takes about three working days (below) | payment.processing |
succeeded | Paid. fee and net are final | payment.succeeded |
canceled | Cancelled before it was paid | payment.canceled |
Once paid, a payment also says:
| Field | |
|---|---|
fee, net | What it cost and what you keep, fixed when the card is accepted (pricing). fee shows once the payment is paid, held or processing, and net once it is paid. A declined, cancelled or unauthenticated payment costs nothing, so both are null |
fee_breakdown | The method it was priced as (card for a card, Apple Pay or Google Pay; bacs_debit) and cap_applied |
payment_method_details | type (card or bacs_debit) with card, or bacs_debit (sort_code, last4, mandate_reference). null until a method is presented |
GET /v1/reports/payments.csv (payments:read; ?from&to in UTC days, default the last 30)
downloads payments one per row, amounts in minor units, with payment_method_type and
payment_method (such as Bacs Direct Debit).
Money that arrives later
Section titled “Money that arrives later”A Bacs Direct Debit is processing once the payer confirms it.
| You receive | payment.processing, with processing.expected_debit_date: when the money should leave the payer’s account (an estimate: about five working days on a new mandate, two on a saved one) |
| Then | payment.succeeded about three working days later. Nothing is paid to you and no fee is taken before then |
| Or | payment.failed, with the bank’s reason in last_error.code (insufficient_funds, debit_not_authorized, account_closed, …). We email you and the payer |
| Subscriptions | An invoice paid by debit is processing until then. The period advances when it succeeds |
| Test mode | POST /v1/test_helpers/clock/advance past the three working days settles it |
Authorise now, capture later
Section titled “Authorise now, capture later”A pre-authorisation (manual capture): the payer approves a price, you do the work, then you take the final amount.
- Create the payment with
capture_method: "manual". - When the payer approves, the money is held:
requires_capture, withamount_capturableand acapture_beforedeadline. You hearpayment.requires_capture. - Then, within six days:
POST /v1/payments/{id}/capturetakes all of it, oramount_to_capture. The rest goes back to the payer, and our fee is on the amount you capture.POST /v1/payments/{id}/cancelreleases all of it.- If you do neither, we cancel it:
payment.canceledwithcancellation_reason: "capture_expired".
curl -X POST https://api.tuppence.ai/v1/payments/pay_…/capture \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"amount_to_capture": 2500}'
# Or release the hold:curl -X POST https://api.tuppence.ai/v1/payments/pay_…/cancel \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)"import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
// The quote: £40, held when the payer approves.const quote = await tuppence.payments.create({ amount: 4000, currency: "gbp", capture_method: "manual",});await tuppence.request("POST", `/v1/payments/${quote.id}/confirm`, { body: { payment_method: "pm_card_gb" }, // test mode only});const held = await tuppence.payments.retrieve(quote.id);console.log(held.status, held.amount_capturable); // requires_capture 4000
// The work came to £25: take that, and the other £15 goes back to the payer.const paid = await tuppence.payments.capture(quote.id, { amount_to_capture: 2500 });console.log(paid.status, paid.amount_received, paid.amount_capturable); // succeeded 2500 0import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
// The quote: $40, held when the payer approves.const quote = await tuppence.payments.create({ amount: 4000, currency: "usd", capture_method: "manual",});await tuppence.request("POST", `/v1/payments/${quote.id}/confirm`, { body: { payment_method: "pm_card_us" }, // test mode only});const held = await tuppence.payments.retrieve(quote.id);console.log(held.status, held.amount_capturable); // requires_capture 4000
// The work came to $25: take that, and the other $15 goes back to the payer.const paid = await tuppence.payments.capture(quote.id, { amount_to_capture: 2500 });console.log(paid.status, paid.amount_received, paid.amount_capturable); // succeeded 2500 0import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
// The quote: €40, held when the payer approves.const quote = await tuppence.payments.create({ amount: 4000, currency: "eur", capture_method: "manual",});await tuppence.request("POST", `/v1/payments/${quote.id}/confirm`, { body: { payment_method: "pm_card_de" }, // test mode only});const held = await tuppence.payments.retrieve(quote.id);console.log(held.status, held.amount_capturable); // requires_capture 4000
// The work came to €25: take that, and the other €15 goes back to the payer.const paid = await tuppence.payments.capture(quote.id, { amount_to_capture: 2500 });console.log(paid.status, paid.amount_received, paid.amount_capturable); // succeeded 2500 0import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
# The quote: £40, held when the payer approves.quote = tuppence.payments.create(amount=4000, currency="gbp", capture_method="manual")tuppence.payments.confirm(quote.id, payment_method="pm_card_gb") # test mode onlyheld = tuppence.payments.retrieve(quote.id)print(held.status, held.amount_capturable) # requires_capture 4000
# The work came to £25: take that, and the other £15 goes back to the payer.paid = tuppence.payments.capture(quote.id, amount_to_capture=2500)print(paid.status, paid.amount_received) # succeeded 2500import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
# The quote: $40, held when the payer approves.quote = tuppence.payments.create(amount=4000, currency="usd", capture_method="manual")tuppence.payments.confirm(quote.id, payment_method="pm_card_us") # test mode onlyheld = tuppence.payments.retrieve(quote.id)print(held.status, held.amount_capturable) # requires_capture 4000
# The work came to $25: take that, and the other $15 goes back to the payer.paid = tuppence.payments.capture(quote.id, amount_to_capture=2500)print(paid.status, paid.amount_received) # succeeded 2500import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
# The quote: €40, held when the payer approves.quote = tuppence.payments.create(amount=4000, currency="eur", capture_method="manual")tuppence.payments.confirm(quote.id, payment_method="pm_card_de") # test mode onlyheld = tuppence.payments.retrieve(quote.id)print(held.status, held.amount_capturable) # requires_capture 4000
# The work came to €25: take that, and the other €15 goes back to the payer.paid = tuppence.payments.capture(quote.id, amount_to_capture=2500)print(paid.status, paid.amount_received) # succeeded 2500Over MCP, agents use create_payment with capture_method: "manual", then
capture_payment or cancel_payment.
Who can use manual capture
Section titled “Who can use manual capture”| Live mode | Accounts on our Established tier and above. Below it, you get 403 manual_capture_unavailable, whose fix_url opens Ask for higher limits. Use automatic capture until then |
| Test mode | Anyone |
| Limits | A hold counts toward your account’s limits from when the payer approves it. Cancel a hold you won’t capture to free it |
| Refused | A capture while your account is paused (account_paused) or awaiting review (account_review_pending) |
Refunds
Section titled “Refunds”POST /v1/refunds with the payment, and an amount for a partial refund. It needs an
idempotency key, so two identical refund requests make one refund.
curl https://api.tuppence.ai/v1/refunds \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"payment": "pay_…", "amount": 500}'import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const payment = await tuppence.payments.create({ amount: 2000, currency: "gbp" });await tuppence.payments.confirm(payment.id, { payment_method: "pm_card_gb" }); // test mode only
const refund = await tuppence.refunds.create({ payment: payment.id, amount: 500 });console.log("refunded", refund.amount, "fee returned", refund.fee_returned); // 500, 0import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const payment = await tuppence.payments.create({ amount: 2000, currency: "usd" });await tuppence.payments.confirm(payment.id, { payment_method: "pm_card_us" }); // test mode only
const refund = await tuppence.refunds.create({ payment: payment.id, amount: 500 });console.log("refunded", refund.amount, "fee returned", refund.fee_returned); // 500, 0import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const payment = await tuppence.payments.create({ amount: 2000, currency: "eur" });await tuppence.payments.confirm(payment.id, { payment_method: "pm_card_de" }); // test mode only
const refund = await tuppence.refunds.create({ payment: payment.id, amount: 500 });console.log("refunded", refund.amount, "fee returned", refund.fee_returned); // 500, 0import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
payment = tuppence.payments.create(amount=2000, currency="gbp")tuppence.payments.confirm(payment.id, payment_method="pm_card_gb") # test mode only
refund = tuppence.refunds.create(payment=payment.id, amount=500)print("refunded", refund.amount, "fee returned", refund.fee_returned) # 500 0import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
payment = tuppence.payments.create(amount=2000, currency="usd")tuppence.payments.confirm(payment.id, payment_method="pm_card_us") # test mode only
refund = tuppence.refunds.create(payment=payment.id, amount=500)print("refunded", refund.amount, "fee returned", refund.fee_returned) # 500 0import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
payment = tuppence.payments.create(amount=2000, currency="eur")tuppence.payments.confirm(payment.id, payment_method="pm_card_de") # test mode only
refund = tuppence.refunds.create(payment=payment.id, amount=500)print("refunded", refund.amount, "fee returned", refund.fee_returned) # 500 0| Card | Bacs Direct Debit | |
|---|---|---|
| Status | succeeded at once | pending for a few working days, then refund.updated (succeeded) or refund.failed (refundable again; we email you) |
| Needs | Nothing | The debit’s money available in your balance, a working day after it succeeds. Otherwise it fails at once |
In every case, the payer gets the full refund amount back and our processing fee isn’t returned
(fee_returned is 0). A refund larger than what’s left is amount_too_large.
Saved cards
Section titled “Saved cards”A card on file, charged later with nobody there.
- Create a payment with
customer,save_card: "off_session"and an optionalmandate(descriptionin your words,max_amountper later charge). The payer agrees when they pay. - Charge it later with
POST /v1/payments/charge_saved, within the mandate. It uses the customer’s most recent saved card unless you name apayment_method.
curl https://api.tuppence.ai/v1/payments/charge_saved \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"customer": "cus_…", "amount": 1500, "currency": "gbp"}'curl https://api.tuppence.ai/v1/payments/charge_saved \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"customer": "cus_…", "amount": 1500, "currency": "usd"}'curl https://api.tuppence.ai/v1/payments/charge_saved \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"customer": "cus_…", "amount": 1500, "currency": "eur"}'// 1. The first payment saves the card.const first = await tuppence.payments.create({ amount: 2000, currency: "gbp", customer: customer.id, save_card: "off_session", mandate: { description: "Research tasks as you run them", max_amount: 5000 },});
// 2. Later, with nobody there.const later = await tuppence.payments.chargeSaved({ customer: customer.id, amount: 1500, currency: "gbp" });// 1. The first payment saves the card.const first = await tuppence.payments.create({ amount: 2000, currency: "usd", customer: customer.id, save_card: "off_session", mandate: { description: "Research tasks as you run them", max_amount: 5000 },});
// 2. Later, with nobody there.const later = await tuppence.payments.chargeSaved({ customer: customer.id, amount: 1500, currency: "usd" });// 1. The first payment saves the card.const first = await tuppence.payments.create({ amount: 2000, currency: "eur", customer: customer.id, save_card: "off_session", mandate: { description: "Research tasks as you run them", max_amount: 5000 },});
// 2. Later, with nobody there.const later = await tuppence.payments.chargeSaved({ customer: customer.id, amount: 1500, currency: "eur" });# 1. The first payment saves the card.first = tuppence.payments.create( amount=2000, currency="gbp", customer=customer.id, save_card="off_session", mandate={"description": "Research tasks as you run them", "max_amount": 5000},)
# 2. Later, with nobody there.later = tuppence.payments.charge_saved(customer=customer.id, amount=1500, currency="gbp")# 1. The first payment saves the card.first = tuppence.payments.create( amount=2000, currency="usd", customer=customer.id, save_card="off_session", mandate={"description": "Research tasks as you run them", "max_amount": 5000},)
# 2. Later, with nobody there.later = tuppence.payments.charge_saved(customer=customer.id, amount=1500, currency="usd")# 1. The first payment saves the card.first = tuppence.payments.create( amount=2000, currency="eur", customer=customer.id, save_card="off_session", mandate={"description": "Research tasks as you run them", "max_amount": 5000},)
# 2. Later, with nobody there.later = tuppence.payments.charge_saved(customer=customer.id, amount=1500, currency="eur")If the bank wants to check the cardholder, the charge returns requires_payment_method with
last_error.code authentication_required, and you receive payment.requires_action. Send them the
approval_url (3-D Secure).
Saved bank accounts (Direct Debit)
Section titled “Saved bank accounts (Direct Debit)”A Bacs Direct Debit paid with save_card: "off_session" saves the payer’s bank account and
its mandate as soon as the debit is processing.
| The saved method | type: "bacs_debit", bacs_debit (sort_code, last4; never the account number), and mandate: its reference (on the payer’s Direct Debit emails and statement, and on each payment as payment_method_details.bacs_debit.mandate_reference) and status (pending until the first debit goes through, then active) |
| Charge it | POST /v1/payments/charge_saved: processing, then succeeded about three working days later, within your Direct Debit limits. The payer gets the advance notice by email |
| Mandate cancelled | By the payer or their bank. The method’s status becomes unusable, you get payment_method.mandate_cancelled and an email, and charges are refused with payment_method_unusable. Ask the customer to pay again |
| Account switched | If their bank moves their Direct Debits to a new account, the saved details follow |
| Mandate alone | Not available yet. The first payment sets it up |
Saving a card with nothing charged
Section titled “Saving a card with nothing charged”To hold a booking or bill later, save a card without charging it with a setup session.
- Create
POST /v1/setup_sessions - Customer agreescard + mandate, in their browser
- Charge later
POST /v1/payments/charge_saved
| Create | POST /v1/setup_sessions with customer and mandate (description, max_amount). Needs an idempotency key |
client_secret | The customer’s key to it, shown once. Sent in the Tuppence-Client-Secret header, never in an address |
| In their browser | GET /public/setup_sessions/{id} shows the terms and who they’re agreeing with; …/confirm presents a card. They agree to the mandate at that point, and we keep their address and browser with it. Their bank may check them (requires_action), so later charges needn’t |
| Result | setup_session.succeeded: the session’s payment_method is a saved card (saved_by_setup_session) with an active mandate. setup_session.failed on a decline: nothing is saved and they can try another card |
| Charge | POST /v1/payments/charge_saved, never above max_amount. The card is saved on your business’s own account, so only you can charge it |
A charge far out of character for your agent
Section titled “A charge far out of character for your agent”Each API key has a profile of its charges over the last 30 days: typical, high and largest amounts, and the hours it charges at. Once a key has made 20 charges on 7 different days, we don’t make an off-session charge that is far out of character with nobody there. That means over three times its usual high and over 1.5 times its largest ever (a runaway loop or a stolen key).
| The response | requires_payment_method, step_up: true, unusual: ["amount_spike"], last_error.code authentication_required. Handle it like a bank check: send the customer the approval_url |
| On that page | The customer confirms with their saved card, and their bank is always asked to check them (3-D Secure) |
| Until then | Nothing is charged; retrying doesn’t change that |
| An unusual hour | Noted (unusual: ["unusual_hour"]) and charged as normal. A customer the agent hasn’t charged before isn’t a warning sign: they agreed themselves |
| Setting | Each key’s unusual_charges: confirm (default), flag (charge, with unusual set) or off. Only a person can change it, in the dashboard under API keys. A key can’t switch off its own check. A rolled key keeps its setting and history |
| Test mode | The profile moves with your test clock. On the simulated gateway, the payment page shows a pretend bank check to pass or fail |
Disputes
Section titled “Disputes”A cardholder can ask their bank to reverse a payment. You respond with evidence:
POST /v1/disputes/:id, then POST /v1/disputes/:id/submit (once; the bank decides).
| The payment | The dispute opens |
|---|---|
| Made through the API (your backend, or an agent charging a saved card under a mandate) | With evidence drafted from our records (evidence_drafted_at set): what was bought, the customer, where they accepted the mandate, every API call that made the payment (request ids, keys, idempotency keys, while request logs last, 30 days) and the receipt. Edit any field, clear one with null, and submit when ready |
| Paid on a hosted page (a payment link) | Empty |
| A booking’s deposit, balance, late-cancellation or no-show fee | Drafted from the booking: the policy as the client accepted it (words, version, when and from where), the card’s mandate, the visit or no-show mark, the fee and the sentence it was charged under, the receipt. See Bookings |
| A Bacs Direct Debit | As an indemnity claim under the Direct Debit Guarantee: payment_method_type: "bacs_debit", contestable: false, already lost. The payer was refunded; the amount and dispute fee leave your balance; evidence answers 400 dispute_not_contestable. Contact your customer if it’s a mistake |
A card dispute says payment_method_type: "card" and contestable: true.
- Webhooks: receive
payment.succeededand fulfil. - 3-D Secure: when the bank checks the payer.
- Testing: every test card and helper.