Skip to content

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.

Terminal window
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.

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

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, 0
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.

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}/capture takes it — all of it, or amount_to_capture of it. What you leave goes back to the payer, and our fee follows what you capture.
  • POST /v1/payments/{id}/cancel releases all of it.
  • Neither within six days, and we cancel it for you: the hold is released, and payment.canceled arrives with cancellation_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 0

Agents do the same over MCP: create_payment with capture_method: "manual", then capture_payment or cancel_payment.

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

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_method with step_up: true, unusual: ["amount_spike"], and last_error.code authentication_required — the same answer as when a bank asks for the payer, and handled the same way: send the customer the approval_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.

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.