Server-to-server card payments
When you hold a token for a saved card, two kinds of charge can
run entirely from your server, with no collect_card or collect_cvv step:
- Merchant-initiated - the cardholder is not present: a subscription renewal, metered billing, an
auto top-up, an instalment. You declare it with
stored_credential, and the charge runs under the agreement the cardholder gave when the card was saved. No 3-D Secure. - Customer-present, without the security code - the cardholder is at your checkout but you have
chosen not to make them re-type the CVC (one-click, or a page that does not load Upprove.js). You
opt in per request with
collect_cvv: false, send the cardholder'sdevice_data, and handle 3-D Secure yourself when the issuer demands it.
A create that carries a card_token with neither comes back with a
collect_cvv action for the browser instead.
A tok_… is an opaque reference to a vaulted card, not card data. Tokenizing happens in the
browser, on Orchestr's origin - see Card payments - or, for PCI DSS compliant
merchants, from your server through the Vault API. Either way, the charges on
this page work the same.
Merchant-initiated charges
POST /v1/payments with payment_method.details.card_token and
payment_method.details.stored_credential declaring initiator: "merchant" and the reason:
- 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",
"stored_credential": {
"initiator": "merchant",
"reason": "recurring"
}
}
},
"merchant_reference": "sub-2026-08-renewal"
}'
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: '9.99',
currency: 'GBP',
country: 'GB',
payment_method: {
code: 'card',
details: {
card_token: 'tok_1PqRsT2eZvKYlo2C',
stored_credential: {initiator: 'merchant', reason: 'recurring'},
},
},
merchant_reference: 'sub-2026-08-renewal',
});
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();
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",
"stored_credential": {"initiator": "merchant", "reason": "recurring"},
},
},
"merchant_reference": "sub-2026-08-renewal",
},
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()
Nobody is redirected on this path, so success_url and cancel_url are optional here.
Declaring the charge
stored_credential states who initiates the charge and, for a merchant, under which agreement.
Card networks require this declaration on every charge against a stored credential, and Orchestr
forwards it to the issuer.
| Field | Values | Notes |
|---|---|---|
initiator | customer, merchant | Required inside the object. Absent object means customer. |
reason | recurring, unscheduled, installment | Required when initiator is merchant; rejected otherwise. |
The reason must match the kind of consent the cardholder gave
when the card was saved, one to one: a card saved under a recurring agreement can be charged with
reason: "recurring" and nothing else. A card saved as card_on_file carries no merchant-initiated
agreement at all, so it can only be charged with the customer present.
A merchant-initiated charge has no shopper in front of a browser, so the request is rejected with
400 validation_failed when it also carries anything that describes one:
device_dataorthree_ds_return_url- there is no browser and no challenge.fail_url- there is nobody to send there.collect_cvv- a merchant-initiated charge never collects a security code.save_cardorconsent- consent is given by a cardholder on a customer-present payment, never asserted and consumed in the same call.amount: "0"- a zero-amount setup is by definition customer-initiated.
The declaration is only meaningful on payment_method.code: card; on any other method it is
rejected rather than ignored. Both values come back on every payment.*
webhook as stored_credential_initiator and
stored_credential_reason.
A merchant-initiated charge never comes back requires_action: no 3-D Secure runs, because the
cardholder authenticated when the card was saved, and the issuer is told the charge chains to that
original, authenticated payment.
Customer present, without the CVV step
By default a customer-present charge on a saved card sends the cardholder back through Upprove.js
to confirm the security code. The card networks do not require the code on a card-on-file charge,
so a merchant willing to accept the higher fraud and decline risk can skip that step by setting
payment_method.details.collect_cvv to false. The create then authorizes immediately, as a
customer-initiated charge, and nothing is collected in the browser:
{
"amount": "42.00",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card",
"details": {
"card_token": "tok_1PqRsT2eZvKYlo2C",
"collect_cvv": false,
"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
}
}
},
"merchant_reference": "order-1002",
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel"
}
collect_cvv is only valid together with a card_token on a customer-present charge: a new card is
always collected in full, and a merchant-initiated charge never collects a code. success_url and
cancel_url stay required, since a 3-D Secure redirect still returns the cardholder to you.
The cardholder is still present, so the issuer may still demand 3-D Secure - and now the challenge
reaches your server, not Upprove.js. Send device_data and handle the
3-D Secure outcomes below.
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.
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 flows (collect_card and collect_cvv) Upprove.js gathers this
itself; a device_data you send there is accepted but unused. On a merchant-initiated charge it is
rejected.
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 "$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"
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.
The 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, errored, or refused by Orchestr before reaching the issuer. | 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. Only on a collect_cvv: false charge. | Route the cardholder through the challenge - see 3-D Secure. |
{
"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.
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 |
|---|---|---|
validation_failed | 400 | The declaration is incomplete or contradicts the request, or collect_cvv was sent without a token or on a merchant-initiated charge. |
card_mit_not_permitted | 409 | The stored card carries no consent that permits this charge: the card was never saved with an agreement, the agreement is of a different kind than reason, or the saving payment did not succeed. |
card_mit_provenance_missing | 409 | The stored card has a matching consent, but no initial customer-present authorization with a network transaction reference for a merchant-initiated charge to chain to. |
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. |
card_api_unavailable | 502 | The card authorization service is unreachable. Surfaces with code: provider_unavailable - see Errors. |
Not every acquirer supports merchant-initiated charges. When the one your account routes to does
not, the attempt fails with status: "failed" and decline_code: mit_not_supported_by_provider
after Orchestr has tried every eligible channel.
Next steps
- Charging a saved card - the default customer-present flow, with
collect_cvv. - 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.