Card payments
Accept cards directly on your page with Orchestr.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.
Orchestr.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(). - Confirm — call
confirmCardPayment(); 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
ORCHESTR_SECRET_KEY / ORCHESTR_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 "$ORCHESTR_SIGNING_SECRET" | sed 's/^.*= //')
curl -sS -X POST "https://api.sandbox.upprove.com${REQ_PATH}" \
-H "Authorization: Bearer $ORCHESTR_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.ORCHESTR_SECRET_KEY}`,
'X-Signature': buildSignatureHeader(process.env.ORCHESTR_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 {ORCHESTR_SECRET_KEY}",
"X-Signature": build_signature_header(ORCHESTR_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('ORCHESTR_SECRET_KEY'),
'X-Signature: ' . build_signature_header(getenv('ORCHESTR_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("ORCHESTR_SECRET_KEY"))
.header("X-Signature", buildSignatureHeader(System.getenv("ORCHESTR_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("ORCHESTR_SECRET_KEY")}");
request.Headers.Add("X-Signature",
BuildSignatureHeader(Environment.GetEnvironmentVariable("ORCHESTR_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 getSending payment_method.details.card_token on the create switches to a
server-to-server authorization instead — a real charge attempt that can
return completed, failed, authorized, or requires_action with a 3-D Secure challenge. Only
the token-less request produces collect_card.
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 Orchestr.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/orchestr.js"></script>
<div id="card-element"></div>
<button id="pay" disabled>Pay £42.00</button>
Using a bundler? npm install @orchestr/card-js gives you a typed loader that injects the same
script — see the Orchestr.js reference.
const orchestr = Orchestr('pk_test_…'); // your publishable key
const card = orchestr.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 orchestr.collectCard(clientSecret);
if (error) return showError(error.code);
// Non-sensitive: safe to display or send to your server.
console.log(collected.brand, collected.last4);
};
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. Confirm the payment
Confirm with the token. 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:
const {payment, error} = await orchestr.confirmCardPayment({
orderId: 'pay_5b4a3c2d', // order_id from step 1
token: collected.token,
});
if (error) return showError(error.code);
if (payment.status === 'completed') showSuccessUi();
The value confirmCardPayment() 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.
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). |
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
Charge a stored card server-side by passing its token:
{
"amount": "9.99",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card",
"details": {"card_token": "tok_1PqRsT2eZvKYlo2C"}
},
"merchant_reference": "sub-2026-07-renewal",
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel"
}
The consent type recorded at save time governs how the stored card may be used: recurring and
unscheduled agreements permit merchant-initiated charges like the one above; card_on_file
covers customer-initiated purchases where you present the saved card at checkout.
A create carrying card_token runs a server-to-server authorization — there is no
collect_card step, but the issuer can still demand 3-D Secure, in which case you get back
requires_action and a challenge to route the cardholder through. Handle all four outcomes; see
Server-to-server card payments.
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.
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 — charging a token from your server, device data, and 3-D Secure.
- Manual capture — authorize now, settle later; partial captures and voids.
- Orchestr.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.