Server-to-server payments
Take full control of the payment flow when you collect card data on your own infrastructure.
Server-to-server (S2S) is the right choice when you already have a card-collection UI and need to keep customers on your own domain. You handle the card form; Fynex handles the processor integration, 3DS, and settlement.
Caution
S2S means your servers receive PAN/CVV. You are responsible for PCI DSS compliance up to SAQ D scope. If that is not workable, use hosted checkout instead — it keeps you at SAQ A.
Lifecycle
Note
Fynex delivers a
PaymentCompletedwebhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). As a backstop, pollGET /payments-api/v1/payments/{payment_id}with the same bearer token (the path value is yourexternalOrderRef). See Polling & SSE.
Step-by-step
- Collect card details
Build a form on your frontend that captures PAN, expiry month/year, CVV, and cardholder name. Submit them to your backend over HTTPS — never log raw card numbers.
- Initialize the payment
POST /payments-api/v1/initialize-payment with Authorization: Bearer <token> and a unique Idempotency-Key (UUID) header.
curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"externalOrderRef": "ORDER-1042",
"amount": 49.00,
"paymentType": "card",
"paymentMethod": "card",
"currencyCode": "GBP",
"countryCode": "GB",
"autoSettlement": false,
"captureMode": "manual",
"cardData": {
"cardNumber": "4111111111111111",
"expMonth": 12,
"expYear": 2028,
"cvv": "123",
"holderName": "Jane Doe"
},
"billingDetails": {
"country": "GB",
"zip": "SW1A1AA",
"city": "London",
"street": "1 Example Street"
},
"returnLinks": [
{ "rel": "on_completed", "href": "https://example.com/orders/1042/success", "method": "GET" },
{ "rel": "on_failed", "href": "https://example.com/orders/1042/failure", "method": "GET" },
{ "rel": "default", "href": "https://example.com/orders/1042/return", "method": "GET" }
]
}'
The response is 202 Accepted for a new payment:
{
"paymentId": "ORDER-1042",
"status": "provider_pending",
"lifecycleStatus": "pending",
"legacyStatus": "provider_pending",
"amount": 49.00,
"currencyCode": "GBP",
"requiresAction": false,
"actionUrl": ""
}
When 3DS is required the response looks like:
{
"paymentId": "ORDER-1042",
"status": "provider_pending",
"lifecycleStatus": "pending",
"legacyStatus": "provider_pending",
"amount": 49.00,
"currencyCode": "GBP",
"requiresAction": true,
"actionUrl": "https://3ds.example.com/challenge/..."
}
- Handle 3DS (if
requiresAction: true)
See 3DS handling below for the full flow. Short version: redirect the customer's browser to actionUrl, then proceed to finalize once they return.
When requiresAction: false, skip this step entirely.
- Finalize the payment
POST /payments-api/v1/finalize-payment with paymentId (your externalOrderRef) and a new unique Idempotency-Key.
curl -sS -X POST "$FYNEX_API/payments-api/v1/finalize-payment" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "paymentId": "ORDER-1042" }'
The response is 200 OK:
{
"paymentId": "ORDER-1042",
"status": "provider_completed",
"lifecycleStatus": "captured",
"legacyStatus": "provider_completed",
"providerPaymentId": "pay_01J2EXAMPLE",
"capturedAmount": 49.00,
"currencyCode": "GBP"
}
- Verify and fulfil
Mark the order paid only when lifecycleStatus is captured — a manual-capture payment rests at authorized until you capture it (see Payment Lifecycle). If failureCode is present, the payment was declined — surface the failureDescription to the customer as appropriate.
Poll GET /payments-api/v1/payments/{payment_id} if you need to check status asynchronously. See Polling & SSE.
Request fields reference
POST /initialize-payment — required fields
| Field | Type | Description |
|---|---|---|
externalOrderRef |
string | Your order reference. Returned as paymentId in all subsequent responses and used as the path param for capture/refund. |
amount |
float | Major units (e.g., 49.00). |
paymentType |
string | card, bank_transfer, or apm. |
paymentMethod |
string | card, bank_account, google_pay, or apple_pay. Must be compatible with paymentType: card type accepts card/google_pay/apple_pay; bank_transfer accepts only bank_account; apm type accepts bancontact/multibanco/mbway/wero/swish (see Alternative payment methods (APM) for the redirect flow). |
currencyCode |
string (3-letter) | Must be one of: EUR, USD, GBP, DKK, NOK, SEK. Upper-cased server-side. |
countryCode |
string (2-letter) | ISO 3166-1 alpha-2. Length checked; no enum validation. |
cardData.cardNumber |
string | Required when paymentMethod=card. Non-empty check only at this layer — length/Luhn validation occurs downstream. |
cardData.expMonth |
int | Required when paymentMethod=card. Non-nil check only. |
cardData.expYear |
int | Required when paymentMethod=card. Non-nil check only. |
cardData.cvv |
string | Required when paymentMethod=card. Non-empty check only. |
POST /initialize-payment — optional fields
| Field | Type | Description |
|---|---|---|
cardData.holderName |
string | Cardholder name. The upstream processor accepts Latin letters (A-Z), spaces, apostrophes, dots, and hyphens only. |
autoSettlement |
bool | true captures automatically. false makes it a manual-capture payment that rests at authorized until POST /payments/{id}/capture. Left out, your account's default applies: automatic capture if the account defaults to it, otherwise manual. |
captureMode |
string | auto (default) or manual. Accepted, but it does not decide capture today — autoSettlement does. |
merchantCustomerId |
string | Your identifier for the customer. Stored on the payment as SellerCustomerRef. |
billingDetails |
object | Cardholder billing address. Fields: firstName, lastName, email, phone, addressLine1 (or street), addressLine2, city, state, postalCode (or zip, at most 10 characters; longer is rejected with 400 billingDetails.postalCode must be at most 10 characters, or billingDetails.zip must be at most 10 characters when only zip was sent; not checked for paymentType: apm), countryCode (or country). Optional at the API level, but the upstream card processor requires country and zip. Omitting them returns 502 with {"error":"<provider> ... returned 400"}. Always populate at least country and zip. |
deviceSessionId |
string | Optional device-intelligence session id. Create it with POST /payments-api/v1/device-intelligence/token, initialize @sumsub/fisherman in the customer's browser with the returned accessToken, and pass the same sessionId here so the compliance transaction can be linked to captured device signals. |
returnLinks |
array | Where the customer is redirected after a 3DS challenge or other action. Array (not the {success, failure} object used by /checkout) of { "rel": <enum>, "href": <absolute http/https URL>, "method": "GET" }. Valid rel values: default, on_completed, on_failed, on_cancelled. If omitted, the API falls back to the return links configured on the seller account; if neither source has at least one valid link, the request returns 400 {"error":"valid returnLinks are required"}. |
orderData.payeeDistribution |
array | Split the captured amount across payees. Each element: { "payeeId": <int64>, "amountMinor": <int64> }, the share in integer minor units (8500 for £85.00). The deprecated { "payeeId": <int64>, "amount": <float> } in major units is still accepted; every element must use the same one. The shares must add up to the payment amount. |
holdPeriod |
int | Hours to keep the authorization (informational). |
subscription.enabled |
bool | When true, the payment opts into the recurring/saved-card flow and the upstream card processor creates a customer record so future /initialize-payment calls can charge the same card without re-collecting it. Card-only. Default: false. |
subscription.allowSubsequentMerchantInitiated |
bool | When true together with subscription.enabled, allows future merchant-initiated charges (recurring billing, top-ups) without the cardholder present. Send false for one-tap re-purchase flows where the cardholder is always present. Default: false. |
skip3DS |
bool | When true, the upstream payment-handle request is sent with the processor's "skip 3DS" flag and no threeDs block — the customer is not redirected to a 3DS challenge and the payment auto-finalizes. Default: omitted (standard 3DS flow). Persisted on the payment for audit. SCA bypass: in EU/UK, only use this when the payment qualifies for an exemption (merchant-initiated transactions, exempt MOTO, recurring with stored credentials). The flag is honored on every operational mode — do not pass it on Live merchants for fresh customer-present card payments. |
Caution
Fields that do NOT exist on
InitiatePaymentRequest:saveCard,customerEmail,splitRules(top-level),metadata,successUrl,cancelUrl. Do not send these.
returnUrlIS a real top-level field, but it's specific to theapmpayment type. It's where the buyer is sent after a redirect-based alternative payment method completes — see Alternative payment methods (APM). Card and bank_transfer payments do not usereturnUrl; they use thereturnLinksarray instead.
returnLinksis an array, not an object. The{success, failure}shape belongs to/checkout(returnUrls). On/initialize-paymentthe field isreturnLinks: [{rel, href, method}]. Sending an object —"returnLinks": {"success": ..., "failure": ...}— fails JSON decoding and returns400 {"error":"invalid request body"}.
Tip
Testing both 3DS and non-3DS in staging: the same Bearer token can drive both flows just by toggling
skip3DS. Send"skip3DS": trueto skip the redirect (handle returnsPAYABLE, payment auto-finalizes); omit the field or send"skip3DS": falsefor the standardrequiresAction: true+actionUrlflow.
POST /finalize-payment — fields
| Field | Type | Required | Description |
|---|---|---|---|
paymentId |
string | Yes | The externalOrderRef from initialize. Used for lookup. |
amount |
float | No | Partial capture: provide a value lower than the authorized amount to capture less. Omit to capture the full amount. |
merchantReference |
string | No | Your internal reference for this capture. Stored at your discretion. |
Note
Idempotency-Keyon finalize: the header is required and validated as a UUID, but it is not used for replay protection on this endpoint. The payment state machine itself prevents double-finalization — a second call on an already-finalized payment will return an error reflecting the current status. Use the key anyway; the server will reject a missing or malformed one.
Partial capture
If you authorized £100 but only need to capture £80, pass amount on /finalize-payment:
{
"paymentId": "ORDER-1042",
"amount": 80.00
}
The remaining £20 authorization is released to the cardholder's available balance.
Split payments
Pass orderData.payeeDistribution on /initialize-payment to distribute funds across payees. Retrieve payee IDs from GET /payments-api/v1/payees.
{
"externalOrderRef": "ORDER-1042",
"amount": 100.00,
"paymentType": "card",
"paymentMethod": "card",
"currencyCode": "GBP",
"countryCode": "GB",
"cardData": { "..." : "..." },
"orderData": {
"totalAmount": 100.00,
"payeeDistribution": [
{ "payeeId": 101, "amountMinor": 8500 },
{ "payeeId": 102, "amountMinor": 1500 }
]
}
}
amount and orderData.totalAmount are in major units. Each share's amountMinor is in integer minor units, so 8500 is £85.00. The sum of payeeDistribution[*].amountMinor must equal amount in minor units, which is 10000 for the £100.00 above. A distribution sent in the wrong unit cannot add up, so it is refused with 400. The deprecated major-units amount on a share is still accepted, but every share in one distribution must use the same unit — see Split Rules.
3DS handling
When the processor requires a 3DS challenge, /initialize-payment returns:
{
"requiresAction": true,
"actionUrl": "https://3ds.example.com/challenge/..."
}
Browser-based flow:
- Redirect the customer's browser to
actionUrl(full-page redirect, not an iframe). - The issuer redirects back to a URL configured in your payment setup after the challenge.
- Once the customer is back, call
/finalize-paymentwith the samepaymentId.
localStorage bridge pitfall: Because the redirect is a full-page navigation away from your origin, any in-memory state (React state, session-scoped variables) is lost. If your frontend needs to resume after the redirect, persist the paymentId and relevant UI state to localStorage or a server-side session before redirecting. Read it back on return and call /finalize-payment from there.
Note
A deeper 3DS guide — covering flows for Google Pay, Apple Pay, and edge cases — will be published as a separate Wave 2 guide. The above covers the common card path.
Skipping 3DS
For payments that qualify for an SCA exemption (merchant-initiated transactions, exempt MOTO, recurring with stored credentials) — or for staging integration tests where you don't want to drive a browser — pass "skip3DS": true on /initialize-payment:
curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \
-H "Authorization: Bearer $FYNEX_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"externalOrderRef": "ORDER-1042",
"amount": 49.00,
"paymentType": "card",
"paymentMethod": "card",
"currencyCode": "GBP",
"countryCode": "GB",
"skip3DS": true,
"cardData": {
"cardNumber": "4111111111111111",
"expMonth": 12,
"expYear": 2028,
"cvv": "123",
"holderName": "Jane Doe"
},
"billingDetails": {
"country": "GB",
"zip": "SW1A1AA",
"city": "London",
"street": "1 Example Street"
}
}'
Response shape with skip3DS: true: the upstream payment handle is created in PAYABLE state and the payment is auto-finalized by Fynex's upstream status poller (5-second tick), so its lifecycleStatus typically reaches captured (authorized for a manual-capture payment) within ~5 seconds of /initialize-payment returning. The /initialize-payment response will contain:
{
"paymentId": "ORDER-1042",
"status": "provider_pending",
"lifecycleStatus": "pending",
"legacyStatus": "provider_pending",
"amount": 49.00,
"currencyCode": "GBP",
"requiresAction": false
}
actionUrl is absent. You do not need to call /finalize-payment — poll GET /payments-api/v1/payments/{payment_id} (or wait ~5s for one poller cycle) and lifecycleStatus will be captured (authorized for a manual-capture payment).
Caution
SCA bypass.
skip3DS: trueskips Strong Customer Authentication. In EU/UK this is regulated — only use it on payments that genuinely qualify for an exemption. Customer-present card payments on Live merchants almost always require 3DS; do not passskip3DS: truefor them. The flag is honored on every operational mode (the API does not gate it by Demo/Live), so it is your responsibility to ensure the request is appropriate.
Saved cards and recurring payments
The subscription block above opts the first payment into the card processor's stored-credential flow; this API has no call that charges a stored card by reference afterwards. Recurring billing — subscriptions, invoices and off-session collection against a card the customer saved on hosted checkout — is the separate Billing API at /billing-api/v1. See Recurring payments for how the two fit together. Do not rely on the legacy /api/v1/checkout/customer endpoint documented elsewhere — that is the dashboard's internal path, not part of this public API surface.
Polling for status
GET /payments-api/v1/payments/{payment_id} returns the current lifecycle state of a payment. The payment_id path segment is your externalOrderRef (same convention as /capture and /refund); if you sent multiple attempts under the same externalOrderRef, the latest attempt is returned. Auth is the same seller bearer token you already use — no cookie session required.
curl -sS "$FYNEX_API/payments-api/v1/payments/ORDER-1042" \
-H "Authorization: Bearer $FYNEX_TOKEN"
The response includes lifecycleStatus (alongside status, the deprecated legacyStatus and, once captured, settlementStatus), the originally authorized amount, currencyCode / countryCode, paymentType / paymentMethod, externalOrderRef, the latest failureCode / failureDescription / failureStage if the payment failed, and createdAt / updatedAt / failedAt timestamps. (The raw upstream providerStatus is not part of this REST response — it is exposed only on the GraphQL genericPayment type.) Stop polling once lifecycleStatus is refunded, failed or cancelled (or captured if you don't need settlement progress) — see Payment Lifecycle. Everything else is intermediate — keep polling, or wait for the next state-changing call on your side.
Alongside the outbound PaymentCompleted webhook, this is a reliable way for a server-to-server backend to verify payment outcomes — and a recommended backstop in case a webhook delivery is missed. See Polling & SSE for backoff schedules and resilience patterns.
Common pitfalls
Warning
Do not fulfil the order based on the
/initialize-paymentresponse alone. That call returns202 Acceptedfor a new payment — it does not confirm capture. Always call/finalize-paymentand checklifecycleStatusbefore fulfilling.
autoSettlementmust befalsefor manual capture. If you want to callPOST /payments/{id}/captureseparately, initialize withautoSettlement: false— left out, your account's default applies, which may be automatic capture.captureMode: "manual"alone does not make a payment manual-capture. The capture endpoint refuses a payment that captured automatically.paymentTypeandpaymentMethodmust be compatible.paymentType: "card"withpaymentMethod: "bank_account"returns a 400 validation error.currencyCodemust be one of the supported currencies. Unlike/checkout,/initialize-paymentenforces the allowlist: EUR, USD, GBP, DKK, NOK, SEK. An unrecognized code returns a 400.- Idempotency-Key replay on init: re-sending the same key returns the existing payment as
200 OK(not202). For an active redirect APM with a persisted provider charge, Fynex fetches the existing charge and returnsrequiresAction,actionUrl, andredirectFullPageagain; it does not create a second charge. paymentIdis yourexternalOrderRef. The path param for/captureand/refundis the string you passed asexternalOrderRefon init — not a numeric internal ID.- Empty or missing
billingDetailsfails at the upstream processor. The Fynex DTO marksbillingDetailsas optional, but the upstream card processor rejects requests withoutcountryandzip— surfaced to your client as502 Bad Gatewaywith body{"error":"upstream card processor returned 400"}andfailureCode: 2002on the payment record. Always include at leastbillingDetails.country(orcountryCode) andbillingDetails.zip(orpostalCode) on card payments. returnLinksshape mismatch returns 400. The field is an array of{rel, href, method}— not the{success, failure}object used by/checkout. Sending an object decodes as400 {"error":"invalid request body"}. Omitting the field is fine only if the seller account has return links configured in the Dashboard; otherwise the request returns400 {"error":"valid returnLinks are required"}. PassreturnLinksexplicitly when you want per-payment overrides.
See also
- Polling & SSE — Verify payment outcomes without webhooks.
- Captures & refunds — Capture, partially capture, or refund a payment after the fact.
- Idempotency & retries — Make S2S calls resilient to network failures.
- Apple Pay — Accepting Apple Pay or Google Pay? Use the dedicated guides.
- Google Pay — Accepting Google Pay with server-to-server integration.
- Alternative payment methods (APM) — Accepting Bancontact, Multibanco, MB WAY, Wero, or Swish with
paymentType: "apm".