Skip to content
In this section

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

CustomerYour backendFynexsubmitPOST202requiresActionredirectreturnsno actionPOST200Fill card formPAN, expiry, CVV, nameInitialize paymentPOST /initialize-paymentProcessor authorizesReturns status + actionUrl?202 responseCheck requiresActionRedirect browserTo actionUrlCustomer completes 3DSBrowser bounces backFinalize paymentPOST /finalize-paymentCapture / finalizeReturns final statusVerify and fulfil200 result; check status

Note

Fynex delivers a PaymentCompleted webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). As a backstop, poll GET /payments-api/v1/payments/{payment_id} with the same bearer token (the path value is your externalOrderRef). See Polling & SSE.

Step-by-step

  1. 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.

  1. 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/..."
   }
  1. 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.

  1. 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"
   }
  1. 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.

returnUrl IS a real top-level field, but it's specific to the apm payment 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 use returnUrl; they use the returnLinks array instead.

returnLinks is an array, not an object. The {success, failure} shape belongs to /checkout (returnUrls). On /initialize-payment the field is returnLinks: [{rel, href, method}]. Sending an object — "returnLinks": {"success": ..., "failure": ...} — fails JSON decoding and returns 400 {"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": true to skip the redirect (handle returns PAYABLE, payment auto-finalizes); omit the field or send "skip3DS": false for the standard requiresAction: true + actionUrl flow.

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-Key on 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:

  1. Redirect the customer's browser to actionUrl (full-page redirect, not an iframe).
  2. The issuer redirects back to a URL configured in your payment setup after the challenge.
  3. Once the customer is back, call /finalize-payment with the same paymentId.

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: true skips 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 pass skip3DS: true for 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-payment response alone. That call returns 202 Accepted for a new payment — it does not confirm capture. Always call /finalize-payment and check lifecycleStatus before fulfilling.

  • autoSettlement must be false for manual capture. If you want to call POST /payments/{id}/capture separately, initialize with autoSettlement: 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.
  • paymentType and paymentMethod must be compatible. paymentType: "card" with paymentMethod: "bank_account" returns a 400 validation error.
  • currencyCode must be one of the supported currencies. Unlike /checkout, /initialize-payment enforces 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 (not 202). For an active redirect APM with a persisted provider charge, Fynex fetches the existing charge and returns requiresAction, actionUrl, and redirectFullPage again; it does not create a second charge.
  • paymentId is your externalOrderRef. The path param for /capture and /refund is the string you passed as externalOrderRef on init — not a numeric internal ID.
  • Empty or missing billingDetails fails at the upstream processor. The Fynex DTO marks billingDetails as optional, but the upstream card processor rejects requests without country and zip — surfaced to your client as 502 Bad Gateway with body {"error":"upstream card processor returned 400"} and failureCode: 2002 on the payment record. Always include at least billingDetails.country (or countryCode) and billingDetails.zip (or postalCode) on card payments.
  • returnLinks shape mismatch returns 400. The field is an array of {rel, href, method} — not the {success, failure} object used by /checkout. Sending an object decodes as 400 {"error":"invalid request body"}. Omitting the field is fine only if the seller account has return links configured in the Dashboard; otherwise the request returns 400 {"error":"valid returnLinks are required"}. Pass returnLinks explicitly when you want per-payment overrides.

See also