Skip to main content

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.

Card data still never reaches your servers

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.

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"

The four outcomes

A 200 OK returns a PaymentView. Unlike the browser flow, status can be terminal immediately — branch on all four:

statusMeaningWhat to do
completedAuthorized and captured.Fulfil on the webhook, not on this response.
failedDeclined or errored.Read decline_code on the transaction to decide whether a retry is sensible.
authorizedFunds held, nothing moved. Only with capture_method: manual.Settle later — see Manual capture.
requires_actionThe 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"
}
The response is not the authority

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.

FieldNotes
user_agentMax 500 characters.
accept_headerThe browser's Accept header. Max 2048.
ipThe cardholder's IP, not your server's. Max 45 (IPv6-safe).
languageBCP-47 tag, e.g. en-GB.
color_depthDigits only, e.g. "24".
screen_height / screen_widthDigits only, in pixels.
time_zone_offsetMinutes from UTC, as JavaScript's getTimezoneOffset() reports it — so "-60" for UTC+1.
java_enabledAccepted, but pinned to false regardless of what you send. Not the same thing as javascript_enabled.
javascript_enabledBoolean.
Not for browser-collected payments

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.typeredirectthree_ds_challenge
Who renders the challengeOrchestr, on a hosted pageYou
Where the issuer returns the cardholderAn Orchestr return pageYour three_ds_return_url
How the payment resumesAutomaticallyYou 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 to target. form_fields is empty.
  • post — submit form_fields to target as an application/x-www-form-urlencoded form 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.

You take on the ACS hosting burden

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 your three_ds_return_url. Relay it verbatim, base64 as delivered (max 65536 characters).
  • Send no body at all when the provider fetches the result itself.
Signing an empty body

The signature covers the request body. If you send no body, sign the empty stringtimestamp + 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.

codeHTTPWhen it happens
card_payment_invalid_order_state409The order is not awaiting a card payment.
card_payment_in_progress409Another payment attempt for this order is already running.
consent_conflict409A different consent was asserted for the same payment.
card_api_unavailable502The card authorization service is unreachable. Surfaces with code: provider_unavailable — see Errors.

Next steps