# Server-to-server payments

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](https://docs.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) instead — it keeps you at SAQ A.

## Lifecycle

```flow
lanes: Customer | Your backend | Fynex
node card 0 0 | Fill card form | PAN, expiry, CVV, name | | end
node init 1 1 | Initialize payment | POST /initialize-payment
node auth 2 2 | Processor authorizes | Returns status + actionUrl?
node accepted 1 3 | 202 response | Check requiresAction
node redirect 0 4 | Redirect browser | To actionUrl
node threeds 2 5 | Customer completes 3DS | Browser bounces back
node finalize 1 6 | Finalize payment | POST /finalize-payment
node capture 2 7 | Capture / finalize | Returns final status
node verify 1 8 | Verify and fulfil | 200 result; check status | | end ok
edge card init | submit
edge init auth | POST
edge auth accepted | 202
edge accepted redirect | requiresAction
edge redirect threeds | redirect
edge threeds finalize vh | returns
edge accepted finalize dashed | no action
edge finalize capture | POST
edge capture verify | 200
```

> [!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](https://docs.fynex.ai/payments-api/v2/docs#tag/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.

2. **Initialize the payment**

   `POST /payments-api/v1/initialize-payment` with `Authorization: Bearer <token>` and a unique `Idempotency-Key` (UUID) header.

```bash
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:

   ```json
   {
     "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:

   ```json
   {
     "paymentId": "ORDER-1042",
     "status": "provider_pending",
     "lifecycleStatus": "pending",
     "legacyStatus": "provider_pending",
     "amount": 49.00,
     "currencyCode": "GBP",
     "requiresAction": true,
     "actionUrl": "https://3ds.example.com/challenge/..."
   }
   ```

3. **Handle 3DS (if `requiresAction: true`)**

   See [3DS handling](#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.

4. **Finalize the payment**

   `POST /payments-api/v1/finalize-payment` with `paymentId` (your `externalOrderRef`) and a new unique `Idempotency-Key`.

```bash
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`:

   ```json
   {
     "paymentId": "ORDER-1042",
     "status": "provider_completed",
     "lifecycleStatus": "captured",
     "legacyStatus": "provider_completed",
     "providerPaymentId": "pay_01J2EXAMPLE",
     "capturedAmount": 49.00,
     "currencyCode": "GBP"
   }
   ```

5. **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](https://docs.fynex.ai/payments-api/v2/docs#tag/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](https://docs.fynex.ai/payments-api/v2/docs#tag/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)](https://docs.fynex.ai/payments-api/v2/docs#tag/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)](https://docs.fynex.ai/payments-api/v2/docs#tag/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`:

```json
{
  "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`.

```json
{
  "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](https://docs.fynex.ai/payments-api/v2/docs#tag/splits).

## 3DS handling

When the processor requires a 3DS challenge, `/initialize-payment` returns:

```json
{
  "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`:

```bash
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:

```json
{
  "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](https://docs.fynex.ai/payments-api/v2/docs#tag/recurring) 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.

```bash
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](https://docs.fynex.ai/payments-api/v2/docs#tag/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](https://docs.fynex.ai/payments-api/v2/docs#tag/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

- **[Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment outcomes without webhooks.
- **[Captures & refunds](https://docs.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — Capture, partially capture, or refund a payment after the fact.
- **[Idempotency & retries](https://docs.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make S2S calls resilient to network failures.
- **[Apple Pay](https://docs.fynex.ai/payments-api/v2/docs#tag/apple-pay)** — Accepting Apple Pay or Google Pay? Use the dedicated guides.
- **[Google Pay](https://docs.fynex.ai/payments-api/v2/docs#tag/google-pay)** — Accepting Google Pay with server-to-server integration.
- **[Alternative payment methods (APM)](https://docs.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm)** — Accepting Bancontact, Multibanco, MB WAY, Wero, or Swish with `paymentType: "apm"`.
