Skip to main content

Card payments

Accept cards directly on your page with Upprove.js, our browser SDK. The SDK renders the card fields inside secure, Orchestr-hosted inputs - the card number, expiry, and CVC are entered into a document served from our PCI-audited origin and never touch your page or your servers. You stay in the lightest PCI DSS scope (SAQ A) while keeping full control of your checkout's look and flow.

If your systems are already PCI DSS compliant and hold card details, you can instead send them from your server through the Vault API and pay with the resulting token.

Upprove.js does the heavy lifting: tokenizing the card, confirming the payment, and rendering the 3-D Secure challenge when the issuer demands one. Your integration is four steps:

  1. Create a payment on your server - POST /v1/payments with payment_method: {"code": "card"}.
  2. Collect the card in the browser - mount the card element and call collectCard().
  3. Pay - call pay(); the SDK handles 3-D Secure inline.
  4. Fulfil on the webhook - payment.succeeded is the authoritative result.

1. Create a payment​

The server-side call is the same POST /v1/payments you use for direct payments, with payment_method: {"code": "card"}. It reuses the buildSignatureHeader helper from the Signing guide and assumes UPPROVE_SECRET_KEY / UPPROVE_SIGNING_SECRET are exported as shown in Getting started.

REQ_PATH="/v1/payments"
BODY='{
"amount": "42.00",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card"
},
"merchant_reference": "order-1001",
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel",
"customer": {
"email": "jane@example.com"
}
}'
TS=$(date +%s)
SIG=$(printf '%s' "${TS}${REQ_PATH}${BODY}" \
| openssl dgst -sha256 -hmac "$UPPROVE_SIGNING_SECRET" | sed 's/^.*= //')

curl -sS -X POST "https://api.sandbox.upprove.com${REQ_PATH}" \
-H "Authorization: Bearer $UPPROVE_SECRET_KEY" \
-H "X-Signature: t=${TS}, v1=${SIG}" \
-H "Content-Type: application/json" \
--data-raw "$BODY"

Because this request carries no card_token, it comes back requires_action with a collect_card next action - the browser flow this page describes:

{
"order_id": "pay_5b4a3c2d",
"status": "requires_action",
"amount": 42.00,
"currency": "GBP",
"next_action": {
"type": "collect_card",
"client_secret": "eyJhbGciOiJFUzI1NiJ9…"
}
}
card_token decides which flow you get

Only the token-less request produces collect_card. Sending payment_method.details.card_token on the create means the card is already on file, and what happens next depends on who is initiating the charge: with the customer present you get a collect_cvv action and the SDK collects just the security code - see Charging a saved card - while a merchant-initiated charge authorizes server-side with no browser step.

The client_secret is a short-lived, single-payment grant that authorizes your page to collect this card for this payment. Hand that one string to the browser - nothing else. It expires after 15 minutes; create a new payment to mint a fresh one.

note

Never send your secret key (sk_…) or signing secret to the browser. The browser only ever sees your publishable key (pk_…) and the client_secret.

2. Collect the card​

Load Upprove.js from our CDN and mount the card element. The script is served at a pinned major version, so security fixes reach your payment page without you redeploying - and PCI DSS v4's script-integrity duties (6.4.3, 11.6.1) stay on our side of the line.

<script src="https://js.upprove.com/v1/upprove.js"></script>
<div id="card-element"></div>
<button id="pay" disabled>Pay £42.00</button>

Using a bundler? npm install @upprove/card-js gives you a typed loader that injects the same script - see the Upprove.js reference.

const upprove = Upprove('pk_test_…'); // your publishable key
const card = upprove.elements().create('card');

card.mount('#card-element');
card.on('change', ({complete, error}) => {
payButton.disabled = !complete;
errorEl.textContent = error ?? '';
});

payButton.onclick = async () => {
const {card: collected, error} = await upprove.collectCard(clientSecret);
if (error) return showError(error.code);

// Non-sensitive: safe to display or send to your server.
console.log(collected.brand, collected.last4);
};

