Skip to main content

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's device_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 token is not card data

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:

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"

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.

FieldValuesNotes
initiatorcustomer, merchantRequired inside the object. Absent object means customer.
reasonrecurring, unscheduled, installmentRequired 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_data or three_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_card or consent - 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.

You take on what the SDK would otherwise absorb

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.

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 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.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 "$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 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 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:

statusMeaningWhat to do
completedAuthorized and captured.Fulfil on the webhook, not on this response.
failedDeclined, errored, or refused by Orchestr before reaching the issuer.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. 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"
}
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.

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
validation_failed400The declaration is incomplete or contradicts the request, or collect_cvv was sent without a token or on a merchant-initiated charge.
card_mit_not_permitted409The 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_missing409The 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_state409The order is not awaiting a card payment.
card_payment_in_progress409Another payment attempt for this order is already running.
card_api_unavailable502The 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​