Server-to-server card payments
When you already hold a card token — a saved card, or one your
page tokenized earlier — you can charge it entirely from your server. There is no browser step for
the charge itself, and no collect_card action.
This is the path for subscription renewals, metered billing, auto top-ups, and any merchant-initiated charge. The trade-off is that you handle the outcomes the SDK would otherwise absorb, including 3-D Secure.
A tok_… is an opaque reference to a vaulted card, not card data. Tokenizing still happens in the
browser, on Orchestr's origin — see Card payments.
Charge a token
POST /v1/payments with payment_method.details.card_token. The presence of that field is what
selects this flow.
- cURL
- Node.js
- Python
REQ_PATH="/v1/payments"
BODY='{"amount":"9.99","currency":"GBP","country":"GB","payment_method":{"code":"card","details":{"card_token":"tok_1PqRsT2eZvKYlo2C"}},"merchant_reference":"sub-2026-08-renewal","success_url":"https://shop.example.com/ok","cancel_url":"https://shop.example.com/cancel"}'
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: '9.99',
currency: 'GBP',
country: 'GB',
payment_method: {
code: 'card',
details: {card_token: 'tok_1PqRsT2eZvKYlo2C'},
},
merchant_reference: 'sub-2026-08-renewal',
success_url: 'https://shop.example.com/ok',
cancel_url: 'https://shop.example.com/cancel',
});
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();
import json
import requests
path = "/v1/payments"
body = json.dumps(
{
"amount": "9.99",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card",
"details": {"card_token": "tok_1PqRsT2eZvKYlo2C"},
},
"merchant_reference": "sub-2026-08-renewal",
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel",
},
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()
The four outcomes
A 200 OK returns a PaymentView. Unlike the browser flow, status can be terminal immediately —
branch on all four:
status | Meaning | What to do |
|---|---|---|
completed | Authorized and captured. | Fulfil on the webhook, not on this response. |
failed | Declined or errored. | Read decline_code on the transaction to decide whether a retry is sensible. |
authorized | Funds held, nothing moved. Only with capture_method: manual. | Settle later — see Manual capture. |
requires_action | The issuer demands 3-D Secure. | Route the cardholder through the challenge — see below. |
{
"order_id": "pay_5b4a3c2d",
"transaction_id": "txn_9f8e7d6c",
"merchant_reference": "sub-2026-08-renewal",
"status": "completed",
"amount": 9.99,
"currency": "GBP",
"provider_transaction_id": "pi_3PqRsT2eZvKYlo2C1aBcD3eF"
}
As on every other path, the signed payment.* webhook is the outcome you
fulfil on. The create response can be superseded — a settling capture can still fail.
Device data
On this path Orchestr never sees the cardholder's browser, so it cannot collect the device
signals 3-D Secure risk-assesses. Send them yourself in payment_method.details.device_data.
Omitting it measurably increases how often issuers challenge:
"payment_method": {
"code": "card",
"details": {
"card_token": "tok_1PqRsT2eZvKYlo2C",
"device_data": {
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"accept_header": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"ip": "203.0.113.42",
"language": "en-GB",
"color_depth": "24",
"screen_height": "1080",
"screen_width": "1920",
"time_zone_offset": "-60",
"javascript_enabled": true
}
}
}
Every field is optional, but ip and accept_header are the two only you can supply — the platform
would otherwise see your server's, which is worse than sending nothing. Capture these from the
cardholder's session at checkout and carry them through to the charge.
| Field | Notes |
|---|---|
user_agent | Max 500 characters. |
accept_header | The browser's Accept header. Max 2048. |
ip | The cardholder's IP, not your server's. Max 45 (IPv6-safe). |
language | BCP-47 tag, e.g. en-GB. |
color_depth | Digits only, e.g. "24". |
screen_height / screen_width | Digits only, in pixels. |
time_zone_offset | Minutes from UTC, as JavaScript's getTimezoneOffset() reports it — so "-60" for UTC+1. |
java_enabled | Accepted, but pinned to false regardless of what you send. Not the same thing as javascript_enabled. |
javascript_enabled | Boolean. |
On the SDK flow Orchestr.js gathers this itself. Don't send device_data
there.
3-D Secure
When the issuer requires a challenge, the payment comes back requires_action. How the
challenge reaches you depends on a per-account setting with two modes.
redirect (default) | native_server (opt-in) | |
|---|---|---|
next_action.type | redirect | three_ds_challenge |
| Who renders the challenge | Orchestr, on a hosted page | You |
| Where the issuer returns the cardholder | An Orchestr return page | Your three_ds_return_url |
| How the payment resumes | Automatically | You call POST /v1/payments/{id}/3ds/complete |
Default: redirect mode
You get an ordinary redirect action:
{
"order_id": "pay_5b4a3c2d",
"status": "requires_action",
"next_action": {
"type": "redirect",
"redirect_url": "https://api.sandbox.upprove.com/card/3ds/challenge/pay_5b4a3c2d"
}
}
Send the cardholder to redirect_url. Orchestr hosts the challenge and the return page, settles the
payment, and reports the result on the webhook. You make no further API call.
Opt-in: native_server mode
You get the challenge descriptor itself and mount it yourself:
{
"order_id": "pay_5b4a3c2d",
"status": "requires_action",
"next_action": {
"type": "three_ds_challenge",
"challenge": {
"mode": "post",
"target": "https://acs.issuer.example/challenge",
"form_fields": {
"creq": "eyJ0aHJlZURTU2VydmVyVHJhbnNJRCI6...",
"threeDSSessionData": "cGF5XzViNGEzYzJk"
}
}
}
}
mode is normalized to one of two values:
redirect— navigate the cardholder totarget.form_fieldsis empty.post— submitform_fieldstotargetas anapplication/x-www-form-urlencodedform POST, typically from a hidden iframe or a self-submitting form.
Set three_ds_return_url on the create so the issuer returns the cardholder to you when they
finish.
Rendering an issuer's access control server in your own page means its origins must be permitted by your Content Security Policy, and those origins vary by issuer. The default redirect mode exists so you don't have to solve this.
Completing the challenge
Once the cardholder has finished, resume the payment:
REQ_PATH="/v1/payments/pay_5b4a3c2d/3ds/complete"
BODY='{"cres":"eyJhY3NUcmFuc0lEIjoiM2E4ZjJjMWIt..."}'
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"
The body is optional, and which form you use depends on the provider:
- Send
{"cres": "…"}when the issuer's ACS posted a challenge result to yourthree_ds_return_url. Relay it verbatim, base64 as delivered (max 65536 characters). - Send no body at all when the provider fetches the result itself.
The signature covers the request body. If you send no body, sign the empty string —
timestamp + path + "". See Signing.
The response is a PaymentView with the settled state. This endpoint is idempotent — calling it
again on an already-settled payment returns that payment's current state rather than an error, so it
is safe to retry on a timeout.
Errors
Declines are not errors. A refused authorization returns 200 with status: "failed"; read
decline_code on the transaction. HTTP 4xx is reserved for requests the platform rejects before
reaching a provider.
code | HTTP | When it happens |
|---|---|---|
card_payment_invalid_order_state | 409 | The order is not awaiting a card payment. |
card_payment_in_progress | 409 | Another payment attempt for this order is already running. |
consent_conflict | 409 | A different consent was asserted for the same payment. |
card_api_unavailable | 502 | The card authorization service is unreachable. Surfaces with code: provider_unavailable — see Errors. |
Next steps
- Manual capture — authorize now, settle later.
- Decline codes — decide when a retry is worth attempting.
- Test cards — 3-D Secure scenarios in Sandbox.
- Webhooks — the authoritative outcome.