It's late afternoon and an order has just come in from a returning customer. Their card is already saved from last time, but the stock check won't finish until tomorrow, so you don't want to take the money yet and you can't risk losing it either.
If you build checkouts or billing for UK businesses, this is the everyday job of charging a saved card off-session. The customer isn't there, and your code decides when money is reserved, when it's collected and when it's let go. This post walks through auto capture, manual capture and capture before expiry, what happens when a hold expires, where SCA does and doesn't apply, and how idempotency stops a retry from charging someone twice.
Why off-session card charges need care
An off-session charge is a card payment you start when the customer isn't on your site or app. There's no 3D Secure screen for them to tap through, so the only proof they agreed is the consent they gave when they saved the card.
These payments sit in the card-not-present part of the fraud figures. Remote purchase card fraud losses, where criminals use stolen card details to buy online, rose 3% to £423.5 million in 2025, and cases rose 13% to 3.2 million (UK Finance Annual Fraud Report 2026, June 2026). Your charges need to look clean to the card issuer: the right amount, a clear reference and an agreement the customer actually gave.
The rules help here. The FCA has confirmed that transactions started by the payee alone, with no involvement from the payer, are outside the scope of Strong Customer Authentication (SCA). It also says a price rise outside the original agreement needs a new mandate, and SCA applies if that new mandate is set up remotely (FCA PS21/19, November 2021).
Words you'll need
- Saved card (card on file): a card the customer stored with your payment provider, which gives you a payment method ID instead of the card number.
- Authorise: ask the customer's bank to approve a payment and reserve the money. Nothing has moved yet.
- Hold: the reserved amount sitting on the customer's card after an authorisation. It reduces their available balance but isn't taken.
- Capture: tell the provider to actually collect the money you authorised.
- Cancel (void): release the hold so no money moves and the customer's available balance goes back up.
- SCA: Strong Customer Authentication, the UK rule that the payer proves who they are with two factors, such as their phone and a fingerprint. For online card payments this is usually 3D Secure.
- MIT: a merchant-initiated transaction, a charge you start under an agreement the customer gave earlier.
- Idempotent: a request you can safely send twice and still get only one result.
Status values in the examples use US spelling, such as AUTHORIZED, because that's what most card APIs return.
Auto capture, manual capture and capture before expiry
Most card APIs give you three ways to collect the money. The names below follow the shape of a typical card on file API reference, so match them to whatever your provider calls them.
| Capture type | What happens | Status after the card network confirms | Use it when |
|---|---|---|---|
| Auto capture | Authorised and captured in one go | COMPLETED | The amount is final and you deliver straight away |
| Manual capture | Authorised only; you call capture later | AUTHORIZED until you capture or cancel | You need to check stock, fulfil an order or confirm a service first |
| Capture before expiry | Authorised; the provider captures for you before the hold expires, unless you capture or cancel first | AUTHORIZED until captured or cancelled | You'll usually take the money but want a window to cancel |
Three things trip people up.
- The charge call doesn't tell you whether it worked. A typical API returns PENDING straight away to confirm processing has started. The final status (COMPLETED, AUTHORIZED or FAILED) arrives later by webhook, or you poll the payment status.
- Holds don't last forever. For manual capture and capture before expiry, the response usually includes an expiresAt timestamp. Read it from each response and store it. Don't hard-code a number of days, because it depends on the provider and the card.
- Capture before expiry is a default "yes". If an order is cancelled and nobody cancels the hold, the provider captures it anyway, and you're into a refund.
Most UK teams use card on file payments for repeat billing where the amount changes each time, such as retainer top-ups or treatment packages, which is exactly where manual capture earns its place.
When SCA applies and when it doesn't
Think of it as two kinds of payment.
- Customer-initiated (on-session): the customer is present, for example when they first save the card at checkout. SCA applies, so they complete 3D Secure. This is also when you record what they agreed to: amounts, timing and what the card can be charged for.
- Merchant-initiated (off-session): you charge later under that agreement, with no customer involvement. Under the FCA guidance above, this is outside the scope of SCA.
That only covers charges inside the agreement. If you want to charge more, or for something new, you need a fresh agreement, and if you set that up remotely the customer goes through SCA again.
An issuer can still decline an off-session charge. When that happens, don't retry in a loop. Bring the customer back on-session with a payment link or a "confirm your card" email so they can authenticate.
Step by step: charge, wait, then capture or cancel
The examples use Node.js 18 or later with the built-in fetch. Endpoint paths and field names are placeholders, so swap in your provider's.
1. Save the card with the customer present
Save the card during a normal checkout, or with a hosted "save your card" page where the customer completes 3D Secure. Your server gets back a customer ID and a payment method ID. Store those, plus a record of what the customer agreed to. Never store the card number.
2. Charge with an idempotency key
Build the key from your own order ID. A retried job then sends the same key, and the provider returns the first result instead of creating a second charge.
const API_BASE = process.env.PAYMENTS_API_BASE; const API_KEY = process.env.PAYMENTS_API_KEY; async function chargeSavedCard(order) { // Same order, same key: a retry can never create a second charge const idempotencyKey = "charge-" + order.id; const res = await fetch(API_BASE + "/payments/card/charge", { method: "POST", headers: { "Authorization": "Bearer " + API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ customerId: order.customerId, paymentMethodId: order.savedCardId, captureType: "MANUAL_CAPTURE", amount: order.amount, // in pounds, for example 42.50 orderId: order.id }) }); if (!res.ok) { throw new Error("Charge request failed with HTTP " + res.status); } const payment = await res.json(); // Status is PENDING here. The real outcome arrives by webhook. await db.payments.insert({ orderId: order.id, paymentId: payment.paymentRequestId, status: payment.status, expiresAt: payment.expiresAt }); return payment; } Check which unit your provider uses. Some take pounds with decimals and others take pence as whole numbers, and mixing them up charges 100 times too much or too little.
3. Wait for the webhook
A webhook is an HTTP request the provider sends to your server when a payment changes status. Verify its signature, then update your record. Webhooks can arrive more than once, so ignore a status you've already handled.
app.post("/webhooks/payments", express.raw({ type: "application/json" }), async function (req, res) { if (!verifySignature(req.body, req.get("x-signature"))) { return res.status(401).end(); } const event = JSON.parse(req.body.toString("utf8")); const payment = await db.payments.findByPaymentId(event.paymentRequestId); if (!payment) { console.warn("Webhook for unknown payment " + event.paymentRequestId); return res.status(200).end(); } if (payment.status === event.status) { return res.status(200).end(); // duplicate delivery, already handled } await db.payments.update(payment.paymentId, { status: event.status }); if (event.status === "AUTHORIZED") { await queue.add("start-fulfilment", { orderId: payment.orderId }); } if (event.status === "FAILED") { await queue.add("ask-customer-to-confirm-card", { orderId: payment.orderId }); } res.status(200).end(); }); 4. Capture or cancel
Once the order is ready, capture. If it's cancelled, release the hold. Both calls only work on payments that are still AUTHORIZED. You can't capture an auto capture payment, and you can't cancel one that's already captured, because that needs a refund.
async function postAction(paymentId, action, body) { const res = await fetch(API_BASE + "/payments/card/" + paymentId + "/" + action, { method: "POST", headers: { "Authorization": "Bearer " + API_KEY, "Content-Type": "application/json", "Idempotency-Key": action + "-" + paymentId }, body: JSON.stringify(body || {}) }); if (!res.ok) { throw new Error(action + " failed with HTTP " + res.status); } return res.json(); } function capturePayment(paymentId) { return postAction(paymentId, "capture"); } function cancelPayment(paymentId, reason) { return postAction(paymentId, "cancel", { cancelledReasonCode: reason }); } If the final amount can change, check whether your provider lets you capture less than you authorised. If it doesn't, cancel the hold and charge the new amount instead.
5. Sweep holds before they expire
A scheduled job looks for holds close to their expiresAt and either captures them, cancels them or tells a person. The 24-hour window here is a setting you choose, not a rule.
async function sweepExpiringHolds() { const windowEnd = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); const holds = await db.payments.find({ status: "AUTHORIZED", expiresBefore: windowEnd }); for (const hold of holds) { const order = await db.orders.get(hold.orderId); if (order.status === "ready") { await capturePayment(hold.paymentId); } else if (order.status === "cancelled") { await cancelPayment(hold.paymentId, "Abandoned"); } else { await alertOps("Hold for order " + hold.orderId + " expires at " + hold.expiresAt); } } } Mistakes to avoid
- Treating PENDING as paid. Ship only after the webhook says COMPLETED or AUTHORIZED.
- Retrying without an idempotency key. A timeout doesn't mean the charge failed. Without a stable key, the retry can create a second charge.
- Letting holds lapse. Once a manual capture hold expires, the money is no longer reserved, and you may have to charge the card again off-session.
- Forgetting that capture before expiry captures by default. Cancel the hold the moment an order is cancelled.
- Cancelling after capture. Once the money is captured, the only way back is a refund.
- Charging outside the agreement. A higher amount or a new purpose needs fresh consent, and SCA if it's set up remotely.
- Hard-coding the hold length. Read expiresAt from every response.
- Storing card numbers. Keep only the provider's customer ID and payment method ID.
Conclusion
Remote purchase card fraud cost £423.5 million in 2025 (UK Finance, June 2026), so every off-session charge needs a clear agreement behind it, and the FCA keeps these charges outside SCA only while they stay inside that agreement. Pick the capture type per order, treat the webhook as the source of truth and store every hold's expiry. Your next step: add the expiry sweep to your scheduler before you switch any order type to manual capture.

