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:
- Create a payment on your server -
POST /v1/paymentswithpayment_method: {"code": "card"}. - Collect the card in the browser - mount the card element and call
collectCard(). - Pay - call
pay(); the SDK handles 3-D Secure inline. - Fulfil on the webhook -
payment.succeededis 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.
- cURL
- Node.js
- Python
- PHP
- Java
- C#
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"
const path = '/v1/payments';
const body = JSON.stringify({
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'},
});
const res = await fetch(`https://api.sandbox.upprove.com${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.UPPROVE_SECRET_KEY}`,
'X-Signature': buildSignatureHeader(process.env.UPPROVE_SIGNING_SECRET, path, body),
'Content-Type': 'application/json',
},
body,
});
const payment = await res.json();
// Hand payment.next_action.client_secret to your page.
import json
import requests
path = "/v1/payments"
body = json.dumps(
{
"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"},
},
separators=(",", ":"),
)
resp = requests.post(
f"https://api.sandbox.upprove.com{path}",
data=body,
headers={
"Authorization": f"Bearer {UPPROVE_SECRET_KEY}",
"X-Signature": build_signature_header(UPPROVE_SIGNING_SECRET, path, body),
"Content-Type": "application/json",
},
)
payment = resp.json()
# Hand payment["next_action"]["client_secret"] to your page.
<?php
$path = '/v1/payments';
$body = json_encode([
'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'],
], JSON_UNESCAPED_SLASHES);
$ch = curl_init("https://api.sandbox.upprove.com{$path}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('UPPROVE_SECRET_KEY'),
'X-Signature: ' . build_signature_header(getenv('UPPROVE_SIGNING_SECRET'), $path, $body),
'Content-Type: application/json',
],
]);
$payment = json_decode(curl_exec($ch), true);
// Hand $payment['next_action']['client_secret'] to your page.
String path = "/v1/payments";
String 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"
}
}""";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.sandbox.upprove.com" + path))
.header("Authorization", "Bearer " + System.getenv("UPPROVE_SECRET_KEY"))
.header("X-Signature", buildSignatureHeader(System.getenv("UPPROVE_SIGNING_SECRET"), path, body))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Hand next_action.client_secret from the response to your page.
var path = "/v1/payments";
var 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"
}
}
""";
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.upprove.com{path}")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
request.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("UPPROVE_SECRET_KEY")}");
request.Headers.Add("X-Signature",
BuildSignatureHeader(Environment.GetEnvironmentVariable("UPPROVE_SIGNING_SECRET"), path, body));
var response = await http.SendAsync(request);
// Hand next_action.client_secret from the response to your page.
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 getOnly 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.
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 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"
}
}
}
| Field | Description |
|---|---|
captured_at | When the cardholder's affirmative act happened (RFC 3339). Must not be in the future. |
type | The kind of stored-credential agreement - see below. |
reference | Your 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.
type | Use when the cardholder agreed to… |
|---|---|
card_on_file | Storing the card for future purchases they initiate ("remember my card"). |
recurring | A fixed, scheduled series of charges (subscriptions). |
unscheduled | Merchant-initiated charges at irregular times (auto top-up, usage billing). |
installment | A 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 it | What 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 reason | A 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.
device_data or three_ds_return_urlBoth 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.
| Number | Behavior |
|---|---|
4242 4242 4242 4242 | Visa - succeeds. |
4000 0000 0000 9995 | Visa - 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-Signatureheader. - The full API Reference for
POST /v1/payments.