Skip to content

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.

  1. CreatePOST /v1/payments
  2. A card payshosted page or your checkout
  3. Hear itpayment.succeeded
  4. Settledfee and net fixed
Where the card is presentedYou useYou build
Our hosted pageapproval_urlNothing
Your own checkoutclient_secret + @tuppence/jsA card field on your page
A card saved earlier, nobody therePOST /v1/payments/charge_savedNothing
Terminal window
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"}'
Terminal window
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"}'
Terminal window
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"}'

Nothing is charged until a card is presented. The response includes two ways to pay it. Use one:

FieldWhat it is
approval_urlThe hosted page for this payment. On every payment
client_secretThe 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).

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:

YouThe payer’s card statement
Nothing set (business name “Bella’s Barks”)TUPPENCE* BELLAS BARKS
A card statement name under Settings, StatementsTUPPENCE* <YOUR NAME>
statement_descriptor_suffix: "SPRING SALE" on the paymentTUPPENCE* 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.

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.

Send the payer to the approval_url by redirect, email or a message from your agent.

Payer's browserYour serverTuppencePOST /v1/paymentsapproval_urlredirect to approval_urlcard (+ 3-D Secure)payment.succeededfulfil the order

The page runs the bank’s check if it asks (3-D Secure). Fulfil on the webhook, not the redirect.

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.

BrowserYour serverTuppencePOST /v1/paymentsclient_secretclient_secretpaymentSheet().mount()sheet.confirm() · pk_test_…{ error } or { payment }payment.succeeded
import { loadTuppence } from "@tuppence/js";
const tuppence = await loadTuppence("pk_test_…"); // publishable: a secret key is refused
const 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.

Confirm with one of the test payment methods below. No browser needed:

Terminal window
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 .status
Terminal window
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": "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 .status
Terminal window
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": "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 .status
Test payment_methodWhat happens
pm_card_gbUK Visa: succeeds
pm_card_us, pm_card_visaUS Visa: succeeds
pm_card_amexAmex: succeeds, with the Amex surcharge, with the Amex surcharge
pm_card_bypassPendingUS Visa with funds available immediately. Use it before a test payout
pm_card_authenticationRequiredThe bank checks the cardholder. Finish in a browser on the test challenge
pm_card_chargeDeclinedInsufficientFundsDeclined: insufficient funds
pm_card_chargeDeclinedDeclined
pm_card_createDisputeSucceeds, then the cardholder disputes it

A card issued outside your account’s country pays the foreign-card surcharge (pricing).

requires_payment_methodrequires_action3-D SecureprocessingBacs Direct Debitrequires_capturecapture_method: manualsucceededcanceledcard paidbank askspayer passesdebit~3 working daysapprovedcapturecancel · 6 dayscancel
statusMeansEvent
requires_payment_methodCreated; waiting for a card. A declined card leaves it here, payable again, with last_error saying whypayment.created; payment.failed on a decline
requires_actionThe payer’s bank wants 3-D Securepayment.requires_action
requires_captureAuthorised, not yet taken (manual capture)payment.requires_capture
processingOn its way: a Bacs Direct Debit takes about three working days (below)payment.processing
succeededPaid. fee and net are finalpayment.succeeded
canceledCancelled before it was paidpayment.canceled

Once paid, a payment also says:

Field
fee, netWhat 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_breakdownThe method it was priced as (card for a card, Apple Pay or Google Pay; bacs_debit) and cap_applied
payment_method_detailstype (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).

A Bacs Direct Debit is processing once the payer confirms it.

You receivepayment.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)
Thenpayment.succeeded about three working days later. Nothing is paid to you and no fee is taken before then
Orpayment.failed, with the bank’s reason in last_error.code (insufficient_funds, debit_not_authorized, account_closed, …). We email you and the payer
SubscriptionsAn invoice paid by debit is processing until then. The period advances when it succeeds
Test modePOST /v1/test_helpers/clock/advance past the three working days settles it

A pre-authorisation (manual capture): the payer approves a price, you do the work, then you take the final amount.

  1. Create the payment with capture_method: "manual".
  2. When the payer approves, the money is held: requires_capture, with amount_capturable and a capture_before deadline. You hear payment.requires_capture.
  3. Then, within six days:
    • POST /v1/payments/{id}/capture takes all of it, or amount_to_capture. The rest goes back to the payer, and our fee is on the amount you capture.
    • POST /v1/payments/{id}/cancel releases all of it.
    • If you do neither, we cancel it: payment.canceled with cancellation_reason: "capture_expired".
Terminal window
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)"

Over MCP, agents use create_payment with capture_method: "manual", then capture_payment or cancel_payment.

Live modeAccounts 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 modeAnyone
LimitsA hold counts toward your account’s limits from when the payer approves it. Cancel a hold you won’t capture to free it
RefusedA capture while your account is paused (account_paused) or awaiting review (account_review_pending)

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.

Terminal window
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}'
CardBacs Direct Debit
Statussucceeded at oncepending for a few working days, then refund.updated (succeeded) or refund.failed (refundable again; we email you)
NeedsNothingThe 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.

A card on file, charged later with nobody there.

  1. Create a payment with customer, save_card: "off_session" and an optional mandate (description in your words, max_amount per later charge). The payer agrees when they pay.
  2. Charge it later with POST /v1/payments/charge_saved, within the mandate. It uses the customer’s most recent saved card unless you name a payment_method.
Terminal window
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"}'
Terminal window
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"}'
Terminal window
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"}'

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

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 methodtype: "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 itPOST /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 cancelledBy 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 switchedIf their bank moves their Direct Debits to a new account, the saved details follow
Mandate aloneNot available yet. The first payment sets it up

To hold a booking or bill later, save a card without charging it with a setup session.

  1. CreatePOST /v1/setup_sessions
  2. Customer agreescard + mandate, in their browser
  3. Charge laterPOST /v1/payments/charge_saved
CreatePOST /v1/setup_sessions with customer and mandate (description, max_amount). Needs an idempotency key
client_secretThe customer’s key to it, shown once. Sent in the Tuppence-Client-Secret header, never in an address
In their browserGET /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
Resultsetup_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
ChargePOST /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 responserequires_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 pageThe customer confirms with their saved card, and their bank is always asked to check them (3-D Secure)
Until thenNothing is charged; retrying doesn’t change that
An unusual hourNoted (unusual: ["unusual_hour"]) and charged as normal. A customer the agent hasn’t charged before isn’t a warning sign: they agreed themselves
SettingEach 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 modeThe profile moves with your test clock. On the simulated gateway, the payment page shows a pretend bank check to pass or fail

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 paymentThe 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 feeDrafted 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 DebitAs 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.succeeded and fulfil.
  • 3-D Secure: when the bank checks the payer.
  • Testing: every test card and helper.

Docs assistant

Open as a page
Loading the assistant…