The fields come with a neutral look; pass an appearance to match your page - see Appearance in the reference.

collectCard(clientSecret) submits the card from inside the secure fields and resolves with a card token (tok_…) plus display-safe details (brand, last4, expiry). The token is an opaque reference to the vaulted card - it is not card data and is safe to log, store, and send to your server.

3. Pay with the token​

Pay with the token and the same client_secret you collected with. The SDK calls Orchestr directly and - when the issuer requires it - renders the 3-D Secure challenge in your page, so you write no 3DS code:

// clientSecret is the collect_card grant from step 1 - the same one collectCard() used.
const result = await upprove.pay(clientSecret, collected.token);

if (result.status === 'failed') return showDeclined(result.declineCode, result.error);
if (result.status === 'succeeded') showSuccessUi();
The browser result is a hint, not the truth

The value pay() resolves with is a UX convenience - it is spoofable by the cardholder's browser, and it is lost if the tab closes mid-challenge. Never ship goods, grant access, or mark an order paid on the browser result. Fulfil on the webhook (next step).

4. Fulfil on the webhook​

The authoritative outcome arrives on your server as a signed payment.succeeded (or payment.failed) webhook. Verify the signature, then fulfil.

The webhook, and the transaction when you read it back over the API, carry a payment_instrument object with the card brand and last four digits, e.g. for an invoice line such as VISA •••• 4242.

Saving cards​

To charge a card again later - "remember my card", subscriptions, or metered top-ups - ask us to save the card at payment time. Card networks require an explicit, recorded cardholder agreement for stored credentials, so save_card must carry a consent object describing the agreement you captured:

"payment_method": {
"code": "card",
"details": {
"save_card": true,
"consent": {
"captured_at": "2026-07-16T09:41:00Z",
"type": "card_on_file",
"reference": "tos-v3-acc-8842"
}
}
}
FieldDescription
captured_atWhen the cardholder's affirmative act happened (RFC 3339). Must not be in the future.
typeThe kind of stored-credential agreement - see below.
referenceYour identifier for the retained agreement artifact (the checkbox event, signed terms version, subscription id). You keep the artifact; we record that it exists.

All three fields are required whenever save_card is true, and consent is rejected without save_card - the two travel together.

typeUse when the cardholder agreed to…
card_on_fileStoring the card for future purchases they initiate ("remember my card").
recurringA fixed, scheduled series of charges (subscriptions).
unscheduledMerchant-initiated charges at irregular times (auto top-up, usage billing).
installmentA fixed number of scheduled charges adding up to an agreed total.

The card is stored only after the payment's authorization succeeds - a declined first payment never leaves a stored card behind. Asserting a different consent for the same payment returns 409 consent_conflict; re-sending the identical consent is idempotent and safe to retry.

Charging a saved card​

A saved card is charged by passing its token in payment_method.details.card_token. The consent type recorded at save time governs how it may be used, and that maps onto two flows:

The customer is…Consent that covers itWhat the create returns
Present at checkout - "pay with my saved Visa •••• 4242".card_on_file (any type will do)requires_action with a collect_cvv action: the customer confirms the security code in Upprove.js, then the payment is confirmed like a new card.
Absent - a renewal, top-up or instalment you trigger.recurring, unscheduled or installment, matching the declared reasonA server-to-server authorization: a terminal completed / failed, or authorized under manual capture. No browser step.

The create decides between them from payment_method.details.stored_credential: absent, or initiator: "customer", means the customer is present; initiator: "merchant" selects the server-to-server path and is covered on its own page. A merchant willing to accept a customer-present charge without the security code can also skip the browser step with collect_cvv: false - see Customer present, without the CVV step.

Customer present: collect_cvv​

The security code is never stored with the card, so a customer-present charge on a saved card still needs the cardholder to type it. Create the payment with the token and no stored_credential:

