Take a payment
A payment is an amount you want paid. It exists before any card does: you create it, then a card pays it — on our hosted page, in your own checkout, or from a card saved for later.
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" \ -d amount=2000 -d currency=gbp -d description="Order 1042"The answer includes an approval_url: send a person there (or an agent’s user) and they pay on
our page. Nothing is charged until a card is presented.
2. A card pays it
Section titled “2. A card pays it”On the hosted page, the payer enters a card; 3-D Secure runs if their bank asks for it. In your
own checkout, the browser confirms with the publishable key and the payment’s client_secret.
In test mode your server can confirm directly with one of Stripe’s test payment methods — which is how the example below runs end to end:
| Test payment method | What happens |
|---|---|
pm_card_gb |
A UK Visa: succeeds at the standard rate |
pm_card_amex |
An American Express: succeeds, with the Amex surcharge |
pm_card_visa |
A US Visa: succeeds, with the international surcharge |
pm_card_chargeDeclinedInsufficientFunds |
Declined: insufficient funds |
pm_card_chargeDeclined |
Declined |
pm_card_createDispute |
Succeeds — then the cardholder disputes it |
3. The fee, and what you keep
Section titled “3. The fee, and what you keep”The fee is fixed the moment the card is presented and never changes: fee, net and
fee_breakdown (the base rate and each surcharge that applied, and why) are on the payment. A
refund gives the payer the whole amount back; our fee on the payment is not returned
(pricing).
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.request("POST", `/v1/payments/${payment.id}/confirm`, { body: { payment_method: "pm_card_gb" }, // test mode only});
// The fee is recorded as the payment settles — usually within a second.let paid = await tuppence.payments.retrieve(payment.id);for (let i = 0; paid.fee === null && i < 20; i += 1) { await new Promise((r) => setTimeout(r, 500)); paid = await tuppence.payments.retrieve(payment.id);}console.log(paid.status, "fee", paid.fee, "net", paid.net); // succeeded fee 16 net 1984
const refund = await tuppence.refunds.create({ payment: payment.id, amount: 500 });console.log("refunded", refund.amount, "fee returned", refund.fee_returned); // 500, 0Statuses
Section titled “Statuses”status |
Means |
|---|---|
requires_payment_method |
Created; waiting for a card |
requires_action |
The payer’s bank wants 3-D Secure |
requires_capture |
Authorised, not yet taken (capture_method: "manual") |
processing |
The card network has it |
succeeded |
Paid. fee and net are final |
canceled |
Cancelled before it was paid |
A declined card leaves the payment payable — the payer can try another card — and
last_error says why.
Authorise now, capture later
Section titled “Authorise now, capture later”For a quote — the payer approves a price, you do the work, then you take what it came to — create
the payment with capture_method: "manual". When the payer approves, the money is held on
their card, not taken: the payment is requires_capture, with amount_capturable and a
capture_before deadline, and you get payment.requires_capture. Then:
POST /v1/payments/{id}/capturetakes it — all of it, oramount_to_captureof it. What you leave goes back to the payer, and our fee follows what you capture.POST /v1/payments/{id}/cancelreleases all of it.- Neither within six days, and we cancel it for you: the hold is released, and
payment.canceledarrives withcancellation_reason: "capture_expired".
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 0Agents do the same over MCP: create_payment with capture_method: "manual", then
capture_payment or cancel_payment.
Refunds
Section titled “Refunds”POST /v1/refunds with the payment and, for part of it, an amount. A refund can never exceed
what is left (amount_too_large), and needs an idempotency key: two identical refund requests are
one refund. The payer gets the whole amount back; the original processing fee is not returned
(fee_returned is 0).
Saved cards
Section titled “Saved cards”Pass save_card: "off_session" with a mandate when you create a payment, and the card is saved
for later charges — POST /v1/payments/charge_saved — within the mandate’s limits. Off-session
charges that need the cardholder back (authentication) come back as requires_action with an
approval_url to send them.
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 what it usually charges: typical, high and largest amounts over the last 30 days, and the hours it charges at. Once it has made 20 charges on 7 different days, an off-session charge far out of character isn’t made with nobody there. That means over three times its usual high, and half as much again as its largest ever: a runaway loop, say, or a stolen key. Instead:
- the payment comes back
requires_payment_methodwithstep_up: true,unusual: ["amount_spike"], andlast_error.codeauthentication_required— the same answer as when a bank asks for the payer, and handled the same way: send the customer theapproval_url; - on that page the customer confirms it with their saved card, and their bank is asked to check it’s them (3-D Secure), whatever it would have decided on its own;
- until they do, nothing is charged, and retrying the request doesn’t change that.
A charge at an hour your key has never charged at is only noted (unusual: ["unusual_hour"]) and
charged as usual. A customer your agent has never charged before is no warning either: they agreed
to it themselves.
Each key’s unusual_charges setting decides this: confirm (the default), flag (charge it,
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 both its setting and its history.
In test mode the profile moves with your test clock. On the simulated gateway, the payment page shows a pretend bank check you can pass or fail.
Disputes
Section titled “Disputes”A cardholder can ask their bank to take a payment back. You answer with evidence
(POST /v1/disputes/:id, then POST /v1/disputes/:id/submit — once; the bank decides).
When a payment made through the API is disputed — by your backend, or by an agent charging a
saved card off-session under a mandate — the dispute opens with its evidence drafted from our
records (evidence_drafted_at is set): what was bought, the customer, where they were when they
accepted the mandate, every API call that made the payment (request IDs, keys, idempotency keys —
while the request logs last, 30 days) and the receipt we emailed. It is only a draft: change any
field, clear what you don’t want (null), and submit when you’re happy. A payment the customer
made on a hosted page (a payment link) starts empty.