{
"amount": "42.00",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card",
"details": {
"card_token": "tok_1PqRsT2eZvKYlo2C"
}
},
"merchant_reference": "order-1002",
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel",
"customer": {
"email": "jane@example.com"
}
}

The token must belong to your account; an unknown or foreign token is rejected before the payment changes state. The response parks the payment and hands you a collect_cvv action instead of collect_card:

{
"order_id": "pay_7c6b5a4d",
"status": "requires_action",
"amount": 42.00,
"currency": "GBP",
"next_action": {
"type": "collect_cvv",
"client_secret": "eyJhbGciOiJFUzI1NiJ9…"
}
}

The client_secret is the same kind of short-lived, single-payment grant as on collect_card, with one difference: it is bound to this token. Hand it to the card element up front so the secure fields mount straight into their card-on-file shape - the brand and last four digits above a single security-code field, with no number, expiry or name to fill in:

const card = upprove.elements().create('card', {clientSecret}); // the collect_cvv grant
card.mount('#card-element');

payButton.onclick = async () => {
const {card: collected, error} = await upprove.collectCard(clientSecret); // same secret
if (error) return showError(error.code);

// collected.token is the card on file; expMonth / expYear are null.
const result = await upprove.pay(clientSecret, collected.token);
};

collectCard() submits the code against the saved card and resolves with that card's token; pay() then runs exactly as in step 3, 3-D Secure included, and the outcome arrives on the webhook. Orchestr refuses any other token under a collect_cvv secret, so the browser cannot swap the card. A retry after a decline comes with a fresh payment and secret - pass it through card.update({clientSecret}) before collecting again.

Why no device_data or three_ds_return_url

Both fields are accepted on this create but unused: Upprove.js gathers the device data itself and presents the challenge in your page. They matter only when you opt out of the CVV step with collect_cvv: false.

Merchant-initiated: no browser​

For a charge the cardholder is not present for, declare it with stored_credential: {"initiator": "merchant", "reason": "recurring"}. The create then runs a real server-to-server authorization under the agreement the cardholder gave at save time, with no client secret and no 3-D Secure. See Server-to-server card payments for the request, the rules and the outcomes.

Zero-amount card setup​

To store a card without charging it, create a payment with amount: "0" plus save_card and consent. Orchestr runs a zero-amount account-verification authorization against the issuer and stores the card if it succeeds:

{
"amount": "0",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card",
"details": {
"save_card": true,
"consent": {
"captured_at": "2026-08-11T09:41:00Z",
"type": "unscheduled",
"reference": "tos-v3-acc-8842"
}
}
},
"merchant_reference": "setup-acc-8842",
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel"
}

A zero amount is accepted only in this combination - save_card and consent must both be present, otherwise the payment is rejected. Manual capture is also rejected at amount 0, since there is nothing to capture.

Cards on hosted checkout​

The result arrives where all your outcomes arrive - the payment.succeeded webhook.

To authorize now and settle later, create the session with capture_method: "manual" - see Manual capture on hosted checkout. The session then completes at authorization and payment.authorized precedes payment.succeeded.

Test cards​

The sandbox recognizes test cards only - a real card number is rejected before it is ever stored. Use any future expiry and any CVC.

NumberBehavior
4242 4242 4242 4242Visa - succeeds.
4000 0000 0000 9995Visa - declined, insufficient_funds.

See Test cards for the full set, including declines and 3-D Secure scenarios - and for which cards apply to your account, since Sandbox routes to an acquirer the same way Live does.

Next steps​

  • Server-to-server card payments - merchant-initiated charges on a saved card: renewals, top-ups and instalments with no browser step.
  • Manual capture - authorize now, settle later; partial captures and voids.
  • Upprove.js reference - the full SDK API: elements, events, errors, CSP.
  • Test cards - approvals, declines and 3-D Secure scenarios for Sandbox.
  • Webhooks - the payment.* events that report the final outcome.
  • Signing - build and verify the X-Signature header.
  • The full API Reference for POST /v1/payments.