{"components":{"schemas":{"APMDataInput":{"additionalProperties":false,"properties":{"phone":{"examples":["+351912345678"],"type":"string"}},"type":"object"},"ApplePayBillingContactInput":{"additionalProperties":false,"properties":{"addressLines":{"items":{"type":"string"},"type":["array","null"]},"administrativeArea":{"type":"string"},"country":{"type":"string"},"countryCode":{"type":"string"},"familyName":{"type":"string"},"givenName":{"type":"string"},"locality":{"type":"string"},"phoneticFamilyName":{"type":"string"},"phoneticGivenName":{"type":"string"},"postalCode":{"type":"string"},"subAdministrativeArea":{"type":"string"},"subLocality":{"type":"string"}},"type":"object"},"ApplePayDataInput":{"additionalProperties":false,"properties":{"billingContact":{"$ref":"#/components/schemas/ApplePayBillingContactInput"},"label":{"type":"string"},"paymentToken":{"$ref":"#/components/schemas/ApplePayPaymentTokenInput"},"requestBillingAddress":{"type":"boolean"}},"type":"object"},"ApplePayDecryptedDataInput":{"additionalProperties":false,"properties":{"applicationExpirationDate":{"type":"string"},"applicationPrimaryAccountNumber":{"type":"string"},"cardholderName":{"type":"string"},"currencyCode":{"type":"string"},"deviceManufacturerIdentifier":{"type":"string"},"eciIndicator":{"type":"string"},"onlinePaymentCryptogram":{"type":"string"},"paymentDataType":{"type":"string"},"transactionAmount":{"description":"Apple's own decryptedData.transactionAmount, forwarded to the processor verbatim as Apple formats it — not a Fynex amount; the charge is the payment's amount in major units.","type":"string"},"version":{"examples":["EC_v1"],"type":"string"}},"type":"object"},"ApplePayPaymentMethodInput":{"additionalProperties":false,"properties":{"displayName":{"type":"string"},"network":{"type":"string"},"type":{"type":"string"}},"type":"object"},"ApplePayPaymentTokenInput":{"additionalProperties":false,"properties":{"decryptedData":{"$ref":"#/components/schemas/ApplePayDecryptedDataInput"},"paymentMethod":{"$ref":"#/components/schemas/ApplePayPaymentMethodInput"},"transactionIdentifier":{"type":"string"}},"type":"object"},"AppliedRule":{"additionalProperties":false,"properties":{"effects":{"description":"The policies that moved this payee's number after the line was evaluated — the answer to \"the rule says 40% but they got less\". Empty when the line's own arithmetic stood.","items":{"enum":["clamped_to_min","clamped_to_max","over_allocation_resolved"],"type":"string"},"type":["array","null"]},"feeAmountMinor":{"description":"The fee taken from that share, integer minor units.","format":"int64","type":"integer"},"netAmountMinor":{"description":"shareAmountMinor minus feeAmountMinor — what the payee receives, integer minor units.","format":"int64","type":"integer"},"payeeId":{"format":"int64","type":"integer"},"rawShareMinor":{"description":"What the line resolved to BEFORE any policy moved it, integer minor units. The difference against shareAmountMinor is exactly what a policy took or added.","format":"int64","type":"integer"},"shareAmountMinor":{"description":"The payee's gross share after policies, integer minor units.","format":"int64","type":"integer"}},"required":["payeeId","rawShareMinor","shareAmountMinor","feeAmountMinor","netAmountMinor"],"type":"object"},"BillingDetailsInput":{"additionalProperties":false,"properties":{"addressLine1":{"examples":["1 Example Street"],"type":"string"},"addressLine2":{"examples":["Suite 5"],"type":"string"},"city":{"examples":["London"],"type":"string"},"company":{"examples":["Example Ltd"],"type":"string"},"country":{"examples":["GB"],"type":"string"},"countryCode":{"examples":["GB"],"type":"string"},"email":{"examples":["john.doe@example.com"],"type":"string"},"firstName":{"examples":["John"],"type":"string"},"lastName":{"examples":["Doe"],"type":"string"},"phone":{"examples":["+447700900123"],"type":"string"},"postalCode":{"examples":["SW1A1AA"],"type":"string"},"state":{"examples":["Greater London"],"type":"string"},"street":{"examples":["1 Example Street"],"type":"string"},"zip":{"examples":["SW1A1AA"],"type":"string"}},"type":"object"},"BillingTrialWillEndPayload":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The charge that starts when the trial converts, in integer minor units.","examples":[820000],"format":"int64","type":"integer"},"contractId":{"description":"Contract it runs on. Its presence is what marks this delivery as a billing subscription rather than a payment-link one.","examples":[74],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of that charge.","examples":["EUR"],"type":"string"},"source":{"description":"Discriminator: which product emitted this event. Always billing for this shape.","enum":["billing"],"examples":["billing"],"type":"string"},"subscriptionId":{"description":"The billing subscription whose trial is ending.","examples":[15],"format":"int64","type":"integer"},"trialEnd":{"description":"The date the trial ends, YYYY-MM-DD. The event fires one day before it.","examples":["2026-09-17"],"type":"string"}},"required":["source","subscriptionId","contractId","trialEnd","amountMinor","currency"],"type":"object"},"CardDataInput":{"additionalProperties":false,"properties":{"cardNumber":{"examples":["4111111111111111"],"type":"string"},"cvv":{"examples":["123"],"type":"string"},"expMonth":{"examples":[12],"format":"int64","type":"integer"},"expYear":{"examples":[2028],"format":"int64","type":"integer"},"holderName":{"examples":["John Doe"],"type":"string"}},"type":"object"},"CashoutWalletSummary":{"additionalProperties":false,"properties":{"currency":{"examples":["EUR"],"type":"string"},"id":{"examples":[139],"format":"int64","type":"integer"},"status":{"examples":["active"],"type":"string"},"type":{"examples":["cashout_balance"],"type":"string"}},"required":["id","type","currency","status"],"type":"object"},"CheckoutOrderDataInput":{"additionalProperties":false,"properties":{"payeeDistribution":{"items":{"$ref":"#/components/schemas/CheckoutOrderPayeeDistributionInput"},"type":["array","null"]},"totalAmount":{"examples":[49.99],"format":"double","type":"number"}},"type":"object"},"CheckoutOrderPayeeDistributionInput":{"additionalProperties":false,"properties":{"amount":{"examples":[49.99],"format":"double","type":"number"},"payeeId":{"examples":[101],"format":"int64","type":"integer"}},"required":["payeeId","amount"],"type":"object"},"CreateCheckoutRequest":{"additionalProperties":false,"properties":{"amount":{"examples":[49.99],"format":"double","type":"number"},"autoSettlement":{"examples":[true],"type":"boolean"},"countryCode":{"examples":["GB"],"type":"string"},"currencyCode":{"examples":["GBP"],"type":"string"},"description":{"examples":["Order #100106"],"type":"string"},"externalOrderRef":{"examples":["ORDER-100106"],"type":"string"},"force3DS":{"examples":[true],"type":"boolean"},"locale":{"examples":["en"],"type":"string"},"logoUrl":{"examples":["https://merchant.example/logo.svg"],"type":"string"},"orderData":{"$ref":"#/components/schemas/CheckoutOrderDataInput"},"returnUrls":{"$ref":"#/components/schemas/ReturnURLsInput"},"sellerMerchantName":{"examples":["Fynex Store"],"type":"string"}},"required":["externalOrderRef","amount","currencyCode","returnUrls"],"type":"object"},"CreateCheckoutResponse":{"additionalProperties":false,"properties":{"checkoutUrl":{"examples":["https://pay.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"},"expiresAt":{"examples":["2026-04-07T11:30:00Z"],"type":"string"},"sessionId":{"examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}},"required":["sessionId","checkoutUrl","expiresAt"],"type":"object"},"CreatePayeeRequest":{"additionalProperties":false,"properties":{"businessAddress":{"examples":["1 Example Street, London, GB"],"type":"string"},"businessName":{"examples":["Example Ltd"],"type":"string"},"businessType":{"examples":["limited_company"],"type":"string"},"displayName":{"description":"Human-readable payee name. Required.","examples":["Acme Supplies Ltd"],"type":"string"},"email":{"examples":["finance@example.com"],"type":"string"},"kycEntityType":{"description":"KYC entity type. Only Individual is accepted via the API (company payees are onboarded by Fynex support). Required before starting verification.","enum":["Individual"],"examples":["Individual"],"type":"string"},"payeeContractId":{"description":"Your internal contract reference. Must be unique within your seller account.","examples":["pc_001"],"type":"string"},"phone":{"examples":["+447700900123"],"type":"string"},"role":{"description":"Payee role: contractor or tax.","enum":["contractor","tax"],"examples":["contractor"],"type":"string"},"taxId":{"examples":["GB123456789"],"type":"string"}},"required":["displayName","role"],"type":"object"},"CreatePayoutForPayeeRequest":{"additionalProperties":false,"properties":{"amount":{"description":"Decimal amount in the payout currency, in MAJOR units (e.g. \"12.50\" is €12.50). Preferred over amountMinor; when both are set, amount wins.","examples":["12.50"],"type":"string"},"amountMinor":{"description":"DEPRECATED — use amount. Amount in minor units (e.g. 1250 for 12.50). Fallback when amount is omitted.","examples":[1250],"format":"int64","type":"integer"},"idempotencyKey":{"description":"Idempotency key for safe retries. The Idempotency-Key header is read as a fallback when this field is empty.","examples":["a81e6e95-efe2-4e2c-b3e7-fc9a91a9f2c3"],"type":"string"},"payoutMethodId":{"description":"Required. A payout method id from POST /payees/setup or GET /payees/{id}/payout-methods. It must belong to this payee and be active.","examples":[501],"format":"int64","type":["integer","null"]}},"required":["payoutMethodId"],"type":"object"},"CreatePayoutForPayeeRequestDTO":{"allOf":[{"$ref":"#/components/schemas/CreatePayoutForPayeeRequest"}],"deprecated":true,"description":"Deprecated: renamed to `CreatePayoutForPayeeRequest`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"CreatePayoutMethodRequest":{"additionalProperties":false,"properties":{"accountName":{"examples":["Example Ltd"],"type":"string"},"accountNumber":{"description":"Local or SWIFT account number. Required for uk_local and us_local; for swift supply this or iban.","examples":["12345678"],"type":"string"},"bankAccountType":{"description":"Destination account identifier format: iban, uk_local, us_local, or swift. Defaults to iban for backward compatibility. Fynex selects the eventual payment rail.","enum":["iban","uk_local","us_local","swift"],"examples":["iban"],"type":"string"},"bankCountry":{"description":"ISO 3166-1 alpha-2 country of the destination bank. Required for uk_local, us_local, and swift; derived from the IBAN when omitted for iban accounts.","examples":["DE"],"type":"string"},"bankName":{"examples":["Example Bank"],"type":"string"},"bic":{"description":"BIC/SWIFT of the destination bank (ISO 9362). Optional but recommended.","examples":["NWBKGB2L"],"type":"string"},"currency":{"description":"ISO 4217 currency code. Supported destination currencies: EUR, GBP, USD.","examples":["EUR"],"type":"string"},"iban":{"description":"Destination IBAN. Required for iban accounts; may identify a swift account instead of accountNumber.","examples":["GB82WEST12345698765432"],"type":"string"},"routingNumber":{"description":"US ABA routing number. Required for us_local accounts.","examples":["021000021"],"type":"string"},"sortCode":{"description":"UK sort code. Required for uk_local accounts.","examples":["12-34-56"],"type":"string"},"swift":{"description":"Alias of bic — SWIFT/BIC of the destination bank. When both bic and swift are set, bic wins.","examples":["CHASUS33"],"type":"string"},"type":{"description":"Payout method type. Only bank_account is supported; defaults to bank_account when omitted.","examples":["bank_account"],"type":"string"}},"required":["currency"],"type":"object"},"CreatePayoutMethodRequestDTO":{"allOf":[{"$ref":"#/components/schemas/CreatePayoutMethodRequest"}],"deprecated":true,"description":"Deprecated: renamed to `CreatePayoutMethodRequest`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"CreatePayoutRequest":{"additionalProperties":false,"properties":{"amount":{"description":"Decimal amount in the payout currency, in MAJOR units (e.g. \"12.50\" is €12.50). Preferred over amountMinor; when both are set, amount wins. A value with more decimal places than the currency supports is rejected.","examples":["12.50"],"type":"string"},"amountMinor":{"description":"DEPRECATED — use amount instead. Amount in minor units (e.g. 1999 for £19.99). Fallback when amount is omitted.","examples":[1250],"format":"int64","type":"integer"},"currencyCode":{"description":"3-letter ISO currency code. EUR payouts route through the banking provider as a SEPA credit transfer; GBP payouts route through Faster Payments.","examples":["GBP"],"type":"string"},"idempotencyKey":{"description":"Idempotency key for safe retries. The HTTP Idempotency-Key header is read as a fallback when this field is empty; when both are set, the body wins.","examples":["a81e6e95-efe2-4e2c-b3e7-fc9a91a9f2c3"],"type":"string"},"payoutMethodId":{"description":"Required. Explicit payout method id from GET /payees/{id}/payout-methods. The method must belong to the wallet's payee and be active.","examples":[501],"format":"int64","type":["integer","null"]},"walletId":{"description":"Internal numeric id of the seller wallet to debit. **Not** an IBAN or any external bank-account identifier — look up the id from GET /payments-api/v1/wallets. The wallet must belong to the authenticated seller and hold sufficient balance in the requested currency.","examples":[15],"format":"int64","type":"integer"},"webhookUrl":{"description":"Optional per-request webhook URL. Must be HTTPS, must DNS-resolve to IPs covered by an active seller_webhook_allowlist entry. Validated for consistency across all four mutating endpoints; payouts do not yet emit per-payout events.","examples":["https://merchant.example.com/webhooks/payout-9001"],"type":"string"}},"required":["walletId","currencyCode","idempotencyKey","payoutMethodId"],"type":"object"},"CreatePayoutRequestDTO":{"allOf":[{"$ref":"#/components/schemas/CreatePayoutRequest"}],"deprecated":true,"description":"Deprecated: renamed to `CreatePayoutRequest`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"CreateSellerWebhookConfigRequest":{"additionalProperties":false,"properties":{"eventTypes":{"examples":[["PaymentCompleted"]],"items":{"type":"string"},"type":["array","null"]},"status":{"examples":["active"],"type":"string"},"webhookUrl":{"examples":["https://merchant.example.com/webhooks/fynex"],"type":"string"}},"required":["webhookUrl"],"type":"object"},"CreateSellerWebhookConfigResponse":{"additionalProperties":false,"properties":{"createdAt":{"format":"date-time","type":"string"},"eventTypes":{"examples":[["PaymentCompleted"]],"items":{"type":"string"},"type":["array","null"]},"id":{"examples":[7],"format":"int64","type":"integer"},"merchantId":{"examples":["7"],"type":"string"},"secretKey":{"examples":["3b8f1d2c4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"],"type":"string"},"status":{"examples":["active"],"type":"string"},"updatedAt":{"format":"date-time","type":"string"},"webhookUrl":{"examples":["https://merchant.example.com/webhooks/fynex"],"type":"string"}},"required":["id","merchantId","webhookUrl","status","secretKey","createdAt","updatedAt"],"type":"object"},"CreateTopupInvoiceRequest":{"additionalProperties":false,"properties":{"amountMinor":{"description":"Amount to load, in the currency's minor units. Must be \u003e 0.","examples":[250000],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency with an active safeguarding account and a seller main wallet.","examples":["EUR"],"type":"string"},"dueAt":{"description":"Optional explicit payment term (RFC3339). Must lie between the issue date and the expiry date minus the overdue grace window (7 days by default): the term has to leave room for the window before the invoice expires. Omitted means due immediately.","format":"date-time","type":"string"}},"required":["currency","amountMinor"],"type":"object"},"CreateWalletRequest":{"additionalProperties":false,"properties":{"currency":{"description":"3-letter ISO 4217 currency code of the wallet to provision. Must be a currency Fynex can pay out from (EUR, GBP, USD). Provisioning is idempotent — requesting a payee+currency you already hold returns the existing wallet.","examples":["EUR"],"type":"string"},"payeeId":{"description":"Required. Payee to bind the cashout-balance wallet to — e.g. an external contractor from POST /payees. The wallet is a payout-source balance for this payee and is meaningless without one.","examples":[101],"format":"int64","type":"integer"}},"required":["currency","payeeId"],"type":"object"},"CreateWalletRequestDTO":{"allOf":[{"$ref":"#/components/schemas/CreateWalletRequest"}],"deprecated":true,"description":"Deprecated: renamed to `CreateWalletRequest`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"CreateWalletResponse":{"additionalProperties":false,"properties":{"wallets":{"items":{"$ref":"#/components/schemas/WalletResponse"},"type":["array","null"]}},"required":["wallets"],"type":"object"},"CreateWalletResponseDTO":{"allOf":[{"$ref":"#/components/schemas/CreateWalletResponse"}],"deprecated":true,"description":"Deprecated: renamed to `CreateWalletResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"CreateWebhookAllowlistRequest":{"additionalProperties":false,"properties":{"cidr":{"description":"IPv4 or IPv6 in CIDR form. Bare IPs accepted and normalized to /32 (IPv4) or /128 (IPv6).","examples":["35.195.26.26/32"],"type":"string"},"description":{"examples":["Unity Finance webhook receiver"],"type":"string"}},"required":["cidr"],"type":"object"},"DecisionInputs":{"additionalProperties":false,"properties":{"allocationMode":{"description":"weight or absolute — how lines were apportioned.","type":"string"},"amountBase":{"description":"net_settled or gross_payment — what the percentages were taken of.","type":"string"},"distribution":{"description":"The payment's own payee weights; weight mode only.","items":{"$ref":"#/components/schemas/DecisionWeight"},"type":["array","null"]},"grossAmountMinor":{"description":"The amount the split ran against, integer minor units.","format":"int64","type":"integer"},"lines":{"description":"The rule's lines exactly as they were when the split ran — not as the rule reads now.","items":{"$ref":"#/components/schemas/DecisionLineSnapshot"},"type":["array","null"]},"overAllocationPolicy":{"type":"string"},"remainderPolicy":{"type":"string"},"ruleId":{"description":"The rule evaluated.","format":"int64","type":"integer"},"ruleVersion":{"description":"The rule version at evaluation time.","format":"int64","type":"integer"}},"required":["ruleId","ruleVersion","allocationMode","amountBase","grossAmountMinor","lines"],"type":"object"},"DecisionLineSnapshot":{"additionalProperties":false,"properties":{"allocationFixedAmountMinor":{"description":"The line's fixed share, integer minor units.","format":"int64","type":"integer"},"allocationPercentBps":{"description":"The line's share as integer basis points (100 = 1%) of the amount base.","format":"int64","type":"integer"},"allocationType":{"description":"percent_bps, fixed_minor or mixed.","type":"string"},"feeFixedAmountMinor":{"description":"The fixed fee taken from the share, integer minor units.","format":"int64","type":"integer"},"feePercentBps":{"description":"The fee taken from the share, integer basis points (100 = 1%).","format":"int64","type":"integer"},"isEnabled":{"type":"boolean"},"maxAmountMinor":{"description":"Ceiling the share is clamped down to, integer minor units.","format":"int64","type":"integer"},"minAmountMinor":{"description":"Floor the share is clamped up to, integer minor units.","format":"int64","type":"integer"},"payeeId":{"format":"int64","type":"integer"},"priority":{"format":"int64","type":"integer"}},"required":["payeeId","priority","isEnabled"],"type":"object"},"DecisionOutcome":{"additionalProperties":false,"properties":{"grossAmountMinor":{"description":"The amount that was split, integer minor units.","format":"int64","type":"integer"},"overAllocated":{"description":"True when the lines asked for more than the gross and the overAllocationPolicy had to resolve it.","type":"boolean"},"remainderMinor":{"description":"What the shares did not cover, integer minor units; routed per the rule's remainderPolicy.","format":"int64","type":"integer"},"shares":{"description":"The allocation, one entry per payee that received a share; the same entries as rulesApplied.","items":{"$ref":"#/components/schemas/AppliedRule"},"type":["array","null"]},"totalFeeMinor":{"description":"Sum of the fees, integer minor units.","format":"int64","type":"integer"}},"required":["shares","totalFeeMinor","grossAmountMinor","remainderMinor","overAllocated"],"type":"object"},"DecisionWeight":{"additionalProperties":false,"properties":{"distributionAmount":{"description":"The payee's weight from the payment's distribution, integer minor units.","format":"int64","type":"integer"},"payeeId":{"format":"int64","type":"integer"}},"required":["payeeId","distributionAmount"],"type":"object"},"DeletePayeeResponse":{"additionalProperties":false,"properties":{"archived":{"examples":[true],"type":"boolean"}},"required":["archived"],"type":"object"},"DeletePayoutMethodResponse":{"additionalProperties":false,"properties":{"deleted":{"examples":[true],"type":"boolean"}},"required":["deleted"],"type":"object"},"DeletePayoutMethodResponseDTO":{"allOf":[{"$ref":"#/components/schemas/DeletePayoutMethodResponse"}],"deprecated":true,"description":"Deprecated: renamed to `DeletePayoutMethodResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"DeviceIntelligenceTokenRequest":{"additionalProperties":false,"properties":{"sessionId":{"description":"Optional stable Sumsub Device Intelligence session id. Omit it on first call; reuse the returned sessionId when refreshing the accessToken.","examples":["9af95f9b-7f19-472c-946c-2aa684a76979"],"type":"string"}},"type":"object"},"DeviceIntelligenceTokenResponse":{"additionalProperties":false,"properties":{"accessToken":{"examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"type":"string"},"expiresIn":{"examples":[1800],"format":"int64","type":"integer"},"sessionId":{"examples":["9af95f9b-7f19-472c-946c-2aa684a76979"],"type":"string"}},"required":["sessionId","accessToken","expiresIn"],"type":"object"},"ErrorResponse":{"additionalProperties":false,"properties":{"error":{"type":"string"}},"required":["error"],"type":"object"},"FinalizePaymentRequest":{"additionalProperties":false,"properties":{"amount":{"description":"Amount to capture, in MAJOR units (49.99 is £49.99); omit to capture the full authorized amount.","examples":[49.99],"format":"double","type":"number"},"merchantReference":{"examples":["CAPTURE-001"],"type":"string"},"paymentId":{"examples":["ORDER-100106"],"type":"string"}},"required":["paymentId"],"type":"object"},"FinalizePaymentResponse":{"additionalProperties":false,"properties":{"capturedAmount":{"description":"What was captured, in MAJOR units (49.99 is £49.99), paired with currencyCode — the checkout family is the grandfathered exception to the Minor-suffix convention.","examples":[49.99],"format":"double","type":"number"},"currencyCode":{"examples":["GBP"],"type":"string"},"failureCategory":{"description":"What KIND of failure this is, from the failure-code catalogue — the field to count, alert and report on, where `retry` is the field to branch a request loop on. Absent while the payment has not failed. **hard** — a decision was made against the payment and it stands; **transient** — nothing was decided and the attempt can be repeated; **requires_change** — the customer's instrument or authentication has to change first; **integration_error** — the request or the seller's configuration is wrong, not the customer's card; **cancelled** — the payment was called off; **unknown** — the code is not one this catalogue classifies.","enum":["hard","transient","requires_change","integration_error","cancelled","unknown"],"examples":["hard"],"type":"string"},"failureCode":{"description":"Why the payment failed. `0` means it has not.\n\n**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.\n\n**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.\n\n| Code | Meaning | Category | Retry | What to do |\n|---|---|---|---|---|\n| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |\n| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |\n| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |\n| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |\n| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |\n| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |\n| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |\n| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |\n| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |\n| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |\n| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |\n| `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. |\n| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |\n| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |\n| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |\n| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |\n| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |\n| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |\n| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |\n","enum":[0,1001,1002,1003,1004,1005,1006,1007,2001,2002,2005,2003,2004,3001,3002,4001,5001,9001,9002,9999],"examples":[2001],"format":"int64","type":"integer"},"failureDescription":{"description":"Human-readable summary of the failure, safe to show a customer. Prose, not a contract: branch on `failureCode`, never on this string.","examples":["Provider declined the transaction"],"type":"string"},"paymentId":{"examples":["ORDER-100106"],"type":"string"},"providerCode":{"examples":["pp_01"],"type":"string"},"providerPaymentId":{"examples":["pay_01J2EXAMPLE"],"type":"string"},"retry":{"description":"What re-sending achieves, from the failure-code catalogue — the verdict a caller previously had to look up by `failureCode`. Absent while the payment has not failed. **safe** — the same request with the same `Idempotency-Key` can succeed; **fix_first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.","enum":["safe","fix_first","never"],"examples":["never"],"type":"string"},"status":{"examples":["provider_completed"],"type":"string"}},"required":["paymentId","status","capturedAmount","currencyCode"],"type":"object"},"FundWalletRequest":{"additionalProperties":false,"properties":{"amount":{"description":"Decimal amount to move from the seller's main wallet to this cashout wallet, in MAJOR units of the wallet's currency (\"100.00\" is €100.00). Must be positive and within the currency's scale (e.g. 2 decimals for EUR).","examples":["100.00"],"type":"string"},"idempotencyKey":{"description":"Optional caller-supplied key; replaying it returns the already-applied result instead of funding twice.","examples":["fund-2026-06-10-001"],"type":"string"}},"required":["amount"],"type":"object"},"FundWalletRequestDTO":{"allOf":[{"$ref":"#/components/schemas/FundWalletRequest"}],"deprecated":true,"description":"Deprecated: renamed to `FundWalletRequest`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"FundWalletResponse":{"additionalProperties":false,"properties":{"sourceWallet":{"$ref":"#/components/schemas/WalletResponse","description":"The seller's main wallet the funds were drawn from, with its balance after the debit."},"wallet":{"$ref":"#/components/schemas/WalletResponse","description":"The funded cashout_balance wallet, with its balance after the credit."}},"required":["wallet","sourceWallet"],"type":"object"},"FundWalletResponseDTO":{"allOf":[{"$ref":"#/components/schemas/FundWalletResponse"}],"deprecated":true,"description":"Deprecated: renamed to `FundWalletResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"GetPaymentResponse":{"additionalProperties":false,"properties":{"amount":{"description":"The payment amount, in MAJOR units (49.99 is £49.99), paired with currencyCode — the checkout family is the grandfathered exception to the Minor-suffix convention.","examples":[49.99],"format":"double","type":"number"},"countryCode":{"examples":["GB"],"type":"string"},"createdAt":{"examples":["2026-05-11T12:34:56Z"],"format":"date-time","type":"string"},"currencyCode":{"examples":["GBP"],"type":"string"},"externalOrderRef":{"examples":["ORDER-100106"],"type":"string"},"failedAt":{"format":"date-time","type":"string"},"failureCategory":{"description":"What KIND of failure this is, from the failure-code catalogue — the field to count, alert and report on, where `retry` is the field to branch a request loop on. Absent while the payment has not failed. **hard** — a decision was made against the payment and it stands; **transient** — nothing was decided and the attempt can be repeated; **requires_change** — the customer's instrument or authentication has to change first; **integration_error** — the request or the seller's configuration is wrong, not the customer's card; **cancelled** — the payment was called off; **unknown** — the code is not one this catalogue classifies.","enum":["hard","transient","requires_change","integration_error","cancelled","unknown"],"examples":["hard"],"type":"string"},"failureCode":{"description":"Why the payment failed. `0` means it has not.\n\n**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.\n\n**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.\n\n| Code | Meaning | Category | Retry | What to do |\n|---|---|---|---|---|\n| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |\n| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |\n| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |\n| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |\n| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |\n| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |\n| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |\n| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |\n| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |\n| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |\n| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |\n| `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. |\n| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |\n| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |\n| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |\n| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |\n| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |\n| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |\n| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |\n","enum":[0,1001,1002,1003,1004,1005,1006,1007,2001,2002,2005,2003,2004,3001,3002,4001,5001,9001,9002,9999],"examples":[2001],"format":"int64","type":"integer"},"failureDescription":{"description":"Human-readable summary of the failure, safe to show a customer. Prose, not a contract: branch on `failureCode`, never on this string.","examples":["Provider declined the transaction"],"type":"string"},"failureStage":{"description":"How far the payment got before it failed. Narrower than `failureCode`: the stage says WHERE, the code says WHY. `authorization` and later mean the request reached a provider; `validation`, `risk`, `compliance`, `routing` and `checkout` mean it did not, and no money was ever moved.","enum":["validation","risk","compliance","routing","checkout","authorization","capture","settlement","deposit_confirm"],"examples":["authorization"],"type":"string"},"paymentId":{"examples":["ORDER-100106"],"type":"string"},"paymentMethod":{"examples":["card"],"type":"string"},"paymentType":{"examples":["card"],"type":"string"},"retry":{"description":"What re-sending achieves, from the failure-code catalogue — the verdict a caller previously had to look up by `failureCode`. Absent while the payment has not failed. **safe** — the same request with the same `Idempotency-Key` can succeed; **fix_first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.","enum":["safe","fix_first","never"],"examples":["never"],"type":"string"},"status":{"examples":["provider_completed"],"type":"string"},"updatedAt":{"examples":["2026-05-11T12:35:10Z"],"format":"date-time","type":"string"}},"required":["paymentId","status","amount","currencyCode","countryCode","paymentType","paymentMethod","externalOrderRef","createdAt","updatedAt"],"type":"object"},"GooglePayBillingAddressInput":{"additionalProperties":false,"properties":{"address1":{"type":"string"},"address2":{"type":"string"},"address3":{"type":"string"},"administrativeArea":{"type":"string"},"countryCode":{"type":"string"},"locality":{"type":"string"},"name":{"type":"string"},"phoneNumber":{"type":"string"},"postalCode":{"type":"string"}},"type":"object"},"GooglePayDataInput":{"additionalProperties":false,"properties":{"paymentToken":{"$ref":"#/components/schemas/GooglePayPaymentTokenInput"}},"type":"object"},"GooglePayDecryptedTokenInput":{"additionalProperties":false,"properties":{"gatewayMerchantId":{"type":"string"},"messageExpiration":{"type":"string"},"messageId":{"type":"string"},"paymentMethod":{"type":"string"},"paymentMethodDetails":{"$ref":"#/components/schemas/GooglePayPaymentMethodDetailsInput"}},"type":"object"},"GooglePayPaymentMethodDataInput":{"additionalProperties":false,"properties":{"description":{"type":"string"},"info":{"$ref":"#/components/schemas/GooglePayPaymentMethodInfoInput"},"tokenizationData":{"$ref":"#/components/schemas/GooglePayTokenizationDataInput"},"type":{"type":"string"}},"type":"object"},"GooglePayPaymentMethodDetailsInput":{"additionalProperties":false,"properties":{"authMethod":{"type":"string"},"cryptogram":{"type":"string"},"eciIndicator":{"type":"string"},"expirationMonth":{"format":"int64","type":"integer"},"expirationYear":{"format":"int64","type":"integer"},"pan":{"type":"string"}},"type":"object"},"GooglePayPaymentMethodInfoInput":{"additionalProperties":false,"properties":{"billingAddress":{"$ref":"#/components/schemas/GooglePayBillingAddressInput"},"cardDetails":{"type":"string"},"cardNetwork":{"type":"string"}},"type":"object"},"GooglePayPaymentTokenInput":{"additionalProperties":false,"properties":{"apiVersion":{"format":"int64","type":"integer"},"apiVersionMinor":{"format":"int64","type":"integer"},"paymentMethodData":{"$ref":"#/components/schemas/GooglePayPaymentMethodDataInput"}},"type":"object"},"GooglePayTokenizationDataInput":{"additionalProperties":false,"properties":{"decryptedToken":{"$ref":"#/components/schemas/GooglePayDecryptedTokenInput"},"token":{"type":"string"},"type":{"type":"string"}},"type":"object"},"InitiatePaymentRequest":{"additionalProperties":false,"properties":{"amount":{"description":"Amount to charge, in MAJOR units (49.99 is £49.99), paired with currencyCode — the checkout family is the grandfathered exception to the Minor-suffix convention.","examples":[49.99],"format":"double","type":"number"},"apmData":{"$ref":"#/components/schemas/APMDataInput"},"applePayData":{"$ref":"#/components/schemas/ApplePayDataInput"},"autoSettlement":{"examples":[true],"type":"boolean"},"billingDetails":{"$ref":"#/components/schemas/BillingDetailsInput"},"captureMode":{"examples":["auto"],"type":"string"},"cardData":{"$ref":"#/components/schemas/CardDataInput"},"countryCode":{"examples":["GB"],"type":"string"},"currencyCode":{"examples":["GBP"],"type":"string"},"deviceSessionId":{"description":"Optional Sumsub Device Intelligence session id. Generate it via /device-intelligence/token, initialize @sumsub/fisherman with the returned accessToken, then pass the same sessionId here.","examples":["9af95f9b-7f19-472c-946c-2aa684a76979"],"type":"string"},"externalOrderRef":{"examples":["ORDER-100106"],"type":"string"},"googlePayData":{"$ref":"#/components/schemas/GooglePayDataInput"},"holdPeriod":{"examples":[72],"format":"int64","type":"integer"},"merchantCustomerId":{"examples":["cust-42"],"type":"string"},"orderData":{"$ref":"#/components/schemas/OrderDataInput"},"paymentMethod":{"examples":["card"],"type":"string"},"paymentType":{"examples":["card"],"type":"string"},"returnLinks":{"items":{"$ref":"#/components/schemas/ReturnLinkInput"},"type":["array","null"]},"returnUrl":{"examples":["https://merchant.example.com/checkout/return"],"type":"string"},"skip3DS":{"examples":[false],"type":"boolean"},"subscription":{"$ref":"#/components/schemas/SubscriptionInput"},"webhookUrl":{"description":"Optional per-request webhook URL. Must be HTTPS, must DNS-resolve to IPs covered by an active seller_webhook_allowlist entry. Delivery is additive to configured webhook URLs.","examples":["https://merchant.example.com/webhooks/payment-12345"],"type":"string"}},"required":["externalOrderRef","amount","paymentType","paymentMethod","currencyCode","countryCode"],"type":"object"},"InitiatePaymentResponse":{"additionalProperties":false,"properties":{"actionUrl":{"examples":["https://example-acs.test/challenge"],"type":"string"},"amount":{"description":"The payment amount, in MAJOR units (49.99 is £49.99), paired with currencyCode — the checkout family is the grandfathered exception to the Minor-suffix convention.","examples":[49.99],"format":"double","type":"number"},"currencyCode":{"examples":["GBP"],"type":"string"},"failureCategory":{"description":"What KIND of failure this is, from the failure-code catalogue — the field to count, alert and report on, where `retry` is the field to branch a request loop on. Absent while the payment has not failed. **hard** — a decision was made against the payment and it stands; **transient** — nothing was decided and the attempt can be repeated; **requires_change** — the customer's instrument or authentication has to change first; **integration_error** — the request or the seller's configuration is wrong, not the customer's card; **cancelled** — the payment was called off; **unknown** — the code is not one this catalogue classifies.","enum":["hard","transient","requires_change","integration_error","cancelled","unknown"],"examples":["hard"],"type":"string"},"failureCode":{"description":"Why the payment failed. `0` means it has not.\n\n**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.\n\n**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.\n\n| Code | Meaning | Category | Retry | What to do |\n|---|---|---|---|---|\n| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |\n| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |\n| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |\n| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |\n| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |\n| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |\n| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |\n| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |\n| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |\n| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |\n| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |\n| `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. |\n| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |\n| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |\n| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |\n| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |\n| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |\n| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |\n| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |\n","enum":[0,1001,1002,1003,1004,1005,1006,1007,2001,2002,2005,2003,2004,3001,3002,4001,5001,9001,9002,9999],"examples":[2001],"format":"int64","type":"integer"},"failureDescription":{"description":"Human-readable summary of the failure, safe to show a customer. Prose, not a contract: branch on `failureCode`, never on this string.","examples":["Provider declined the transaction"],"type":"string"},"paymentId":{"examples":["ORDER-100106"],"type":"string"},"paymentInstructions":{"$ref":"#/components/schemas/PaymentInstructions"},"redirectFullPage":{"examples":[true],"type":"boolean"},"requiresAction":{"examples":[true],"type":"boolean"},"retry":{"description":"What re-sending achieves, from the failure-code catalogue — the verdict a caller previously had to look up by `failureCode`. Absent while the payment has not failed. **safe** — the same request with the same `Idempotency-Key` can succeed; **fix_first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.","enum":["safe","fix_first","never"],"examples":["never"],"type":"string"},"status":{"examples":["provider_pending"],"type":"string"}},"required":["paymentId","status","amount","currencyCode","requiresAction"],"type":"object"},"KYBVerificationEventPayload":{"additionalProperties":false,"properties":{"occurredAt":{"description":"When the transition happened, RFC 3339 UTC.","examples":["2026-05-15T11:24:50Z"],"type":"string"},"organizationUuid":{"description":"The organization being verified, when the verification belongs to one rather than to a payee.","examples":["2c4e7f4a-ea89-49e6-b4c4-a2f96203e4f6"],"type":"string"},"payeeId":{"description":"The payee being verified, when the verification belongs to one.","examples":[17],"format":"int64","type":"integer"},"reviewRejectLabels":{"description":"The provider's reason labels for a rejection, comma-separated.","type":"string"},"reviewRejectType":{"description":"On a rejection, whether it is retryable (more documents wanted) or final.","type":"string"},"reviewResult":{"description":"The provider's verdict — GREEN or RED — on a completed review.","examples":["GREEN"],"type":"string"},"status":{"description":"Where the verification landed. The event type says the same thing; this is what to store.","examples":["approved"],"type":"string"},"sumsubApplicantId":{"description":"The provider's applicant id, for support conversations about a specific case.","examples":["6a065638b60b8f8c4f1c3578"],"type":"string"},"verificationId":{"description":"The verification this event describes.","examples":[142],"format":"int64","type":"integer"}},"required":["verificationId","sumsubApplicantId","status","occurredAt"],"type":"object"},"KYBWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Which verification transition fired.","enum":["KYBVerificationStarted","KYBVerificationPending","KYBVerificationManualReview","KYBVerificationApproved","KYBVerificationRejected","KYBVerificationLevelChanged"],"examples":["KYBVerificationApproved"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/KYBVerificationEventPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"LegacyAPIError":{"allOf":[{"$ref":"#/components/schemas/ErrorResponse"}],"deprecated":true,"description":"Deprecated: renamed to `ErrorResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"ListPayeesResponse":{"additionalProperties":false,"properties":{"limit":{"examples":[20],"format":"int64","type":"integer"},"offset":{"examples":[0],"format":"int64","type":"integer"},"payees":{"items":{"$ref":"#/components/schemas/PayeeItemResponse"},"type":["array","null"]},"total":{"examples":[100],"format":"int64","type":"integer"}},"required":["payees","total","limit","offset"],"type":"object"},"ListPayoutMethodsResponse":{"additionalProperties":false,"properties":{"limit":{"examples":[20],"format":"int64","type":"integer"},"offset":{"examples":[0],"format":"int64","type":"integer"},"payoutMethods":{"items":{"$ref":"#/components/schemas/PayoutMethodResponse"},"type":["array","null"]},"total":{"examples":[100],"format":"int64","type":"integer"}},"required":["payoutMethods","total","limit","offset"],"type":"object"},"ListPayoutsResponse":{"additionalProperties":false,"properties":{"limit":{"examples":[20],"format":"int64","type":"integer"},"offset":{"examples":[0],"format":"int64","type":"integer"},"payouts":{"items":{"$ref":"#/components/schemas/PayoutResponse"},"type":["array","null"]},"total":{"examples":[100],"format":"int64","type":"integer"}},"required":["payouts","total","limit","offset"],"type":"object"},"ListRefundsForPaymentOutputBody":{"allOf":[{"$ref":"#/components/schemas/ListRefundsResponse"}],"deprecated":true,"description":"Deprecated: renamed to `ListRefundsResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"ListRefundsResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/RefundResponse"},"type":["array","null"]}},"required":["items"],"type":"object"},"ListSellerWebhookConfigsResponse":{"additionalProperties":false,"properties":{"webhooks":{"items":{"$ref":"#/components/schemas/SellerWebhookConfigResponse"},"type":["array","null"]}},"required":["webhooks"],"type":"object"},"ListTopupInvoiceCurrenciesResponse":{"additionalProperties":false,"properties":{"currencies":{"items":{"$ref":"#/components/schemas/TopupInvoiceCurrencyResponse"},"type":["array","null"]}},"required":["currencies"],"type":"object"},"ListTopupInvoicesResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/TopupInvoiceResponse"},"type":["array","null"]},"totalCount":{"examples":[100],"format":"int64","type":"integer"}},"required":["items","totalCount"],"type":"object"},"ListWalletTransactionsResponse":{"additionalProperties":false,"properties":{"limit":{"examples":[20],"format":"int64","type":"integer"},"offset":{"examples":[0],"format":"int64","type":"integer"},"total":{"examples":[101],"format":"int64","type":"integer"},"transactions":{"items":{"$ref":"#/components/schemas/WalletTransactionResponse"},"type":["array","null"]}},"required":["transactions","total","limit","offset"],"type":"object"},"ListWalletsResponse":{"additionalProperties":false,"properties":{"limit":{"examples":[20],"format":"int64","type":"integer"},"offset":{"examples":[0],"format":"int64","type":"integer"},"total":{"examples":[101],"format":"int64","type":"integer"},"wallets":{"items":{"$ref":"#/components/schemas/WalletResponse"},"type":["array","null"]}},"required":["wallets","total","limit","offset"],"type":"object"},"ListWebhookAllowlistResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/WebhookAllowlistResponse"},"type":["array","null"]}},"required":["items"],"type":"object"},"MarketplaceVendorAllowancePayload":{"additionalProperties":false,"properties":{"basis":{"examples":["cumulative_lifetime_all_currencies"],"type":"string"},"currency":{"description":"The limit is defined in GBP; payouts in other currencies count at the current reference rate.","examples":["GBP"],"type":"string"},"deferred":{"description":"true when usedMinor is not carried by this event.","type":"boolean"},"enforcement":{"enum":["monitored","held_for_review","enforced"],"examples":["monitored"],"type":"string"},"limitMinor":{"description":"The ceiling in GBP minor units (pence). 0 when unlimited.","examples":[10000],"format":"int64","type":"integer"},"remainingMinor":{"description":"limitMinor minus usedMinor in GBP minor units (pence), floored at 0; null when usedMinor is null or the vendor is unlimited.","examples":[7500],"format":"int64","type":["integer","null"]},"unlimited":{"type":"boolean"},"usedMinor":{"description":"Cumulative completed payouts to the vendor in GBP minor units (pence); null when this event does not carry the total.","examples":[2500],"format":"int64","type":["integer","null"]}},"required":["currency","limitMinor","usedMinor","remainingMinor","basis","enforcement","unlimited","deferred"],"type":"object"},"MarketplaceVendorEventPayload":{"additionalProperties":false,"properties":{"externalRef":{"description":"Your reference for the vendor, as given at creation.","examples":["vendor_8817"],"type":"string"},"occurredAt":{"description":"When the state changed, RFC 3339 UTC.","examples":["2026-09-05T12:00:00Z"],"type":"string"},"paymentsEnabled":{"type":"boolean"},"payoutAllowance":{"$ref":"#/components/schemas/MarketplaceVendorAllowancePayload"},"payoutsEnabled":{"type":"boolean"},"previousVerificationStatus":{"description":"verification.status before this change, so a consumer that missed an event can see the edge.","examples":["pending_fynex"],"type":"string"},"vendorId":{"description":"Fynex vendor id (UUID).","examples":["7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b"],"type":"string"},"verification":{"$ref":"#/components/schemas/MarketplaceVendorVerificationPayload"}},"required":["vendorId","paymentsEnabled","payoutsEnabled","verification","payoutAllowance","occurredAt"],"type":"object"},"MarketplaceVendorVerificationPayload":{"additionalProperties":false,"properties":{"actionExpiresAt":{"description":"RFC 3339 UTC expiry of actionUrl; null when there is no link.","examples":["2026-09-06T12:00:00Z"],"type":["string","null"]},"actionUrl":{"description":"Present on MarketplaceVendorVerificationRequired when a link was issued: hand it to the vendor. Bearer-like -- do not log it; null otherwise.","examples":["https://verify.example/session/abc"],"type":["string","null"]},"reason":{"examples":["kyb_documents"],"type":"string"},"status":{"enum":["pending_fynex","not_required","required","in_progress","complete","blocked"],"examples":["required"],"type":"string"}},"required":["status","actionUrl","actionExpiresAt"],"type":"object"},"MarketplaceVendorWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Which vendor transition fired.","enum":["MarketplaceVendorActivated","MarketplaceVendorVerificationRequired","MarketplaceVendorVerificationInProgress","MarketplaceVendorVerificationPendingReview","MarketplaceVendorVerificationComplete","MarketplaceVendorBlocked","MarketplaceVendorPayoutAllowanceChanged"],"examples":["MarketplaceVendorActivated"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/MarketplaceVendorEventPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"OrderDataInput":{"additionalProperties":false,"properties":{"payeeDistribution":{"items":{"$ref":"#/components/schemas/OrderPayeeDistributionInput"},"type":["array","null"]},"totalAmount":{"description":"Order total, in MAJOR units (49.99 is £49.99); must equal the payment amount when set.","examples":[49.99],"format":"double","type":"number"}},"type":"object"},"OrderPayeeDistributionInput":{"additionalProperties":false,"properties":{"amount":{"description":"This payee's share of the order, in MAJOR units (49.99 is £49.99) like the payment amount — unlike split rules, previews and executions, which are integer minor units.","examples":[49.99],"format":"double","type":"number"},"payeeId":{"examples":[101],"format":"int64","type":"integer"}},"required":["payeeId","amount"],"type":"object"},"PayeeItemResponse":{"additionalProperties":false,"properties":{"businessAddress":{"examples":["1 Example Street, London, GB"],"type":"string"},"businessName":{"examples":["Example Ltd"],"type":"string"},"businessType":{"examples":["limited_company"],"type":"string"},"createdAt":{"examples":["2026-04-07T10:00:00Z"],"type":"string"},"displayName":{"examples":["Primary Payee"],"type":"string"},"email":{"examples":["finance@example.com"],"type":"string"},"id":{"examples":[101],"format":"int64","type":"integer"},"isActive":{"examples":[true],"type":"boolean"},"kycEntityType":{"description":"KYC entity type: Individual or Company. Empty until set.","examples":["Individual"],"type":"string"},"legalEntityId":{"examples":[77],"format":"int64","type":"integer"},"merchantId":{"examples":["16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117"],"type":"string"},"payeeContractId":{"examples":["pc_001"],"type":"string"},"phone":{"examples":["+447700900123"],"type":"string"},"role":{"examples":["Contractor"],"type":"string"},"status":{"examples":["Active"],"type":"string"},"taxId":{"examples":["GB123456789"],"type":"string"},"updatedAt":{"examples":["2026-04-07T10:00:00Z"],"type":"string"}},"required":["id","legalEntityId","displayName","role","merchantId","status","isActive","createdAt","updatedAt"],"type":"object"},"PaymentActionRequest":{"additionalProperties":false,"properties":{"amount":{"description":"Amount to capture or refund, in MAJOR units (20.00 is £20.00); omit for the full amount.","examples":[20],"format":"double","type":"number"},"webhookUrl":{"description":"Optional per-request webhook URL. Must be HTTPS, must DNS-resolve to IPs covered by an active seller_webhook_allowlist entry. Delivery is additive to configured webhook URLs.","examples":["https://merchant.example.com/webhooks/payment-12345"],"type":"string"}},"type":"object"},"PaymentActionResponse":{"additionalProperties":false,"properties":{"amount":{"description":"The amount this action moved, in MAJOR units (49.99 is £49.99), paired with currencyCode — the checkout family is the grandfathered exception to the Minor-suffix convention.","examples":[20],"format":"double","type":"number"},"currencyCode":{"examples":["GBP"],"type":"string"},"failureCategory":{"description":"What KIND of failure this is, from the failure-code catalogue — the field to count, alert and report on, where `retry` is the field to branch a request loop on. Absent while the payment has not failed. **hard** — a decision was made against the payment and it stands; **transient** — nothing was decided and the attempt can be repeated; **requires_change** — the customer's instrument or authentication has to change first; **integration_error** — the request or the seller's configuration is wrong, not the customer's card; **cancelled** — the payment was called off; **unknown** — the code is not one this catalogue classifies.","enum":["hard","transient","requires_change","integration_error","cancelled","unknown"],"examples":["hard"],"type":"string"},"failureCode":{"description":"Why the payment failed. `0` means it has not.\n\n**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.\n\n**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.\n\n| Code | Meaning | Category | Retry | What to do |\n|---|---|---|---|---|\n| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |\n| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |\n| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |\n| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |\n| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |\n| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |\n| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |\n| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |\n| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |\n| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |\n| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |\n| `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. |\n| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |\n| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |\n| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |\n| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |\n| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |\n| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |\n| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |\n","enum":[0,1001,1002,1003,1004,1005,1006,1007,2001,2002,2005,2003,2004,3001,3002,4001,5001,9001,9002,9999],"examples":[2001],"format":"int64","type":"integer"},"failureDescription":{"description":"Human-readable summary of the failure, safe to show a customer. Prose, not a contract: branch on `failureCode`, never on this string.","examples":["Provider declined the transaction"],"type":"string"},"paymentId":{"examples":["ORDER-100106"],"type":"string"},"providerCode":{"examples":["pp_01"],"type":"string"},"providerPaymentId":{"examples":["pay_01J2EXAMPLE"],"type":"string"},"retry":{"description":"What re-sending achieves, from the failure-code catalogue — the verdict a caller previously had to look up by `failureCode`. Absent while the payment has not failed. **safe** — the same request with the same `Idempotency-Key` can succeed; **fix_first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.","enum":["safe","fix_first","never"],"examples":["never"],"type":"string"},"status":{"examples":["captured"],"type":"string"}},"required":["paymentId","status","amount","currencyCode"],"type":"object"},"PaymentCompletedPayload":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The amount in integer minor units (4999 is €49.99). Note the checkout REQUEST takes major units; this event does not.","examples":[4999],"format":"int64","type":"integer"},"contractId":{"description":"The billing contract the collected invoice belongs to. Present only for payments collected through a Fynex invoice's payment link.","examples":[4180],"format":"int64","type":"integer"},"currencyCode":{"description":"ISO 4217 currency of the amount.","examples":["EUR"],"type":"string"},"failureCategory":{"description":"What KIND of failure this is, from the failure-code catalogue — the field to count, alert and report on, where `retry` is the field to branch a request loop on. Absent while the payment has not failed. **hard** — a decision was made against the payment and it stands; **transient** — nothing was decided and the attempt can be repeated; **requires_change** — the customer's instrument or authentication has to change first; **integration_error** — the request or the seller's configuration is wrong, not the customer's card; **cancelled** — the payment was called off; **unknown** — the code is not one this catalogue classifies.","enum":["hard","transient","requires_change","integration_error","cancelled","unknown"],"type":"string"},"failureCode":{"description":"Why the payment failed. `0` means it has not.\n\n**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.\n\n**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.\n\n| Code | Meaning | Category | Retry | What to do |\n|---|---|---|---|---|\n| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |\n| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |\n| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |\n| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |\n| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |\n| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |\n| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |\n| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |\n| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |\n| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |\n| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |\n| `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. |\n| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |\n| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |\n| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |\n| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |\n| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |\n| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |\n| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |\n","enum":[0,1001,1002,1003,1004,1005,1006,1007,2001,2002,2005,2003,2004,3001,3002,4001,5001,9001,9002,9999],"format":"int64","type":"integer"},"failureDescription":{"description":"Human-readable reason accompanying failureCode.","type":"string"},"genericPaymentId":{"description":"Fynex's own payment id — the one every other API call takes.","examples":[88213],"format":"int64","type":"integer"},"invoiceNumber":{"description":"The billing invoice this payment collected, as printed on the document and quoted on bank transfers. Present only for payments collected through a Fynex invoice's payment link.","examples":["UK2607AA-2608AAB"],"type":"string"},"occurredAt":{"description":"When the payment reached this state, RFC 3339 UTC.","format":"date-time","type":"string"},"paymentId":{"description":"The payment's public reference.","examples":["pay_9f2a1c1e6a1e"],"type":"string"},"providerCode":{"description":"Which acquirer processed it.","type":"string"},"providerPaymentId":{"description":"The acquirer's identifier for the same payment, for reconciliation against their reports.","type":"string"},"retry":{"description":"What re-sending achieves, from the failure-code catalogue — the verdict a caller previously had to look up by `failureCode`. Absent while the payment has not failed. **safe** — the same request with the same `Idempotency-Key` can succeed; **fix_first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.","enum":["safe","fix_first","never"],"type":"string"},"status":{"description":"The terminal state reached. A PaymentCompleted event is not by itself a successful payment — branch on this.","examples":["succeeded"],"type":"string"}},"required":["genericPaymentId","paymentId","status","amountMinor","currencyCode","occurredAt"],"type":"object"},"PaymentInstructions":{"additionalProperties":false,"properties":{"entity":{"description":"Payment-provider entity identifier shown to the buyer. payment_reference only.","examples":["11854"],"type":"string"},"phone":{"description":"The number the authorization was pushed to, echoed back so your page can show it. app_notification only.","examples":["+351912345678"],"type":"string"},"reference":{"description":"Payment reference shown to the buyer. payment_reference only.","examples":["999999964"],"type":"string"},"type":{"description":"Instruction format. payment_reference means the buyer pays using the returned entity and reference. app_notification means the buyer was pushed an authorization request in their scheme app and your page should show a waiting state.","enum":["payment_reference","app_notification"],"examples":["payment_reference"],"type":"string"}},"required":["type"],"type":"object"},"PaymentLinkEmailFailedPayload":{"additionalProperties":false,"properties":{"error":{"description":"Why it failed, as a stable machine token.","examples":["email_send_failed"],"type":"string"},"paymentLinkId":{"description":"The link whose invitation email could not be sent. The link itself was created and is payable.","examples":[4021],"format":"int64","type":"integer"}},"required":["paymentLinkId","error"],"type":"object"},"PaymentLinkEmailFailedWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Always PaymentLinkEmailFailed.","enum":["PaymentLinkEmailFailed"],"examples":["PaymentLinkEmailFailed"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/PaymentLinkEmailFailedPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"PaymentLinkEventPayload":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The link's amount in integer minor units (4999 is €49.99).","examples":[4999],"format":"int64","type":"integer"},"billingSchedule":{"description":"On a subscription link: monthly, annual_upfront, annual_monthly_installments or annual_deferred. Empty on a one-off.","examples":["monthly"],"type":"string"},"currency":{"description":"ISO 4217 currency of the amount.","examples":["EUR"],"type":"string"},"customerEmail":{"description":"The recipient the link was addressed to; empty when it was not addressed to anyone.","examples":["buyer@example.com"],"type":"string"},"paymentLinkId":{"description":"The link this event describes.","examples":[4021],"format":"int64","type":"integer"},"status":{"description":"Where the link landed: active, paid, expired or cancelled.","examples":["paid"],"type":"string"},"title":{"description":"The link's display title.","examples":["Invoice UK2607AA-2608AAB"],"type":"string"},"token":{"description":"The public URL slug — /p/{token} resolves to the hosted page.","examples":["pl_9f2a1c1e6a1e"],"type":"string"},"type":{"description":"one_time or subscription.","examples":["one_time"],"type":"string"},"usageCount":{"description":"How many times the link has been paid. A multi-use link stays active as this climbs.","examples":[1],"format":"int64","type":"integer"}},"required":["paymentLinkId","token","status","type","amountMinor","currency","customerEmail","title","usageCount","billingSchedule"],"type":"object"},"PaymentLinkSubscriptionEventPayload":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The recurring charge in integer minor units.","examples":[4999],"format":"int64","type":"integer"},"billingSchedule":{"description":"monthly, annual_upfront, annual_monthly_installments or annual_deferred.","examples":["monthly"],"type":"string"},"commitmentEndAt":{"description":"When a fixed commitment completes, RFC 3339. Null when the subscription carries no commitment.","format":"date-time","type":["string","null"]},"currency":{"description":"ISO 4217 currency of the amount.","examples":["EUR"],"type":"string"},"currentPeriodEnd":{"description":"End of that term, RFC 3339.","format":"date-time","type":"string"},"currentPeriodStart":{"description":"Start of the term in force when the event fired, RFC 3339.","format":"date-time","type":"string"},"nextChargeAt":{"description":"When the next charge is due, RFC 3339. Null once the subscription no longer charges.","format":"date-time","type":["string","null"]},"periodsCompleted":{"description":"How many terms have been charged so far.","examples":[3],"format":"int64","type":"integer"},"source":{"description":"Discriminator: which product emitted this event. Always payment_links for this shape.","enum":["payment_links"],"examples":["payment_links"],"type":"string"},"status":{"description":"Where the subscription landed.","examples":["active"],"type":"string"},"subscriptionId":{"description":"The subscription this event describes.","examples":[15],"format":"int64","type":"integer"},"trialEnd":{"description":"When the trial ends, RFC 3339. Null when there is no trial.","format":"date-time","type":["string","null"]}},"required":["source","subscriptionId","status","billingSchedule","amountMinor","currency","currentPeriodStart","currentPeriodEnd","nextChargeAt","periodsCompleted","trialEnd","commitmentEndAt"],"type":"object"},"PaymentLinkWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Which transition fired.","enum":["PaymentLinkCreated","PaymentLinkUpdated","PaymentLinkPaid","PaymentLinkExpired","PaymentLinkCancelled"],"examples":["PaymentLinkPaid"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/PaymentLinkEventPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"PaymentMethodsResponse":{"additionalProperties":false,"properties":{"allowedCurrencies":{"items":{"type":"string"},"type":["array","null"]},"allowedPaymentMethods":{"items":{"type":"string"},"type":["array","null"]},"allowedPaymentRails":{"items":{"type":"string"},"type":["array","null"]},"merchantId":{"examples":["16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117"],"type":"string"}},"required":["merchantId","allowedPaymentMethods","allowedCurrencies"],"type":"object"},"PaymentWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Always PaymentCompleted.","enum":["PaymentCompleted"],"examples":["PaymentCompleted"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/PaymentCompletedPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"PayoutListResponseDTO":{"allOf":[{"$ref":"#/components/schemas/ListPayoutsResponse"}],"deprecated":true,"description":"Deprecated: renamed to `ListPayoutsResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"PayoutMethodListResponseDTO":{"allOf":[{"$ref":"#/components/schemas/ListPayoutMethodsResponse"}],"deprecated":true,"description":"Deprecated: renamed to `ListPayoutMethodsResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"PayoutMethodResponse":{"additionalProperties":false,"properties":{"accountName":{"examples":["Example Ltd"],"type":"string"},"accountNumber":{"examples":["12345678"],"type":"string"},"approvalStatus":{"examples":["pending"],"type":"string"},"approvedAt":{"examples":["2026-04-10T10:05:00Z"],"type":"string"},"bankAccountType":{"examples":["iban"],"type":"string"},"bankCountry":{"examples":["DE"],"type":"string"},"bankName":{"examples":["Example Bank"],"type":"string"},"bic":{"examples":["NWBKGB2L"],"type":"string"},"createdAt":{"examples":["2026-04-10T10:00:00Z"],"type":"string"},"currency":{"examples":["EUR"],"type":"string"},"iban":{"examples":["GB82WEST12345698765432"],"type":"string"},"id":{"examples":[501],"format":"int64","type":"integer"},"payeeId":{"examples":[101],"format":"int64","type":"integer"},"rejectionReason":{"examples":[""],"type":"string"},"routingNumber":{"examples":["021000021"],"type":"string"},"sortCode":{"examples":["12-34-56"],"type":"string"},"status":{"examples":["active"],"type":"string"},"type":{"examples":["bank_account"],"type":"string"},"updatedAt":{"examples":["2026-04-10T10:00:00Z"],"type":"string"}},"required":["id","payeeId","type","bankAccountType","currency","status","approvalStatus","createdAt","updatedAt"],"type":"object"},"PayoutMethodResponseDTO":{"allOf":[{"$ref":"#/components/schemas/PayoutMethodResponse"}],"deprecated":true,"description":"Deprecated: renamed to `PayoutMethodResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"PayoutResponse":{"additionalProperties":false,"properties":{"amount":{"description":"Decimal rendering of amountMinor at the currency scale — the same money in MAJOR units (\"12.50\" for 1250).","examples":["12.50"],"type":"string"},"amountMinor":{"description":"Payout amount in the currency's minor units (1250 = 12.50 GBP). The decimal rendering of the same value is on amount.","examples":[1250],"format":"int64","type":"integer"},"approvalStatus":{"examples":["awaiting_approval"],"type":"string"},"approvedAt":{"examples":["2026-04-07T10:02:00Z"],"type":"string"},"bankAccountType":{"description":"Destination account identifier format this payout was sent to: iban, uk_local, us_local or swift, snapshotted at creation. Fynex selects the eventual payment rail. Empty means unknown (payouts created before this field existed).","examples":["uk_local"],"type":"string"},"completedAt":{"examples":["2026-04-07T10:03:00Z"],"type":"string"},"currencyCode":{"examples":["GBP"],"type":"string"},"failureCode":{"description":"Why the payout failed. Absent while it has not.\n\nIn every case the held funds are returned to the seller's available balance before the payout is marked failed, so a failed payout never leaves money stranded — and no payout failure is retryable by simply re-sending the same request. Treat a failed payout as terminal, read `failureMessage` for what to tell the seller, and create a new payout only after the underlying cause is addressed.\n\nThe enumerated code list is not published yet; it is pending a rename that removes supplier-specific prefixes from four of the values.","examples":[""],"type":"string"},"failureMessage":{"description":"Human-readable summary of the failure, safe to show a customer. Prose, not a contract: branch on `failureCode`, never on this string.","examples":[""],"type":"string"},"id":{"examples":[9001],"format":"int64","type":"integer"},"processedAt":{"examples":["2026-04-07T10:01:00Z"],"type":"string"},"providerReference":{"description":"Upstream provider's reference for this payout. Opaque — match, do not parse.","examples":["ref_9f2c1a4b"],"type":"string"},"rejectionReason":{"examples":[""],"type":"string"},"requestedAt":{"examples":["2026-04-07T10:00:00Z"],"type":"string"},"status":{"examples":["processing"],"type":"string"}},"required":["id","status","amountMinor","currencyCode","requestedAt"],"type":"object"},"PayoutResponseDTO":{"allOf":[{"$ref":"#/components/schemas/PayoutResponse"}],"deprecated":true,"description":"Deprecated: renamed to `PayoutResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"PromoCodeRedeemedPayload":{"additionalProperties":false,"properties":{"discountMinor":{"description":"The discount it granted, in integer minor units.","examples":[500],"format":"int64","type":"integer"},"paymentId":{"description":"The payment it was redeemed against, or null.","format":"int64","type":["integer","null"]},"paymentLinkId":{"description":"The link it was redeemed against, or null.","examples":[4021],"format":"int64","type":["integer","null"]},"promoCodeId":{"description":"The code that was redeemed.","examples":[12],"format":"int64","type":"integer"},"redemptionId":{"description":"This redemption; unique per use and the event's natural dedup key.","examples":[3391],"format":"int64","type":"integer"},"sellerCustomerId":{"description":"The customer who redeemed it, or null when the redemption was not tied to one.","examples":[7],"format":"int64","type":["integer","null"]},"subscriptionId":{"description":"The subscription it was redeemed against, or null.","format":"int64","type":["integer","null"]}},"required":["promoCodeId","redemptionId","sellerCustomerId","paymentLinkId","subscriptionId","paymentId","discountMinor"],"type":"object"},"PromoCodeWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Always PromoCodeRedeemed.","enum":["PromoCodeRedeemed"],"examples":["PromoCodeRedeemed"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/PromoCodeRedeemedPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"RefundResponse":{"additionalProperties":false,"properties":{"amount":{"description":"The refunded amount, in MAJOR units (49.99 is £49.99), paired with currencyCode — the checkout family is the grandfathered exception to the Minor-suffix convention.","examples":[10],"format":"double","type":"number"},"completedAt":{"format":"date-time","type":"string"},"createdAt":{"format":"date-time","type":"string"},"currencyCode":{"examples":["GBP"],"type":"string"},"failureCategory":{"description":"What KIND of failure this is, from the failure-code catalogue — the field to count, alert and report on, where `retry` is the field to branch a request loop on. Absent while the payment has not failed. **hard** — a decision was made against the payment and it stands; **transient** — nothing was decided and the attempt can be repeated; **requires_change** — the customer's instrument or authentication has to change first; **integration_error** — the request or the seller's configuration is wrong, not the customer's card; **cancelled** — the payment was called off; **unknown** — the code is not one this catalogue classifies.","enum":["hard","transient","requires_change","integration_error","cancelled","unknown"],"examples":["hard"],"type":"string"},"failureCode":{"description":"Why the payment failed. `0` means it has not.\n\n**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.\n\n**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.\n\n| Code | Meaning | Category | Retry | What to do |\n|---|---|---|---|---|\n| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |\n| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |\n| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |\n| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |\n| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |\n| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |\n| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |\n| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |\n| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |\n| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |\n| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |\n| `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. |\n| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |\n| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |\n| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |\n| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |\n| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |\n| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |\n| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |\n","enum":[0,1001,1002,1003,1004,1005,1006,1007,2001,2002,2005,2003,2004,3001,3002,4001,5001,9001,9002,9999],"examples":[2001],"format":"int64","type":"integer"},"failureDescription":{"description":"Human-readable summary of the failure, safe to show a customer. Prose, not a contract: branch on `failureCode`, never on this string.","examples":["Provider declined the transaction"],"type":"string"},"id":{"examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"},"paymentId":{"examples":["ORDER-1042"],"type":"string"},"providerRefundId":{"examples":["rfnd_01J2EXAMPLE"],"type":"string"},"retry":{"description":"What re-sending achieves, from the failure-code catalogue — the verdict a caller previously had to look up by `failureCode`. Absent while the payment has not failed. **safe** — the same request with the same `Idempotency-Key` can succeed; **fix_first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.","enum":["safe","fix_first","never"],"examples":["never"],"type":"string"},"status":{"enum":["pending","succeeded","failed","cancelled"],"examples":["succeeded"],"type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","paymentId","status","amount","currencyCode","createdAt","updatedAt"],"type":"object"},"RefundWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Always PaymentRefunded.","enum":["PaymentRefunded"],"examples":["PaymentRefunded"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/RefundWebhookPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"RefundWebhookPayload":{"additionalProperties":false,"properties":{"currencyCode":{"description":"ISO 4217 currency of both amounts.","examples":["EUR"],"type":"string"},"externalOrderRef":{"description":"Your own order reference carried on the original payment, when it had one.","type":"string"},"genericPaymentId":{"description":"The payment that was refunded.","examples":[88213],"format":"int64","type":"integer"},"occurredAt":{"description":"When the refund succeeded, RFC 3339 UTC.","format":"date-time","type":"string"},"originalAmountMinor":{"description":"The parent payment's captured amount in integer minor units, so a remaining balance can be computed. Absent when the parent could not be read.","examples":[4999],"format":"int64","type":"integer"},"partial":{"description":"True when this refund is smaller than the parent's captured amount. Per-refund, NOT cumulative: two partials that together settle a payment each report true.","type":"boolean"},"providerPaymentId":{"description":"The acquirer's id for the parent payment, when known.","type":"string"},"providerRefundId":{"description":"The acquirer's id for the refund, for reconciliation against their reports.","type":"string"},"refundAmountMinor":{"description":"What this refund returned, in integer minor units.","examples":[1500],"format":"int64","type":"integer"},"refundId":{"description":"Fynex's id for this refund.","examples":["3f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"type":"string"}},"required":["genericPaymentId","refundId","refundAmountMinor","currencyCode","partial","occurredAt"],"type":"object"},"ReturnLinkInput":{"additionalProperties":false,"properties":{"href":{"examples":["https://merchant.example/payments/return"],"type":"string"},"method":{"examples":["GET"],"type":"string"},"rel":{"examples":["default"],"type":"string"}},"type":"object"},"ReturnURLsInput":{"additionalProperties":false,"properties":{"failure":{"examples":["https://merchant.example/checkout/failure"],"type":"string"},"success":{"examples":["https://merchant.example/checkout/success"],"type":"string"}},"required":["success","failure"],"type":"object"},"SellerWebhookConfigResponse":{"additionalProperties":false,"properties":{"createdAt":{"format":"date-time","type":"string"},"eventTypes":{"examples":[["PaymentCompleted"]],"items":{"type":"string"},"type":["array","null"]},"id":{"examples":[7],"format":"int64","type":"integer"},"merchantId":{"examples":["7"],"type":"string"},"status":{"examples":["active"],"type":"string"},"updatedAt":{"format":"date-time","type":"string"},"webhookUrl":{"examples":["https://merchant.example.com/webhooks/fynex"],"type":"string"}},"required":["id","merchantId","webhookUrl","status","createdAt","updatedAt"],"type":"object"},"SetupCashoutWalletDTO":{"allOf":[{"$ref":"#/components/schemas/CashoutWalletSummary"}],"deprecated":true,"description":"Deprecated: renamed to `CashoutWalletSummary`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"SetupPayeeRequest":{"additionalProperties":false,"properties":{"businessAddress":{"examples":["1 Example Street, London, GB"],"type":"string"},"businessName":{"examples":["Example Ltd"],"type":"string"},"businessType":{"examples":["limited_company"],"type":"string"},"displayName":{"description":"Human-readable payee name. Required.","examples":["Acme Supplies Ltd"],"type":"string"},"email":{"examples":["finance@example.com"],"type":"string"},"payeeContractId":{"description":"Your internal contract reference. Must be unique within your seller account.","examples":["pc_001"],"type":"string"},"payoutMethod":{"$ref":"#/components/schemas/CreatePayoutMethodRequest","description":"The payee's first payout method, created in the same transaction. Required."},"phone":{"examples":["+447700900123"],"type":"string"},"role":{"description":"Payee role: contractor or tax.","enum":["contractor","tax"],"examples":["contractor"],"type":"string"},"taxId":{"examples":["GB123456789"],"type":"string"}},"required":["displayName","role","payoutMethod"],"type":"object"},"SetupPayeeRequestDTO":{"allOf":[{"$ref":"#/components/schemas/SetupPayeeRequest"}],"deprecated":true,"description":"Deprecated: renamed to `SetupPayeeRequest`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"SetupPayeeResponse":{"additionalProperties":false,"properties":{"cashoutWallet":{"$ref":"#/components/schemas/CashoutWalletSummary"},"payee":{"$ref":"#/components/schemas/PayeeItemResponse"},"payoutMethod":{"$ref":"#/components/schemas/PayoutMethodResponse"}},"required":["payee","payoutMethod","cashoutWallet"],"type":"object"},"SetupPayeeResponseDTO":{"allOf":[{"$ref":"#/components/schemas/SetupPayeeResponse"}],"deprecated":true,"description":"Deprecated: renamed to `SetupPayeeResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"SkippedRule":{"additionalProperties":false,"properties":{"payeeId":{"format":"int64","type":"integer"},"reason":{"description":"Why the line produced no share. A disabled line is reported disabled whatever else is true of it.","enum":["line_disabled","no_distribution_weight","zero_allocation"],"type":"string"}},"required":["payeeId","reason"],"type":"object"},"SplitDecisionResponse":{"additionalProperties":false,"properties":{"evaluatedAt":{"description":"When the split was evaluated.","examples":["2026-09-02T12:34:56Z"],"format":"date-time","type":"string"},"id":{"description":"Decision record id.","examples":["7c1f2b2e-1d5b-4d2a-9d3e-0a1b2c3d4e5f"],"type":"string"},"inputs":{"$ref":"#/components/schemas/DecisionInputs"},"outcome":{"$ref":"#/components/schemas/DecisionOutcome"},"paymentId":{"description":"Your externalOrderRef — the same value you put in the path.","examples":["ORDER-100106"],"type":"string"},"ruleId":{"description":"The split rule that was evaluated.","examples":[7],"format":"int64","type":"integer"},"ruleVersion":{"description":"The rule version at evaluation time. The record survives later edits to the rule — it explains what ran, not what the rule says now.","examples":[3],"format":"int64","type":"integer"},"rulesApplied":{"items":{"$ref":"#/components/schemas/AppliedRule"},"type":["array","null"]},"rulesSkipped":{"items":{"$ref":"#/components/schemas/SkippedRule"},"type":["array","null"]}},"required":["id","paymentId","ruleId","ruleVersion","inputs","rulesApplied","rulesSkipped","outcome","evaluatedAt"],"type":"object"},"SplitDecisionsResponse":{"additionalProperties":false,"properties":{"decisions":{"description":"Newest first. More than one record is normal — a re-split after a correction is a second evaluation — and the FIRST entry is the allocation in force. An empty list means no split has been evaluated for this payment; it is not a 404.","items":{"$ref":"#/components/schemas/SplitDecisionResponse"},"type":["array","null"]}},"required":["decisions"],"type":"object"},"SubscriptionInput":{"additionalProperties":false,"properties":{"allowSubsequentMerchantInitiated":{"examples":[false],"type":"boolean"},"enabled":{"examples":[false],"type":"boolean"},"initialTransactionId":{"examples":["fab8973b-953e-4725-9cb1-075fcbbb8bd9"],"type":"string"}},"type":"object"},"SubscriptionTrialWillEndWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Always SubscriptionTrialWillEnd.","enum":["SubscriptionTrialWillEnd"],"examples":["SubscriptionTrialWillEnd"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"description":"The shape depends on which product emitted the event; see the event description.","discriminator":{"mapping":{"billing":"#/components/schemas/BillingTrialWillEndPayload","payment_links":"#/components/schemas/PaymentLinkSubscriptionEventPayload"},"propertyName":"source"},"oneOf":[{"$ref":"#/components/schemas/PaymentLinkSubscriptionEventPayload"},{"$ref":"#/components/schemas/BillingTrialWillEndPayload"}]},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"SubscriptionWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Which transition fired.","enum":["SubscriptionCreated","SubscriptionCharged","SubscriptionInstallmentCharged","SubscriptionTrialEnded","SubscriptionCommitmentCompleted","SubscriptionCancelled","SubscriptionPastDue"],"examples":["SubscriptionCharged"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/PaymentLinkSubscriptionEventPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"TaxLocationResolvedPayload":{"additionalProperties":false,"properties":{"locationProofId":{"description":"Identifier of the stored evidence row this event describes.","examples":["lp_9f2a1c1e"],"type":"string"},"paymentId":{"description":"The payment it belongs to, or null.","format":"int64","type":["integer","null"]},"paymentLinkId":{"description":"The link the resolution belongs to, or null.","examples":[4021],"format":"int64","type":["integer","null"]},"resolutionMethod":{"description":"How it was resolved — which signals agreed.","examples":["billing_address"],"type":"string"},"resolvedCountry":{"description":"ISO 3166-1 alpha-2 country the buyer was resolved to.","examples":["DE"],"type":"string"},"reverseCharge":{"description":"True when the transaction was reverse-charged and no tax was collected.","type":"boolean"},"subscriptionId":{"description":"The subscription it belongs to, or null.","format":"int64","type":["integer","null"]},"taxAmountMinor":{"description":"Tax charged, in integer minor units.","examples":[950],"format":"int64","type":"integer"},"taxRate":{"description":"The rate applied, as a decimal string percentage. A string, not a number, so the fraction cannot drift.","examples":["19.0"],"type":"string"}},"required":["locationProofId","resolvedCountry","resolutionMethod","taxRate","taxAmountMinor","reverseCharge","paymentLinkId","paymentId","subscriptionId"],"type":"object"},"TaxLocationWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Always TaxLocationResolved.","enum":["TaxLocationResolved"],"examples":["TaxLocationResolved"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/TaxLocationResolvedPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"TopupInvoiceBankDetailsResponse":{"additionalProperties":false,"properties":{"beneficiaryName":{"examples":["Fynex Ltd"],"type":["string","null"]},"bic":{"examples":["DABADKKK"],"type":["string","null"]},"iban":{"examples":["DK5000400440116243"],"type":["string","null"]},"label":{"examples":["EUR Safeguarding Account"],"type":"string"}},"required":["label","beneficiaryName","iban","bic"],"type":"object"},"TopupInvoiceCurrencyResponse":{"additionalProperties":false,"properties":{"currency":{"examples":["EUR"],"type":"string"},"currencyScale":{"examples":[2],"format":"int64","type":"integer"}},"required":["currency","currencyScale"],"type":"object"},"TopupInvoiceResponse":{"additionalProperties":false,"properties":{"amountMinor":{"description":"Invoiced amount in the currency's minor units (250000 = 2500.00). This is what must be transferred for the invoice to settle.","examples":[250000],"format":"int64","type":"integer"},"bankDetails":{"$ref":"#/components/schemas/TopupInvoiceBankDetailsResponse"},"cancelledAt":{"format":"date-time","type":["string","null"]},"createdAt":{"format":"date-time","type":"string"},"creditedAt":{"format":"date-time","type":["string","null"]},"currency":{"examples":["EUR"],"type":"string"},"currencyScale":{"examples":[2],"format":"int64","type":"integer"},"dueAt":{"description":"Explicit payment term; null means due immediately upon issue.","format":"date-time","type":["string","null"]},"expiresAt":{"format":"date-time","type":"string"},"issuedAt":{"format":"date-time","type":"string"},"markedPaidAt":{"format":"date-time","type":["string","null"]},"paymentReference":{"examples":["FYN-4R7X2KQ9"],"type":"string"},"status":{"description":"pending | outdated | marked_paid | matched | credited | cancelled | expired","examples":["pending"],"type":"string"},"uuid":{"examples":["7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b"],"type":"string"}},"required":["uuid","currency","currencyScale","amountMinor","status","issuedAt","dueAt","expiresAt","paymentReference","bankDetails","createdAt","markedPaidAt","creditedAt","cancelledAt"],"type":"object"},"UpdatePayeeRequest":{"additionalProperties":false,"properties":{"businessAddress":{"examples":["1 Example Street, London, GB"],"type":"string"},"businessName":{"examples":["Example Ltd"],"type":"string"},"businessType":{"examples":["limited_company"],"type":"string"},"displayName":{"examples":["Acme Supplies Ltd"],"type":"string"},"email":{"examples":["finance@example.com"],"type":"string"},"kycEntityType":{"description":"KYC entity type. Only Individual is accepted via the API.","enum":["Individual"],"examples":["Individual"],"type":"string"},"payeeContractId":{"examples":["pc_001"],"type":"string"},"phone":{"examples":["+447700900123"],"type":"string"},"role":{"description":"Payee role: contractor or tax.","enum":["contractor","tax"],"examples":["contractor"],"type":"string"},"status":{"description":"Payee status: active or disabled. Set to disabled to archive, active to restore.","enum":["active","disabled"],"examples":["active"],"type":"string"},"taxId":{"examples":["GB123456789"],"type":"string"}},"type":"object"},"UpdatePayoutMethodRequest":{"additionalProperties":false,"properties":{"accountName":{"examples":["Example Ltd"],"type":"string"},"accountNumber":{"examples":["12345678"],"type":"string"},"bankAccountType":{"enum":["iban","uk_local","us_local","swift"],"examples":["iban"],"type":"string"},"bankCountry":{"examples":["DE"],"type":"string"},"bankName":{"examples":["Example Bank"],"type":"string"},"bic":{"examples":["NWBKGB2L"],"type":"string"},"iban":{"examples":["GB82WEST12345698765432"],"type":"string"},"routingNumber":{"description":"US ABA routing number.","examples":["021000021"],"type":"string"},"sortCode":{"examples":["12-34-56"],"type":"string"},"status":{"description":"Payout method status: active or inactive. An inactive method is excluded from the GET list and cannot be used for new payouts.","enum":["active","inactive"],"examples":["active"],"type":"string"},"swift":{"description":"Alias of bic — SWIFT/BIC. When both bic and swift are set, bic wins.","examples":["CHASUS33"],"type":"string"}},"type":"object"},"UpdatePayoutMethodRequestDTO":{"allOf":[{"$ref":"#/components/schemas/UpdatePayoutMethodRequest"}],"deprecated":true,"description":"Deprecated: renamed to `UpdatePayoutMethodRequest`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"UpdateSellerWebhookConfigRequest":{"additionalProperties":false,"properties":{"eventTypes":{"examples":[["PaymentCompleted"]],"items":{"type":"string"},"type":"array"},"status":{"examples":["disabled"],"type":"string"},"webhookUrl":{"examples":["https://merchant.example.com/webhooks/fynex"],"type":"string"}},"type":"object"},"WalletListResponseDTO":{"allOf":[{"$ref":"#/components/schemas/ListWalletsResponse"}],"deprecated":true,"description":"Deprecated: renamed to `ListWalletsResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"WalletResponse":{"additionalProperties":false,"properties":{"availableBalanceMinor":{"description":"Settled funds not under hold, in minor units (125000 = 1250.00 EUR). Spendable within the platform; withdrawableBalanceMinor is the part that can leave it today.","examples":[125000],"format":"int64","type":"integer"},"createdAt":{"examples":["2026-04-07T10:00:00Z"],"type":"string"},"currency":{"examples":["EUR"],"type":"string"},"heldBalanceMinor":{"description":"Funds reserved against an in-flight payout and unavailable to spend, in minor units. Released back to available if the payout fails.","examples":[5000],"format":"int64","type":"integer"},"id":{"examples":[15],"format":"int64","type":"integer"},"maturingBalanceMinor":{"description":"Settled funds counted in availableBalanceMinor but not yet withdrawable, in minor units, because they are inside the maturity hold. Usually withdrawable + maturing == available, but withdrawable is floored at 0, so after a debit that takes available below the outstanding maturing sum the two no longer add up. Do not rely on the equality.","examples":[5000],"format":"int64","type":"integer"},"name":{"examples":["Acme Ltd"],"type":"string"},"payeeId":{"description":"Payee the wallet is bound to. A cashout_balance wallet belongs to the payee it pays out; distinguishes multiple wallets a seller holds in the same currency for different payees.","examples":[101],"format":"int64","type":"integer"},"pendingBalanceMinor":{"description":"Legacy field, in minor units. Stays 0 under the current lot-based design — read maturingBalanceMinor instead.","examples":[0],"format":"int64","type":"integer"},"status":{"examples":["active"],"type":"string"},"totalBalanceMinor":{"description":"available + held + pending, in minor units. A convenience sum; it is not a spendable figure, because the held part is committed elsewhere.","examples":[130000],"format":"int64","type":"integer"},"type":{"description":"Wallet type — e.g. main (where settled funds land), cashout_balance (a payee's payout-source balance), or seller_operational.","examples":["main"],"type":"string"},"updatedAt":{"examples":["2026-04-07T10:05:00Z"],"type":"string"},"withdrawableBalanceMinor":{"description":"The part of availableBalanceMinor that can actually be paid out now, in minor units: available minus settled funds still inside the maturity hold. This is the figure to check before creating a payout — available alone will overstate it.","examples":[120000],"format":"int64","type":"integer"}},"required":["id","payeeId","type","name","currency","status","availableBalanceMinor","heldBalanceMinor","pendingBalanceMinor","withdrawableBalanceMinor","maturingBalanceMinor","totalBalanceMinor","createdAt","updatedAt"],"type":"object"},"WalletResponseDTO":{"allOf":[{"$ref":"#/components/schemas/WalletResponse"}],"deprecated":true,"description":"Deprecated: renamed to `WalletResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"WalletTransactionListResponseDTO":{"allOf":[{"$ref":"#/components/schemas/ListWalletTransactionsResponse"}],"deprecated":true,"description":"Deprecated: renamed to `ListWalletTransactionsResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"WalletTransactionResponse":{"additionalProperties":false,"properties":{"amountMinor":{"description":"Unsigned magnitude of this entry, in minor units. Direction is NOT in the sign — read direction, or the signed deltas below.","examples":[5000],"format":"int64","type":"integer"},"availableDeltaMinor":{"description":"Signed change this entry made to the available balance, in minor units. Negative debits it.","examples":[5000],"format":"int64","type":"integer"},"balanceAfterMinor":{"description":"Available balance after this entry was applied, in minor units. Running total — reconcile against it rather than re-summing the deltas.","examples":[125000],"format":"int64","type":"integer"},"createdAt":{"examples":["2026-04-07T10:01:00Z"],"type":"string"},"currency":{"examples":["EUR"],"type":"string"},"direction":{"examples":["credit"],"type":"string"},"heldAfterMinor":{"description":"Held balance after this entry was applied, in minor units.","examples":[5000],"format":"int64","type":"integer"},"heldDeltaMinor":{"description":"Signed change this entry made to the held balance, in minor units. A payout hold is a negative available delta and a positive held delta of the same magnitude.","examples":[0],"format":"int64","type":"integer"},"id":{"examples":[9001],"format":"int64","type":"integer"},"kind":{"examples":["incoming_payment_processed"],"type":"string"},"occurredAt":{"examples":["2026-04-07T10:01:00Z"],"type":"string"},"reference":{"description":"Opaque correlation reference for this entry; embeds the seller's own payout/payment id for reconciliation.","examples":["payout:9001:hold"],"type":"string"},"walletId":{"examples":[15],"format":"int64","type":"integer"}},"required":["id","walletId","direction","kind","amountMinor","currency","availableDeltaMinor","heldDeltaMinor","balanceAfterMinor","heldAfterMinor","reference","occurredAt","createdAt"],"type":"object"},"WalletTransactionResponseDTO":{"allOf":[{"$ref":"#/components/schemas/WalletTransactionResponse"}],"deprecated":true,"description":"Deprecated: renamed to `WalletTransactionResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"WebhookAllowlistResponse":{"additionalProperties":false,"properties":{"cidr":{"examples":["35.195.26.26/32"],"type":"string"},"createdAt":{"format":"date-time","type":"string"},"description":{"examples":["Unity Finance webhook receiver"],"type":"string"},"id":{"examples":[7],"format":"int64","type":"integer"},"status":{"examples":["active"],"type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","cidr","status","createdAt","updatedAt"],"type":"object"}},"securitySchemes":{"SellerAuth":{"description":"Seller API token in the form: Bearer \u003cseller_token\u003e.\n\nEvery request must include the `Authorization` header:\n\n```\nAuthorization: Bearer \u003cseller_token\u003e\n```\n\nTokens are scoped to a single seller account. Treat them as secrets — rotate\nthem from the Fynex Dashboard if compromised. Tokens are minted from the\nDashboard's Integration page. They do not expire; there is no revoke operation —\nrotate (regenerate) the token to invalidate the old one.\n\n**One credential, both APIs.** The same key authenticates the Billing API\n(`/billing-api/v1`) and its usage ingestion. There is no second token to\nobtain, and no separate registration: what the Billing docs call an\n`sk_test_…` / `sk_live_…` secret key is this token, with the prefix naming the\nmode the account is in.\n","in":"header","name":"Authorization","type":"apiKey"}}},"info":{"description":"# Fynex Payments API\n\nIntegration guides and the complete API reference for accepting payments and sending payouts with Fynex. Accept cards, Apple Pay, Google Pay, and bank transfers — and send payouts — with one unified API.\n\n## What is Fynex?\n\nFynex is a unified payments platform that lets you accept money from customers across cards, Apple Pay, Google Pay, and bank transfers, and pay money out to registered counterparties via the banking provider. A single seller-issued bearer token authenticates every call, and the same set of endpoints powers both hosted-checkout and full server-to-server flows.\n\nThese docs walk you through the integration end-to-end. **Getting started** covers your first successful payment. **Concepts** dives into the data model. **Payment flows** describes each acceptance method. **Payouts** covers sending money out. **Operations** has the operational essentials — idempotency, polling \u0026 SSE, errors, reconciliation, and the production checklist. **Reference** lists every endpoint, parameter, and response — generated from the live OpenAPI spec.\n\n## Where to start\n\n- **[Quickstart](#tag/quickstart)** — Take your first test payment in 5 minutes.\n- **[Hosted checkout](#tag/hosted-checkout)** — The fastest way to accept a payment with the lowest PCI scope.\n- **[Server-to-server](#tag/server-to-server)** — Full control of the flow when you collect card data on your own server.\n- **[Payouts](#tag/payouts)** — Move money out from a wallet to a registered payee.\n- **[Going live](#tag/going-live)** — Production checklist: tokens, monitoring, and the launch playbook.\n\n## Related: the Billing API\n\nInvoices, subscriptions, contracts, metered usage and credit balances live on\ntheir own surface, **`/billing-api/v1`**, documented at\n`/billing-api/v1/docs` with the machine contract at\n`/billing-api/v1/openapi.json`. It authenticates with the same seller secret\nkey. One convention difference to know before writing code: Billing carries\nmoney exclusively as integer **minor units** in fields ending `Minor`, while\nthis API's checkout takes major units — see **Amounts and currency**.\n\n\n# Welcome to the Fynex Payments API\n\nFynex is a unified payments platform for accepting cards, Apple Pay, Google Pay,\nand bank transfers, plus moving money out via payouts. This reference describes\nevery endpoint a seller integration needs.\n\nAll routes live under `/payments-api/v1` and authenticate with a seller bearer\ntoken. You can mint a token from the Fynex Dashboard.\n\n## Base URLs\n\n- **Production:** `https://api.fynex.ai/payments-api/v1`\n- **Staging (sandbox):** `https://staging-api.fynex.ai/payments-api/v1`\n\nBoth are declared in the OpenAPI document's `servers` block — production first,\nthe sandbox second, each labelled — so a generated client or an agent reading\nonly the spec can pick the sandbox instead of defaulting to production. Each\nentry is the bare origin; the `/payments-api/v1` prefix is already part of every\npath in the document.\n\nWhich host you call does not decide whether money is real: that follows your\n**account's** operational mode. Demo accounts exist on both.\n\n## Versioning\n\n`/payments-api/v1` and `/payments-api/v2` serve the **same document and the same\nroutes**. They are not two versions you can choose between: `v2` was the path\nthe current spec was first published under during the docs migration, and `v1`\nis aliased to it so existing bookmarks and generated clients keep resolving.\nRequests to either prefix reach identical handlers.\n\n**Use `/payments-api/v1`.** It is the path every example here, every SDK and\nevery guide uses. If you are pinned to `/v2` nothing is broken — but you are not\non a newer contract, and you will not be left behind by staying on `/v1`.\n\nWhen a genuinely incompatible version ships it will be a new prefix, announced\nbefore it lands, and this section will say what changed and by when the old one\nstops. Until then there is one contract.\n\n### How a change is signalled\n\nAdditive changes — a new endpoint, a new optional field, a new enum value —\nship without notice, so write clients that ignore fields they do not recognise\nand do not fail on an unknown enum value.\n\nAnything being withdrawn is marked `deprecated: true` in `openapi.json` before\nit goes, on the field, schema or operation itself. A renamed schema keeps its\nformer name published as a deprecated alias pointing at the replacement, so a\nclient generated against the older document still resolves. Read the spec, not\njust the guides: the deprecation markers are in the schema, which is where a\ngenerated client and an agent will see them.\n\n## Integration paths\n\nPick the flow that fits your product:\n\n- **Hosted checkout** — call `POST /checkout`, redirect the buyer to the\n  returned URL, receive the result via webhook. Lowest PCI scope.\n- **Server-to-server** — call `POST /initialize-payment`, complete any 3DS\n  challenge client-side, then call `POST /finalize-payment`. You handle card data.\n- **Capture / refund** — once a payment is authorized or captured, use\n  `POST /payments/{payment_id}/capture` and `/refund` for the full amount or partials.\n- **Payouts** — `POST /payouts` to send money from a wallet to a registered\n  payee's payout method.\n- **Top-up invoices** — `POST /payments-api/v1/topup-invoices` issues an\n  invoice a buyer settles by bank transfer, funding the seller's wallet once\n  the deposit is matched.\n\n### The open top-up invoice cap\n\nA seller may hold at most **20 open** top-up invoices at a time — open being\n`pending`, `outdated` or `marked_paid`. Issuing the twenty-first answers `422`\nwith `too many open top-up invoices`; settle or cancel one and the same request\nsucceeds.\n\nThe bound exists because every issued invoice burns one number from the shared\n`(agreement number, YYMM)` invoice-number series, which is also drawn on by\nbilling and settlement invoices. It is a supply bound, not a rate limit:\nretrying the same request without clearing an open invoice will keep answering\n`422`, and there is nothing to back off for.\n\n## Errors\n\nErrors return JSON in the form `{ \"error\": \"\u003cmessage\u003e\" }`. Status codes follow\nthe usual conventions:\n\n| Code | Meaning |\n|------|---------|\n| 400  | Validation error |\n| 401  | Missing or invalid bearer token |\n| 404  | Resource not found for this seller |\n| 409  | Conflict (e.g., invalid status transition, duplicate refund) |\n| 422  | The request is well formed but not applicable in the seller's current state (e.g., the open top-up invoice cap above) |\n| 500  | Internal error |\n| 502  | Upstream provider failure |\n\n## Amounts and currency\n\nMost endpoints accept the amount in **major units** (e.g., `19.99`). Payouts\naccept **minor units** (`amountMinor`, e.g., `1999` for £19.99) — see each\nendpoint for specifics. Currencies are ISO 4217 codes (`GBP`, `EUR`, `USD`).\n\n\n## Error format\n\nAll handler errors return JSON with a single `error` field:\n\n```json\n{ \"error\": \"\u003cmessage\u003e\" }\n```\n\nStatus codes:\n\n| Code | Meaning |\n|------|---------|\n| 400  | Validation error |\n| 401  | Missing or invalid bearer token |\n| 404  | Resource not found for this seller |\n| 409  | Conflict (e.g., invalid status transition, duplicate refund) |\n| 500  | Internal error |\n| 502  | Upstream provider failure |\n\n## Quoting a failure to support\n\nEvery response carries `X-Request-Id` — a `req_`-prefixed identifier generated\nat the boundary, on failures as well as successes, and on a 404 for a URL that\ndoes not exist. Log it alongside your own request and quote it when reporting a\nproblem: it is what turns \"a call failed this morning\" into one line in ours.\n\nIt is generated here and never taken from your request, so it is a correlation\nid and not a field you can set. If you need to carry your own, send it in a\nheader of your own choosing — and note that on writes, `Idempotency-Key` is the\none caller-supplied value this API does act on.\n\n\n## Amounts and currency\n\nMost endpoints accept the amount in **major units** (e.g., `19.99`). Payouts\naccept **minor units** (`amountMinor`, e.g., `1999` for £19.99) — see each\nendpoint for specifics. Currencies are ISO 4217 codes (`GBP`, `EUR`, `USD`).\n\n**Read the field name before the value — this is where the 100× mistake\nlives.** The platform's house convention (the Billing API follows it\nthroughout, and new Payments fields adopt it) is:\n\n| Value | Type | Field name | Example |\n| --- | --- | --- | --- |\n| Money amount | Integer, **minor units** | ends `Minor` | `4999` is £49.99 |\n| Rate / percentage | Integer, **basis points** | ends `Bps` | `275` is 2.75% |\n| Quantity | Decimal **string** | — | `\"1250.5\"` |\n| Tax rate | Decimal **string**, percent | — | `\"20.0\"` |\n\nCheckout's major-unit fields predate that convention and remain accepted.\nThe trap is real in both directions: `49.99` sent into a `…Minor` field\nunderpays a hundredfold, and `4999` sent into checkout's `totalAmount`\novercharges by the same factor — **neither errors**. When a field name ends\n`Minor`, it is an integer count of the currency's smallest unit, everywhere,\non both APIs.\n","title":"Fynex Payments API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/payments-api/v1/checkout":{"post":{"description":"Creates a hosted checkout session backed by a draft payment. The response contains a `sessionId` and a `checkoutUrl` you can redirect the customer to — Fynex collects card details on its hosted page so your servers stay out of PCI scope. The session is idempotent on `Idempotency-Key`. After the customer pays you receive the result via your configured webhook, or you can poll the session state. Sessions expire after a configurable TTL (default 30 minutes).","operationId":"create-checkout-session","parameters":[{"description":"Idempotency key (UUID). Required — replays return the same session ID instead of creating a duplicate.","in":"header","name":"Idempotency-Key","schema":{"description":"Idempotency key (UUID). Required — replays return the same session ID instead of creating a duplicate.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}},{"description":"Optional device fingerprint.","in":"header","name":"X-Device-Fingerprint","schema":{"description":"Optional device fingerprint.","examples":["device_fingerprint_abc123"],"type":"string"}},{"description":"Optional source channel. Defaults to api.","in":"header","name":"X-Source-Channel","schema":{"description":"Optional source channel. Defaults to api.","enum":["api","admin_panel"],"examples":["api"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCheckoutRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCheckoutResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a hosted checkout session","tags":["Payments"]}},"/payments-api/v1/device-intelligence/token":{"post":{"description":"Creates a short-lived browser token for Sumsub Device Intelligence fingerprint collection. Call this from your backend for the authenticated seller, pass the returned `accessToken` to `@sumsub/fisherman`, and include the returned `sessionId` as `deviceSessionId` when you call `/initialize-payment`. On token refresh, send the same `sessionId` so Sumsub correlates the device signals with the later transaction.","operationId":"create-device-intelligence-token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceIntelligenceTokenRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceIntelligenceTokenResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a Sumsub Device Intelligence token","tags":["Payments"]}},"/payments-api/v1/finalize-payment":{"post":{"description":"Confirms a payment after `POST /initialize-payment` and any 3DS or redirect challenge has completed on the customer's side. Returns the final payment state (`succeeded` / `failed` / `cancelled` / intermediate processor status) and the captured amount. You may pass an `amount` lower than the originally authorized amount to capture less than the hold; otherwise the full amount is captured. Pass the same `Idempotency-Key` as in `/initialize-payment` for safe retries.","operationId":"finalize-payment","parameters":[{"description":"Idempotency key (UUID). Pass the same key as /initialize-payment for safe retries.","in":"header","name":"Idempotency-Key","schema":{"description":"Idempotency key (UUID). Pass the same key as /initialize-payment for safe retries.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}},{"description":"Optional device fingerprint.","in":"header","name":"X-Device-Fingerprint","schema":{"description":"Optional device fingerprint.","examples":["device_fingerprint_abc123"],"type":"string"}},{"description":"Optional source channel. Defaults to api.","in":"header","name":"X-Source-Channel","schema":{"description":"Optional source channel. Defaults to api.","enum":["api","admin_panel"],"examples":["api"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinalizePaymentRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinalizePaymentResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Finalize a payment","tags":["Payments"]}},"/payments-api/v1/initialize-payment":{"post":{"description":"Creates a new payment intent and submits it to the upstream processor for authorization. Idempotent on `Idempotency-Key` — submitting the same UUID returns the existing payment instead of creating a duplicate. On a fresh request returns 202 with the upstream provider's `providerPaymentId` and, when `requiresAction` is true, a redirect URL for 3DS or other customer challenges. On an idempotency-key replay returns 200 with the persisted payment.\n\n### Dynamic webhook URL\n\nThis endpoint accepts an optional `webhookUrl` field on the request body. When set, the resulting webhook event is delivered to that URL **in addition** to the seller's configured `SellerWebhookConfig` URLs. The dynamic URL must:\n\n- Be HTTPS (`http://` rejected with `webhook_url_not_https`)\n- Be ≤ 1024 chars (`webhook_url_too_long`)\n- Resolve via DNS (`webhook_url_dns_failed`)\n- Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`)\n- Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth)\n\nThe seller must have at least one active `SellerWebhookConfig` row before `webhookUrl` is accepted — otherwise the request fails with `webhook_url_requires_configured_webhook` (422). Manage the per-seller allowlist via `GET`/`POST`/`DELETE /payments-api/v1/webhooks/allowlist`. Outgoing deliveries to the dynamic URL are signed with the lexicographically-first active config's secret using HMAC-SHA256 (`X-Fynex-Signature: sha256=\u003chex\u003e`, `X-Fynex-Timestamp: \u003cunix-seconds\u003e`). See the `Webhooks` tag for the signature verification flow.","operationId":"initialize-payment","parameters":[{"description":"Idempotency key (UUID). Required for mutating endpoints — submitting the same UUID + body returns the existing payment; reusing it with a different body returns 409.","in":"header","name":"Idempotency-Key","schema":{"description":"Idempotency key (UUID). Required for mutating endpoints — submitting the same UUID + body returns the existing payment; reusing it with a different body returns 409.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}},{"description":"Optional device fingerprint to bind to the payment for risk scoring.","in":"header","name":"X-Device-Fingerprint","schema":{"description":"Optional device fingerprint to bind to the payment for risk scoring.","examples":["device_fingerprint_abc123"],"type":"string"}},{"description":"Optional source channel. Defaults to api.","in":"header","name":"X-Source-Channel","schema":{"description":"Optional source channel. Defaults to api.","enum":["api","admin_panel"],"examples":["api"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitiatePaymentRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitiatePaymentResponse"}}},"description":"Idempotency-key replay — the previously persisted payment is returned without re-calling the upstream processor."},"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitiatePaymentResponse"}}},"description":"Accepted"}},"security":[{"SellerAuth":[]}],"summary":"Initialize a payment (server-to-server)","tags":["Payments"]}},"/payments-api/v1/payees":{"get":{"description":"Returns the paginated list of payees registered under the authenticated seller account. A payee is a counterparty (sub-merchant, marketplace seller, or recipient) that can receive split payments or payouts. Use this list when populating a picker for split-rule destinations or payout creation.","operationId":"list-payees","parameters":[{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","examples":["20"],"type":"string"}},{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","explode":false,"in":"query","name":"offset","schema":{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","examples":["0"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPayeesResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List payees","tags":["Payments"]},"post":{"description":"Registers a new payee under the authenticated seller account. The payee is automatically pinned to your seller account and legal entity — `merchantId` and `legalEntityId` are derived from your Bearer token and are not accepted in the body. Each payee gets an own-currency wallet provisioned on creation.","operationId":"create-payee","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePayeeRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayeeItemResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a payee","tags":["Payments"]}},"/payments-api/v1/payees/setup":{"post":{"description":"Creates a payee and its first payout method in a single atomic transaction — if the method is invalid, the payee is not created either. `displayName`, `role` (contractor|tax) and `payoutMethod.currency` are required. Supply an IBAN for EUR/GBP/USD, or use a GBP `uk_local` method with `bankCountry: GB`, an 8-digit account number, and a 6-digit sort code. The seller account and legal entity are derived from your Bearer token. Returns `201 Created` with both the `payee` and the `payoutMethod` — carry their ids to `POST /payees/{payee_id}/payouts` to pay.","operationId":"setup-payee","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupPayeeRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupPayeeResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a payee with a payout method (one call)","tags":["Payments"]}},"/payments-api/v1/payees/{payee_id}":{"delete":{"description":"Deletes a payee the authenticated seller owns. A payee that does not exist, or belongs to another seller, returns 404.","operationId":"delete-payee","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletePayeeResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Delete a payee","tags":["Payments"]},"get":{"description":"Returns a single payee by its numeric ID, scoped to the authenticated seller account. A payee that does not exist, or belongs to another seller, returns 404.","operationId":"get-payee","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayeeItemResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get a payee","tags":["Payments"]},"patch":{"description":"Updates the supplied fields of a payee the authenticated seller owns. Only fields present in the body are changed; omitted fields are left untouched. A payee that does not exist, or belongs to another seller, returns 404.","operationId":"update-payee","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePayeeRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayeeItemResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Update a payee","tags":["Payments"]}},"/payments-api/v1/payees/{payee_id}/payout-methods":{"get":{"description":"Returns the active payout destinations (bank accounts, virtual IBANs, etc.) registered for a specific payee under the authenticated seller. Use this when picking which destination to use on `POST /payouts` — only methods returned here are eligible.","operationId":"list-payee-payout-methods","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}},{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","examples":["20"],"type":"string"}},{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","explode":false,"in":"query","name":"offset","schema":{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","examples":["0"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPayoutMethodsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List a payee's payout methods","tags":["Payments"]},"post":{"description":"Registers a new payout destination (bank account) for a specific payee under the authenticated seller. Only `bank_account` methods are supported. `currency` is required. EUR/GBP/USD IBAN methods are payable; GBP `uk_local` methods require `bankCountry: GB`, an 8-digit `accountNumber`, and a 6-digit `sortCode` and route through Faster Payments. US-local and SWIFT methods may be registered but are rejected at `POST /payouts` before funds are held. A payee that does not exist, or belongs to another seller, returns 404. Returns `201 Created` with the payout method.","operationId":"create-payee-payout-method","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePayoutMethodRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutMethodResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a payee's payout method","tags":["Payments"]}},"/payments-api/v1/payees/{payee_id}/payout-methods/{method_id}":{"delete":{"description":"Deletes a payout method the authenticated seller owns. The method must belong to the `{payee_id}` in the path and to your seller account — otherwise 404.","operationId":"delete-payee-payout-method","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}},{"description":"Payout method ID","in":"path","name":"method_id","required":true,"schema":{"description":"Payout method ID","examples":[501],"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletePayoutMethodResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Delete a payee's payout method","tags":["Payments"]},"patch":{"description":"Updates the supplied fields of a payout method the authenticated seller owns. Only fields present in the body are changed; omitted fields are left untouched. `currency` cannot be changed. Set `status` to `inactive` to retire a method (it is then excluded from the list and cannot be used for new payouts) or `active` to restore it. The method must belong to the `{payee_id}` in the path and to your seller account — otherwise 404.","operationId":"update-payee-payout-method","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}},{"description":"Payout method ID","in":"path","name":"method_id","required":true,"schema":{"description":"Payout method ID","examples":[501],"format":"int64","type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePayoutMethodRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutMethodResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Update a payee's payout method","tags":["Payments"]}},"/payments-api/v1/payees/{payee_id}/payouts":{"post":{"description":"Creates a payout to one of the payee's payout methods, resolving the source wallet from the method's currency so you don't need to look up a `walletId`. For sellers enabled for both payee payout auto-funding and wallet creation, a missing managed-payee `cashout_balance` wallet is provisioned automatically, even when an ordinary split wallet exists, and only the payout shortfall is moved from the seller's same-currency main wallet. The seller's own `Itself` payee keeps using its existing main wallet. `payoutMethodId` is required and must belong to this payee and be active. Specify `amount` as a decimal in the payout currency. Idempotent on `idempotencyKey` (or the Idempotency-Key header). Returns `201 Created` with the payout in `pending` state; settlement is asynchronous via the banking provider. Poll `GET /payouts/{id}`.","operationId":"create-payout-for-payee","parameters":[{"description":"Payee ID","in":"path","name":"payee_id","required":true,"schema":{"description":"Payee ID","examples":[101],"format":"int64","type":"integer"}},{"description":"REST-conventional idempotency key. Used when the body's idempotencyKey is omitted; when both are set, the body wins.","in":"header","name":"Idempotency-Key","schema":{"description":"REST-conventional idempotency key. Used when the body's idempotencyKey is omitted; when both are set, the body wins.","examples":["a81e6e95-efe2-4e2c-b3e7-fc9a91a9f2c3"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePayoutForPayeeRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Pay a payee (wallet auto-resolved)","tags":["Payments"]}},"/payments-api/v1/payment-methods":{"get":{"description":"Returns the payment rails (card, bank transfer), currencies, and instruments (card, Apple Pay, Google Pay, bank account) enabled for the authenticated seller. Use this to render the available options dynamically at checkout — the result reflects both the seller's configuration and the active terminals.","operationId":"list-payment-methods","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentMethodsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List allowed payment methods","tags":["Payments"]}},"/payments-api/v1/payments/{paymentId}/split-decisions":{"get":{"description":"Returns the decision records behind a payment's split: the rule and lines exactly as they were when the split ran, what each line contributed, which lines did not fire and the machine-readable reason, and which policy moved a payee's number afterwards. This is the answer to \"why did this payee get 90 and not 95\" — a snapshot, so it still explains the outcome after the rule has been edited.\n\n**More than one record is normal.** A re-split after a correction is a second evaluation; records are returned newest first and the first entry is the allocation in force. An integrator that assumes one record per payment will read a superseded allocation as current.\n\n**An empty list is not a 404.** It means no split has been evaluated for this payment. `404` means the payment is not yours or does not exist.\n\n**One reference, the newest attempt.** `paymentId` is your `externalOrderRef`, as on every other payment read here; when you reused a reference across attempts, the decisions returned are those of the most recent payment carrying it.\n\n**Units.** Every amount in a decision record is an integer in minor units (`grossAmountMinor`, `shareAmountMinor`, `distributionAmount`…). The payment it explains still reports `amount` in major units as a decimal number, so reconciling the two means multiplying the payment by 100 — the record is the one to trust for arithmetic. `rulesSkipped[].reason` and `rulesApplied[].effects[]` are closed enumerations, listed on their fields.","operationId":"list-split-decisions","parameters":[{"description":"External payment ID (your externalOrderRef from /initialize-payment).","in":"path","name":"paymentId","required":true,"schema":{"description":"External payment ID (your externalOrderRef from /initialize-payment).","examples":["ORDER-100106"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitDecisionsResponse"}}},"description":"OK","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Explain how a payment was split","tags":["Splits"]}},"/payments-api/v1/payments/{payment_id}":{"get":{"description":"Returns the current lifecycle state of a payment created by the authenticated seller. Useful for server-to-server flows that need to poll for terminal status when webhooks are not (yet) wired up. The `payment_id` path parameter is the `externalOrderRef` you sent on `/initialize-payment` — the same value used by `/capture` and `/refund`. If multiple payment attempts share the same `externalOrderRef`, the latest attempt for the authenticated seller is returned. Raw upstream-provider identifiers are intentionally omitted. For real-time fan-out, prefer configuring a webhook (`POST /webhooks`) over polling.\n\n### Use after `/capture` or `/refund`\n\nThis is the canonical way to check whether a capture or refund has reached its terminal state. After `POST /payments/{payment_id}/capture` the `status` will move through `funds_in_flight` to `settled`. After `POST /payments/{payment_id}/refund` the `status` will move from `refund_pending` to `refunded` (success) or `refund_failed` (rejected by the upstream processor). For refunds, poll every 30s for the first 5 minutes, then every 60s.","operationId":"get-payment","parameters":[{"description":"External payment ID (your externalOrderRef from /initialize-payment).","in":"path","name":"payment_id","required":true,"schema":{"description":"External payment ID (your externalOrderRef from /initialize-payment).","examples":["ORDER-100106"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetPaymentResponse"}}},"description":"OK","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get a payment's current status","tags":["Payments"]}},"/payments-api/v1/payments/{payment_id}/capture":{"post":{"description":"Captures funds for a payment that has been authorized but not yet captured. Pass an `amount` in the body to capture **less** than the authorized amount; omit it to capture the full amount. APM-backed payments currently support full capture only: omit `amount` or pass the full authorized amount; a smaller amount is rejected until partial-capture accounting is available. The payment moves to `captured` (or `partially_captured` for partial captures). Captures must occur within the provider's authorization window (typically 7 days for cards). Use the same external `payment_id` (your order reference) you sent on `/initialize-payment`.\n\n### Dynamic webhook URL\n\nThis endpoint accepts an optional `webhookUrl` field on the request body. When set, the resulting webhook event is delivered to that URL **in addition** to the seller's configured `SellerWebhookConfig` URLs. The dynamic URL must:\n\n- Be HTTPS (`http://` rejected with `webhook_url_not_https`)\n- Be ≤ 1024 chars (`webhook_url_too_long`)\n- Resolve via DNS (`webhook_url_dns_failed`)\n- Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`)\n- Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth)\n\nThe seller must have at least one active `SellerWebhookConfig` row before `webhookUrl` is accepted — otherwise the request fails with `webhook_url_requires_configured_webhook` (422). Manage the per-seller allowlist via `GET`/`POST`/`DELETE /payments-api/v1/webhooks/allowlist`. Outgoing deliveries to the dynamic URL are signed with the lexicographically-first active config's secret using HMAC-SHA256 (`X-Fynex-Signature: sha256=\u003chex\u003e`, `X-Fynex-Timestamp: \u003cunix-seconds\u003e`). See the `Webhooks` tag for the signature verification flow.","operationId":"capture-payment","parameters":[{"description":"External payment ID (your externalOrderRef from /initialize-payment).","in":"path","name":"payment_id","required":true,"schema":{"description":"External payment ID (your externalOrderRef from /initialize-payment).","examples":["ORDER-100106"],"type":"string"}},{"description":"Idempotency key (UUID). Required — replays return the previously captured amount instead of double-charging.","in":"header","name":"Idempotency-Key","schema":{"description":"Idempotency key (UUID). Required — replays return the previously captured amount instead of double-charging.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}},{"description":"Optional device fingerprint.","in":"header","name":"X-Device-Fingerprint","schema":{"description":"Optional device fingerprint.","examples":["device_fingerprint_abc123"],"type":"string"}},{"description":"Optional source channel. Defaults to api.","in":"header","name":"X-Source-Channel","schema":{"description":"Optional source channel. Defaults to api.","enum":["api","admin_panel"],"examples":["api"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentActionRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentActionResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Capture an authorized payment","tags":["Payments","Captures \u0026 refunds"]}},"/payments-api/v1/payments/{payment_id}/refund":{"post":{"description":"Refunds a previously captured payment in full or in part. Pass an `amount` in the body for a partial refund or omit it to refund the remaining refundable balance. Multiple partial refunds are allowed up to the total captured. Funds typically settle back to the customer within a few business days depending on the underlying processor and the customer's bank.\n\n### Checking refund status\n\nThe response is a `RefundResponse` describing the per-refund ledger row created by this call. The `id` field is the refund's UUID — pass it to `GET /payments-api/v1/refunds/{refund_id}` to fetch the latest status without ambiguity across multiple refunds on the same payment. For asynchronous providers (the upstream card processor and the APM provider), the initial `status` is `pending` and the refund terminalises to `succeeded` or `failed` via webhook or provider-status reconciliation; synchronous providers return a terminal status on this response directly. Recommended polling: hit `GET /refunds/{refund_id}` every 30s for the first 5 minutes after this POST, then every 60s. To enumerate every refund on a payment use `GET /payments-api/v1/payments/{payment_id}/refunds`. If the original POST times out while the refund is `pending`, retry only with the same `Idempotency-Key` to replay the same refund row. A stuck `pending` after ~20 minutes is a support case.\n\n### Concurrent partial refunds\n\nMultiple partial refunds are allowed up to the total captured, but they must be issued sequentially — a `POST /refund` while a previous refund on the same payment is still `pending` returns 409 with `refund is already in progress`. A different `Idempotency-Key` is treated as a new refund request and will not be accepted until the pending refund reaches `succeeded`, `failed`, or `cancelled`; reusing the original key safely returns the existing row. Omit `amount` to refund the remaining refundable balance after successful prior refunds. The per-refund `amount` returned by this endpoint is the authoritative record of what each individual refund call requested.\n\n### Dynamic webhook URL\n\nThis endpoint accepts an optional `webhookUrl` field on the request body. When set, the resulting webhook event is delivered to that URL **in addition** to the seller's configured `SellerWebhookConfig` URLs. The dynamic URL must:\n\n- Be HTTPS (`http://` rejected with `webhook_url_not_https`)\n- Be ≤ 1024 chars (`webhook_url_too_long`)\n- Resolve via DNS (`webhook_url_dns_failed`)\n- Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`)\n- Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth)\n\nThe seller must have at least one active `SellerWebhookConfig` row before `webhookUrl` is accepted — otherwise the request fails with `webhook_url_requires_configured_webhook` (422). Manage the per-seller allowlist via `GET`/`POST`/`DELETE /payments-api/v1/webhooks/allowlist`. Outgoing deliveries to the dynamic URL are signed with the lexicographically-first active config's secret using HMAC-SHA256 (`X-Fynex-Signature: sha256=\u003chex\u003e`, `X-Fynex-Timestamp: \u003cunix-seconds\u003e`). See the `Webhooks` tag for the signature verification flow.","operationId":"refund-payment","parameters":[{"description":"External payment ID (your externalOrderRef from /initialize-payment).","in":"path","name":"payment_id","required":true,"schema":{"description":"External payment ID (your externalOrderRef from /initialize-payment).","examples":["ORDER-100106"],"type":"string"}},{"description":"Idempotency key (UUID). Required — multiple partial refunds with the same key are idempotent.","in":"header","name":"Idempotency-Key","schema":{"description":"Idempotency key (UUID). Required — multiple partial refunds with the same key are idempotent.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}},{"description":"Optional device fingerprint.","in":"header","name":"X-Device-Fingerprint","schema":{"description":"Optional device fingerprint.","examples":["device_fingerprint_abc123"],"type":"string"}},{"description":"Optional source channel. Defaults to api.","in":"header","name":"X-Source-Channel","schema":{"description":"Optional source channel. Defaults to api.","enum":["api","admin_panel"],"examples":["api"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentActionRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefundResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Refund a captured payment","tags":["Payments","Captures \u0026 refunds"]}},"/payments-api/v1/payments/{payment_id}/refunds":{"get":{"description":"Lists every refund recorded against a payment, ordered by `createdAt` descending. Each refund row has its own UUID (returned as `id`), `status`, and `amount` — independent of the parent payment's lifecycle status. The `payment_id` path parameter is the `externalOrderRef` you sent on `/initialize-payment` (the same identifier used by `/capture`, `/refund`, and `GET /payments/{payment_id}`). If multiple payment attempts share the same `externalOrderRef`, refunds for the latest attempt are returned — matching `GET /payments/{payment_id}` semantics.\n\n### Lifecycle\n\nRefund `status` moves through `pending` → `succeeded` / `failed`, or settles in `cancelled` for refunds rejected before reaching the upstream processor. For card- and APM-backed payments, terminal status arrives asynchronously via webhook or provider-status reconciliation; synchronous providers include terminal status in the `POST /refund` response. Poll this endpoint every 30s for the first 5 minutes after a `POST /refund`, then every 60s. Concurrent partial refunds are not supported — a new `POST /refund` with a different `Idempotency-Key` returns 409 until the previous one terminalizes; retry the original key to replay the existing refund row.","operationId":"list-refunds-for-payment","parameters":[{"description":"External payment ID (your externalOrderRef from /initialize-payment).","in":"path","name":"payment_id","required":true,"schema":{"description":"External payment ID (your externalOrderRef from /initialize-payment).","examples":["ORDER-1042"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListRefundsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List refunds for a payment","tags":["Payments"]}},"/payments-api/v1/payout-methods":{"get":{"description":"Returns the bank accounts you have registered to cash your own balance out to. These are payout destinations for self-withdrawals from your own wallets — distinct from payee payout methods (which pay third parties). `approvalStatus` is `pending` until a method is approved; only `approved` methods can be paid out from.","operationId":"list-own-payout-methods","parameters":[{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","examples":["20"],"type":"string"}},{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","explode":false,"in":"query","name":"offset","schema":{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","examples":["0"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPayoutMethodsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List your own payout methods","tags":["Payments"]},"post":{"description":"Registers a new bank account to cash your own balance out to (a self-withdrawal destination). You do not supply a payee — the method is attached to your own account. Only `bank_account` methods are supported. `currency` is required. EUR/GBP/USD IBAN methods are payable; GBP `uk_local` methods require `bankCountry: GB`, an 8-digit `accountNumber`, and a 6-digit `sortCode` and route through Faster Payments. US-local and SWIFT methods may be registered but are rejected at payout time before funds are held. The method lands with `approvalStatus: pending` and cannot be paid out from until it is approved (it is approved automatically when your account has auto-approval enabled). Returns `201 Created` with the payout method.","operationId":"create-own-payout-method","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePayoutMethodRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutMethodResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Add a payout method to cash out your own balance","tags":["Payments"]}},"/payments-api/v1/payout-methods/{method_id}":{"delete":{"description":"Deletes one of your own payout methods. The method must belong to your account — otherwise 404.","operationId":"delete-own-payout-method","parameters":[{"description":"Payout method ID","in":"path","name":"method_id","required":true,"schema":{"description":"Payout method ID","examples":[501],"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletePayoutMethodResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Delete your own payout method","tags":["Payments"]},"patch":{"description":"Updates the supplied fields of one of your own payout methods. Only fields present in the body are changed; omitted fields are left untouched. `currency` cannot be changed. Changing any bank-routing field sends the method back to `pending` for re-approval (unless your account has auto-approval). Set `status` to `inactive` to retire a method or `active` to restore it. The method must belong to your account — otherwise 404.","operationId":"update-own-payout-method","parameters":[{"description":"Payout method ID","in":"path","name":"method_id","required":true,"schema":{"description":"Payout method ID","examples":[501],"format":"int64","type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePayoutMethodRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutMethodResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Update your own payout method","tags":["Payments"]}},"/payments-api/v1/payouts":{"get":{"description":"Returns a paginated list of payouts created by the authenticated seller, ordered by `requestedAt` descending. Use `limit` and `offset` to page through results; the response includes a `total` count for the full result set.","operationId":"list-payouts","parameters":[{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","examples":["20"],"type":"string"}},{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","explode":false,"in":"query","name":"offset","schema":{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","examples":["0"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPayoutsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List payouts","tags":["Payments"]},"post":{"description":"Initiates a payout from one of the seller's wallets to a registered payee payout method. Specify the amount as a decimal `amount` in the payout currency (e.g. \"12.50\"); the deprecated `amountMinor` (minor units, e.g. 1999 for £19.99) is still accepted as a fallback, and when both are sent `amount` wins. Exactly one is required. The wallet must hold sufficient balance in the requested currency. `walletId` is the internal numeric id from `GET /payments-api/v1/wallets` — **not** an IBAN. Pick the wallet id for the currency you want to pay out from. `payoutMethodId` is **required** — pass an id from `GET /payees/{id}/payout-methods`; it must belong to the wallet's payee and be active. The endpoint is idempotent on `idempotencyKey` — replays return the original payout. The response is `201 Created` with the payout in `pending` state; settlement happens asynchronously through the banking provider. EUR IBAN methods use SEPA, GBP `uk_local` methods use Faster Payments, and GBP/USD IBAN methods use cross-border transfers. Poll `GET /payouts/{id}` for status updates or rely on configured webhooks.\n\n### Dynamic webhook URL\n\nThis endpoint accepts an optional `webhookUrl` field on the request body. When set, the resulting webhook event is delivered to that URL **in addition** to the seller's configured `SellerWebhookConfig` URLs. The dynamic URL must:\n\n- Be HTTPS (`http://` rejected with `webhook_url_not_https`)\n- Be ≤ 1024 chars (`webhook_url_too_long`)\n- Resolve via DNS (`webhook_url_dns_failed`)\n- Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`)\n- Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth)\n\nThe seller must have at least one active `SellerWebhookConfig` row before `webhookUrl` is accepted — otherwise the request fails with `webhook_url_requires_configured_webhook` (422). Manage the per-seller allowlist via `GET`/`POST`/`DELETE /payments-api/v1/webhooks/allowlist`. Outgoing deliveries to the dynamic URL are signed with the lexicographically-first active config's secret using HMAC-SHA256 (`X-Fynex-Signature: sha256=\u003chex\u003e`, `X-Fynex-Timestamp: \u003cunix-seconds\u003e`). See the `Webhooks` tag for the signature verification flow.","operationId":"create-payout","parameters":[{"description":"REST-conventional idempotency key. Used when the body's idempotencyKey is omitted; when both are set, the body wins.","in":"header","name":"Idempotency-Key","schema":{"description":"REST-conventional idempotency key. Used when the body's idempotencyKey is omitted; when both are set, the body wins.","examples":["a81e6e95-efe2-4e2c-b3e7-fc9a91a9f2c3"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePayoutRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a payout","tags":["Payments"]}},"/payments-api/v1/payouts/{id}":{"get":{"description":"Returns a payout by its numeric ID, scoped to the authenticated seller. Includes status, amount, currency, the upstream `providerReference`, and timing fields (`requestedAt`, `processedAt`, `completedAt`) populated as the payout progresses through its lifecycle.","operationId":"get-payout","parameters":[{"description":"Payout ID","in":"path","name":"id","required":true,"schema":{"description":"Payout ID","examples":[9001],"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get a payout","tags":["Payments"]}},"/payments-api/v1/payouts/{id}/cancel":{"post":{"description":"Cancels a payout that is held in `awaiting_approval` (pending review), releasing the held funds back to the wallet's available balance. Only the seller that created the payout can cancel it, and only while it is still awaiting approval — an approved/dispatched or otherwise terminal payout returns 409. Returns the payout in `cancelled` state.","operationId":"cancel-payout","parameters":[{"description":"Payout ID","in":"path","name":"id","required":true,"schema":{"description":"Payout ID","examples":[9001],"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Cancel a payout awaiting approval","tags":["Payments"]}},"/payments-api/v1/refunds/{refund_id}":{"get":{"description":"Returns a single refund by its UUID, scoped to the authenticated seller. Use this as the canonical way to confirm whether a specific refund has reached its terminal state — `GET /payments/{payment_id}` only reports the parent payment's status, which collapses multiple refunds into one. The `paymentId` field on the response is the seller-facing `externalOrderRef` of the parent payment (the same identifier you sent on `/initialize-payment`).\n\n### Lifecycle\n\nRefund `status` moves through `pending` → `succeeded` / `failed`, or settles in `cancelled` for refunds rejected before reaching the upstream processor. For card- and APM-backed payments, terminal status arrives asynchronously via webhook or provider-status reconciliation; synchronous providers include terminal status in the `POST /refund` response. Poll this endpoint every 30s for the first 5 minutes after a `POST /refund`, then every 60s. A stuck `pending` after ~20 minutes is a support case. Do not issue a second refund with a new `Idempotency-Key` while this refund is `pending`; retry the original key to replay the same row.","operationId":"get-refund","parameters":[{"description":"Refund UUID returned by POST /refund or the list endpoint.","in":"path","name":"refund_id","required":true,"schema":{"description":"Refund UUID returned by POST /refund or the list endpoint.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefundResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get a refund by ID","tags":["Payments"]}},"/payments-api/v1/refunds/{refund_id}/cancel":{"post":{"description":"Cancels a refund that is still `pending` at the upstream processor, then returns the refund ledger row in `cancelled` state. This is currently implemented for card refunds at the upstream card processor (a provider-side cancel call) and is only valid before the provider terminalises the refund. If a cancellation response is lost, retry with the same `Idempotency-Key`; a different key is rejected while the original cancellation lease is active. A refund that is already `succeeded`, `failed`, or otherwise no longer cancellable returns 409. Use `GET /payments-api/v1/refunds/{refund_id}` before and after this call to confirm the refund's status.","operationId":"cancel-refund","parameters":[{"description":"Refund UUID returned by POST /refund or the list endpoint.","in":"path","name":"refund_id","required":true,"schema":{"description":"Refund UUID returned by POST /refund or the list endpoint.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}},{"description":"Idempotency key (UUID). Required — safe retries return the already-cancelled refund row.","in":"header","name":"Idempotency-Key","schema":{"description":"Idempotency key (UUID). Required — safe retries return the already-cancelled refund row.","examples":["a81e6e95-efe2-4e2c-b3e7-fc9a91a9f2c3"],"type":"string"}},{"description":"Optional device fingerprint.","in":"header","name":"X-Device-Fingerprint","schema":{"description":"Optional device fingerprint.","examples":["device_fingerprint_abc123"],"type":"string"}},{"description":"Optional source channel. Defaults to api.","in":"header","name":"X-Source-Channel","schema":{"description":"Optional source channel. Defaults to api.","enum":["api","admin_panel"],"examples":["api"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefundResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Cancel a pending refund","tags":["Payments","Captures \u0026 refunds"]}},"/payments-api/v1/topup-invoices":{"get":{"description":"Lists the authenticated seller's top-up invoices (newest first) with optional status/currency filters.","operationId":"list-topup-invoices","parameters":[{"description":"Max invoices to return (1-200, default 20).","explode":false,"in":"query","name":"limit","schema":{"default":20,"description":"Max invoices to return (1-200, default 20).","examples":[20],"format":"int64","maximum":200,"minimum":1,"type":"integer"}},{"description":"Pagination offset.","explode":false,"in":"query","name":"offset","schema":{"description":"Pagination offset.","examples":[0],"format":"int64","type":"integer"}},{"description":"Filter by status.","explode":false,"in":"query","name":"status","schema":{"description":"Filter by status.","examples":["pending"],"type":"string"}},{"description":"Filter by currency (ISO 4217).","explode":false,"in":"query","name":"currency","schema":{"description":"Filter by currency (ISO 4217).","examples":["EUR"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListTopupInvoicesResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List top-up invoices","tags":["Payments"]},"post":{"description":"Issues a top-up invoice for the authenticated seller in the given currency and amount (minor units).","operationId":"create-topup-invoice","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTopupInvoiceRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopupInvoiceResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a top-up invoice","tags":["Payments"]}},"/payments-api/v1/topup-invoices/currencies":{"get":{"description":"Currencies the authenticated seller can issue a top-up invoice in (active safeguarding account + a seller main wallet).","operationId":"list-topup-invoice-currencies","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListTopupInvoiceCurrenciesResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List invoiceable currencies","tags":["Payments"]}},"/payments-api/v1/topup-invoices/{uuid}":{"get":{"description":"Returns one of the authenticated seller's top-up invoices by UUID.","operationId":"get-topup-invoice","parameters":[{"description":"Invoice UUID.","in":"path","name":"uuid","required":true,"schema":{"description":"Invoice UUID.","examples":["7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopupInvoiceResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get a top-up invoice","tags":["Payments"]}},"/payments-api/v1/topup-invoices/{uuid}/cancel":{"post":{"description":"Voids the seller's own pending/marked_paid invoice.","operationId":"cancel-topup-invoice","parameters":[{"description":"Invoice UUID.","in":"path","name":"uuid","required":true,"schema":{"description":"Invoice UUID.","examples":["7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopupInvoiceResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Cancel a top-up invoice","tags":["Payments"]}},"/payments-api/v1/topup-invoices/{uuid}/document":{"get":{"description":"Returns the printable top-up invoice as HTML, for an invoice belonging to the authenticated seller. Served inline so it renders and prints in the browser; it is not a PDF.","operationId":"get-topup-invoice-document","parameters":[{"description":"Invoice UUID.","in":"path","name":"uuid","required":true,"schema":{"description":"Invoice UUID.","examples":["7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"contentEncoding":"base64","type":"string"}}},"description":"OK","headers":{"Cache-Control":{"schema":{"type":"string"}},"Content-Disposition":{"schema":{"type":"string"}},"Content-Security-Policy":{"schema":{"type":"string"}},"Content-Type":{"schema":{"type":"string"}},"X-Content-Type-Options":{"schema":{"type":"string"}},"X-Robots-Tag":{"schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get the printable top-up invoice document","tags":["Payments"]}},"/payments-api/v1/topup-invoices/{uuid}/mark-paid":{"post":{"description":"Records the seller's declaration that they have paid the invoice (pending → marked_paid). Does not credit funds.","operationId":"mark-topup-invoice-paid","parameters":[{"description":"Invoice UUID.","in":"path","name":"uuid","required":true,"schema":{"description":"Invoice UUID.","examples":["7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopupInvoiceResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Mark a top-up invoice as paid","tags":["Payments"]}},"/payments-api/v1/wallets":{"get":{"description":"Returns the authenticated seller's wallets, each with its current balance snapshot. Balances are in minor units (e.g. 125000 for 1250.00). Use `limit` and `offset` to page; the response includes a `total` count for the full result set.","operationId":"list-wallets","parameters":[{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","examples":["20"],"type":"string"}},{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","explode":false,"in":"query","name":"offset","schema":{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","examples":["0"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWalletsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List wallets","tags":["Payments"]},"post":{"description":"Provisions a cashout_balance wallet for a payee in the requested currency, so the seller can pay that payee in a currency beyond their account currency. `payeeId` is required and must be an active payee owned by the authenticated seller (404 otherwise); the payout debits this wallet. This endpoint is disabled by default per seller and must be enabled by Fynex support before use — a disabled account receives 403. Only currencies Fynex can pay out from are accepted (today: EUR); other currencies return 422. Provisioning is idempotent per (payee, currency): requesting one that already exists returns the existing wallet without creating a duplicate.","operationId":"create-wallet","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWalletRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWalletResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a wallet","tags":["Payments"]}},"/payments-api/v1/wallets/{wallet_id}":{"get":{"description":"Returns a single wallet (with balance snapshot) by its numeric ID, scoped to the authenticated seller. A wallet that does not exist, or belongs to another seller, returns 404.","operationId":"get-wallet","parameters":[{"description":"Wallet ID","in":"path","name":"wallet_id","required":true,"schema":{"description":"Wallet ID","examples":[15],"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get a wallet","tags":["Payments"]}},"/payments-api/v1/wallets/{wallet_id}/fund":{"post":{"description":"Moves funds from the authenticated seller's main wallet to one of their payee cashout_balance wallets, in the same currency. This is the pay-in step: a cashout_balance wallet is created empty and must be topped up from the seller's settled main balance before a payout can draw from it. `amount` is a positive decimal within the currency's scale; the funding source is the seller's main wallet in the wallet's currency (resolved server-side). A wallet that does not exist or belongs to another seller returns 404; a non-cashout or inactive target wallet, no main wallet in the currency, or insufficient balance return 422. Pass `idempotencyKey` to make the call safe to retry — replaying it returns the already-applied result instead of funding twice.","operationId":"fund-wallet","parameters":[{"description":"Cashout wallet to fund","in":"path","name":"wallet_id","required":true,"schema":{"description":"Cashout wallet to fund","examples":[15],"format":"int64","type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FundWalletRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FundWalletResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Fund a cashout wallet from the main balance","tags":["Payments"]}},"/payments-api/v1/wallets/{wallet_id}/transactions":{"get":{"description":"Returns the ledger entries for a wallet the authenticated seller owns, newest first. The ledger is delta-based: each entry carries signed `availableDeltaMinor` / `heldDeltaMinor` and the resulting balances after it was applied. Use `limit` and `offset` to page; the response includes a `total` count. A wallet that does not exist, or belongs to another seller, returns 404.","operationId":"list-wallet-transactions","parameters":[{"description":"Wallet ID","in":"path","name":"wallet_id","required":true,"schema":{"description":"Wallet ID","examples":[15],"format":"int64","type":"integer"}},{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100). Out-of-range or non-numeric values silently fall back to the default (20).","examples":["20"],"type":"string"}},{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","explode":false,"in":"query","name":"offset","schema":{"description":"Offset (\u003e=0). Out-of-range or non-numeric values silently fall back to 0.","examples":["0"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWalletTransactionsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List wallet transactions","tags":["Payments"]}},"/payments-api/v1/webhooks":{"get":{"description":"Lists webhook URLs configured for the authenticated seller account.","operationId":"list-webhooks","parameters":[{"description":"Webhook status filter (defaults to all). Unknown values surface as 400.","explode":false,"in":"query","name":"status","schema":{"description":"Webhook status filter (defaults to all). Unknown values surface as 400.","enum":["active","disabled","all"],"examples":["all"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListSellerWebhookConfigsResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List seller webhook URLs","tags":["Payments"]},"post":{"description":"Creates a webhook URL for the authenticated seller account. If the URL exists in disabled status, it is reactivated and the response uses 200 instead of 201.","operationId":"create-webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSellerWebhookConfigRequest"}}},"required":true},"responses":{"200":{"description":"Reactivated an existing disabled webhook URL with the same address. No new row created."},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSellerWebhookConfigResponse"}}},"description":"Created"}},"security":[{"SellerAuth":[]}],"summary":"Create seller webhook URL","tags":["Payments"]}},"/payments-api/v1/webhooks/allowlist":{"get":{"description":"Returns active webhook allowlist entries for the authenticated seller. Each entry is an IPv4 or IPv6 CIDR; dynamic webhook URLs (passed on `POST /initialize-payment`, `/capture`, `/refund`, `/payouts`) are accepted only when the request URL's resolved IPs all fall within at least one active entry.","operationId":"list-webhook-allowlist","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebhookAllowlistResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List webhook allowlist entries","tags":["Webhooks"]},"post":{"description":"Adds an IPv4 or IPv6 CIDR to the authenticated seller's webhook allowlist. Bare IPs are accepted and normalized to /32 (IPv4) or /128 (IPv6). Private, loopback, link-local, and reserved ranges (RFC 1918, RFC 3927, RFC 4193, etc.) plus the unbounded /0 blocks are hard-rejected with 400 — a compromised API client cannot whitelist internal infrastructure. Duplicate entries (same seller + same normalized CIDR) return 409.","operationId":"create-webhook-allowlist","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookAllowlistRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookAllowlistResponse"}}},"description":"Created"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Add webhook allowlist entry","tags":["Webhooks"]}},"/payments-api/v1/webhooks/allowlist/{id}":{"delete":{"description":"Soft-deletes a webhook allowlist entry for the authenticated seller. The row is removed from active matching (so dynamic URLs resolving to its CIDR start being rejected) but is retained in the database for audit. Returns 404 if the entry is not owned by the caller.","operationId":"delete-webhook-allowlist","parameters":[{"description":"Allowlist entry ID","in":"path","name":"id","required":true,"schema":{"description":"Allowlist entry ID","examples":[7],"format":"int64","type":"integer"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Remove webhook allowlist entry","tags":["Webhooks"]}},"/payments-api/v1/webhooks/{id}":{"delete":{"description":"Disables a webhook URL for the authenticated seller account while preserving history (soft delete — the row stays with status=disabled).","operationId":"delete-webhook","parameters":[{"description":"Webhook config ID","in":"path","name":"id","required":true,"schema":{"description":"Webhook config ID","examples":[7],"format":"int64","type":"integer"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Disable seller webhook URL","tags":["Payments"]},"patch":{"description":"Updates a webhook URL or status for the authenticated seller account. Both body fields are optional — absent fields are left unchanged.","operationId":"update-webhook","parameters":[{"description":"Webhook config ID","in":"path","name":"id","required":true,"schema":{"description":"Webhook config ID","examples":[7],"format":"int64","type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSellerWebhookConfigRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SellerWebhookConfigResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Update seller webhook URL","tags":["Payments"]}}},"servers":[{"description":"Fynex Payments API","url":"https://api.fynex.ai"},{"description":"Sandbox — test-mode accounts, test cards, no live money","url":"https://staging-api.fynex.ai"}],"tags":[{"description":"## Pagination\n\nList endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and\nreturn a `total` count alongside the page. Iterate by incrementing `offset` by\n`limit` until the response returns fewer than `limit` items or `offset \u003e= total`.\n\n\n## Idempotency\n\nAll mutating endpoints accept the `Idempotency-Key` header (a UUID). Submitting\nthe same key twice within the retention window returns the existing operation —\nsafe to retry on network errors without risking duplicate charges.\n\nRecommended practice:\n\n- Generate a fresh UUID per logical operation (e.g., per checkout attempt).\n- Persist the key alongside the operation in your own store so retries reuse it.\n- Treat any 2xx response as authoritative; retry only on 5xx or timeouts.\n- On `/initialize-payment`, a replay returns `200`; active redirect APMs rehydrate the existing provider redirect instead of creating a second charge.\n","name":"Payments"},{"name":"Payment Methods"},{"description":"## Pagination\n\nList endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and\nreturn a `total` count alongside the page. Iterate by incrementing `offset` by\n`limit` until the response returns fewer than `limit` items or `offset \u003e= total`.\n\n\n---\n\nFynex pushes lifecycle events to a webhook URL you register on your seller account. Every outbound request is signed with HMAC-SHA256 so you can verify it came from Fynex and was not modified in transit.\n\n\u003e [!IMPORTANT]\n\u003e The signing secret is returned **once** on the create response. Store it somewhere safe (a secret manager, your platform's encrypted config). If you lose it, rotate via the dashboard to mint a new one.\n\n## Register a webhook URL\n\n`POST /payments-api/v1/webhooks` registers a URL and returns the per-config signing secret.\n\n### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/webhooks\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"webhookUrl\":\"https://merchant.example.com/webhooks/fynex\"}'\n```\n\n**Response (201 Created):**\n\n```json\n{\n  \"id\": 1,\n  \"sellerAccountId\": 42,\n  \"webhookUrl\": \"https://merchant.example.com/webhooks/fynex\",\n  \"status\": \"active\",\n  \"secretKey\": \"3b8f1d2c4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c\",\n  \"createdAt\": \"2026-05-19T10:11:12Z\",\n  \"updatedAt\": \"2026-05-19T10:11:12Z\"\n}\n```\n\n`secretKey` is **only** returned on this response. Subsequent `GET /webhooks` and `PATCH /webhooks/{id}` responses omit it. If you lose the value, use the dashboard's rotate-secret action to mint a fresh one — the old secret stops verifying as soon as the new one is issued.\n\nIf the same `webhookUrl` already exists in `disabled` state for your account, the endpoint reactivates the existing row and returns **200 OK** with the original secret (not a new one). This means rotating status `disabled → active` does not change the secret your verifier needs.\n\n## Choosing what you receive\n\nAn endpoint registered without an `eventTypes` field receives **every event\ntype except those marked opt-in below**. That is the default, and it is what\nevery endpoint created before subscriptions existed still does — you do not\nhave to change anything.\n\nTo narrow an endpoint, send the list you want:\n\n```json\n{\n  \"webhookUrl\": \"https://merchant.example.com/webhooks/fynex\",\n  \"eventTypes\": [\"PaymentCompleted\", \"PaymentRefunded\"]\n}\n```\n\nA non-empty list is an allow-list: exactly those types and nothing else. An\nunrecognised type is rejected with `400`, so a typo cannot leave your endpoint\nsilently receiving nothing. `PATCH /webhooks/{id}` replaces the list wholesale;\nsend `\"eventTypes\": []` to go back to the default.\n\nTwo things worth knowing before you narrow an endpoint:\n\n- **Only `200` counts as delivered.** Everything else is retried up to three\n  times and then marked failed, so returning a non-2xx for an event you did not\n  want costs you a retry storm rather than filtering anything. Narrow the\n  subscription instead.\n- **Ignore unknown `eventType` values.** New types are added over time. An\n  endpoint on the default subscription will start receiving new non-opt-in\n  types without any action from you, so treat an unfamiliar `eventType` as a\n  no-op rather than an error.\n\n## What you can receive\n\nEvery event below is also published in the OpenAPI document under its\ntop-level `webhooks` key, so a generated client carries the delivered body\ntype instead of a map. Register once and you receive all of them, unless you\nnarrow the endpoint with `eventTypes` as described above.\n\n### Payments\n\n| Event | Fires when |\n| --- | --- |\n| `PaymentCompleted` | A payment reached a terminal state. `status` says which one — this is not by itself a success, and `failureCode` / `retry` / `failureDescription` carry the reason, and whether re-sending can help, when it is not. |\n| `PaymentRefunded` | A refund succeeded. `partial` is true when it is smaller than the payment's captured amount, per refund rather than cumulatively. A FAILED refund emits nothing; it surfaces as a seller email. |\n\n### Payment links\n\n| Event | Fires when |\n| --- | --- |\n| `PaymentLinkCreated` | A link was created and is payable. |\n| `PaymentLinkUpdated` | Its amount, title, expiry or recipient changed. |\n| `PaymentLinkPaid` | Someone paid it. A multi-use link stays active with a higher `usageCount`; a single-use link moves to `paid`. |\n| `PaymentLinkExpired` | It passed its expiry unpaid. |\n| `PaymentLinkCancelled` | It was cancelled before payment. |\n| `PaymentLinkEmailFailed` | The invitation email could not be sent. The link itself is fine — send the URL another way rather than recreating it. |\n\n### Recurring subscriptions\n\nThese describe **payment-link** subscriptions. Billing subscriptions emit a\nseparate `BillingSubscription*` family on this same pipe — see the Billing\nAPI's webhooks guide. Two products, one word; subscribing to these tells you\nnothing about the other.\n\n`SubscriptionTrialWillEnd` is the one exception: **both** products emit it,\nbecause the billing family has no trial-ending event of its own. Its payload\nis therefore one of two shapes — the payment-link one below, or a billing\nsubscription's smaller `{source, subscriptionId, contractId, trialEnd,\namountMinor, currency}`.\n\nBranch on **`source`**: every payload on this event carries it, `payment_links`\non the payment-link shape and `billing` on the billing one. It is the OpenAPI\ndiscriminator for the `oneOf` the document publishes, so a generated client\nselects the right type from it without inspecting anything else.\n(`contractId` is still present on the billing shape only, and remains a valid\ntell for a receiver written before `source` existed.)\n\n| Event | Fires when |\n| --- | --- |\n| `SubscriptionCreated` | A recurring subscription was created on a link. |\n| `SubscriptionCharged` | A term was charged and the period advanced. |\n| `SubscriptionInstallmentCharged` | One monthly installment of an annual commitment was charged. |\n| `SubscriptionTrialWillEnd` | A trial ends within a day — the last chance to collect a payment method. Emitted for payment-link **and** billing subscriptions, with a different payload each; see the note below the table. |\n| `SubscriptionTrialEnded` | The trial ended and the subscription became active. |\n| `SubscriptionCommitmentCompleted` | The final installment of an annual commitment was charged. |\n| `SubscriptionCancelled` | It was cancelled, on request or because a trial ended with cancel-on-trial-end behaviour. |\n| `SubscriptionPastDue` | A charge failed on its last permitted attempt. |\n\n### Payee verification\n\n| Event | Fires when |\n| --- | --- |\n| `KYBVerificationStarted` | A verification was opened for a payee. |\n| `KYBVerificationPending` | Documents are with the provider, under automated review. |\n| `KYBVerificationManualReview` | The provider escalated to a human reviewer. |\n| `KYBVerificationApproved` | The payee passed — this is what unlocks payouts to them. |\n| `KYBVerificationRejected` | The payee failed. `reviewRejectType` separates a retryable request for documents from a final refusal. |\n| `KYBVerificationLevelChanged` | The verification level changed, usually a raised limit tier. |\n\n### Marketplace vendors\n\nDelivered to the **platform** that onboarded the vendor (see the [Vendors\nguide](/payments-api/v2/docs/vendors.md)). This family is **opt-in**: an\nendpoint receives it only when its `eventTypes` names the events you want,\nso adding it to your integration is a deliberate step and existing endpoints\nnever start receiving vendor events unasked. The payload is the vendor's\ncapability view -- `payoutsEnabled`, `verification`, `payoutAllowance` --\nnever the vendor's personal details.\n\n| Event | Fires when |\n| --- | --- |\n| `MarketplaceVendorActivated` | Screening passed and `payoutsEnabled` became true: the vendor's payee can be a split or payout target, under the cumulative limit. |\n| `MarketplaceVendorVerificationRequired` | The vendor must complete a verification step. `actionUrl` / `actionExpiresAt` are present when a link was issued; otherwise fetch one with `POST /vendors/{vendor_id}/verification-link`. |\n| `MarketplaceVendorVerificationInProgress` | The vendor submitted; the provider is reviewing. |\n| `MarketplaceVendorVerificationPendingReview` | The case is with a human reviewer. |\n| `MarketplaceVendorVerificationComplete` | Verification passed; the cumulative limit no longer applies. |\n| `MarketplaceVendorBlocked` | Refused at screening or verification, or blocked manually. No funds can reach the vendor. |\n| `MarketplaceVendorPayoutAllowanceChanged` | `payoutAllowance.limitMinor`, `enforcement` or `unlimited` changed. |\n\n### Commerce\n\n| Event | Fires when |\n| --- | --- |\n| `PromoCodeRedeemed` | A promotion code was redeemed. The context ids say what against; the ones that do not apply are null. |\n| `TaxLocationResolved` | A checkout's tax jurisdiction was resolved and the evidence stored, so you can archive the trail beside your own records. |\n\n## The delivery body\n\nWhatever fired, the body is an envelope with the event's payload nested inside\nit:\n\n```json\n{\n  \"eventId\": 918204,\n  \"eventType\": \"PaymentCompleted\",\n  \"sellerAccountUuid\": \"6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55\",\n  \"occurredAt\": \"2026-08-20T14:02:11Z\",\n  \"payload\": { }\n}\n```\n\n`eventId` is the delivery's stable identity — deduplicate on it, not on the\npayload. `occurredAt` is when the state changed, not when delivery was\nattempted: a retry repeats the original value. Per-event payload fields are in\nthe OpenAPI document.\n\n\u003e [!IMPORTANT]\n\u003e Money in these payloads is in **integer minor units** (`\"amountMinor\": 4999`\n\u003e is €49.99), including on `PaymentCompleted` — even though the checkout\n\u003e request that created the payment takes major units. Read the field name.\n\n## Verifying the signature\n\nEvery outbound webhook request carries two headers:\n\n| Header | Format | Example |\n|---|---|---|\n| `X-Fynex-Signature` | `sha256=\u003chex\u003e` | `sha256=27c6f9c3...` |\n| `X-Fynex-Timestamp` | unix-seconds (decimal string) | `1747654272` |\n\n### Algorithm\n\n1. Read the raw request body **before** any JSON parsing — the signature is computed over the exact bytes Fynex sent.\n2. Build the signed string: `\u003cX-Fynex-Timestamp\u003e + \".\" + \u003craw-body\u003e` (no whitespace between the parts).\n3. Compute `HMAC-SHA256(secret, signed-string)` using your stored `secretKey` as the key.\n4. Hex-encode the digest (lowercase) and compare it to the value after `sha256=` in `X-Fynex-Signature` using a constant-time comparison.\n5. Reject the event if the timestamp is more than 5 minutes from your server's clock. This blocks replay attacks where an attacker captured a valid signature and resends it later.\n\n### Why timestamp + body, not just body?\n\nSigning the timestamp alongside the body means a captured signature cannot be replayed under a different timestamp — the HMAC binds the two together. If your verifier accepts a signature without also checking the timestamp's freshness, an attacker who once intercepted a valid event can resend it indefinitely.\n\n### Node.js\n\n```js\nimport crypto from 'node:crypto';\n\nexport function verifyFynexWebhook(req, secret) {\n  const signatureHeader = req.headers['x-fynex-signature'] || '';\n  const timestamp = req.headers['x-fynex-timestamp'] || '';\n  const rawBody = req.rawBody; // express.raw() / fastify rawBody / etc.\n\n  // 1. Freshness — reject anything older than 5 minutes.\n  const now = Math.floor(Date.now() / 1000);\n  if (Math.abs(now - Number(timestamp)) \u003e 300) {\n    return false;\n  }\n\n  // 2. Re-compute the HMAC.\n  const expected = crypto\n    .createHmac('sha256', secret)\n    .update(timestamp + '.' + rawBody)\n    .digest('hex');\n  const signature = signatureHeader.startsWith('sha256=')\n    ? signatureHeader.slice('sha256='.length)\n    : signatureHeader;\n\n  // 3. Constant-time comparison.\n  const a = Buffer.from(signature, 'hex');\n  const b = Buffer.from(expected, 'hex');\n  return a.length === b.length \u0026\u0026 crypto.timingSafeEqual(a, b);\n}\n```\n\n### Python\n\n```python\nimport hmac, hashlib, time\n\ndef verify_fynex_webhook(headers, raw_body: bytes, secret: str) -\u003e bool:\n    sig = headers.get(\"X-Fynex-Signature\", \"\")\n    ts = headers.get(\"X-Fynex-Timestamp\", \"\")\n\n    # 1. Freshness — reject events older than 5 minutes.\n    try:\n        if abs(int(time.time()) - int(ts)) \u003e 300:\n            return False\n    except ValueError:\n        return False\n\n    # 2. Re-compute the HMAC.\n    signed_string = f\"{ts}.\".encode() + raw_body\n    expected = hmac.new(secret.encode(), signed_string, hashlib.sha256).hexdigest()\n    received = sig[len(\"sha256=\"):] if sig.startswith(\"sha256=\") else sig\n\n    # 3. Constant-time comparison.\n    return hmac.compare_digest(received, expected)\n```\n\n### PHP\n\n```php\n\u003c?php\n\n/**\n * @param array\u003cstring,string\u003e $headers  Case-sensitive header names as Fynex sends them.\n * @param string $rawBody                file_get_contents('php://input') — never the parsed array.\n */\nfunction verify_fynex_webhook(array $headers, string $rawBody, string $secret): bool\n{\n    $sig = $headers['X-Fynex-Signature'] ?? '';\n    $ts  = $headers['X-Fynex-Timestamp'] ?? '';\n\n    // 1. Freshness — reject events older than 5 minutes.\n    if (!ctype_digit(ltrim($ts, '-')) || abs(time() - (int) $ts) \u003e 300) {\n        return false;\n    }\n\n    // 2. Re-compute the HMAC.\n    $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);\n    $received = str_starts_with($sig, 'sha256=') ? substr($sig, strlen('sha256=')) : $sig;\n\n    // 3. Constant-time comparison.\n    return hash_equals($expected, $received);\n}\n```\n\n### curl smoke-test\n\nYou can replay a captured event against your local verifier:\n\n```bash\ncurl -sS -X POST \"http://localhost:3000/webhooks/fynex\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-Fynex-Timestamp: 1747654272\" \\\n  -H \"X-Fynex-Signature: sha256=27c6f9c3...\" \\\n  --data-binary '{\"eventId\":42,\"eventType\":\"PaymentCompleted\",\"sellerAccountUuid\":\"6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55\",\"occurredAt\":\"2026-05-19T10:11:12Z\",\"payload\":{}}'\n```\n\nThe exact signature for that body and timestamp depends on your secret — generate it with the Node or Python snippet above.\n\n## Retry and delivery semantics\n\n- Each event is delivered to every active webhook URL on your seller account.\n- A delivery is considered successful only on a `200 OK` response from your endpoint within 10 seconds.\n- Failed deliveries retry up to **3 attempts total**. After the third failure the delivery is marked `failed` and not retried automatically.\n- Acknowledge fast (200 in \u003c2s) and process asynchronously — long-running handlers risk hitting the 10s timeout.\n\n## See also\n\n- **[Polling \u0026 SSE guide](#tag/polling-sse)** — How to know when a payment, payout, or refund changes state — without webhooks.\n","name":"Webhooks"},{"description":"## Pagination\n\nList endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and\nreturn a `total` count alongside the page. Iterate by incrementing `offset` by\n`limit` until the response returns fewer than `limit` items or `offset \u003e= total`.\n\n\n---\n\nA **payee** is a registered counterparty under your seller account — a sub-merchant, marketplace seller, or any other recipient that is eligible to receive payouts and split shares. Every payout and split rule line targets a payee.\n\nPayees have two API surfaces with different auth models:\n\n| Surface | Auth | Operations |\n|---------|------|------------|\n| REST `/payments-api/v1/payees` | Bearer token | Full CRUD: list, get, create, update, delete |\n| GraphQL `/dashboard/graphql` | `dashboard_session` cookie | Full CRUD: list, get, create, update, delete |\n\n\u003e [!NOTE]\n\u003e The REST surface is the integrator-facing API. Every REST payee operation is scoped to the seller account behind your Bearer token: the `sellerAccountId` and `legalEntityId` are derived from the token and are **never** read from the request body, and a payee belonging to another seller returns `404` (so payee IDs cannot be probed across sellers). The GraphQL surface is the backoffice/dashboard API (cookie-authenticated, legal-entity scoped) and exposes the same operations plus split-rule-line attachment.\n\n---\n\n## Payee fields (from `schema.graphql`)\n\n### `type Payee`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Numeric payee ID — use this as the `payeeId` when creating payout methods |\n| `legalEntityId` | `Int!` | Legal entity this payee belongs to |\n| `sellerAccountId` | `Int!` | Your seller account ID |\n| `displayName` | `String` | Human-readable name shown in the dashboard |\n| `role` | `PayeeRole!` | `Seller`, `Contractor`, or `Tax` |\n| `status` | `PayeeStatus!` | `Active` or `Disabled` |\n| `isActive` | `Boolean!` | Convenience flag derived from `status` |\n| `email` | `String` | Contact email |\n| `phone` | `String` | Contact phone |\n| `businessName` | `String` | Legal business name |\n| `businessType` | `String` | Type of business entity |\n| `businessAddress` | `String` | Registered business address |\n| `taxId` | `String` | Tax identification number |\n| `payeeContractId` | `String` | Your internal contract reference |\n| `wallets` | `[Wallet!]!` | Wallets assigned to this payee |\n| `createdAt` | `Time!` | Creation timestamp |\n| `updatedAt` | `Time!` | Last update timestamp |\n\n---\n\n## REST — Full CRUD\n\n**Auth: Bearer token** (`Authorization: Bearer \u003ctoken\u003e`)\n\n| Operation | Method \u0026 path |\n|-----------|---------------|\n| List payees | `GET /payments-api/v1/payees?limit={n}\u0026offset={n}` |\n| Get a payee | `GET /payments-api/v1/payees/{payee_id}` |\n| Create a payee | `POST /payments-api/v1/payees` |\n| Update a payee | `PATCH /payments-api/v1/payees/{payee_id}` |\n| Delete a payee | `DELETE /payments-api/v1/payees/{payee_id}` |\n\nEvery operation is scoped to the seller account behind your Bearer token. `role` accepts the lowercase values `contractor` and `tax`.\n\n\u003e [!TIP]\n\u003e **Onboarding a payee just to pay it?** `POST /payments-api/v1/payees/setup` creates a payee **and** its first payout method in a single atomic call, so you can pay a brand-new payee in two requests total. See the [2-call quick path](#tag/payouts) in the Payouts guide.\n\n### Payee response shape\n\nAll single-payee responses (`GET` / `POST` / `PATCH`) return the same object; the list endpoint wraps an array of these under `payees`:\n\n```json\n{\n  \"id\": 101,\n  \"legalEntityId\": 77,\n  \"sellerAccountId\": 42,\n  \"displayName\": \"Acme Supplies Ltd\",\n  \"role\": \"Contractor\",\n  \"status\": \"Active\",\n  \"isActive\": true,\n  \"payeeContractId\": \"pc_001\",\n  \"email\": \"finance@acme.example.com\",\n  \"phone\": \"+447700900123\",\n  \"businessName\": \"Acme Supplies Limited\",\n  \"businessType\": \"limited_company\",\n  \"businessAddress\": \"1 Example Street, London, GB\",\n  \"taxId\": \"GB123456789\",\n  \"createdAt\": \"2026-06-05T10:00:00Z\",\n  \"updatedAt\": \"2026-06-05T10:00:00Z\"\n}\n```\n\n### List payees\n\n```\nGET /payments-api/v1/payees?limit={n}\u0026offset={n}\n```\n\nReturns a paginated list of payees belonging to your seller account. `limit` (1–100, default 20) and `offset` (≥0) are tolerant — out-of-range or non-numeric values silently fall back to the defaults.\n\n```bash\ncurl -sS \"https://api.fynex.ai/payments-api/v1/payees?limit=20\u0026offset=0\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n```json\n{ \"payees\": [ { \"id\": 101, \"displayName\": \"Acme Supplies Ltd\", \"role\": \"Contractor\", \"status\": \"Active\" } ], \"total\": 1, \"limit\": 20, \"offset\": 0 }\n```\n\n### Get a payee\n\n```bash\ncurl -sS \"https://api.fynex.ai/payments-api/v1/payees/101\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\nA payee that does not exist, or belongs to another seller, returns `404`.\n\n### Create a payee\n\n`sellerAccountId` and `legalEntityId` are **not** accepted in the body — they are derived from your token. A wallet in your seller account's currency is provisioned for the payee automatically.\n\n```bash\ncurl -sS -X POST \"https://api.fynex.ai/payments-api/v1/payees\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"displayName\": \"Acme Supplies Ltd\",\n    \"role\": \"contractor\",\n    \"payeeContractId\": \"pc_001\",\n    \"email\": \"finance@acme.example.com\",\n    \"businessName\": \"Acme Supplies Limited\",\n    \"taxId\": \"GB123456789\"\n  }'\n```\n\nReturns `201 Created` with the payee object. A `payeeContractId` already in use within your seller account returns `409 Conflict`.\n\n### Update a payee\n\n`PATCH` is a partial update — send only the fields you want to change; omitted fields are left untouched.\n\n```bash\ncurl -sS -X PATCH \"https://api.fynex.ai/payments-api/v1/payees/101\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"displayName\": \"Acme Supplies (EU) Ltd\", \"email\": \"eu-finance@acme.example.com\" }'\n```\n\nReturns `200 OK` with the updated payee object. A foreign/unknown `payee_id` returns `404`.\n\n\u003e [!NOTE]\n\u003e `role` is immutable after creation. Supplying a `role` that differs from the payee's current role returns `400` (`role cannot be changed after creation`) — the payee's wallet is provisioned by role at creation time and is not re-provisioned on update. Sending the same `role`, or omitting it, is fine.\n\n#### Archive / restore via `status`\n\n`status` accepts the lowercase values `active` and `disabled`. Setting it archives or restores the payee; `isActive` is kept in sync automatically (you do not send it):\n\n```bash\n# Archive (equivalent to DELETE, see below)\ncurl -sS -X PATCH \"https://api.fynex.ai/payments-api/v1/payees/101\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{ \"status\": \"disabled\" }'\n\n# Restore a previously archived payee\ncurl -sS -X PATCH \"https://api.fynex.ai/payments-api/v1/payees/101\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{ \"status\": \"active\" }'\n```\n\n### Delete (archive) a payee\n\n`DELETE` is **non-destructive and reversible**: it *archives* the payee by setting its status to `Disabled` (and `isActive` to `false`). The payee is **not** removed — a subsequent `GET /payees/{payee_id}` still returns it (now `Disabled`), and you can restore it at any time with `PATCH {\"status\":\"active\"}`.\n\n```bash\ncurl -sS -X DELETE \"https://api.fynex.ai/payments-api/v1/payees/101\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n```json\n{ \"archived\": true }\n```\n\n\u003e [!WARNING]\n\u003e **Archiving a payee that is still referenced by an active split rule will cause its payouts to fail — by design.** The split/payout engine is fail-closed: it never routes funds to a `Disabled` payee. If an archived payee is still a target of an active split rule, the affected split executions and payouts **fail with an explanatory error** (funds are never partially routed or silently dropped) until you either restore the payee (`PATCH {\"status\":\"active\"}`) or remove it from the split rule. The split-rule lines themselves are left untouched by archiving, so restoring the payee cleanly resumes payouts.\n\nReturns `200 OK`. Archiving an already-archived payee is a no-op that still returns `200`. A foreign/unknown `payee_id` returns `404`. `DELETE` is exactly equivalent to `PATCH {\"status\":\"disabled\"}` — use whichever fits your client.\n\n---\n\n## GraphQL — Full CRUD\n\n**Auth: `dashboard_session` cookie** — see [GraphQL Authentication](#tag/graphql-auth) for the login flow.\n\nAll five operations are available on `/dashboard/graphql`:\n\n| Operation | Type | Permission |\n|-----------|------|------------|\n| `payees(limit, offset)` | Query | `PAYEES_READ` |\n| `payee(id: Int!)` | Query | `PAYEES_READ` |\n| `createPayee(input: CreatePayeeInput!)` | Mutation | `PAYEES_CREATE` |\n| `updatePayee(id: Int!, input: UpdatePayeeInput!)` | Mutation | `PAYEES_UPDATE` |\n| `deletePayee(id: Int!)` | Mutation | `PAYEES_DELETE` — **staff surface only**, not callable on `/dashboard/graphql` |\n\n### `CreatePayeeInput` fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `legalEntityId` | `Int!` | Yes | Legal entity to associate this payee with |\n| `merchantId` | `ID!` | Yes | Your seller account ID, as a GraphQL `ID` (`\"42\"` and `42` are both accepted) |\n| `role` | `PayeeRole!` | Yes | `Seller`, `Contractor`, or `Tax` |\n| `displayName` | `String!` | Yes | Human-readable payee name |\n| `payeeContractId` | `String` | No | Your internal reference |\n| `email` | `String` | No | Payee contact email |\n| `phone` | `String` | No | Payee contact phone |\n| `businessName` | `String` | No | Legal business name |\n| `businessType` | `String` | No | Business entity type |\n| `businessAddress` | `String` | No | Registered address |\n| `taxId` | `String` | No | Tax identification number |\n| `splitRuleLine` | `PayeeSplitRuleLineInput` | No | Attach a split rule allocation at creation time |\n\n### `UpdatePayeeInput` fields\n\nAll fields are optional. Only fields you supply are updated; omit any field to leave it unchanged.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `legalEntityId` | `Int!` | Legal entity (required if updating) |\n| `displayName` | `String` | New display name |\n| `role` | `PayeeRole` | `Seller`, `Contractor`, or `Tax` |\n| `merchantId` | `ID` | Seller account ID |\n| `email` | `String` | Contact email |\n| `phone` | `String` | Contact phone |\n| `businessName` | `String` | Legal business name |\n| `businessType` | `String` | Business entity type |\n| `businessAddress` | `String` | Registered address |\n| `taxId` | `String` | Tax identification number |\n| `payeeContractId` | `String` | Internal contract reference |\n| `splitRuleLine` | `PayeeSplitRuleLineInput` | Update split allocation |\n\n---\n\n## Create a payee — code samples\n\n#### curl\n\n```bash\n# First obtain the session cookie (see GraphQL Authentication guide)\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Create the payee\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"mutation CreatePayee($input: CreatePayeeInput!) { createPayee(input: $input) { id displayName role status } }\",\n    \"variables\": {\n      \"input\": {\n        \"legalEntityId\": 1,\n        \"merchantId\": \"42\",\n        \"role\": \"Seller\",\n        \"displayName\": \"Acme Supplies Ltd\",\n        \"email\": \"payments@acme.example.com\",\n        \"businessName\": \"Acme Supplies Limited\",\n        \"taxId\": \"GB123456789\"\n      }\n    }\n  }'\n```\n\n#### JavaScript (Apollo/fetch)\n\n```js\nconst BASE = 'https://api.fynex.ai';\n\n// Assumes login was already called and the cookie is present\nasync function createPayee(input) {\n  const res = await fetch(`${BASE}/dashboard/graphql`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({\n      query: `\n        mutation CreatePayee($input: CreatePayeeInput!) {\n          createPayee(input: $input) {\n            id\n            displayName\n            role\n            status\n          }\n        }\n      `,\n      variables: { input },\n    }),\n  });\n  const { data, errors } = await res.json();\n  if (errors?.length) throw new Error(errors[0].message);\n  return data.createPayee;\n}\n\nconst payee = await createPayee({\n  legalEntityId: 1,\n  merchantId: '42',\n  role: 'Seller',\n  displayName: 'Acme Supplies Ltd',\n  email: 'payments@acme.example.com',\n  businessName: 'Acme Supplies Limited',\n  taxId: 'GB123456789',\n});\nconsole.log(payee); // { id: 7, displayName: 'Acme Supplies Ltd', role: 'Seller', status: 'Active' }\n```\n\n#### Python\n\n```python\nimport requests\n\nBASE = \"https://api.fynex.ai\"\nsession = requests.Session()\n\n# Login first\nsession.post(\n    f\"{BASE}/api/v1/login/dashboard\",\n    json={\"email\": \"you@example.com\", \"password\": \"your_password\"},\n).raise_for_status()\n\nmutation = \"\"\"\n  mutation CreatePayee($input: CreatePayeeInput!) {\n    createPayee(input: $input) {\n      id\n      displayName\n      role\n      status\n    }\n  }\n\"\"\"\nvariables = {\n    \"input\": {\n        \"legalEntityId\": 1,\n        \"merchantId\": \"42\",\n        \"role\": \"Seller\",\n        \"displayName\": \"Acme Supplies Ltd\",\n        \"email\": \"payments@acme.example.com\",\n        \"businessName\": \"Acme Supplies Limited\",\n        \"taxId\": \"GB123456789\",\n    }\n}\nresp = session.post(\n    f\"{BASE}/dashboard/graphql\",\n    json={\"query\": mutation, \"variables\": variables},\n)\nresp.raise_for_status()\nbody = resp.json()\nif \"errors\" in body:\n    raise RuntimeError(body[\"errors\"][0][\"message\"])\nprint(body[\"data\"][\"createPayee\"])\n# {'id': 7, 'displayName': 'Acme Supplies Ltd', 'role': 'Seller', 'status': 'Active'}\n```\n\n---\n\n## Other operations\n\n### List payees via GraphQL\n\n```graphql\nquery ListPayees($limit: Int, $offset: Int) {\n  payees(limit: $limit, offset: $offset) {\n    id\n    displayName\n    role\n    status\n    email\n    businessName\n  }\n}\n```\n\n### Get a single payee\n\n```graphql\nquery GetPayee($id: Int!) {\n  payee(id: $id) {\n    id\n    displayName\n    role\n    status\n    email\n    phone\n    businessName\n    businessAddress\n    taxId\n    payeeContractId\n    wallets { id currencyCode }\n    createdAt\n  }\n}\n```\n\n### Update a payee\n\n```graphql\nmutation UpdatePayee($id: Int!, $input: UpdatePayeeInput!) {\n  updatePayee(id: $id, input: $input) {\n    id\n    displayName\n    email\n    updatedAt\n  }\n}\n```\n\nPass only the fields you want to change in `$input`. `legalEntityId` is required in `UpdatePayeeInput` even when you are not changing it.\n\n### Delete a payee\n\n```graphql\n# Not available on /dashboard/graphql — staff surface only. Shown for reference;\n# sellers archive a payee with the REST DELETE below.\nmutation DeletePayee($id: Int!) {\n  deletePayee(id: $id)\n}\n```\n\nReturns `true` on success.\n\n\u003e [!CAUTION]\n\u003e **`deletePayee` is not exposed on `/dashboard/graphql`.** A dashboard session calling it\n\u003e receives `Cannot query field \"deletePayee\" on type \"Mutation\"`; the mutation exists on the\n\u003e Fynex staff surface only. From your integration, use the REST `DELETE /payees/{payeeId}`\n\u003e described above — it archives the payee and is reversible.\n\n\u003e [!WARNING]\n\u003e Where it is available, the GraphQL `deletePayee` does **not** behave like the REST `DELETE`. GraphQL performs a database soft-delete (sets `deleted_at`), which **hides** the payee from subsequent reads and is **not** restorable through the API. The REST `DELETE` instead *archives* the payee (status `Disabled`, still queryable, restorable via `PATCH {\"status\":\"active\"}`). Ensure no active payout methods or split rule lines reference the payee before calling the GraphQL mutation.\n\n---\n\n## Common pitfalls\n\n| Pitfall | Resolution |\n|---------|------------|\n| Calling `createPayee` (GraphQL) with a Bearer token | The `/dashboard/graphql` endpoint requires the `dashboard_session` cookie. Integrators should use the REST endpoints (Bearer token) instead. |\n| Sending `sellerAccountId` / `legalEntityId` in a REST body | They are ignored — REST payees are always pinned to the seller account behind your Bearer token. |\n| Forgetting `legalEntityId` on a GraphQL update | `UpdatePayeeInput.legalEntityId` is `Int!` — required even when not changing it. The REST `PATCH` has no such requirement. |\n| Using an uppercase `role` on REST | REST accepts the lowercase values `contractor` and `tax`; anything else returns `400`. |\n| Expecting REST `DELETE` to remove the payee | REST `DELETE` *archives* (status `Disabled`); the payee stays queryable and is restorable via `PATCH {\"status\":\"active\"}`. Only the GraphQL `deletePayee` hides the row (soft-delete). |\n\n## See also\n\n- **[GraphQL Authentication](#tag/graphql-auth)** — Obtain a dashboard_session cookie before calling any GraphQL mutation.\n- **[Payout Methods](#tag/payout-methods)** — Register bank accounts as payout destinations for a payee.\n- **[Payouts](#tag/payouts)** — Send money from a seller wallet to a payee\n","name":"Payees"},{"description":"## Pagination\n\nList endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and\nreturn a `total` count alongside the page. Iterate by incrementing `offset` by\n`limit` until the response returns fewer than `limit` items or `offset \u003e= total`.\n\n\n---\n\nA **payout** moves money from one of your seller wallets to a payee's registered bank account. Payouts settle asynchronously through the banking provider using the route selected from the payout currency and destination identifiers.\n\n\u003e [!IMPORTANT]\n\u003e Supported routes are **EUR IBAN → SEPA**, **GBP UK local account → Faster Payments**, and **GBP/USD IBAN → cross-border transfer**. UK local methods must use `currency: GBP`, `bankAccountType: uk_local`, `bankCountry: GB`, an 8-digit account number, and a 6-digit sort code. US-local and SWIFT payout methods can be registered but are rejected before funds are held.\n\n\u003e [!NOTE]\n\u003e **Amount: decimal preferred.** Send the decimal `amount` as a string (e.g. `\"19.99\"`) — the same convention as the rest of the API. The legacy integer `amountMinor` (e.g. `1999`) is still accepted as a fallback for older integrations; when both are sent, `amount` wins. Exactly one is required.\n\n\u003e [!NOTE]\n\u003e **Idempotency precedence: body over header.** `POST /payouts` reads `idempotencyKey` from the request body; the `Idempotency-Key` HTTP header is used as a fallback when the body field is empty. When both are set, the body value wins.\n\n---\n\n## Quick path: onboard and pay a new payee\n\nStarting from scratch? The convenience endpoints collapse the full flow into a\nshort, self-contained sequence using a dedicated **cashout_balance** wallet —\nfunded directly from your balance, isolated from split earnings. The same\nsupported bank routes and Bearer auth apply as for the primitive payout flow.\n\n\u003e [!IMPORTANT]\n\u003e This flow provisions a `cashout_balance` wallet, so it is **gated per seller**\n\u003e by `walletCreationEnabled` (off by default). If it is not enabled for your\n\u003e account, `POST /payees/setup` returns `403 wallet creation is not enabled for\n\u003e this account; contact support`. Contact support to enable it.\n\n**1. Create the payee, its payout method, and a cashout wallet** — `POST /payees/setup` (one atomic call):\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payees/setup\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"displayName\": \"Acme Supplies Ltd\",\n    \"role\": \"contractor\",\n    \"payoutMethod\": { \"currency\": \"EUR\", \"iban\": \"DE89370400440532013000\" }\n  }'\n```\n\nResponse (`201 Created`) returns all three records — keep `payee.id`, `payoutMethod.id`, and `cashoutWallet.id`:\n\n```json\n{\n  \"payee\": { \"id\": 101, \"role\": \"Contractor\" },\n  \"payoutMethod\": { \"id\": 501, \"status\": \"active\" },\n  \"cashoutWallet\": { \"id\": 139, \"type\": \"cashout_balance\", \"currency\": \"EUR\", \"status\": \"active\" }\n}\n```\n\n**2. Fund the cashout wallet from your main balance** — `POST /wallets/{cashout_wallet_id}/fund` (internal, same-currency):\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/wallets/139/fund\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"amount\": \"12.50\", \"idempotencyKey\": \"'$(uuidgen)'\" }'\n```\n\nMoves funds from your seller `main` wallet (same currency) into the payee's cashout wallet. Your main wallet must hold sufficient EUR.\n\n\u003e [!NOTE]\n\u003e **This step is optional for sellers enabled for payee payout auto-funding.**\n\u003e On `POST /payees/{payee_id}/payouts`, Fynex atomically moves only the\n\u003e cashout wallet's shortfall from the seller's same-currency `main` wallet and\n\u003e then holds the payout amount. If either operation fails, neither movement is\n\u003e committed. Auto-funding is off by default and must be enabled by Fynex for the\n\u003e seller account. It does not apply to the primitive `POST /payouts` endpoint.\n\u003e If the payout is definitively rejected or cancelled, the auto-funded\n\u003e shortfall is returned to the seller's main wallet in the same transaction as\n\u003e the payout hold release. An ambiguous provider outcome stays held until it is\n\u003e reconciled, so the platform never returns funds that the provider may still\n\u003e settle. When both `payeePayoutAutoFundingEnabled` and\n\u003e `walletCreationEnabled` are enabled for the seller and the payee has no\n\u003e `cashout_balance` wallet in the payout method's currency, this endpoint first\n\u003e provisions one automatically and then auto-funds it. This also applies when\n\u003e the payee already has an ordinary `other` or `tax` wallet, because those\n\u003e split-destination wallets are intentionally not debited by auto-funding.\n\u003e The seller's own `Itself` payee is excluded: its payouts keep using the\n\u003e existing seller `main` wallet and never trigger cashout provisioning.\n\u003e Provisioning is idempotent and remains separate from the money-movement\n\u003e transaction, so a payout failure may leave the empty wallet available for a\n\u003e safe retry but cannot leave a partial ledger movement.\n\n**3. Pay the payee** — `POST /payees/{payee_id}/payouts` (wallet auto-resolved):\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payees/101/payouts\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"payoutMethodId\": 501,\n    \"amount\": \"12.50\",\n    \"idempotencyKey\": \"'$(uuidgen)'\"\n  }'\n```\n\nYou don't pass a `walletId` — the payout draws from the payee's `cashout_balance` wallet (resolution prefers it). Response is the same `201 Created` payout body as `POST /payouts` (status `pending`). Poll `GET /payouts/{id}` for the final status (see step 3 of the primitive flow below).\n\n\u003e [!NOTE]\n\u003e `payoutMethodId` is **required** — there is no implicit default. The convenience layer auto-resolves the `walletId`; holds, idempotency, and banking-provider settlement use the same payout engine as `POST /payouts`. Unless the seller account is enabled for payee payout auto-funding, a payout before the cashout wallet is funded returns `409 insufficient balance`.\n\nThe four primitive endpoints below still work unchanged when you need finer control (an existing payee, a specific source wallet, multiple payout methods, or the split-funded `other` wallet).\n\n---\n\n## Prerequisites\n\n- A seller wallet with sufficient balance in the target currency.\n- A **registered payee** — create one with `POST /payments-api/v1/payees` (see [Payees](#tag/payees)) if you don't have one yet.\n- That payee must have **at least one active payout method** (bank account). Create one with `POST /payments-api/v1/payees/{payee_id}/payout-methods` (see [Payout Methods](#tag/payout-methods)), then use `GET /payees/{payee_id}/payout-methods` to look up the available methods and their ids.\n\n\u003e [!NOTE]\n\u003e The step-by-step below assumes the payee and payout method already exist. If you are starting from scratch, use the [quick path](#quick-path-onboard-and-pay-a-new-payee) above, or follow the full order: **create payee → create payout method → request payout**.\n\n---\n\n## Step-by-step\n\n1. **Find the payee's payout methods**\n\n   `payee_id` is the numeric payee ID from `GET /payees`.\n\n#### curl\n\n```bash\ncurl -sS \"$FYNEX_API/payees/42/payout-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" | jq\n```\n\n#### JavaScript\n\n```js\nconst res = await fetch(`${process.env.FYNEX_API}/payees/42/payout-methods`, {\n  headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` },\n});\nconst { payoutMethods } = await res.json();\n```\n\n#### Python\n\n```python\nimport os, requests\n\ndata = requests.get(\n    f\"{os.environ['FYNEX_API']}/payees/42/payout-methods\",\n    headers={\"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\"},\n).json()\npayout_methods = data[\"payoutMethods\"]\n```\n\n   The response lists bank accounts with their `id`, `currency`, `bankAccountType`, and destination identifiers. Note the `payoutMethodId` you want to pay to, and the `walletId` you want to debit (confirm it has enough balance).\n\n2. **Create the payout**\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payouts\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"walletId\": 15,\n    \"payoutMethodId\": 501,\n    \"amount\": \"1999.00\",\n    \"currencyCode\": \"EUR\",\n    \"idempotencyKey\": \"'$(uuidgen)'\"\n  }'\n```\n\n#### JavaScript\n\n```js\nimport { randomUUID } from 'node:crypto';\n\nconst res = await fetch(`${process.env.FYNEX_API}/payouts`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n    walletId: 15,\n    payoutMethodId: 501,\n    amount: '1999.00', // €1,999.00\n    currencyCode: 'EUR',\n    idempotencyKey: randomUUID(),\n  }),\n});\nconst payout = await res.json();\n```\n\n#### Python\n\n```python\nimport os, uuid, requests\n\nres = requests.post(\n    f\"{os.environ['FYNEX_API']}/payouts\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n        \"Content-Type\": \"application/json\",\n    },\n    json={\n        \"walletId\": 15,\n        \"payoutMethodId\": 501,\n        \"amount\": \"1999.00\",  # €1,999.00\n        \"currencyCode\": \"EUR\",\n        \"idempotencyKey\": str(uuid.uuid4()),\n    },\n)\npayout = res.json()\n```\n\n   **Response (201 Created):**\n\n   ```json\n   {\n     \"id\": 9001,\n     \"status\": \"processing\",\n     \"amountMinor\": 199900,\n     \"currencyCode\": \"EUR\",\n     \"providerReference\": \"ref_9f2c1a4b\",\n     \"bankAccountType\": \"uk_local\"\n   }\n   ```\n\n   Save the `id` — you will need it to poll status.\n\n3. **Poll for the final status**\n\n   There are no outbound webhook events for payouts. Poll `GET /payouts/\\{id\\}` until `status` reaches a terminal value.\n\n#### curl\n\n```bash\ncurl -sS \"$FYNEX_API/payouts/9001\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" | jq .status\n```\n\n#### JavaScript\n\n```js\nconst res = await fetch(`${process.env.FYNEX_API}/payouts/9001`, {\n  headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` },\n});\nconst { status } = await res.json();\n```\n\n#### Python\n\n```python\nimport os, requests\n\ndata = requests.get(\n    f\"{os.environ['FYNEX_API']}/payouts/9001\",\n    headers={\"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\"},\n).json()\nprint(data[\"status\"])\n```\n\n   Poll every 30–60 seconds until status is `completed`, `failed`, or `cancelled`.\n\n---\n\n## Request body fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `walletId` | `int64` | Yes | Internal numeric id of the seller wallet to debit. **Not** an IBAN or any external bank-account identifier — look up the id from the dashboard's wallets list (contact support if you do not yet have dashboard access). Must belong to the authenticated seller. |\n| `payoutMethodId` | `int64` | **Yes** | Explicit payout method id from `GET /payees/{id}/payout-methods`. Must belong to the wallet's payee and be active. **There is no implicit default** — a missing or zero value is rejected with `400`. (This removed the old wallet-default → payee-default → first-method fallback so a payout can never silently re-route.) |\n| `amount` | `string` | Yes\\* | Decimal amount in the payout currency, e.g. `\"19.99\"`. Preferred over `amountMinor`. |\n| `amountMinor` | `int64` | Yes\\* | **Deprecated.** Amount in minor units (e.g. `1999` for €19.99). Fallback when `amount` is omitted; ignored when `amount` is set. |\n| `currencyCode` | `string` | Yes | `EUR`, `GBP`, or `USD`, matching both the wallet and payout method. The destination shape selects SEPA, Faster Payments, or cross-border routing. |\n| `idempotencyKey` | `string` (UUID) | No | If omitted, a UUID is auto-generated. Replays with the same key return the original payout. |\n\n\\* Exactly one of `amount` or `amountMinor` is required.\n\n\u003e [!NOTE]\n\u003e The `Idempotency-Key` HTTP header is read as a fallback when the body's `idempotencyKey` is empty. When both are set, the body wins.\n\n## SEPA payouts — mandatory parameters\n\nEUR payouts are sent as **SEPA credit transfers** through the banking provider (payment scheme `SEPA`, charge bearer `SHA`). The required parameters span two calls — registering the destination **payout method** and creating the **payout** itself.\n\n**1. Payout method (destination)** — `POST /payees/{id}/payout-methods` (or via the dashboard):\n\n| Field | Required | Notes |\n|-------|----------|-------|\n| `payeeId` | **Yes** | Payee that owns the destination. Over REST it comes from the `{payee_id}` path segment (not the body); over GraphQL it is a body field. |\n| `currency` | **Yes** | Must be `EUR`. |\n| `iban` | **Yes** | Destination IBAN — the only hard-required bank field. A payout to a method with no IBAN is rejected (`payout method has no IBAN`). The creditor country is derived from the IBAN prefix. |\n| `bic` | No | Recommended. Forwarded to the banking provider as the creditor institution only when present. |\n| `accountName` | No | Recommended — used as the SEPA creditor name. Falls back to `bankName`, then `Payee {id}` when omitted. |\n\nThe payout method `type` is `bank_account` — the only supported value.\n\n**2. Payout request** — `POST /payouts`:\n\n| Field | Required | Notes |\n|-------|----------|-------|\n| `walletId` | **Yes** | Seller EUR wallet to debit; must hold sufficient EUR. |\n| `payoutMethodId` | **Yes** | The EUR IBAN method from step 1. Must belong to the wallet's payee and be active — there is no implicit default. |\n| `amount` | **Yes**\\* | Decimal in the payout currency (`\"1999.00\"` = €1,999.00). Preferred over `amountMinor`. |\n| `amountMinor` | **Yes**\\* | Deprecated minor-units fallback (`199900` = €1,999.00). \\*Exactly one of `amount`/`amountMinor`. |\n| `currencyCode` | **Yes** | Must be `EUR`. |\n| `idempotencyKey` | No | UUID; auto-generated if omitted. |\n\n\u003e [!NOTE]\n\u003e The banking provider's payment scheme (`SEPA`), payment reference, requested execution date, charge bearer (`SHA`), creditor country, and the unstructured remittance line are all set by Fynex — you do not send them.\n\nThe payout transitions through `pending` → `processing` → `completed` once the banking provider confirms the SEPA settlement.\n\n## GBP Faster Payments — mandatory parameters\n\nGBP payouts to a UK local account are sent through the banking provider's Faster Payments rail. The client supplies domestic bank identifiers; Fynex normalizes them and applies the banking provider's national-clearing-code representation at dispatch.\n\n**1. Payout method (destination)** — `POST /payees/{id}/payout-methods` (or via the dashboard):\n\n| Field | Required | Notes |\n|-------|----------|-------|\n| `currency` | **Yes** | Must be `GBP`. |\n| `bankAccountType` | **Yes** | Must be `uk_local`. |\n| `bankCountry` | **Yes** | Must be `GB`. |\n| `accountNumber` | **Yes** | Eight-digit UK account number. Spaces are accepted and removed. |\n| `sortCode` | **Yes** | Six-digit UK sort code. Spaces and hyphens are accepted and removed. |\n| `accountName` | No | Recommended; used as the creditor name. |\n\n**2. Payout request** — provide a GBP `walletId`, the UK-local `payoutMethodId`, the amount, and `currencyCode: GBP`. Bank identifiers stay on the payout method and are not repeated in the payout request.\n\n\u003e [!NOTE]\n\u003e The banking provider's payment scheme and the `SC` national-clearing-code prefix are set by Fynex. Clients send the six sort-code digits only.\n\n\u003e [!CAUTION]\n\u003e **Card payouts (push-to-card) are not supported.** Payouts settle only to bank accounts through the banking provider. There is no card-out / OCT endpoint.\n\n---\n\n## Response fields\n\n### POST /payouts (201 Created)\n\nThe create response is intentionally minimal:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `int64` | Internal payout ID — use this for `GET /payouts/\\{id\\}` |\n| `status` | `string` | Typically `processing` on a fresh create |\n| `amountMinor` | `int64` | Minor units |\n| `currencyCode` | `string` | |\n| `providerReference` | `string` | Upstream provider's reference for this payout. Opaque — match, do not parse (omitempty) |\n| `bankAccountType` | `string` | Destination account identifier format: `iban`, `uk_local`, `us_local`, `swift`. Empty means unknown (omitempty) |\n\n\n### GET /payouts/\\{id\\} (200 OK)\n\nThe detail response adds timing fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `int64` | |\n| `status` | `string` | Current status |\n| `amountMinor` | `int64` | |\n| `currencyCode` | `string` | |\n| `providerReference` | `string` | Upstream provider's reference for this payout. Opaque — match, do not parse (omitempty) |\n| `bankAccountType` | `string` | Destination account identifier format: `iban`, `uk_local`, `us_local`, `swift`. Empty means unknown (omitempty) |\n\n| `failureCode` | `string` | Set when the payout fails (omitempty) |\n| `failureMessage` | `string` | Declared on the response but is never populated by the current implementation |\n| `requestedAt` | `string` | RFC3339 UTC |\n| `processedAt` | `string` | RFC3339 UTC (omitempty) |\n| `completedAt` | `string` | RFC3339 UTC (omitempty) |\n\n\u003e [!NOTE]\n\u003e `failureMessage` appears in the response schema but is never set by the server today. Use `failureCode` to detect failures; do not rely on `failureMessage` for message text.\n\n---\n\n## Status values\n\n| Status | Meaning |\n|--------|---------|\n| `pending` | Created in Fynex, queued for processing |\n| `processing` | Submitted to the payment network |\n| `completed` | Funds left the wallet and reached the destination |\n| `failed` | Network rejected the payment; balance returned to wallet |\n| `cancelled` | Payout was cancelled before processing |\n\n---\n\n## Listing payouts\n\n```bash\ncurl -sS \"$FYNEX_API/payouts?limit=20\u0026offset=0\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" | jq\n```\n\nReturns payouts ordered by `requestedAt` descending. Query params `limit` (1–100, default 20) and `offset` (default 0).\n\n---\n\n## Insufficient balance\n\nIf the wallet does not cover `amountMinor`, the request returns `409 Conflict`:\n\n```json\n{ \"error\": \"insufficient balance\" }\n```\n\nTop up the wallet via incoming payments or a treasury transfer, then retry with the **same** `idempotencyKey`.\n\nReplaying a **successful** create with the same `idempotencyKey` returns the original payout — it does not create a second one and does not return `409`.\n\n---\n\n## Common errors\n\n| Status | Body | Cause |\n|--------|------|-------|\n| `400` | `walletId is required` | Missing or zero `walletId` |\n| `400` | `payoutMethodId is required` | Missing or zero `payoutMethodId` (no implicit default) |\n| `400` | `amount must be positive` | Zero or negative amount |\n| `400` | `currencyCode is required` | Missing currency |\n| `401` | `unauthorized` (plain text) | Missing or invalid bearer token |\n| `403` | `wallet does not belong to seller account` | `walletId` belongs to another seller |\n| `404` | `wallet not found for this currency` | `walletId` exists but currency mismatches |\n| `404` | `payee not found` | `payee_id` doesn't exist or belongs to another seller |\n| `409` | `insufficient balance` | Wallet balance below requested amount |\n| `422` | `currency not supported for payouts` | `currencyCode` has no enabled payout rail |\n| `422` | `this payout account is saved, but payouts for its account format are not enabled yet` | The payout method identifier format is not enabled for that currency, or its required bank identifiers are invalid |\n| `422` | `payout method does not belong to this wallet's payee` | `payoutMethodId` belongs to a different payee than the wallet |\n| `422` | provider rejection message | The banking provider rejected the payment at create time; check `failureCode` via `GET /payouts/{id}` |\n\n## See also\n\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Poll payout status or subscribe to status events.\n- **[Idempotency \u0026 retries](#tag/idempotency)** — Safe retry patterns for payout creation.\n- **[Split rules](#tag/splits)** — Splitting incoming funds across multiple payees? Use Split rules.\n- **[Virtual accounts](#tag/virtual-accounts)** — Need a dedicated bank account for incoming payments? See Virtual accounts.\n","name":"Payouts"},{"description":"## Pagination\n\nList endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and\nreturn a `total` count alongside the page. Iterate by incrementing `offset` by\n`limit` until the response returns fewer than `limit` items or `offset \u003e= total`.\n\n\n---\n\nA **vendor** is a sub-merchant you onboard under your **platform** account in the merchant-of-record model: your buyers pay you, and you pass each vendor its share. Fynex screens every vendor from the details you supply, holds it to a cumulative payout limit until it completes verification, and gives you one payee per vendor to pay it through.\n\nThe vendor surface is available to **Platform** accounts only, using the platform's **own** API key. A merchant account, or a delegated dashboard session, receives `403 platformRequired`.\n\n\u003e [!NOTE]\n\u003e These endpoints are enabled per environment. If `POST /payments-api/v1/vendors` answers `404` on your account, ask support to enable vendor onboarding for you.\n\n---\n\n## Lifecycle\n\nEvery vendor response carries a `verification` block. `verification.status` is the one field to branch on:\n\n| `verification.status` | Meaning | `payoutsEnabled` |\n|---|---|---|\n| `pending_fynex` | Fynex is screening (`reason: screening`) or reviewing. Nothing for you or the vendor to do. | `false` |\n| `not_required` | Screening passed; no verification step is due. | `true`, under the cumulative limit |\n| `required` | The vendor must complete a step. Fetch the link with `POST /vendors/{vendor_id}/verification-link` and hand it to the vendor. | `true` until the limit is reached |\n| `in_progress` | The vendor has started the step. | unchanged |\n| `complete` | Fully verified; the cumulative limit no longer applies. | `true` |\n| `blocked` | Refused at screening (`reason: screening_refused`), declined at verification (`reason: declined`) or manually blocked (`reason: blocked`). | `false`, permanently |\n\nScreening is **non-interactive**: the vendor is not contacted and uploads nothing. You supply the identity (company number + country, or name + date of birth + country) and Fynex screens it against sanctions, politically-exposed-person and adverse-media lists. It usually completes within the request; otherwise poll `GET /vendors/{vendor_id}` until `verification.status` leaves `pending_fynex`.\n\n`paymentsEnabled` is always `false`: in the merchant-of-record model the vendor never takes a payment itself.\n\n---\n\n## Create a vendor\n\n```http\nPOST /payments-api/v1/vendors\nAuthorization: Bearer \u003cplatform_sk_key\u003e\nContent-Type: application/json\n\n{\n  \"externalRef\": \"vendor_8817\",\n  \"entityType\": \"company\",\n  \"displayName\": \"Blue Door Ceramics Ltd\",\n  \"contactEmail\": \"owner@bluedoor.example\",\n  \"country\": \"GB\",\n  \"companyNumber\": \"09876543\",\n  \"merchantCategoryCode\": \"5999\",\n  \"storefrontUrl\": \"https://market.example/shops/blue-door\",\n  \"address\": { \"street\": \"1 High Street\", \"city\": \"Edinburgh\", \"postalCode\": \"EH1 1AA\" },\n  \"consent\": { \"sumsubPrivacyNoticeAccepted\": true, \"acceptedAt\": \"2026-09-03T12:00:00Z\", \"ip\": \"203.0.113.7\", \"noticeVersion\": \"2026-07\" }\n}\n```\n\nFor an individual (sole trader), send `\"entityType\": \"individual\"` with a `person` object instead of `companyNumber`:\n\n```json\n\"person\": { \"firstName\": \"Jane\", \"lastName\": \"Maker\", \"dob\": \"1990-04-12\" }\n```\n\n`dob` is **required** (`YYYY-MM-DD`; the vendor must be at least 18). `displayName` is the registered name for a company and the trading name for an individual. `merchantCategoryCode` is the four-digit MCC describing what the vendor sells; it is required and screened. `storefrontUrl` is the vendor's page on your marketplace (https). `consent` is your attestation that the vendor accepted the verification provider's privacy notice on your side; it is required (`422 consentRequired`) once consent enforcement is on for your environment, and recorded whenever you send it.\n\n```json\n{\n  \"vendorId\": \"7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b\",\n  \"externalRef\": \"vendor_8817\",\n  \"entityType\": \"company\",\n  \"displayName\": \"Blue Door Ceramics Ltd\",\n  \"paymentsEnabled\": false,\n  \"payoutsEnabled\": false,\n  \"payeeId\": 101,\n  \"verification\": { \"status\": \"pending_fynex\", \"reason\": \"screening\", \"actionUrl\": null, \"actionExpiresAt\": null },\n  \"payoutAllowance\": { \"currency\": \"GBP\", \"limitMinor\": 10000, \"usedMinor\": 0, \"remainingMinor\": 10000,\n                       \"basis\": \"cumulative_lifetime_all_currencies\", \"enforcement\": \"monitored\", \"unlimited\": false, \"deferred\": false },\n  \"nextTier\": { \"limitMinor\": null, \"unlimited\": true,\n                \"requirements\": [ { \"code\": \"screening_passed\", \"status\": \"in_progress\" },\n                                  { \"code\": \"identity_verification\", \"status\": \"not_started\" },\n                                  { \"code\": \"agreement_signed\", \"status\": \"not_started\" } ] },\n  \"createdAt\": \"2026-09-05T12:00:00Z\",\n  \"updatedAt\": \"2026-09-05T12:00:00Z\"\n}\n```\n\n`201 Created` on creation. When the screening completes synchronously the verdict is applied before the response is written, so the first response is often already `not_required` with `payoutsEnabled: true`.\n\n### Idempotency\n\n`externalRef` is your identifier for the vendor and is unique within your platform. Re-sending an **equivalent** request (whitespace and letter case do not count) for the same reference returns the existing vendor with `200 OK` — safe to retry after a timeout. The same reference with a **different** body is `409 externalRefConflict`: you are reusing a reference for a different vendor.\n\n### Responses\n\nAll errors use the standard envelope `{\"error\": \"\u003ccode\u003e\"}`. Field-level validation failures name the field: `{\"error\": \"person.dob: required; YYYY-MM-DD\"}`.\n\n| Status | `error` | When |\n|--------|---------|------|\n| `201` | — | Vendor created |\n| `200` | — | Idempotent replay of an equivalent request |\n| `400` | `\u003cfield\u003e: \u003creason\u003e` | A field is missing or malformed |\n| `401` | | Missing or invalid Bearer token |\n| `403` | `platformRequired` | Your account is not an active platform, or the credential is a delegated dashboard session rather than the platform's own key |\n| `409` | `externalRefConflict` | `externalRef` already refers to a vendor created with a different request |\n| `422` | `invalidCountry` | `country` is not an ISO 3166-1 alpha-2 code |\n| `422` | `countryNotSupported` | A real country Fynex does not onboard vendors from |\n| `422` | `mccProhibited` | The merchant category is prohibited |\n| `422` | `vendorScreeningRefused` | This vendor cannot be onboarded through the API. Contact support if you believe this is wrong |\n| `422` | `consentRequired` | Consent enforcement is on and `consent.sumsubPrivacyNoticeAccepted` was not `true` |\n| `429` | `dailyVendorLimitReached` | Daily vendor creation limit for your platform; retry after 24 hours |\n| `503` | `screeningUnavailable` | Screening is temporarily unavailable. Nothing was created; retry with the same `externalRef` |\n\nThe vendor mutation routes also have their own per-platform rate-limit bucket, separate from the general API limit; a `429` with an empty-body `Retry-After` header is that bucket.\n\n---\n\n## Read vendors\n\n```http\nGET /payments-api/v1/vendors/{vendor_id}\nGET /payments-api/v1/vendors?limit=20\u0026offset=0\n```\n\nThe list is newest first and paginated like `/payees` (`limit` 1–100, default 20). A vendor that belongs to another platform, or does not exist, is `404 vendorNotFound`.\n\n---\n\n## Verification link\n\nWhen a vendor's `verification.status` becomes `required` — you receive `MarketplaceVendorVerificationRequired` if you subscribed to the vendor events, and `GET /vendors/{vendor_id}` shows it — request a link and hand it to the vendor:\n\n```http\nPOST /payments-api/v1/vendors/{vendor_id}/verification-link\nAuthorization: Bearer \u003cplatform_sk_key\u003e\n```\n\n```json\n{ \"actionUrl\": \"https://…\", \"actionExpiresAt\": \"2026-09-06T12:00:00Z\" }\n```\n\nThe vendor opens the link and completes verification with the provider; you are not involved in the flow itself. Every call mints a fresh link (the previous one stays valid until its own expiry), so call it again if the vendor lost theirs. The link is bearer-like: send it over a channel you trust and do not log it.\n\n| Status | `error` | When |\n|--------|---------|------|\n| `200` | — | Link minted |\n| `403` | `platformRequired` | Not the platform's own key |\n| `404` | `vendorNotFound` | Not your vendor |\n| `409` | `verificationNotRequired` | No step is due for this vendor |\n| `503` | `screeningUnavailable` | The provider could not mint a link; retry |\n\nAs the vendor progresses, `verification.status` moves through `in_progress` and `pending_fynex` to `complete` (or `blocked`), and the matching `MarketplaceVendorVerification*` events fire.\n\n## Paying a vendor\n\nEach vendor has exactly one **payee** (`payeeId`), owned by your platform account. Use it exactly like any other payee:\n\n1. Attach bank details: `POST /payees/{payee_id}/payout-methods` — see [Payout methods](/payments-api/v2/docs/payout-methods.md).\n2. Route the vendor's share: name the payee in a split rule — see [Splits](/payments-api/v2/docs/splits.md).\n3. Pay out: `POST /payouts` from the payee's wallet — see [Payouts](/payments-api/v2/docs/payouts.md).\n\nThe payee is **disabled while `verification.status` is `pending_fynex`** and stays disabled if the vendor is `blocked`: a split naming it is refused at payment time, so no funds can accrue to an unscreened vendor. It is enabled the moment the screening passes.\n\n### The cumulative payout limit\n\nA screened vendor may receive up to **£100 (or equivalent) in cumulative payouts** before it must complete full verification. `payoutAllowance` shows where the vendor stands: `limitMinor` and `usedMinor` in GBP minor units (payouts in other currencies count at the current reference rate), `remainingMinor` what is left, and `enforcement` what happens at the boundary — `monitored` means a crossing is detected after the fact and verification is requested; `held_for_review` means a payout that would cross the limit waits for Fynex compliance. `deferred: true` means the total could not be computed right now (a payout currency has no current rate); nothing is enabled or disabled by it.\n\nWhen a vendor crosses the limit Fynex asks it to verify and notifies you (`verification.status: required`). Vendors that complete verification show `unlimited: true` and no longer have a cumulative limit.\n\n`nextTier` says exactly what lifts the limit: three requirements — `screening_passed` (Gate 1, automatic), `identity_verification` (the vendor completes verification via the link above) and `agreement_signed` (the vendor signs the seller agreement Fynex sends at the crossing) — each `complete`, `in_progress` or `not_started`. Once all three are complete the vendor is unlimited and `nextTier.requirements` is empty.\n\n---\n\n## What vendors are not\n\n- A vendor is **not** a seller account you can authenticate as. It has no API key and no dashboard login; you operate it through your platform key.\n- A vendor is **not** a payee you create yourself. Creating a payee directly (`POST /payees`) gives you a payout counterparty without screening; creating a vendor gives you a screened sub-merchant *with* a payee. Use vendors for sub-merchants whose goods or services your buyers pay you for.\n","name":"Vendors"},{"name":"Checkout"},{"description":"This guide takes you from zero to your first successful **test payment** on the Fynex sandbox. It is the broad \"first steps\" tour: get a token, verify it, choose an integration style, use sandbox cards, confirm the outcome, and learn the conventions you'll rely on everywhere else.\n\n\u003e [!NOTE]\n\u003e All examples target the **staging** environment — no real cards, no real money. Switch the base URL to `https://api.fynex.ai/payments-api/v1` when you are ready for production.\n\n- **API base URL:** `https://staging-api.fynex.ai/payments-api/v1`\n- **Full API reference:** this site\n- **Dashboard:** `https://staging-dashboard.fynex.ai`\n\n## 1. Get your API token\n\nEvery request is authenticated with a **seller bearer token**, which you obtain yourself from the dashboard:\n\n1. Log in to `https://staging-dashboard.fynex.ai` with your username and password.\n2. Select your seller account.\n3. Open the **Integration** page.\n4. Click the reveal (eye) icon to show the token, then **Copy**.\n\nThe token is a plain string — there is no `sk_test_`-style prefix. It is exactly the value you pass as `Authorization: Bearer \u003ctoken\u003e`. Treat it like a password.\n\n\u003e [!CAUTION]\n\u003e The **Regenerate** button on the Integration page issues a *new* token and **immediately invalidates the old one** — there is no overlap window. Only use it when you intend to rotate. For first-time setup, just reveal and copy.\n\nIf you don't have dashboard access yet, ask your Fynex contact to set you up. Store the token and base URL in your environment:\n\n```bash\nexport FYNEX_API=\"https://staging-api.fynex.ai/payments-api/v1\"\nexport FYNEX_TOKEN=\"\u003cthe token you copied\u003e\"\n```\n\nSee [Authentication](#tag/authentication) for token storage, rotation, and error details.\n\n## 2. Verify the token works\n\nThe quickest \"is my token alive?\" check returns the payment methods enabled on your account:\n\n```bash\ncurl -sS \"$FYNEX_API/payment-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n- **`200 OK`** with a JSON body → your token works and your account is active.\n- **`401 Unauthorized`** → the token is missing, malformed, or invalid.\n- **`403 seller account is not active`** → the token is **valid and recognised**, but the account isn't active. New **demo** accounts are activated automatically, so you normally won't see this in the sandbox; if you do, contact Fynex with your seller account ID. (Going **live** for real-money payments requires KYB + a Fynex-assigned live terminal.)\n\n## 3. Take your first test payment\n\nThere are two integration styles. Start with **hosted checkout** — it's the fastest and keeps your servers out of PCI scope.\n\n### Option A — Hosted checkout (recommended first)\n\nFynex hosts the card form; you create a session and redirect the customer to it.\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/checkout\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-DEMO-1\",\n    \"amount\": 19.99,\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"autoSettlement\": true,\n    \"returnUrls\": {\n      \"success\": \"https://example.com/success\",\n      \"failure\": \"https://example.com/failure\"\n    }\n  }'\n```\n\nA `201 Created` response returns a `checkoutUrl`:\n\n```json\n{\n  \"sessionId\": \"6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n  \"checkoutUrl\": \"https://staging-api.fynex.ai/checkout/6f9b84e1-...\",\n  \"expiresAt\": \"2026-04-29T11:30:00Z\"\n}\n```\n\nOpen `checkoutUrl` in a browser and pay with a sandbox card (see step 4). The page redirects to your `returnUrls.success` or `returnUrls.failure` when done. For the full field-by-field hosted-checkout walkthrough, see the [Quickstart](#tag/quickstart).\n\n### Option B — Server-to-server\n\nSubmit card details directly to the API. Use this when you collect card data yourself.\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"TEST-HAPPY-1\",\n    \"amount\": 19.99,\n    \"paymentType\": \"card\",\n    \"paymentMethod\": \"card\",\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"autoSettlement\": true,\n    \"skip3DS\": true,\n    \"cardData\": {\n      \"cardNumber\": \"4111111111111111\",\n      \"expMonth\": 12,\n      \"expYear\": 2028,\n      \"holderName\": \"Test User\",\n      \"cvv\": \"123\"\n    },\n    \"returnLinks\": [\n      { \"rel\": \"default\", \"href\": \"https://example.com/return\", \"method\": \"GET\" }\n    ]\n  }'\n```\n\nWith `\"skip3DS\": true` the response has `requiresAction: false` and the payment proceeds straight through. To test the 3DS flow, omit `skip3DS` (or set it `false`) — the response then returns `requiresAction: true` and an `actionUrl`; redirect the customer there, let them complete the challenge, then call `POST /finalize-payment` with the same `paymentId`. See [Server-to-server](#tag/server-to-server) for the full lifecycle.\n\n## 4. Sandbox test cards\n\nTest cards work on a **Demo** account. What decides it is the account's operational mode,\nnever the host — Demo accounts exist on both `https://staging-api.fynex.ai` and\n`https://api.fynex.ai`. Use any of these Visa sandbox numbers:\n\n| Card number | Notes |\n|-------------|-------|\n| `4111 1111 1111 1111` | Universal Visa test card |\n| `4530 9100 0001 2345` | Visa |\n| `4037 1122 3300 0001` | Visa |\n\nFor all of them:\n\n- **Expiry month:** any future month (e.g. `12`)\n- **Expiry year:** any future year — 4-digit (`2028`) recommended\n- **CVV:** any 3 digits (e.g. `123`)\n- **Cardholder name:** Latin letters (`A-Z`), spaces, apostrophes, dots, or hyphens only\n\n\u003e [!NOTE]\n\u003e 3DS is controlled by the `skip3DS` request flag, **not** by the card number. Don't expect a particular card number to force \"approved\" vs \"declined\" — that's controlled by the flow and the sandbox challenge page.\n\nYou can use any well-formed billing data (e.g. `test@example.com`, `Test User`, `+44 7700 900000`) — the sandbox doesn't validate it against real services, and no emails are sent. See [Test cards](#tag/test-cards) for the full reference.\n\n## 5. Check the result\n\nYou have two ways to confirm a payment's outcome:\n\n1. **Poll** the payment status (same token):\n\n   ```bash\n   curl -sS \"$FYNEX_API/payments/TEST-HAPPY-1\" \\\n     -H \"Authorization: Bearer $FYNEX_TOKEN\"\n   ```\n\n   The response includes the Fynex `status` (e.g. `provider_completed`, `settled`, `failed`, `cancelled`) along with `amount`, `currencyCode`, `countryCode`, `paymentType`, `paymentMethod`, `externalOrderRef`, the `failureCode` / `failureDescription` / `failureStage` if the payment failed, and the `createdAt` / `updatedAt` / `failedAt` timestamps.\n\n2. **Webhooks** — Fynex sends a `PaymentCompleted` webhook to the URL(s) configured on your account. **Your endpoint must return HTTP `200`**; any other status is retried (up to 3 times) and then marked failed.\n\n\u003e [!NOTE]\n\u003e Distinguish `cancelled` (customer abandoned the form) from `failed` (hard decline) — show a neutral \"payment not completed\" message for `cancelled`, not an error.\n\nSee [Polling \u0026 SSE](#tag/polling-sse) for the full verification pattern.\n\n## Conventions to know\n\n- **`Idempotency-Key` header is mandatory** on every `POST` (checkout, initialize-payment, finalize-payment, refund). Use a fresh UUID per logical request; retrying with the *same* key safely returns the existing operation instead of creating a duplicate.\n- **`returnLinks` vs `returnUrls`:** server-to-server uses `returnLinks` (an array; each item has `rel` ∈ `default | on_completed | on_failed | on_cancelled`, an `href`, and `method: \"GET\"`). Hosted checkout uses `returnUrls` at session creation.\n- **`countryCode` is required on `initialize-payment`** (and recommended on `checkout`); if your account/terminal is pinned to a country, it must match it.\n- **Amounts** are in major units (e.g. `19.99` = £19.99).\n\n## Common next steps\n\n- **[Captures \u0026 refunds](#tag/captures-refunds)** — manual capture (`autoSettlement: false`) and refunding settled payments.\n- **[Server-to-server](#tag/server-to-server)** — the full direct-integration and 3DS redirect lifecycle.\n- **[Polling \u0026 SSE](#tag/polling-sse)** — verify payment status without webhooks.\n\n## Going to production\n\nWhen you're ready, use your **Live** account's token — get it from the production dashboard's (`https://dashboard.fynex.ai`) Integration page, the same way as staging. Tokens are per-environment and independent.\n\n**Never send test card numbers on a Live account.** Note the wording: sandbox versus real card networks is a property of your account's **operational mode**, not of the host you call — Demo accounts exist on both environments, and test cards are correct on any of them. Confirm `operationalMode` before sending a test PAN; see [Test cards \u0026 sandbox](#tag/test-cards).\n\n## Need help?\n\nReach out to your Fynex contact with your `externalOrderRef` and the approximate time of the request, and we can trace it end to end.\n","name":"Getting started"},{"description":"This guide takes you from zero to a successful sandbox payment using **hosted checkout** — the simplest integration path. Fynex hosts the card form; you redirect the customer and verify the result. Plan for about 5 minutes.\n\n\u003e [!NOTE]\n\u003e All examples target the **staging** environment. Switch the base URL to `https://api.fynex.ai` when you are ready for production.\n\n## Prerequisites\n\n- A seller API token — mint your own from the dashboard **Integration** page, or via the API (see [Authentication](#tag/authentication)).\n- A seller account in **demo** mode. New accounts are created in `Demo` mode and activated automatically, so the sandbox calls below work right away. (If you ever get `403 seller account is not active`, the account isn't active — contact Fynex with your seller account ID. Going **live** for real-money payments requires KYB + a Fynex-assigned live terminal.)\n- A terminal with `curl`, plus Node.js 20+ or Python 3.10+ if you prefer those snippets.\n\n## Step-by-step\n\n1. **Get a token and configure your environment**\n\n   Get your token yourself from the dashboard: log in to `https://staging-dashboard.fynex.ai`, select your seller account, open the **Integration** page, reveal the token, and copy it (see [Authentication](#tag/authentication) for details). Then export it:\n\n   ```bash\n   export FYNEX_API=\"https://staging-api.fynex.ai/payments-api/v1\"\n   export FYNEX_TOKEN=\"\u003cthe token you copied\u003e\"\n   ```\n\n2. **Create a hosted checkout session**\n\n   Send a `POST /checkout` request with your order details. The `Idempotency-Key` header is mandatory — use a fresh UUID per request to prevent duplicate sessions.\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/checkout\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-DEMO-1\",\n    \"amount\": 19.99,\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"autoSettlement\": true,\n    \"returnUrls\": {\n      \"success\": \"https://example.com/success\",\n      \"failure\": \"https://example.com/failure\"\n    }\n  }'\n```\n\n#### JavaScript\n\n```js\nimport { randomUUID } from 'node:crypto';\n\nconst res = await fetch(`${process.env.FYNEX_API}/checkout`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n    'Content-Type': 'application/json',\n    'Idempotency-Key': randomUUID(),\n  },\n  body: JSON.stringify({\n    externalOrderRef: 'ORDER-DEMO-1',\n    amount: 19.99,\n    currencyCode: 'GBP',\n    countryCode: 'GB',\n    autoSettlement: true,\n    returnUrls: {\n      success: 'https://example.com/success',\n      failure: 'https://example.com/failure',\n    },\n  }),\n});\n\nconst session = await res.json();\nconsole.log(session.checkoutUrl); // redirect the customer here\n```\n\n#### Python\n\n```python\nimport os, uuid, requests\n\nres = requests.post(\n    f\"{os.environ['FYNEX_API']}/checkout\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n        \"Content-Type\": \"application/json\",\n        \"Idempotency-Key\": str(uuid.uuid4()),\n    },\n    json={\n        \"externalOrderRef\": \"ORDER-DEMO-1\",\n        \"amount\": 19.99,\n        \"currencyCode\": \"GBP\",\n        \"countryCode\": \"GB\",\n        \"autoSettlement\": True,\n        \"returnUrls\": {\n            \"success\": \"https://example.com/success\",\n            \"failure\": \"https://example.com/failure\",\n        },\n    },\n)\nsession = res.json()\nprint(session[\"checkoutUrl\"])  # redirect the customer here\n```\n\n   A `201 Created` response returns:\n\n   ```json\n   {\n     \"sessionId\": \"6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n     \"checkoutUrl\": \"https://staging-api.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n     \"expiresAt\": \"2026-04-29T11:30:00Z\"\n   }\n   ```\n\n   ### Request field reference\n\n   | Field | Type | Required | Notes |\n   |-------|------|----------|-------|\n   | `externalOrderRef` | string | Yes | Your order ID — must be unique per seller |\n   | `amount` | float | Yes | Major units (e.g., `19.99` for £19.99) |\n   | `currencyCode` | string | Yes | 3-letter ISO code (e.g., `GBP`). Unsupported currencies may be rejected when the payment is processed. |\n   | `countryCode` | string | Yes | 2-letter ISO code (e.g., `GB`) |\n   | `autoSettlement` | bool | No | `true` to auto-capture; `false` for manual capture later |\n   | `returnUrls.success` | string | No | Customer redirected here on success; falls back to seller checkout settings, then the hosted checkout page |\n   | `returnUrls.failure` | string | No | Customer redirected here on failure; falls back to seller checkout settings, then the hosted checkout page |\n   | `sellerMerchantName` | string | No | Displayed on the checkout page |\n   | `logoUrl` | string | No | Merchant logo URL shown on the checkout page |\n   | `locale` | string | No | Falls back to seller account locale, then `\"en\"` |\n   | `description` | string | No | Order description shown to the customer |\n\n3. **Redirect the customer to the checkout URL**\n\n   Take `checkoutUrl` from the response and redirect the customer's browser to it:\n\n   ```\n   HTTP/1.1 302 Found\n   Location: https://staging-api.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\n   ```\n\n   Fynex hosts the card entry form. Your server stays out of PCI scope.\n\n4. **Complete the payment with a test card**\n\n   On the hosted checkout page, enter:\n\n   - **Card number:** `4111 1111 1111 1111`\n   - **Expiry:** any future month/year\n   - **CVV:** any 3 digits\n\n\u003e [!NOTE]\n\u003e This is a common sandbox test card. Confirm with your Fynex contact for the current set of accepted test cards and any outcome-specific numbers (decline, 3DS, etc.).\n\n   Submit the form. The page will redirect to `returnUrls.success` on completion, or `returnUrls.failure` if the payment fails.\n\n5. **Verify the result**\n\n   Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200), and you can also poll for the payment status as a backstop. The simplest polling path for server-to-server backends is `GET /payments-api/v1/payments/{externalOrderRef}` — same bearer token you already have, returning the current Fynex `status`, amount, currency, payment method, timestamps, and any seller-safe failure summary. For browser/dashboard contexts the GraphQL `genericPayment` query or the SSE stream are also available. See the [Polling \u0026 SSE guide](#tag/polling-sse) for the full verification pattern.\n\n## What just happened\n\n- Fynex created a draft payment tied to your seller account, returned a hosted page URL, and collected card details on its own domain — your integration never touches raw card data.\n- `autoSettlement: true` means the payment was automatically captured when the card was charged. Set it to `false` if you want to capture manually later (see [Captures \u0026 Refunds](#tag/captures-refunds)).\n- The `Idempotency-Key` you sent guarantees that retrying the same request (e.g., after a network timeout) returns the **same session** instead of creating a duplicate.\n\n## See also\n\n- **[Authentication \u0026 Tokens](#tag/authentication)** — How tokens work, how to rotate them, and what to do if one leaks.\n- **[Hosted Checkout](#tag/hosted-checkout)** — Full hosted checkout reference — optional fields, return URL handling, and more.\n- **[Polling \u0026 SSE](#tag/polling-sse)** — How to verify payment status without webhooks.\n- **[Payment methods](#tag/payment-methods)** — Discover what payment methods are enabled on your account.\n","name":"Quickstart"},{"description":"Every request to `/payments-api/v1` authenticates with a **seller bearer token**. Tokens are scoped to a single seller account and grant full API access — treat them like passwords.\n\n## The auth header\n\nInclude the token in every request as an HTTP Bearer token:\n\n```http\nAuthorization: Bearer \u003cyour_token\u003e\n```\n\nAnything else — missing header, malformed value, or an unrecognised token — returns `401 Unauthorized`.\n\n## How to get a token\n\nYou obtain your token yourself — there is no need to wait on the Fynex team.\n\n**From the dashboard (easiest):**\n\n1. Log in to the dashboard with your username and password — staging: `https://staging-dashboard.fynex.ai`, production: `https://dashboard.fynex.ai`.\n2. Select your seller account.\n3. Open the **Integration** page. It shows the API token masked, with a reveal (eye) toggle and a copy button.\n4. Click the reveal icon, then copy the token. This value is exactly what you pass as `Authorization: Bearer \u003ctoken\u003e`.\n\nIf you don't have a dashboard login yet, ask your Fynex contact to set you up.\n\n**Programmatically:** `POST /api/v1/onboarding/start` (sign-up; sets the session cookie itself) — or, for an existing account, `POST /api/v1/login/dashboard` (which sets the\nsession cookie) → the `createSellerAccount` GraphQL mutation, which returns the new seller\naccount's `authorizationToken`. The first user of a new organization is automatically granted the\n`selleraccounts:create` / `selleraccounts:update` permissions this requires. See the\n[Account setup \u0026 onboarding](#tag/onboarding) guide for the full walkthrough.\n\n\u003e [!IMPORTANT]\n\u003e A new seller account is created in `Demo` mode and **activated automatically**, so your token\n\u003e works immediately for **sandbox testing** — you can take test payments straight away (see the\n\u003e [Quickstart](#tag/quickstart)). **Going live** (real-money processing) is separate: it requires\n\u003e KYB approval and a Fynex-assigned live acquirer terminal. If a sandbox call ever returns\n\u003e `403 seller account is not active`, the token is still valid — the account just isn't active\n\u003e yet; contact Fynex with your seller account ID.\n\n\u003e [!NOTE]\n\u003e A separate token is issued per seller account, and staging and production are independent — get the staging token from `https://staging-dashboard.fynex.ai` and the production token from `https://dashboard.fynex.ai`.\n\n\u003e [!CAUTION]\n\u003e The Integration page also has a **Regenerate** button. Regenerating issues a new token and **immediately invalidates the old one** — there is no overlap window. Only use it when you intend to rotate (see [Rotating a token](#rotating-a-token)).\n\n## What a token looks like\n\nTokens are opaque database strings. There are **no prefix conventions** such as `sk_test_` or `sk_live_` — the string you receive is the full token value.\n\n## Verifying a token\n\nThe quickest \"is this token alive?\" check is `GET /payment-methods`. It requires only a valid seller token and returns the payment methods enabled on your account.\n\n#### curl\n\n```bash\ncurl -sS \"$FYNEX_API/payment-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n#### JavaScript\n\n```js\nconst res = await fetch(`${process.env.FYNEX_API}/payment-methods`, {\n  headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` },\n});\nconst data = await res.json();\nconsole.log(data); // { sellerAccountId, allowedPaymentMethods, allowedCurrencies, ... }\n```\n\n#### Python\n\n```python\nimport os, requests\n\nres = requests.get(\n    f\"{os.environ['FYNEX_API']}/payment-methods\",\n    headers={\"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\"},\n)\nprint(res.json())  # { \"sellerAccountId\": ..., \"allowedPaymentMethods\": [...], ... }\n```\n\nA `200 OK` JSON response confirms the token works and the account is active. A `401` means the token is missing, malformed, or invalid. A `403 seller account is not active` means the token is **valid and recognised** but the account isn't active — contact Fynex with your seller account ID (see [How to get a token](#how-to-get-a-token)).\n\n## Storing tokens securely\n\n- **Never commit tokens to source control.** Use a secret manager (HashiCorp Vault, AWS Secrets Manager, GitHub Encrypted Secrets) or environment variables loaded at runtime.\n- **One token per environment.** Keep staging and production tokens separate.\n- **One token per seller account.** There is no cross-account access; operate multiple sellers with one token each.\n\n```bash\n# .env — never commit this file\nFYNEX_TOKEN=\u003cyour_token_here\u003e\nFYNEX_API=https://api.fynex.ai/payments-api/v1\n```\n\n## Rotating a token\n\nThe simplest way to rotate is the **Regenerate** button on the dashboard Integration page (the same page you got the token from). It issues a new token and reveals it for copying.\n\n\u003e [!CAUTION]\n\u003e Rotation **atomically replaces** the existing token. The old token becomes invalid **immediately** — there is no two-token overlap window. Plan a brief service restart or deploy when rotating.\n\nIf you prefer to automate it, the same operation is exposed as the GraphQL mutation `regenerateSellerAccountToken` on the `/dashboard/graphql` endpoint (cookie-session authenticated).\n\n### Rotation steps (GraphQL)\n\n1. **Authenticate with the dashboard** to obtain a `dashboard_session` cookie:\n\n   ```bash\n   curl -c cookies.txt -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n     -H \"Content-Type: application/json\" \\\n     -d '{\"email\": \"you@example.com\", \"password\": \"...\"}'\n   ```\n\n2. **Call `regenerateSellerAccountToken`** with your seller account ID as `merchantId`:\n\n   ```bash\n   curl -b cookies.txt -X POST https://api.fynex.ai/dashboard/graphql \\\n     -H \"Content-Type: application/json\" \\\n     -d '{\n       \"query\": \"mutation Rotate($merchantId: ID!) { regenerateSellerAccountToken(merchantId: $merchantId) { authorizationToken } }\",\n       \"variables\": { \"merchantId\": \"42\" }\n     }'\n   ```\n\n   The response contains the new `authorizationToken` value.\n\n3. **Update your services** — replace the token in your secret manager / environment and restart affected services before the old token is invalidated (which happened in step 2).\n\n### When to rotate\n\n- A developer with access leaves the team.\n- You suspect or confirm a token leak.\n- As a precautionary measure on a regular schedule (quarterly is a common default).\n\n## Token properties\n\n| Property | Value |\n|----------|-------|\n| Scope | Single seller account |\n| Expiry | None — tokens do not expire |\n| Revoke operation | None — rotate to invalidate |\n| Multiple tokens per account | Not supported — one bearer token per seller account |\n| Rate limiting | Per-seller token-bucket rate limit; the budget differs by environment — read `X-RateLimit-Limit` / `X-RateLimit-Remaining` rather than assuming a rate. Returns `429` with a `Retry-After` header when exceeded |\n\n## Common errors\n\n| Status | Body | Cause |\n|--------|------|-------|\n| `401` | `authorization token is required` (plaintext) | Header absent or malformed at the middleware level |\n| `401` | `invalid authorization token` (plaintext) | Token value is not recognised |\n| `401` | `{\"error\": \"seller auth is required\"}` | Missing seller context inside a handler |\n| `403` | `seller account is not active` (plaintext) | Token is valid but the seller account isn't active — new demo accounts activate automatically; if you see this, contact Fynex with your seller account ID |\n| `403` | `{\"error\": \"resource does not belong to this seller\"}` | Token is valid but the resource belongs to a different seller |\n\n\u003e [!NOTE]\n\u003e The `401` response body from the auth middleware is plain text, not JSON. Once inside a handler, all error responses are JSON `{\"error\": \"...\"}`.\n\n## See also\n\n- **[Quickstart](#tag/quickstart)** — Take your first test payment in 5 minutes.\n- **[Errors](#tag/errors)** — Full status code and error body reference.\n- **[Request headers](#tag/headers)** — All request headers reference — required, conditional, and optional.\n","name":"Authentication"},{"description":"All public REST endpoints live under `/payments-api/v1`. This page documents every HTTP request header the API reads. Headers not listed here are ignored.\n\n---\n\n## Required headers\n\n### `Authorization`\n\n```http\nAuthorization: Bearer \u003ctoken\u003e\n```\n\n| Property | Value |\n|----------|-------|\n| Type | String |\n| Required | Yes — all endpoints |\n| Format | `Bearer ` followed by the seller bearer token (no quotes, no extra whitespace) |\n\nEvery request to `/payments-api/v1/*` is processed through `SellerAccountAuthMiddleware`, which reads this header and resolves the token to a seller account. There is no other authentication mechanism on the REST surface.\n\n**Failure modes:**\n\n| Condition | Status | Body (note: plaintext, not JSON) |\n|-----------|--------|----------------------------------|\n| Header absent or empty | `401` | `authorization token is required` |\n| Token not recognized | `401` | `invalid authorization token` |\n| Seller account inactive | `403` | `seller account is not active` |\n\n\u003e [!CAUTION]\n\u003e The `401` response from the auth middleware is **plain text**, not JSON. Once a request passes authentication and enters a handler, all subsequent errors are JSON `{\"error\": \"...\"}`.\n\n---\n\n### `Content-Type`\n\n```http\nContent-Type: application/json\n```\n\n| Property | Value |\n|----------|-------|\n| Type | String |\n| Required | Yes — POST requests with a body |\n| Format | `application/json` |\n\nRequired on all POST endpoints that accept a request body: `/initialize-payment`, `/finalize-payment`, `/payments/{id}/capture`, `/payments/{id}/refund`, `/payouts`, `/checkout`. GET requests do not require a `Content-Type` header.\n\n---\n\n### `Idempotency-Key`\n\n```http\nIdempotency-Key: 6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\n```\n\n| Property | Value |\n|----------|-------|\n| Type | UUID string |\n| Required | Yes — all POST endpoints (except `/payouts`; see note below) |\n| Format | UUID v4, lower-case, hyphenated — e.g. `6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b` |\n\nThe idempotency key makes POST requests safe to retry. If the server has already processed a request with the same key for the same seller account, it returns the existing operation rather than creating a new resource.\n\n**Validation:**\n\n| Condition | Status | Body |\n|-----------|--------|------|\n| Header absent or empty | `400` | `{\"error\": \"Idempotency-Key header is required\"}` |\n| Value is not a valid UUID | `400` | `{\"error\": \"Idempotency-Key must be a valid UUID\"}` |\n\n**On `/initialize-payment`:** a matching idempotency key returns `200 OK` with the existing payment instead of the usual `202 Accepted`. For an active APM, Fynex re-reads the provider charge and includes the current buyer action again, including Multibanco `paymentInstructions` when available.\n\n**On `/finalize-payment`:** the header is validated and must be a valid UUID, but the value is not used for idempotency lookup on this endpoint — it is discarded after validation.\n\n\u003e [!NOTE]\n\u003e `POST /payouts` uses an `idempotencyKey` field in the **request body** rather than this header. On `/payouts` the `Idempotency-Key` header is **optional**: the body field `idempotencyKey` is authoritative and drives deduplication, and the header is used only as a fallback when the body field is empty.\n\n---\n\n## Optional headers\n\n### `X-Device-Fingerprint`\n\n```http\nX-Device-Fingerprint: \u003cfingerprint-string\u003e\n```\n\n| Property | Value |\n|----------|-------|\n| Type | String |\n| Required | No |\n| Format | Opaque string, trimmed of whitespace |\n\nDevice fingerprint forwarded to the upstream card processor as a risk signal. If present, it is stored on `GenericPayment.DeviceFingerprint` and included in the provider authorization request. Collecting a device fingerprint from the customer's browser and forwarding it here improves authorization rates on risk-sensitive transactions.\n\nFor device intelligence, first call `POST /payments-api/v1/device-intelligence/token`, initialize `@sumsub/fisherman` in the customer's browser with the returned `accessToken`, then send the returned `sessionId` as `deviceSessionId` in the `/initialize-payment` body. If your browser SDK also returns a visitor id, you may continue to send it as `X-Device-Fingerprint`.\n\n---\n\n### `X-Source-Channel`\n\n```http\nX-Source-Channel: api\n```\n\n| Property | Value |\n|----------|-------|\n| Type | Enum string |\n| Required | No |\n| Allowed values | `api` (default), `admin_panel` |\n\nIdentifies the origin channel of the request. The value is lower-cased before processing. Any value other than `admin_panel` — including an absent header — is treated as `api`. Stored on `GenericPayment.SourceChannel`.\n\nUse `admin_panel` only when the request originates from a Fynex internal backoffice action. Partner integrations should omit this header or use `api`.\n\n---\n\n### `Accept-Language`\n\n```http\nAccept-Language: en-GB,en;q=0.9\n```\n\n| Property | Value |\n|----------|-------|\n| Type | String (standard HTTP) |\n| Required | No |\n| Format | Standard `Accept-Language` value per RFC 7231 |\n\nStored on `GenericPayment.AcceptLanguage` and may be forwarded to the upstream processor. Include when you want to pass the customer's preferred language for any provider-side communication or challenge pages.\n\n---\n\n## Headers the server does not set\n\nThe Fynex API does not currently set `X-Fynex-Request-Id` or `X-Fynex-Trace-Id` response headers. Do not rely on these for correlation — use the `paymentId` (your `externalOrderRef`) and the provider's `providerPaymentId` from the response body instead.\n\n---\n\n## Quick reference\n\n| Header | Required | Endpoints |\n|--------|----------|-----------|\n| `Authorization: Bearer \u003ctoken\u003e` | Yes | All |\n| `Content-Type: application/json` | Yes | POST with body |\n| `Idempotency-Key: \u003cuuid\u003e` | Yes | All POST |\n| `X-Device-Fingerprint: \u003cstring\u003e` | No | All POST |\n| `X-Source-Channel: api\\|admin_panel` | No | All POST |\n| `Accept-Language: \u003cvalue\u003e` | No | All POST |\n\n---\n\n## Example request with all headers\n\n```http\nPOST /payments-api/v1/initialize-payment HTTP/1.1\nHost: api.fynex.ai\nAuthorization: Bearer YOUR_SELLER_TOKEN\nContent-Type: application/json\nIdempotency-Key: 6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\nX-Device-Fingerprint: fp_a1b2c3d4e5f6\nX-Source-Channel: api\nAccept-Language: en-GB,en;q=0.9\n\n{ ... }\n```\n\nTokens are opaque strings with no prefix (e.g. no `sk_live_` or `sk_test_`). Replace `YOUR_SELLER_TOKEN` with the full token value provided by Fynex.\n\n## See also\n\n- **[Authentication](#tag/authentication)** — How to obtain, use, and rotate seller bearer tokens.\n- **[Idempotency](#tag/idempotency)** — How the Idempotency-Key header prevents duplicate payments.\n","name":"Headers"},{"description":"Networks fail. Servers restart. The right response to a flaky call is to **retry safely** — and that's what idempotency keys are for.\n\n## How it works\n\nEvery mutating endpoint accepts the `Idempotency-Key` header (a UUID you generate). Fynex remembers the response body for that key and replays it on subsequent calls with the same key, so you can retry without creating duplicate payments, duplicate refunds, or duplicate payouts.\n\n```\n1st request:  POST /initialize-payment  Idempotency-Key: abc...  ─►  202 Accepted (payment created)\n2nd request:  POST /initialize-payment  Idempotency-Key: abc...  ─►  200 OK      (replay of original)\n```\n\n## Where keys are accepted\n\n| Endpoint | Header | Purpose |\n|----------|--------|---------|\n| `POST /checkout` | `Idempotency-Key` | Don't create duplicate hosted sessions |\n| `POST /initialize-payment` | `Idempotency-Key` | Don't double-charge |\n| `POST /finalize-payment` | `Idempotency-Key` | Idempotent capture |\n| `POST /payments/{id}/capture` | `Idempotency-Key` | Don't double-capture |\n| `POST /payments/{id}/refund` | `Idempotency-Key` | Don't double-refund |\n| `POST /payouts` | Body field `idempotencyKey` | Don't double-pay out |\n\nReplay semantics depend on the endpoint — see each endpoint's docs. The `Idempotency-Key` on `/finalize-payment` is required as a valid UUID but is not used for replay; the payment's state machine itself prevents double-finalize. `POST /payouts` is special: idempotency lives in the body field `idempotencyKey`, not the header (see [Payouts](#tag/payouts)).\n\n## Generating keys\n\nUse a UUID v4 from any standard library:\n\n```js\nimport { randomUUID } from 'node:crypto';\nconst key = randomUUID();\n```\n\n```python\nimport uuid\nkey = str(uuid.uuid4())\n```\n\n```bash\nkey=$(uuidgen)\n```\n\n## When to mint a new key vs. reuse\n\n- **One operation → one key.** Generate the key when you start the operation; persist it alongside the order so retries reuse it.\n- **A retry of the same operation reuses the same key.** That's the whole point.\n- **A new attempt after a definitive failure uses a new key.** If the original returned `400 invalid amount`, that key is now permanently associated with that error — fix the input and use a fresh key.\n\n```js\n// Pseudocode for resilient charge logic\nasync function charge(order) {\n  if (!order.idempotencyKey) {\n    order.idempotencyKey = randomUUID();\n    await db.orders.update(order.id, { idempotencyKey: order.idempotencyKey });\n  }\n\n  for (let attempt = 1; attempt \u003c= 3; attempt++) {\n    try {\n      return await fynex.initializePayment({ ...order, key: order.idempotencyKey });\n    } catch (err) {\n      if (!isRetryable(err)) throw err;\n      await sleep(2 ** attempt * 100); // 200ms, 400ms, 800ms\n    }\n  }\n}\n```\n\n## Retry strategy\n\nRetry on:\n\n- Network errors (`ECONNRESET`, timeouts, DNS failures)\n- HTTP `502 Bad Gateway` (upstream provider hiccup)\n- HTTP `503 Service Unavailable`\n\nDon't retry on:\n\n- `400 Bad Request` — fix the payload\n- `401 Unauthorized` — fix the token\n- `403 Forbidden` — fix the permission/seller\n- `404 Not Found` — fix the resource ID\n- `409 Conflict` — read the body and decide; usually a state issue, not transient\n\nUse **exponential backoff with jitter**: 200ms → 400ms → 800ms with random jitter, capped at 3–5 attempts.\n\n```js\nasync function retry(fn, max = 4) {\n  for (let i = 0; i \u003c max; i++) {\n    try { return await fn(); }\n    catch (e) {\n      if (!isRetryable(e) || i === max - 1) throw e;\n      const base = Math.min(2 ** i * 200, 5000);\n      const jitter = Math.random() * base * 0.3;\n      await new Promise(r =\u003e setTimeout(r, base + jitter));\n    }\n  }\n}\n```\n\n\u003e [!CAUTION]\n\u003e **Don't retry forever.** If three retries fail, surface the error to your operator and stop. A stuck payment with a known idempotency key can be inspected and resolved manually.\n\n## What gets replayed\n\nFynex binds the key to the original operation and returns the existing resource instead of creating another one. On `POST /initialize-payment`, a replay returns `200 OK`. For an active APM, Fynex re-reads the existing provider charge and rehydrates its current buyer action: `actionUrl` / `redirectFullPage` for redirects and `paymentInstructions` for a Multibanco payment reference. The payment's canonical status can still advance asynchronously through webhooks or polling.\n\n## Reusing a key with a different body\n\n`POST /initialize-payment` enforces strict idempotency on the high-stakes financial fields of the request: `amount`, `currencyCode`, `countryCode`, `externalOrderRef`, `paymentType`, `paymentMethod`. If any of those values differ from the original request that minted the key, the API returns `409 Conflict` with a concrete reason naming the field and both values:\n\n```json\n{ \"error\": \"Idempotency-Key reused with a different currencyCode: original=USD, request=EUR\" }\n```\n\n```json\n{ \"error\": \"Idempotency-Key reused with a different amount: original=4999 minor units, request=9999 minor units\" }\n```\n\nThis matters: a silent replay of the original response on a body mismatch could cause an integrator to charge a customer an amount or currency they did not intend.\n\nFields that are **not** part of the conflict check (e.g. `billingDetails.addressLine2`, `cardData.cvv`, customer profile metadata, headers like `X-Device-Fingerprint`) can vary between retries, but the replay still refers to the original payment; it does not re-run provider creation with the changed ancillary data. The intent is \"same financial transaction is safe to look up again; different financial transaction requires a fresh key.\"\n\nWhen you see a `409` from this check:\n\n1. Decide whether you actually want to replay the original payment or create a new one.\n2. To replay → re-send the request with the original body unchanged.\n3. To create a new payment → mint a **fresh `Idempotency-Key`** (fresh UUID) and send the new body.\n\n## Errors and replays\n\nError replay behaviour is endpoint-specific and not guaranteed across all operations. For safe recovery from a validation error, fix your input and use a **new** idempotency key regardless of whether the original error was replayed.\n\n## Next steps\n\n- [Errors](#tag/errors) — distinguish retryable from permanent failures\n- [Polling \u0026 SSE](#tag/polling-sse) — poll for the canonical payment state\n","name":"Idempotency"},{"description":"## Error format\n\nAll handler errors return JSON with a single `error` field:\n\n```json\n{ \"error\": \"amount must be greater than 0\" }\n```\n\n\u003e [!CAUTION]\n\u003e **Auth middleware errors are plain text, not JSON.** The `SellerAccountAuthMiddleware` uses `http.Error()` which returns a `text/plain` body. If your client parses every response as JSON, handle the 401 case separately. Example plain-text bodies: `authorization token is required` (401, header absent/malformed), `invalid authorization token` (401, token not recognised), `seller account is not active` (403, account inactive).\n\u003e\n\u003e Once a request passes the middleware and reaches a handler, all subsequent error responses are JSON.\n\n---\n\n## Status codes\n\n| Code | Meaning | Retry safely? |\n|------|---------|---------------|\n| `200` / `201` / `202` | Success | n/a |\n| `400` | Validation error in your request | No — fix the input |\n| `401` | Missing or invalid bearer token | No — fix auth |\n| `403` | Token valid but resource belongs to another seller | No — use the right resource |\n| `404` | Resource not found for this seller | No — check the ID |\n| `409` | Conflict — current state forbids the action | No — read the body and decide |\n| `429` | Per-seller rate limit exceeded | Yes — honor `Retry-After`, then retry |\n| `500` | Internal error | Yes — backoff + retry; alert on persistence |\n| `502` | Upstream provider failure | Yes — backoff + retry |\n| `503` | Service temporarily unavailable | Yes — backoff + retry |\n\n---\n\n## Common 400 responses\n\n| Message | Likely cause |\n|---------|--------------|\n| `invalid request body` | JSON parse failed — check `Content-Type` and body syntax. On `/initialize-payment`, a common cause is sending `returnLinks` as an object (`{\"success\": ..., \"failure\": ...}` — that shape belongs to `/checkout`) instead of an array of `{rel, href, method}`. |\n| `Idempotency-Key header is required` | POST endpoint called without the header |\n| `Idempotency-Key must be a valid UUID` | Header value is not a UUID |\n| `currencyCode is required` | Missing required field |\n| `paymentMethod wero is not supported for currency EUR and country IT` | The APM does not support the requested currency/country. Wero supports EUR in `BE`, `DE`, and `FR` only. The request is rejected before a payment is persisted or routed. |\n| `amount must be greater than 0` | Zero or negative `amount` |\n| `valid returnLinks are required` | On `/initialize-payment`, the request had no `returnLinks` (or invalid ones) **and** the seller account also has no valid return links. Pass `returnLinks: [{rel, href, method}]` in the request body, or configure default links on the seller account in the Dashboard. |\n| `payment configuration is not set for seller account` | Seller has no payment methods configured |\n\n---\n\n## Common 409 responses\n\nThese mean the resource's current state forbids the action. Read the message before deciding whether to retry:\n\n| Message | What to do |\n|---------|------------|\n| `Idempotency-Key reused with a different \u003cfield\u003e: original=X, request=Y` | On `/initialize-payment` you re-sent a known `Idempotency-Key` with a body that differs in one of the financial fields (`amount`, `currencyCode`, `countryCode`, `externalOrderRef`, `paymentType`, `paymentMethod`). To replay the original payment, send the original body unchanged. To create a new payment, mint a fresh `Idempotency-Key`. See [Idempotency \u0026 retries](#tag/idempotency). |\n| `invalid status transition from \u003cstatus\u003e to capture` | Payment is not in `authorized` or `provider_completed` — check status first |\n| `manual settlement required` | Payment was created with `autoSettlement: true` — cannot capture manually |\n| `payment is already refunded` | Nothing left to refund |\n| `capture is not allowed while a refund is in progress` | Wait for the pending refund to finish before retrying any capture decision |\n| `capture is not allowed after a successful refund` | Do not capture again after any successful refund on the payment |\n| `refund is already in progress` | A refund is in `refund_pending`. Retry the original `POST /refund` with the same `Idempotency-Key` to replay that row; use a different key only after it reaches `succeeded`, `failed`, or `cancelled`. |\n| `refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments` | Payment is not in a refundable captured state and is not a retryable failed/cancelled refund |\n| `refund amount exceeds remaining refundable amount` | The requested refund exceeds the remaining captured balance after successful prior refunds |\n| `payout with this idempotency key already exists` | Replay of a successful create — fetch the existing payout instead |\n| `insufficient balance` | Top up the wallet, then retry with a fresh `idempotencyKey` |\n\n---\n\n## Rate limiting (429 responses)\n\nEvery endpoint under `/payments-api/v1/*` is rate-limited per seller account using a token-bucket keyed on the authenticated `seller_account_id`. **The budget differs between staging and production**, so do not hard-code a rate: read `X-RateLimit-Limit` and `X-RateLimit-Remaining` from the response, which are authoritative for the environment you are calling. The budget is per-seller (one noisy client cannot starve others).\n\nEvery authorized response — both 200 and 429 — normally carries:\n\n| Header | Meaning |\n|--------|---------|\n| `X-RateLimit-Limit` | The bucket size for this seller (e.g. `30`) |\n| `X-RateLimit-Remaining` | Tokens left in the bucket after this request |\n| `X-RateLimit-Reset` | Whole seconds until the bucket has refilled to `X-RateLimit-Limit` |\n| `RateLimit-Policy` | The budget itself, in IETF structured-field syntax: `\"seller\";q=30;qu=\"requests\";w=60` — quota, unit, window in seconds |\n| `RateLimit` | Where you stand against it: `\"seller\";r=12;t=45` — remaining, and seconds until reset |\n| `Retry-After` *(429 only)* | Whole seconds to wait before the next request is guaranteed to succeed |\n\n\u003e [!NOTE]\n\u003e `RateLimit` and `RateLimit-Policy` are the current standards-track fields\n\u003e (`draft-ietf-httpapi-ratelimit-headers`). They are **not** the\n\u003e `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` triple you may\n\u003e remember — that spelling is from an earlier revision of the same draft and\n\u003e Fynex does not send it. If your client only knows the old shape, read the\n\u003e `X-RateLimit-*` headers, which carry the same numbers.\n\n`X-RateLimit-Reset` is what lets you pace a loop *before* you are refused:\n`X-RateLimit-Remaining` alone tells you how many requests are left but not how\nlong you have to spend them over, and `Retry-After` arrives only once you have\nalready been throttled.\n\nWhen the bucket is empty:\n\n```http\nHTTP/1.1 429 Too Many Requests\nContent-Type: application/json\nRetry-After: 1\nX-RateLimit-Limit: 30\nX-RateLimit-Remaining: 0\nX-RateLimit-Reset: 12\nRateLimit-Policy: \"seller\";q=30;qu=\"requests\";w=60\nRateLimit: \"seller\";r=0;t=12\n\n{\"error\":\"rate limit exceeded; retry after the Retry-After header value\"}\n```\n\n**How to react:** sleep for at least `Retry-After` seconds, then retry the **same request** with the **same `Idempotency-Key`**. Idempotency-Key replay is safe; the original response is returned once the bucket has capacity.\n\n**How to avoid it:** read `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response and back off proactively when the remaining count approaches zero. Drive your polling interval from those values rather than from a fixed number of seconds — the budget differs by environment, so an interval that is comfortable in one may exhaust the other. Add ±20% jitter to any loop.\n\n**Missing headers:** authorized responses — both `2xx` and `429` — normally carry the `X-RateLimit-*` headers. `401`/`403` responses never do, because the budget is keyed on the authenticated seller and the limit is applied after authentication. If the headers are absent on an authorized response, hold your most conservative polling interval rather than reading it as headroom, and contact support if it persists.\n\n---\n\n## 502 vs 500\n\n- **`502 Bad Gateway`** — Fynex called an upstream processor and it returned a non-OK response or timed out. The action may or may not have been processed upstream — your idempotency key protects you on retry.\n- **`500 Internal Server Error`** — Fynex itself encountered an unexpected error. Should be rare. If you see persistent 500s, contact support with the request details.\n\n### Common 502 causes\n\nA 502 can come from either Fynex-side routing (no terminal selected) or an upstream processor returning a non-OK response. An unsupported APM market is instead rejected with `400` before routing. Upstream-side 502s all share the same body shape:\n\n```json\n{ \"error\": \"upstream card processor returned \u003cstatus\u003e\" }\n```\n\nTo distinguish *why* the upstream rejected the request, fetch the payment afterwards (`GET /payments-api/v1/payments/{externalOrderRef}`) and inspect its `failureCode` + `failureStage` fields.\n\n| Body (abridged) | failureCode | Cause | Fix |\n|------|-------------|-------|-----|\n| `no active terminal found for seller account` | `1003` (`routing`) | The method/currency/country combination is supported, but Fynex routing couldn't find an active seller terminal that matches the request's method, `operationalMode` (Demo vs Live), `countryCode`, and `currencyCode`. | Check the seller's attached terminals in the Dashboard and confirm at least one active link supports the APM and matches the request's `countryCode` + `currencyCode` under the seller's `operationalMode`. See [Troubleshooting](#tag/troubleshooting). |\n| `upstream card processor returned 400` | `2002` (`authorization`) — `billingDetails` missing | Card payment sent without billing country and/or zip. The Fynex DTO marks `billingDetails` as optional but the upstream card processor requires both. | Always populate `billingDetails.country` (or `countryCode`) and `billingDetails.zip` (or `postalCode`) on card initialize requests. See [Server-to-Server](#tag/server-to-server). |\n| `upstream card processor returned 400` | `2002` (`authorization`) — payment handle in wrong state | `/finalize-payment` was called before the customer completed the 3DS challenge at `actionUrl`. The upstream payment handle is still in its initial state and cannot authorize a payment. | Visit `actionUrl` from the initialize response, complete the challenge, then call `/finalize-payment`. See [3DS Authentication](#tag/3ds) and [Troubleshooting](#tag/troubleshooting). |\n| `upstream card processor returned 409` | `2002` (`authorization`) — duplicate merchant reference | Re-using the same `externalOrderRef` on a fresh idempotency key. The upstream processor deduplicates on its own merchant reference, independent of the Fynex `Idempotency-Key`. | Either re-use the original `Idempotency-Key` (replays the original response) or send a fresh `externalOrderRef`. |\n| `upstream card processor returned \u003cother\u003e` | `2001` / `2002` / `2003` | Generic upstream processor failure, decline, or timeout. | Backoff and retry with the same `Idempotency-Key`. Persistent failures: contact Fynex support with the payment's `externalOrderRef` so we can correlate against upstream logs. |\n\n---\n\n## Failure codes inside payment responses\n\nEven when the HTTP call returns `200`, the payment itself may have failed at the processor. Inspect the response body:\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"failed\",\n  \"failureCode\": 2001,\n  \"retry\": \"never\",\n  \"failureCategory\": \"hard\",\n  \"failureDescription\": \"Provider declined the transaction\"\n}\n```\n\n`failureCode` is the contract; `failureDescription` is prose for a human and may\nbe reworded in any release, so branch on the code and show the description.\nEvery code is enumerated below with its cause, whether retrying can succeed, and\nwhat to do — and the same table is on the field itself in `openapi.json`, so a\ngenerated client carries it. The retry verdict also rides on the response as\n`retry` (`safe`, `fix_first` or `never`), on every payment and refund response\nand on the `PaymentCompleted` webhook, so you can branch on it without joining\nthe table yourself.\n\nThe retry column is the part worth reading twice. Getting it wrong costs money in\nboth directions: retrying a decline the issuer already made loses the sale and can\nget the card blocked, while *not* retrying a timeout with the **original**\n`Idempotency-Key` is how a customer gets charged twice.\n\n`failureCategory` rides beside it and answers the other question — not *what to\ndo* but *what happened*: `hard`, `transient`, `requires_change`,\n`integration_error`, `cancelled`, or `unknown` for a code this catalogue does\nnot classify. Branch your retry loop on `retry`; count, chart and alert on\n`failureCategory`. The split is what keeps a spike of `integration_error` (your\nbug) out of the same number as a spike of `hard` (your conversion rate). Both\nfields are absent while the payment has not failed.\n\n`failureStage` narrows it further — the stage says *where* the payment stopped,\nthe code says *why*. Anything before `authorization` never reached a provider, so\nno money moved.\n\n### Two card declines that leave a hold\n\nA declined payment is not always a released card. On an **AVS mismatch** — the\nbilling address or postal code did not match the issuer's record — the issuer\nmay have placed a temporary authorization hold that the cardholder sees on their\nstatement for up to 3–5 business days even though the payment failed and Fynex\ncaptured nothing. The same is true on a **CVV mismatch**: the security code was\nwrong, the payment failed, and the cardholder may still see a pending\nauthorization for up to 3–5 business days before their issuer drops it.\n\nNeither hold is something Fynex can release, and neither is a charge. Say so in\nyour own customer-facing copy before the buyer calls their bank — and do not\nre-run the same card repeatedly to \"clear\" it, because each attempt can add\nanother hold.\n\nNote that the API does not currently distinguish either decline from an ordinary\n`2001`: there is no dedicated `failureCode` for an AVS or CVV mismatch, and\n`avsResult` / `cvvResult` are not published on the payment. The advice above\ntherefore applies when you already know the decline reason from your own\ncheckout flow.\n\n### Soft declines and 3-D Secure\n\n`3002` is the one decline that is not a refusal of the card: the issuer\nsoft-declined the authorization and asked for strong customer authentication\ninstead. The card processor reports its own soft-decline code at the\nauthorization gate and Fynex maps it onto `3002`, so branch on `3002` rather\nthan on any processor-specific number. It carries `retry: fix_first` and `failureCategory: requires_change`,\nnot `hard` — re-run the payment through a 3-D Secure flow and it can succeed.\nRetrying without one fails identically, and counting it as a decline throws away\na sale you can still make. See [3DS Authentication](#tag/3ds).\n\n---\n\n## The `/checkout` Idempotency-Key pitfall\n\n`POST /checkout` validates the `Idempotency-Key` header **before** auth, in `initFinalizeHeaderMiddleware`. A missing or malformed key returns a JSON `400` — `{\"error\":\"Idempotency-Key header is required\"}` (header absent or empty) or `{\"error\":\"Idempotency-Key must be a valid UUID\"}` (value is not a UUID) — not a 401. If you see one of these 400s from `/checkout`, fix the `Idempotency-Key` header rather than your token.\n\n---\n\n## Recovering from partial failures\n\nIf `/initialize-payment` returns `502` but you don't know whether the processor created or charged the payment:\n\n1. Wait 30–60 seconds.\n2. Retry the unchanged request with the **same idempotency key**. Fynex and its APM provider integration reuse the same provider-attempt idempotency key and byte-stable create body, so a timed-out APM create is reconciled instead of creating a second charge. If the charge is still awaiting buyer action, the replay returns the current redirect or Multibanco payment instructions again.\n3. If the retry also fails, poll the canonical state. From a server-to-server backend the simplest path is `GET /payments-api/v1/payments/{externalOrderRef}` (bearer auth, same token). From cookie-session contexts use the GraphQL `genericPayment(id)` query instead.\n\nThe same pattern applies to `/finalize-payment`, `/payments/{id}/capture`, `/payments/{id}/refund`, and `/payouts`.\n\n## See also\n\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Verify payment and payout state by polling or server-sent events.\n- **[Idempotency \u0026 retries](#tag/idempotency)** — Make your retries safe with idempotency keys.\n\n\n---\n\n## Payment failure codes\n\nReturned as `failureCode` on every payment and refund response, and on the\n`payment.completed` webhook. A `200` does not mean the payment succeeded —\nread `status`, and when it is `failed` read this.\n\nWhy the payment failed. `0` means it has not.\n\n**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.\n\n**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.\n\n| Code | Meaning | Category | Retry | What to do |\n|---|---|---|---|---|\n| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |\n| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |\n| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |\n| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |\n| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |\n| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |\n| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |\n| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |\n| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |\n| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |\n| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |\n| `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. |\n| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |\n| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |\n| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |\n| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |\n| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |\n| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |\n| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |\n\n## Payout failure codes\n\nWhy the payout failed. Absent while it has not.\n\nIn every case the held funds are returned to the seller's available balance before the payout is marked failed, so a failed payout never leaves money stranded — and no payout failure is retryable by simply re-sending the same request. Treat a failed payout as terminal, read `failureMessage` for what to tell the seller, and create a new payout only after the underlying cause is addressed.\n\nThe enumerated code list is not published yet; it is pending a rename that removes supplier-specific prefixes from four of the values.\n","name":"Errors"},{"description":"\u003e [!NOTE]\n\u003e Whether a card payment reaches real card networks is decided by your account's\n\u003e **operational mode**, not by the host you call. A **Demo** account routes to the upstream\n\u003e card processor's **sandbox** — no real card networks, no money movement. A **Live** account\n\u003e does not: test PANs on a Live account can reach real cards, so confirm your\n\u003e `operationalMode` before sending one.\n\u003e\n\u003e Whether a payment goes through a 3DS challenge is controlled by the **`skip3DS` request flag**, not by the card number (see below).\n\nThis page is your self-service reference for sandbox testing.\n\n\u003e [!TIP]\n\u003e **No account yet?** `POST /sandbox/accounts` with `Content-Type: application/json` and a\n\u003e `{}` body mints an anonymous demo seller and returns its `sk_test_` key — time-limited\n\u003e (`expiresAt` in the response says when), no e-mail, no real money. Everything below works\n\u003e against it. The door is open on the sandbox host (`staging-api.fynex.ai`), whose `/sandbox`\n\u003e page has the details; a host with it switched off answers `503`.\n\n---\n\n## How the sandbox works\n\n**Sandbox or live is a property of your ACCOUNT, not of the URL you call.**\n\nYour seller account carries an `operationalMode`, and that mode — not the hostname — decides\nwhere a payment goes:\n\n- **Demo** routes to the upstream processor's **sandbox**. No real card networks are involved\n  and no money moves, so test card numbers are exactly what you should be using.\n- **Live** routes to **real card networks**. Never send a test card number on a Live account —\n  it will be declined or, worse, charged to a real card if the number happens to exist.\n- The Fynex-hosted checkout page, the server-to-server flow, and the checkout widget all\n  route through the same upstream sandbox when the account is in Demo mode.\n- Alternative payment methods (APMs) use a separate local-payment sandbox flow. Bancontact\n  has its own public test PANs, Multibanco / MB WAY use the provider-hosted mock page, Wero\n  uses a UAT consent site, and Swish credentials are provisioned separately. See\n  [Alternative payment methods (APM)](#tag/alternative-payment-methods-apm) for the\n  step-by-step APM testing table.\n\n\u003e [!CAUTION]\n\u003e **Never send a test PAN on a Live account.** Confirm your mode first: query your seller\n\u003e account and check `operationalMode` — the same check described in\n\u003e [Going live](#tag/going-live). If it reads `Live`, test cards are not safe. Use real cards,\n\u003e or ask us to provision a Demo account for integration work.\n\n### Which base URL do I use?\n\nMode is independent of environment. Demo accounts exist on **both** `https://api.fynex.ai` and\n`https://staging-api.fynex.ai` — most integration accounts are issued on staging, some on\nproduction, and neither is a misconfiguration.\n\nSo the host does not tell you your mode, and your mode does not tell you the host. **Use the\nbase URL you were issued alongside your token.**\n\nThe two environments keep entirely separate credential stores, so a token only authenticates\nagainst the host it was issued for. Pointing it at the other one returns HTTP **401** with the\nplain-text body `invalid authorization token`. That means *wrong host for this token* — not\nthat your credentials are broken, and not that you should switch hosts to fix it.\n\n\u003e [!NOTE]\n\u003e Fynex delivers outbound `PaymentCompleted` webhooks to the URLs configured on your seller\n\u003e account (and to any per-request `webhookUrl`). Your receiver must return **HTTP 200** for a\n\u003e delivery to count as successful — any other status is retried (up to 3 attempts) and then\n\u003e marked failed. You can also verify payment outcomes by polling the GraphQL `genericPayment`\n\u003e query or subscribing to the SSE stream — see the [Polling \u0026 SSE](#tag/polling-sse) guide.\n\n---\n\n## Controlling 3DS\n\nOn the server-to-server flow (`POST /payments-api/v1/initialize-payment`), **whether** a 3DS\nflow runs is selected by the **`skip3DS`** flag in the request body. When 3DS does run, the\n**card number** then determines the authentication *outcome* (`threeDResult` — see\n[Card numbers](#card-numbers)):\n\n| `skip3DS` | Behaviour |\n|-----------|-----------|\n| `true` | No 3DS. The response has `requiresAction: false` and the payment proceeds straight to authorization (`provider_completed`). Use this for the **no-redirect** test flow. |\n| `false` / omitted | 3DS is requested. The response has `requiresAction: true` and an `actionUrl`. Redirect the customer to `actionUrl`, let them complete the challenge on the processor's sandbox page, then call `POST /finalize-payment`. Use this for the **3DS redirect** test flow. See the [3DS guide](#tag/3ds). |\n\nOn the **hosted checkout** (`POST /checkout`) the hosted page manages 3DS internally — you only\nobserve the final outcome when polling.\n\n---\n\n## Card numbers\n\n\u003e [!IMPORTANT]\n\u003e On this sandbox the **card number selects the 3DS authentication outcome** (`threeDResult`).\n\u003e `skip3DS` only controls *whether* a 3DS flow runs at all — when 3DS runs (e.g. on the hosted\n\u003e checkout, which always runs it), the **PAN** determines whether authentication succeeds (`Y`),\n\u003e challenges (`C`), or is rejected/unable (`R`/`U`/`N`). A handle whose authentication is not\n\u003e `Y`/`A` is failed by the processor's risk rule and **cannot be settled** — the settle call comes\n\u003e back with a field-level error saying the payment handle is in a non-payable state. Use the right PAN.\n\n### Cards that authenticate successfully (`threeDResult=Y`, frictionless — no challenge)\n\nUse these for a **happy-path success** on the hosted checkout — they authenticate without an OTP\nand settle to `provider_completed`:\n\n| Card number | Brand |\n|-------------|-------|\n| `4000 0000 0000 2701` | Visa — frictionless `Y` |\n| `5200 0000 0000 2235` | Mastercard — frictionless `Y` |\n\n### Cards that trigger a 3DS challenge (`threeDResult=C`)\n\nThese redirect to the processor's 3DS **emulator**, where you select the authentication\nstatus/reason to test (choose `Y` to complete successfully):\n\n| Card number | Brand |\n|-------------|-------|\n| `4000 0000 0000 2503` / `4000 0000 0000 2370` / `4000 0000 0000 2420` | Visa — challenge |\n| `5200 0000 0000 2490` / `5200 0000 0000 2151` / `5200 0000 0000 2664` | Mastercard — challenge |\n\n### Cards that fail authentication (for decline/UI testing)\n\n| Card number | Outcome |\n|-------------|---------|\n| `4530 9100 0001 2345`, `4500 0300 0000 0004`, `4037 1122 3300 0001` | `threeDResult=U` (unable to authenticate) → risk-rule rejected |\n| `4000 0000 0000 2925` | `threeDResult=N` (not enrolled) |\n\nUnless noted otherwise, use:\n\n- **Expiry month:** any future month, 2 digits (e.g. `12`)\n- **Expiry year:** any future year. **4-digit (`2028`) is recommended.** A 2-digit year\n  (`28`) is also accepted — Fynex widens it to `2028` before calling the processor.\n- **CVV / CVC:** any 3-digit number (e.g. `123`); American Express CID: any 4-digit number\n- **Cardholder name:** Latin letters (`A-Z`), spaces, apostrophes, dots, or hyphens only\n\n\u003e [!NOTE]\n\u003e The card numbers listed in this section are the set Fynex supports; the upstream sandbox can\n\u003e add or retire PANs without notice, so email support@fynex.ai if one stops behaving as documented.\n\u003e On a **Live** account a real issuer returns `Y` for a genuine card, so the `U`/`N` failure modes\n\u003e above are sandbox-only artifacts of the test PANs.\n\n---\n\n## Common test scenarios\n\n#### Happy path — no 3DS\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"TEST-HAPPY-1\",\n    \"amount\": 19.99,\n    \"paymentType\": \"card\",\n    \"paymentMethod\": \"card\",\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"autoSettlement\": true,\n    \"skip3DS\": true,\n    \"cardData\": {\n      \"cardNumber\": \"4111111111111111\",\n      \"expMonth\": 12,\n      \"expYear\": 2028,\n      \"holderName\": \"Test User\",\n      \"cvv\": \"123\"\n    },\n    \"returnLinks\": [\n      { \"rel\": \"default\", \"href\": \"https://example.com/return\", \"method\": \"GET\" }\n    ]\n  }'\n```\n\nThe response has `requiresAction: false` and the payment transitions to `provider_completed`.\nPoll `genericPayment` to confirm before fulfilling the order.\n\n\u003e [!NOTE]\n\u003e `countryCode` is required and, if your terminal pins a country, must match it. `returnLinks`\n\u003e (note: not `returnUrls`) is required on the server-to-server flow — each link needs\n\u003e `rel` ∈ `default | on_completed | on_failed | on_cancelled`, an `http`/`https` `href`, and\n\u003e `method: \"GET\"`. The hosted checkout flow uses `returnUrls` at session creation instead.\n\n#### 3DS challenge\n\nSend the same request **without** `skip3DS` (or with `\"skip3DS\": false`). The response is:\n\n```json\n{\n  \"paymentId\": \"TEST-3DS-1\",\n  \"status\": \"provider_pending\",\n  \"requiresAction\": true,\n  \"actionUrl\": \"https://3ds.example.com/.../authentication/...\"\n}\n```\n\nRedirect the customer's browser to `actionUrl`, let them complete the sandbox challenge, then\ncall `POST /finalize-payment` with the same `paymentId`. See the [3DS guide](#tag/3ds) for the\nfull localStorage bridge pattern.\n\n#### Decline / failure UI testing\n\nA payment that the processor declines (or a 3DS challenge the customer fails) transitions to\n`failed`; a customer who abandons the challenge returns `cancelled`. When you poll for status,\nthe response includes:\n\n```json\n{\n  \"paymentId\": \"TEST-DECLINE-1\",\n  \"status\": \"failed\",\n  \"failureCode\": 2001,\n  \"failureDescription\": \"Card declined by issuer\"\n}\n```\n\n| Field | Purpose |\n|-------|---------|\n| `status` | `\"failed\"` for hard declines; `\"cancelled\"` if the customer cancelled |\n| `failureCode` | Numeric code — see the [Errors guide](#tag/errors) for the full list |\n| `failureDescription` | Human-readable reason — do not display verbatim to customers |\n\n\u003e [!CAUTION]\n\u003e Distinguish `\"cancelled\"` from `\"failed\"`. A customer who clicks \"Back\" or abandons the\n\u003e form returns a `cancelled` status — show a neutral \"payment not completed\" message rather\n\u003e than an error.\n\n#### Refund testing\n\n1. Pay and wait for the payment to reach a refundable status (`provider_completed`, `settled`, or `deposit_confirmed` — see [Captures \u0026 Refunds](#tag/captures-refunds)).\n2. Issue the refund using the public endpoint with your `externalOrderRef` as the path param:\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/payments/TEST-REFUND-1/refund\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{ \"amount\": 19.99 }'\n```\n\n\u003e [!CAUTION]\n\u003e Only captured/settled payments can be refunded — pre-capture refund attempts return `409 \"refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments\"`. Omit `amount` to refund the remaining refundable amount, or pass a smaller value for a partial refund.\n\n#### Partial capture\n\nCreate a payment with `autoSettlement: false` (or `captureMode: \"manual\"`), then capture less\nthan the authorized amount:\n\n```bash\n# Authorize £50\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"TEST-PARTIAL-1\",\n    \"amount\": 50.00,\n    \"paymentType\": \"card\",\n    \"paymentMethod\": \"card\",\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"autoSettlement\": false,\n    \"captureMode\": \"manual\",\n    \"skip3DS\": true,\n    \"cardData\": {\n      \"cardNumber\": \"4111111111111111\",\n      \"expMonth\": 12,\n      \"expYear\": 2028,\n      \"holderName\": \"Test User\",\n      \"cvv\": \"123\"\n    },\n    \"returnLinks\": [\n      { \"rel\": \"default\", \"href\": \"https://example.com/return\", \"method\": \"GET\" }\n    ]\n  }'\n\n# Capture only £35\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/finalize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{ \"paymentId\": \"TEST-PARTIAL-1\", \"amount\": 35.00 }'\n```\n\nThe response `capturedAmount` will be `35.00`. The remaining £15 is released back to the\ncustomer's card.\n\n---\n\n## Test customer data\n\nThe sandbox does not validate billing data against real services. Use any\nwell-formed values:\n\n| Field | Suggested test value | Notes |\n|-------|---------------------|-------|\n| Email | `test@example.com` | Any `*@example.com` address works; no emails are sent from sandbox |\n| Billing name | `Test User` | Any non-empty string |\n| Billing address | `1 Test Street, London, EC1A 1BB, GB` | Staging does not run AVS checks against real addresses |\n| Phone | `+44 7700 900000` | Any well-formed `+44` number; not validated |\n| Postcode / ZIP | `EC1A 1BB` | Not validated by AVS in sandbox |\n\n---\n\n## What does NOT work in sandbox\n\n| Feature | Status | Notes |\n|---------|--------|-------|\n| Real customer email receipts | **Sent on a Demo account** | Suppressed on `staging-api.fynex.ai` only. There is no operational-mode gate on the email path, so a Demo account on production sends real mail to whatever address you supply — use addresses you control when testing |\n| KYB / identity checks | Separate sandbox | Contact your Fynex representative for a KYB sandbox link; it is independent of the payments sandbox |\n| Apple Pay merchant validation | Faked | `completeMerchantValidation({})` is accepted in sandbox but will fail against real Apple Pay; a real server-side merchant validation endpoint is required for production |\n| Google Pay in production mode | Not available | The dashboard uses `environment: 'TEST'`; switch to `environment: 'PRODUCTION'` only when going live |\n\n---\n\n## Sandbox limits\n\n| Limit | Value | Notes |\n|-------|-------|-------|\n| Rate limiting | Enforced | The same mechanism as production, with its own budget — read `X-RateLimit-Remaining`; tell Fynex before high-volume load testing |\n| Maximum test amount | £10,000 (recommended) | No hard cap is enforced by Fynex, but use reasonable amounts; very large amounts may be rejected by the upstream sandbox's own rules |\n| Minimum test amount | £0.01 | |\n| Session expiry | Set server-side | Check `expiresAt` in the checkout session response |\n\n---\n\n## See also\n\n- **[Quickstart](#tag/quickstart)** — Take your first test payment in 5 minutes.\n- **[Hosted checkout](#tag/hosted-checkout)** — Full hosted checkout reference — optional fields, return URL handling, and more.\n- **[Server-to-server](#tag/server-to-server)** — POST /initialize-payment and POST /finalize-payment, including 3DS handling.\n- **[3DS Authentication](#tag/3ds)** — The redirect lifecycle, localStorage bridge, and finalize call.\n- **[Alternative payment methods (APM)](#tag/alternative-payment-methods-apm)** — Sandbox steps for Bancontact, Multibanco, MB WAY, Wero, and Swish.\n- **[Errors](#tag/errors)** — HTTP status codes, failure codes, and error response shapes.\n","name":"Test cards"},{"description":"Fynex's data model revolves around a small set of domain entities. Understanding these entities and their relationships is the fastest way to make sense of the REST and GraphQL surfaces.\n\nEntities are grouped by concern. For each one you will find: a definition, where it appears in the API (REST endpoint, GraphQL query/mutation, or internal-only), and the key fields a partner needs.\n\n---\n\n## Account \u0026 configuration\n\nThese entities define who you are in Fynex and how payments are routed.\n\n### SellerAccount\n\nA seller's top-level commercial profile in Fynex. One account owns the API bearer token, one or more wallets, terminals, and split rules.\n\n**API surface:** GraphQL query `sellerAccount(id)` / `sellerAccounts`. Token is rotated via `regenerateSellerAccountToken(id)`. Not directly accessible via REST — the bearer token implicitly identifies the account on every request.\n\n**Key fields:**\n- `authorizationToken` — the bearer token used on all `Authorization: Bearer` REST calls\n- `operationalMode` — `demo` or `live`; governs which provider environment receives payments\n- `id` (int) — required for GraphQL mutations that target a specific account\n\n\n---\n\n### Terminal\n\nA processor-bound acceptance point — for example, a specific upstream-processor MID. Each terminal is tied to one `PaymentPartner`, a currency, and a set of allowed instruments. The routing engine selects a terminal for each payment.\n\n**API surface:** GraphQL queries `terminal(id)` / `terminals`. Internal-only for configuration; partners do not reference terminals directly in REST requests.\n\n**Key fields:**\n- `paymentPartner` — the upstream PSP this terminal belongs to\n- `currencyCode` — currency this terminal accepts\n- `paymentMethods` — instrument types (card, google_pay, apple_pay, bank_account)\n\n\n---\n\n### PaymentPartner\n\nThe upstream payment service provider (PSP) routing the request. Partners do not interact with `PaymentPartner` directly; it surfaces in response fields like `providerCode` on payment responses.\n\n**API surface:** GraphQL query (backoffice/internal). The value appears in REST responses as the `providerCode` string — treat its concrete values as platform-managed; check the actual response for the resolved provider.\n\n\n---\n\n### PaymentConfiguration\n\nThe seller-level allowed payment methods, currencies, and rails. Determines what `GET /payment-methods` returns. Configuration is managed by Fynex on your behalf during onboarding.\n\n**API surface:** GraphQL queries `paymentConfiguration(id)` / `paymentConfigurations`. The effective values are surfaced via `GET /payments-api/v1/payment-methods`.\n\n**Key fields:**\n- `allowedPaymentMethods` — instrument types enabled for this seller\n- `allowedCurrencies` — supported ISO-4217 currency codes\n- `allowedPaymentRails` — `card`, `bank_transfer`\n\n\n---\n\n## Money in\n\nThese entities represent inbound payment flows.\n\n### GenericPayment\n\nThe canonical payment object across all providers. Created by `POST /initialize-payment` (or internally when a hosted checkout session is submitted). Carries the full payment lifecycle from `draft` through to `settled` or `failed`.\n\n**API surface:** REST — created via `POST /initialize-payment`, acted on by `POST /capture` and `POST /refund`. GraphQL queries `genericPayment(id)` / `genericPayments(...)`.\n\n**Key fields:**\n- `externalOrderRef` — your order reference; returned as `paymentId` in REST responses and used as the path parameter for capture/refund\n- `status` — see [Payment Lifecycle](#tag/payment-lifecycle) for all values\n- `amount` — in major units via REST responses; stored in minor units internally\n- `captureMode` — `auto` or `manual`\n- `requiresAction` / `actionUrl` — set when a 3DS redirect is needed (see [3DS Authentication](#tag/3ds))\n- `failureCode` / `failureDescription` — populated when status is `failed`\n- `threeDs` — 3DS authentication result object (version, ECI, CAVV, liability shift)\n\n\n---\n\n### CheckoutSession\n\nThe database row created when you call `POST /checkout` to start a hosted checkout. It is backed by a `draft` `GenericPayment`. The session carries the `sessionId` (UUID) used in all subsequent browser-side checkout routes (`/checkout/{session_id}/*`).\n\n**API surface:** REST — created via `POST /payments-api/v1/checkout`; subsequent browser-side routes (`/checkout/{session_id}/initialize`, `/checkout/{session_id}/finalize`, `/checkout/{session_id}/poll`, `/checkout/{session_id}/events`) are consumed by the hosted page, not directly by partners. Not exposed via GraphQL queries.\n\n**Key fields:**\n- `sessionId` — UUID returned on checkout creation; embed in the `checkoutUrl`\n- `checkoutUrl` — the URL to redirect the customer to\n- `returnUrls.success` / `returnUrls.failure` — where the hosted page redirects after completion\n\n\u003e [!NOTE]\n\u003e `CheckoutSession` is internal-only on the GraphQL surface. Use the REST response from `POST /checkout` to obtain the `sessionId` and `checkoutUrl`.\n\n---\n\n### BillingDetails\n\nThe customer-side billing identity attached to a payment — name, email, phone, and address. Passed as `billingDetails` in `POST /initialize-payment` and stored on `GenericPayment`.\n\n**API surface:** Nested field in REST request and response bodies; also surfaced as a sub-type in GraphQL `GenericPayment`.\n\n**Key fields:**\n- `firstName`, `lastName`, `email`, `phone`\n- `addressLine1`, `city`, `postalCode`, `countryCode`\n- Aliases accepted on input: `street` = `addressLine1`, `zip` = `postalCode`, `country` = `countryCode`\n\n\n---\n\n### SavedCard\n\nA tokenized card, optionally linked to a `SellerCustomer`, for use in merchant-initiated (MIT) or recurring payments. Created automatically by the hosted checkout flow when the customer opts in to saving their card.\n\n**API surface:** Internal — referenced in recurring payment flows. The token itself is held by the upstream card processor; Fynex stores only an opaque reference. Partners trigger saved-card payments by passing a `merchantCustomerId` on `/initialize-payment` with `subscription.enabled: true`.\n\n---\n\n### SellerCustomer\n\nA repeat customer under a specific seller, used for recurring billing. Linked to a `SavedCard` and identified by the seller's own `merchantCustomerId`.\n\n**API surface:** Internal — created automatically when a hosted checkout saves a card. Referenced on `/initialize-payment` via `merchantCustomerId` field.\n\n**Key fields:**\n- `sellerCustomerRef` — the `merchantCustomerId` you passed on `/initialize-payment`\n- `sellerAccountId` — the owning seller\n\n---\n\n## Money out\n\nThese entities represent outbound payment flows to payees.\n\n### Payout\n\nA wallet-to-payee transfer requested by the seller. Sent via the banking provider. Each payout debits a specific wallet and credits a specific `PayoutMethod`.\n\n**API surface:** REST — `POST /payments-api/v1/payouts` (create), `GET /payouts` (list), `GET /payouts/{id}` (get). GraphQL queries `payout(id)` / `payouts(...)`.\n\n**Key fields:**\n- `amountMinor` — amount in minor units (e.g. `4999` for £49.99); note this differs from payment endpoints which use major units\n- `currencyCode`\n- `walletId` — source wallet\n- `payoutMethodId` — destination bank account\n- `idempotencyKey` — passed in the **request body** (not the `Idempotency-Key` header; the header is still required)\n- `status` — `pending`, `processing`, `completed`, `failed`, `cancelled`\n\n\n---\n\n### PayoutMethod\n\nA registered bank-account destination for a payee — IBAN, sort code/account number, or other bank-transfer credentials. A payee can have multiple payout methods.\n\n**API surface:** REST `GET /payments-api/v1/payees/{payee_id}/payout-methods`. GraphQL queries `payoutMethod(id)` / `payoutMethods(payeeId, ...)` and mutations `createPayoutMethod`, `updatePayoutMethod`, `deletePayoutMethod`.\n\n**Key fields:**\n- `payeeId` — the owning payee\n- `currency` — supported currency for this method\n- `status` — `Active` or `Disabled`; only active methods appear in REST listing\n\n\n---\n\n### Payee\n\nA counterparty under a seller — a sub-merchant, marketplace seller, or any recipient of split funds or payouts. Each payee has a role and can have multiple `PayoutMethod` records.\n\n**API surface:** REST `GET /payments-api/v1/payees` (list). GraphQL queries `payee(id)` / `payees` and mutations `createPayee`, `updatePayee`, `deletePayee`.\n\n**Key fields:**\n- `displayName` — human-readable label\n- `role` — `Itself` (the seller itself), `Contractor`, `Tax`, or `fynex_platform`\n- `status` — `Active` or `Disabled`\n\n\n---\n\n### VirtualAccount\n\nA customer pay-in IBAN issued via the virtual-account provider. Customers can be given a dedicated IBAN to send funds directly into the seller's account without a card payment.\n\n**API surface:** GraphQL queries `virtualAccount(id)` / `virtualAccounts(first, after, type)`.\n\n**Key fields:**\n- `iban` — the virtual IBAN assigned to this account\n- `currency` — account currency\n- `type` — virtual account type\n\n\n---\n\n## Balances\n\nThese entities represent the seller's internal ledger.\n\n### Wallet\n\nA per-seller, per-currency balance ledger. Settlement of card payments credits the relevant wallet. Payouts debit from it.\n\n**API surface:** GraphQL queries `wallet(id)` / `wallets(...)` and mutations `createWallet`, `updateWallet`.\n\n**Key fields:**\n- `currencyCode` — one wallet per currency\n- `balance` — current available balance\n- `sellerAccountId`\n\n\n---\n\n### WalletEntry\n\nAn append-only ledger entry on a wallet — every credit or debit is recorded as an immutable entry. Used for reconciliation.\n\n**API surface:** GraphQL (nested within `Wallet`).\n\n**Key fields:**\n- `amount` — signed; positive = credit, negative = debit\n- `type` — entry type (settlement, payout, fee, etc.)\n- `referenceId` — links back to the originating `GenericPayment` or `Payout`\n\n\n---\n\n### WalletTransfer\n\nAn inter-wallet movement — for example, when a split execution moves funds from the seller wallet to a payee wallet.\n\n**API surface:** GraphQL (nested within `Wallet`).\n\n**Key fields:**\n- `fromWalletId` / `toWalletId`\n- `amount`\n- `referenceType` — indicates the source (split execution, manual transfer, etc.)\n\n\n---\n\n## Splits\n\nThese entities distribute payment proceeds across multiple payees.\n\n### SplitRule\n\nA per-seller rule that distributes a settled payment's proceeds across one or more payees. Each rule has one or more `SplitRuleLine` entries.\n\n**API surface:** GraphQL queries `splitRule(id)` / `splitRules(...)` and mutations `createSplitRule`, `updateSplitRule`, `deleteSplitRule`.\n\n**Key fields:**\n- `name` — descriptive name for this rule\n- `sellerAccountId`\n- `lines` — list of `SplitRuleLine`\n\n\n---\n\n### SplitRuleLine\n\nOne line within a `SplitRule` — specifies a payee and their share (by amount or percentage).\n\n**API surface:** Nested within `SplitRule` in GraphQL.\n\n**Key fields:**\n- `payeeId` — recipient\n- `amount` or `percentage` — the payee's share\n- `currencyCode`\n\n\n---\n\n### SplitExecution\n\nA historical record of a split rule being applied to a specific settled payment. Created automatically when a payment reaches `settled` and a matching split rule exists.\n\n**API surface:** GraphQL query `splitExecutions(dateFrom, dateTo, sellerAccountId)`.\n\n**Key fields:**\n- `splitRuleId` — the rule that was applied\n- `genericPaymentId` — the payment that triggered the execution\n- `executedAt`\n- `lines` — actual amounts distributed per payee\n\n\n## See also\n\n- **[Quickstart](#tag/quickstart)** — Take your first payment in 5 minutes.\n- **[Payment Lifecycle](#tag/payment-lifecycle)** — All GenericPayment and Payout statuses and transitions.\n- **[Payouts](#tag/payouts)** — Create and track wallet-to-payee payouts.\n- **[Hosted Checkout](#tag/hosted-checkout)** — Use the hosted checkout page to accept payments without handling card data.\n","name":"Concepts"},{"description":"This page is the authoritative reference for status values and transitions. Whenever a response body contains a `status` field, the values come from one of the two enums described here.\n\n---\n\n## GenericPayment status\n\n### Status values\n\n| Status | Meaning | Terminal? |\n|--------|---------|-----------|\n| `draft` | Payment row created but not yet submitted to any processor (e.g. a hosted-checkout session opened but the customer has not submitted card data). | No |\n| `new` | Submitted to the processing pipeline; routing not yet resolved. | No |\n| `routed` | A terminal (processor + MID) has been selected; submission to the provider is imminent. | No |\n| `provider_pending` | Request sent to the upstream provider; waiting for an async confirmation or 3DS challenge. **This is the status returned when `requiresAction: true`** — the payment waits here until `/finalize-payment` is called. | No |\n| `authorized` | Provider has authorized the funds but not yet captured them. Capture is required before settlement. Eligible for `POST /capture`. | No |\n| `provider_completed` | Provider confirmed capture. Also eligible for `POST /capture` (some providers report completion at this stage). | No |\n| `provider_risk_review` | The provider has placed the payment into a risk-review queue. Fynex waits for a review decision before proceeding. | No |\n| `funds_in_flight` | Captured funds are moving through the settlement pipeline. | No |\n| `settled` | Funds have been received into the operational account. Refunds become eligible at this point. | No |\n| `deposit_confirmed` | Settlement confirmed at the safeguarding/deposit layer. Refunds remain eligible. | No |\n| `refund_pending` | A refund has been requested and is being processed by the provider. | No |\n| `refunded` | Refund completed successfully. | **Yes** |\n| `refund_failed` | Refund attempt failed. The engine can re-queue to `refund_pending`. | No |\n| `refund_cancelled` | Refund was cancelled. The engine can re-queue to `refund_pending`. | No |\n| `failed` | Payment failed at any stage. See `failureCode` and `failureDescription` in the response for details. | **Yes** |\n| `cancelled` | Payment was cancelled (by merchant or system) before completion. | **Yes** |\n\n### What triggers each status\n\n| Transition | Trigger |\n|------------|---------|\n| `draft` → `new` | `POST /initialize-payment` is processed; routing starts. |\n| `new` → `routed` | Internal routing engine selects a terminal. |\n| `routed` → `provider_pending` | Payment sent to provider; 3DS or async response required. |\n| `provider_pending` → `authorized` | `POST /finalize-payment` called after 3DS; provider confirms authorization. |\n| `provider_pending` → `failed`/`cancelled` | Customer cancels 3DS, OTP fails, issuer declines, or system timeout. |\n| `authorized` → `provider_completed` | `POST /payments/{id}/capture` called (manual-capture mode) or auto-capture by provider. |\n| `provider_completed` → `funds_in_flight` | Settlement pipeline picks up the captured payment. |\n| `funds_in_flight` → `settled` | Operational account receives the funds. |\n| `settled` → `deposit_confirmed` | Safeguarding layer confirms the deposit. |\n| `provider_completed`/`settled`/`deposit_confirmed` → `refund_pending` | `POST /payments/{id}/refund` called. |\n| `refund_pending` → `provider_completed` | Provider confirms a partial refund while refundable balance remains. |\n| `refund_pending` → `refunded` | Provider confirms the cumulative refunded amount equals the captured amount. |\n| `refund_pending` → `refund_failed`/`refund_cancelled` | Provider rejects or system cancels the refund. |\n| `refund_failed`/`refund_cancelled` → `refund_pending` | Refund can be retried. |\n| any non-terminal → `failed`/`cancelled` | Provider decline, system error, or merchant cancellation. |\n\n### Capture pre-conditions\n\n`POST /payments/{id}/capture` enforces two pre-conditions:\n\n1. `captureMode` must be `manual` (set on `/initialize-payment`). Auto-settlement payments return `409`.\n2. Current status must be `authorized` or `provider_completed`. Any other status returns `409`.\n\n### ASCII state diagram\n\n```\n                     ┌──────────────────────────────────────────────┐\n                     │              POST /initialize-payment          │\n                     ▼                                                │ idempotent\n                   draft                                             │ replay\n                     │                                               │\n                     ▼\n                    new\n                     │\n                     ▼\n                  routed\n                     │\n                     ▼\n            provider_pending ───────────────────────────► failed\n                     │                                       ▲\n   POST /finalize-   │                          cancelled ◄──┤ (any stage)\n   payment (3DS ok)  │                                        │\n                     ▼\n               authorized ──────────────────────────────────►│\n                     │   POST /capture                        │\n                     ▼                                        │\n          provider_completed                                  │\n                     │────────────── POST /refund ───────────▼\n          provider_risk_review ─────────────────────────────►│\n                     │                                        │\n                     ▼                                        │\n            funds_in_flight                                   │\n                     │                                        │\n                     ▼\n                  settled ─────────────────────────────────►deposit_confirmed\n                     │                                              │\n              POST /refund                                   POST /refund\n                     │                                              │\n                     ▼                                              ▼\n             refund_pending ◄──────────── refund_failed / refund_cancelled\n                     │\\\\\n       partial success│ \\\\ full cumulative refund\n                     ▼  ▼\n          provider_completed refunded  (terminal)\n```\n\n---\n\n## Payout status\n\nPayouts represent wallet-to-payee transfers sent via the banking provider.\n\n### Status values\n\n| Status | Meaning | Terminal? |\n|--------|---------|-----------|\n| `pending` | Payout created; funds held in the wallet; not yet submitted to the banking provider. | No |\n| `processing` | Payout submitted to the banking provider. | No |\n| `completed` | The banking provider confirmed the payment reached the destination. | **Yes** |\n| `failed` | The banking provider rejected the payment or an error occurred. See `failureCode` and `failureDescription`. | **Yes** |\n| `cancelled` | Payout was cancelled before it reached the banking provider. | **Yes** |\n\n### Status transitions\n\n| From | To | Trigger |\n|------|----|---------|\n| `pending` | `processing` | Internal dispatcher submits to the banking provider. |\n| `processing` | `completed` | The banking provider's webhook confirms success. |\n| `processing` | `failed` | The banking provider rejects or a timeout occurs. |\n| `pending` | `cancelled` | Seller or system cancels before dispatch. |\n\n### ASCII state diagram\n\n```\nPOST /payouts\n      │\n      ▼\n   pending\n      │\n      ▼\n  processing\n      │\n   ┌──┴──────────┐\n   ▼             ▼\ncompleted      failed   (both terminal)\n\npending ──► cancelled   (terminal, if cancelled before dispatch)\n```\n\n### Payout amount field\n\nPayout amounts use **minor units** in the API wire format (`amountMinor`). This differs from payment endpoints, which use major units. For example, £49.99 is `4999` in a payout request.\n\n---\n\n## Cross-reference: actions and status transitions\n\n| API action | Required pre-status | Resulting status |\n|-----------|---------------------|-----------------|\n| `POST /initialize-payment` | — (new payment) | `provider_pending` (if 3DS needed) or `authorized`/`provider_completed` (if frictionless) |\n| `POST /finalize-payment` | `provider_pending` | `authorized` or `provider_completed` (on success) / `failed`/`cancelled` (on provider decline) |\n| `POST /payments/{id}/capture` | `authorized` or `provider_completed` | `provider_completed` → continues toward `funds_in_flight` |\n| `POST /payments/{id}/refund` | `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled` | `refund_pending` |\n| `POST /payouts` | — (new payout) | `pending` |\n\n\u003e [!CAUTION]\n\u003e There are no `captured` or `partially_captured` statuses. Partial capture is supported by passing a lower `amount` to `POST /capture`, but the status after capture is always `provider_completed`.\n\n---\n\n## Failure codes\n\nWhen a payment reaches `failed`, the response includes a numeric `failureCode`:\n\n| Code | Name | Stage |\n|------|------|-------|\n| `0` | None | — |\n| `1001` | Validation failed | validation |\n| `1002` | Risk rejected | risk |\n| `1003` | Routing failed | routing |\n| `2001` | Provider declined | authorization |\n| `2002` | Provider error | authorization |\n| `2003` | Provider timeout | authorization |\n| `3001` | Capture failed | capture |\n| `4001` | Settlement failed | settlement |\n| `5001` | Deposit-confirm timeout | deposit_confirm |\n| `9001` | Cancelled by merchant | — |\n| `9002` | Cancelled by system | — |\n| `9999` | Unknown | — |\n\nThe `failureDescription` field carries a human-readable message from the provider. The `failureStage` field (`validation`, `risk`, `routing`, `authorization`, `capture`, `settlement`, `deposit_confirm`) tells you where in the pipeline the failure occurred.\n\n---\n\n## Polling for status\n\nFynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). Polling remains available as a backstop. To track status changes:\n\n- Poll `genericPayment(id)` via GraphQL (dashboard-authenticated).\n- Poll `GET /payments-api/v1/payouts/{id}` for payout status.\n- While a customer is on the hosted checkout page, the browser can use the SSE stream at `GET /checkout/{session_id}/events`.\n\nSee [Polling \u0026 SSE](#tag/polling-sse) for details.\n\n## See also\n\n- **[Captures \u0026 Refunds](#tag/captures-refunds)** — How to capture authorized payments and issue refunds.\n- **[Payouts](#tag/payouts)** — Create and track wallet-to-payee payouts.\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Monitor payment status changes in real time.\n- **[3DS Authentication](#tag/3ds)** — Handle 3DS challenges in the server-to-server flow.\n","name":"Payment lifecycle"},{"description":"`GET /payments-api/v1/payment-methods` is the first call a server integration should make.\nIt returns the exact set of instruments, currencies, and payment rails your seller account\nhas been configured for — the intersection of your account settings and what the active\nterminals support. Use this response to drive your checkout UI rather than hard-coding\nassumptions.\n\n## Request\n\n| Aspect | Value |\n|--------|-------|\n| Method | `GET` |\n| Path | `/payments-api/v1/payment-methods` |\n| Auth | `Authorization: Bearer \u003ctoken\u003e` |\n| Body | None |\n| Query params | None |\n\nNo `Idempotency-Key` is required — this is a read-only endpoint.\n\n#### curl\n\n```bash\ncurl -sS \"$FYNEX_API/payments-api/v1/payment-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n#### JavaScript\n\n```js\nexport async function getPaymentMethods() {\n  const res = await fetch(\n    `${process.env.FYNEX_API}/payments-api/v1/payment-methods`,\n    {\n      headers: {\n        Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n      },\n    }\n  );\n  if (!res.ok) throw new Error(await res.text());\n  return res.json();\n}\n```\n\n#### Python\n\n```python\nimport os\nimport requests\n\ndef get_payment_methods() -\u003e dict:\n    res = requests.get(\n        f\"{os.environ['FYNEX_API']}/payments-api/v1/payment-methods\",\n        headers={\"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\"},\n        timeout=10,\n    )\n    res.raise_for_status()\n    return res.json()\n```\n\n## Response\n\n**`200 OK`** — returns `dtos.PaymentMethodsResponse`:\n\n```json\n{\n  \"sellerAccountId\": 42,\n  \"allowedPaymentMethods\": [\"card\", \"google_pay\", \"apple_pay\"],\n  \"allowedCurrencies\": [\"GBP\", \"EUR\"],\n  \"allowedPaymentRails\": [\"card\"]\n}\n```\n\n### Response fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `sellerAccountId` | `integer` | The numeric ID of the authenticated seller account. |\n| `allowedPaymentMethods` | `string[]` | Payment instruments enabled for this account. See [Payment instruments](#payment-instruments) below. |\n| `allowedCurrencies` | `string[]` | ISO 4217 currency codes supported by this account. See [Currencies](#currencies) below. |\n| `allowedPaymentRails` | `string[]` | Active payment rails. Omitted (`omitempty`) when no rails are configured. See [Payment rails](#payment-rails) below. |\n\n## Enum values\n\n### Payment instruments\n\nValues from `model.PaymentInstrumentType`:\n\n| Value | Description |\n|-------|-------------|\n| `card` | Standard card payment (Visa, Mastercard, Amex, etc.) |\n| `bank_account` | Bank account / direct debit |\n| `google_pay` | Google Pay (tokenised card via Google's wallet) |\n| `apple_pay` | Apple Pay (tokenised card via Apple's wallet) |\n| `bancontact` | Bancontact (Belgian local scheme, `apm` rail) |\n| `multibanco` | Multibanco (Portuguese reference/voucher payment, `apm` rail) |\n| `mbway` | MB WAY (Portuguese mobile-app payment, `apm` rail) |\n| `wero` | Wero (European account-to-account wallet, `apm` rail) |\n| `swish` | Swish (Swedish mobile payment, `apm` rail) |\n\n### Currencies\n\nValues from `model.CurrencyCode`. All 3-letter ISO 4217:\n\n| Code | Currency |\n|------|----------|\n| `EUR` | Euro |\n| `USD` | US Dollar |\n| `GBP` | British Pound |\n| `DKK` | Danish Krone |\n| `NOK` | Norwegian Krone |\n| `SEK` | Swedish Krona |\n\n### Payment rails\n\nValues from `model.PaymentRailType`:\n\n| Value | Description | Instruments |\n|-------|-------------|-------------|\n| `card` | Card network processing | `card`, `google_pay`, `apple_pay` |\n| `bank_transfer` | Bank transfer / account-to-account | `bank_account` |\n| `apm` | Alternative / local payment methods (redirect-based) | `bancontact`, `multibanco`, `mbway`, `wero`, `swish` |\n\nThe instrument-to-rail mapping is enforced by the server: a `card` rail will only accept\n`card`, `google_pay`, or `apple_pay` instruments; `bank_transfer` only accepts `bank_account`;\nthe `apm` rail carries the local-scheme instruments (`bancontact`, `multibanco`, `mbway`,\n`wero`, `swish`).\n\n\u003e [!NOTE]\n\u003e The `apm` instruments are **redirect-based**: accept them either server-to-server via\n\u003e `POST /initialize-payment` with `paymentType: \"apm\"` (follow the returned redirect action),\n\u003e or through Fynex-hosted checkout. See [Alternative payment methods\n\u003e (APM)](#tag/alternative-payment-methods-apm) for both flows and the per-checkout\n\u003e availability rules.\n\n## Status codes\n\n| Status | When |\n|--------|------|\n| `200` | Success |\n| `401` | Missing or invalid bearer token |\n| `404` | Payment configuration not found for this seller account |\n| `500` | Service dependency unavailable or terminal load failure |\n\n## Using the response to drive your checkout UI\n\n\u003e [!NOTE]\n\u003e Always fetch payment methods at session start and render **only** what the response\n\u003e contains. Do not hard-code which instruments or currencies to show — your account\n\u003e configuration can change without a code deployment.\n\n1. **Fetch on server startup or per-request**\n\n   Call `GET /payment-methods` when your server starts (and cache for a short period),\n   or fetch it once per checkout session before rendering the payment form.\n\n2. **Render only enabled instruments**\n\n   ```js\n   const { allowedPaymentMethods, allowedCurrencies } = await getPaymentMethods();\n\n   // Show card form only if enabled\n   const showCard = allowedPaymentMethods.includes('card');\n\n   // Show Google Pay button only if enabled and browser supports it\n   const showGooglePay =\n     allowedPaymentMethods.includes('google_pay') \u0026\u0026 await isGooglePayReady();\n\n   // Show Apple Pay button only if enabled and browser supports it\n   const showApplePay =\n     allowedPaymentMethods.includes('apple_pay') \u0026\u0026 isApplePayAvailable();\n\n   // Populate currency selector from the live list\n   renderCurrencySelect(allowedCurrencies);\n   ```\n\n3. **Pass the correct instrument and rail when initializing a payment**\n\n   When you call `POST /payments-api/v1/initialize-payment` or\n   `POST /payments-api/v1/checkout`, the `paymentType` (rail) and `paymentMethod`\n   (instrument) fields must be values the server already told you are allowed.\n   Sending a disallowed combination returns `400 Bad Request`.\n\n\u003e [!CAUTION]\n\u003e `allowedPaymentRails` uses `omitempty` — it will be absent from the response if no\n\u003e rails are configured on the account. Always check for the key's presence before\n\u003e reading it; don't assume an empty array.\n\n## See also\n\n- **[Hosted checkout](#tag/hosted-checkout)** — Accept payments by redirecting customers to a Fynex-hosted page.\n- **[Server-to-server payments](#tag/server-to-server)** — Collect card details in your own UI and call the API directly.\n","name":"Payment methods"},{"description":"A **wallet** is Fynex's per-currency balance ledger for a seller account. Each seller can hold multiple wallets — one per currency — and every settlement, payout, and split execution produces append-only ledger entries on the relevant wallet.\n\n\u003e [!NOTE]\n\u003e **Two ways to read wallets.** This guide documents the **GraphQL** surface (`POST /dashboard/graphql`, cookie-session authenticated), which exposes the full ledger including individual `WalletEntry` rows and `WalletTransfer` records. There is also a **seller-bearer-token REST** surface for integrators who only need balances and transactions:\n\u003e - `GET /payments-api/v1/wallets` — list the seller's wallets with balance snapshots\n\u003e - `GET /payments-api/v1/wallets/{wallet_id}` — a single wallet\n\u003e - `GET /payments-api/v1/wallets/{wallet_id}/transactions` — that wallet's transactions\n\u003e\n\u003e The REST surface uses your `Authorization: Bearer \u003cseller_token\u003e` (no cookie), is seller-scoped, hides Fynex system wallets, and returns balances as `availableBalanceMinor` / `heldBalanceMinor` / `pendingBalanceMinor` / `totalBalanceMinor` (minor units). See [GraphQL Authentication](#tag/graphql-auth) for the cookie flow used by the GraphQL queries below.\n\n---\n\n## Wallet types\n\nThe `WalletType` enum describes the role of each wallet:\n\n| Type | Purpose |\n|------|---------|\n| `MAIN` | Primary seller balance — most payments settle here |\n| `SELLER_OPERATIONAL` | Operational float held by the seller |\n| `PAYOUT` | Funds earmarked for outgoing payouts |\n| `PLATFORM_COMMISSION` | Fynex platform fee collection wallet |\n| `TAX` | Tax reserve wallet |\n| `OTHER` | Custom-purpose wallet |\n| `EXTERNAL_SAFEGUARDING_ACCOUNT` | Funds held in a safeguarding account |\n| `FYNEX_OPERATIONAL` | Internal Fynex operational ledger |\n| `EXTERNAL_CREDIT` | Credit facility wallet |\n\nWallet status can be `ACTIVE`, `INACTIVE`, or `FROZEN`.\n\n---\n\n## GraphQL operations\n\n### List all wallets for a seller\n\n```graphql\nquery ListWallets($merchantId: ID, $limit: Int, $offset: Int) {\n  wallets(merchantId: $merchantId, limit: $limit, offset: $offset) {\n    id\n    name\n    type\n    status\n    currencyCode\n    currencyScale\n    isSystem\n    createdAt\n    snapshot {\n      availableBalanceMinor\n      heldBalanceMinor\n      pendingBalanceMinor\n      updatedAt\n    }\n  }\n}\n```\n\n### Get a single wallet with ledger entries\n\n```graphql\nquery GetWallet($id: Int!, $entryLimit: Int, $entryOffset: Int) {\n  wallet(id: $id) {\n    id\n    name\n    type\n    status\n    currencyCode\n    currencyScale\n    snapshot {\n      availableBalanceMinor\n      heldBalanceMinor\n      pendingBalanceMinor\n    }\n    entries(limit: $entryLimit, offset: $entryOffset) {\n      id\n      entrySeq\n      direction\n      kind\n      amountMinor\n      availableDeltaMinor\n      heldDeltaMinor\n      balanceBeforeMinor\n      balanceAfterMinor\n      occurredAt\n    }\n  }\n}\n```\n\n---\n\n## Type reference\n\n### `Wallet`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Numeric wallet ID |\n| `payeeId` | `Int!` | Payee this wallet belongs to |\n| `sellerAccountId` | `Int!` | Owning seller account |\n| `type` | `WalletType!` | Wallet role (see table above) |\n| `status` | `WalletStatus!` | `ACTIVE`, `INACTIVE`, or `FROZEN` |\n| `name` | `String!` | Human-readable label |\n| `currencyCode` | `CurrencyCode!` | ISO 4217 currency code |\n| `currencyScale` | `Int!` | Decimal scale (e.g. `2` for EUR meaning amounts are in cents) |\n| `isSystem` | `Boolean!` | `true` for Fynex-managed system wallets |\n| `createdAt` | `Time!` | Creation timestamp |\n| `snapshot` | `WalletBalanceSnapshot` | Latest balance snapshot |\n| `entries` | `[WalletEntry!]!` | Paginated ledger entries |\n| `payoutMethod` | `PayoutMethod` | Associated payout method if any |\n\n### `WalletBalanceSnapshot`\n\nThe snapshot is updated after each ledger write and represents the current balance at the time of the last entry.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `availableBalanceMinor` | `Int!` | Spendable balance in minor currency units |\n| `heldBalanceMinor` | `Int!` | Funds on hold (pending authorization) |\n| `pendingBalanceMinor` | `Int!` | Funds in transit |\n| `lastEntrySeq` | `Int!` | Sequence number of the entry that produced this snapshot |\n| `updatedAt` | `Time!` | When the snapshot was last written |\n\nAll balance amounts are in **minor currency units** (e.g. cents for EUR/USD). Divide by `10^currencyScale` to get the decimal amount.\n\n### `WalletEntry`\n\nEach entry is an append-only record of a balance movement.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Entry ID |\n| `entrySeq` | `Int!` | Monotonically increasing per-wallet sequence |\n| `direction` | `WalletEntryDirection!` | `DEBIT` or `CREDIT` |\n| `kind` | `WalletEntryKind!` | Cause: `PAYMENT`, `PAYOUT`, `SPLIT_EXECUTION`, `MANUAL_ADJUST`, `INCOMING_PAYMENT_PROCESSED`, `OUTGOING_PAYMENT_PROCESSED` |\n| `amountMinor` | `Int!` | Movement amount in minor units |\n| `balanceBeforeMinor` | `Int!` | Available balance before this entry |\n| `balanceAfterMinor` | `Int!` | Available balance after this entry |\n| `heldDeltaMinor` | `Int!` | Change in held balance |\n| `occurredAt` | `Time!` | When the movement occurred |\n| `transferId` | `Int` | Linked `WalletTransfer` ID if this entry resulted from an inter-wallet movement |\n| `reconciledAt` | `Time` | Set when the entry is reconciled |\n\n### `WalletTransfer`\n\nA transfer records an inter-wallet movement (e.g. when a split rule distributes funds from the main wallet to a payee wallet).\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Transfer ID |\n| `type` | `String!` | Transfer type |\n| `status` | `String!` | Current status |\n| `sourceWalletId` | `Int!` | Origin wallet |\n| `destinationWalletId` | `Int` | Target wallet (if intra-Fynex) |\n| `destinationPayoutMethodId` | `Int` | Target payout method (if external) |\n| `sourceAmountMinor` | `Int!` | Amount debited from source |\n| `destinationAmountMinor` | `Int!` | Amount credited to destination |\n| `sourceCurrencyCode` | `CurrencyCode!` | Source currency |\n| `destinationCurrencyCode` | `CurrencyCode!` | Destination currency |\n| `splitExecutionId` | `Int` | Linked split execution if applicable |\n| `requestedAt` | `Time` | When the transfer was requested |\n| `postedAt` | `Time` | When the transfer was posted |\n| `errorCode` | `String` | Present if the transfer failed |\n\n---\n\n## Code samples\n\n#### curl\n\n```bash\n# Step 1 — login and save the cookie\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Step 2 — list wallets for a seller account\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"query ListWallets($merchantId: ID, $limit: Int, $offset: Int) { wallets(merchantId: $merchantId, limit: $limit, offset: $offset) { id name type status currencyCode snapshot { availableBalanceMinor heldBalanceMinor pendingBalanceMinor updatedAt } } }\",\n    \"variables\": { \"merchantId\": \"42\", \"limit\": 20, \"offset\": 0 }\n  }'\n```\n\n#### JavaScript\n\n```js\nconst BASE = 'https://api.fynex.ai';\n\nasync function login(email, password) {\n  const res = await fetch(`${BASE}/api/v1/login/dashboard`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ email, password }),\n  });\n  if (!res.ok) throw new Error(`Login failed: ${res.status}`);\n}\n\nasync function gql(query, variables = {}) {\n  const res = await fetch(`${BASE}/dashboard/graphql`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ query, variables }),\n  });\n  const { data, errors } = await res.json();\n  if (errors?.length) throw new Error(errors[0].message);\n  return data;\n}\n\nawait login('you@example.com', 'your_password');\n\nconst { wallets } = await gql(\n  `query ListWallets($merchantId: ID, $limit: Int, $offset: Int) {\n     wallets(merchantId: $merchantId, limit: $limit, offset: $offset) {\n       id\n       name\n       type\n       status\n       currencyCode\n       snapshot {\n         availableBalanceMinor\n         heldBalanceMinor\n         pendingBalanceMinor\n         updatedAt\n       }\n     }\n   }`,\n  { merchantId: '42', limit: 20, offset: 0 }\n);\n\nfor (const w of wallets) {\n  const scale = Math.pow(10, 2); // adjust if currencyScale != 2\n  console.log(\n    `${w.name} (${w.currencyCode}): ${w.snapshot.availableBalanceMinor / scale} available`\n  );\n}\n```\n\n#### Python\n\n```python\nimport requests\n\nBASE = \"https://api.fynex.ai\"\nsession = requests.Session()\n\n# Step 1 — login\nsession.post(\n    f\"{BASE}/api/v1/login/dashboard\",\n    json={\"email\": \"you@example.com\", \"password\": \"your_password\"},\n).raise_for_status()\n\n# Step 2 — list wallets\nquery = \"\"\"\n  query ListWallets($merchantId: ID, $limit: Int, $offset: Int) {\n    wallets(merchantId: $merchantId, limit: $limit, offset: $offset) {\n      id\n      name\n      type\n      status\n      currencyCode\n      currencyScale\n      snapshot {\n        availableBalanceMinor\n        heldBalanceMinor\n        pendingBalanceMinor\n        updatedAt\n      }\n    }\n  }\n\"\"\"\n\nresp = session.post(\n    f\"{BASE}/dashboard/graphql\",\n    json={\"query\": query, \"variables\": {\"sellerAccountId\": 42, \"limit\": 20, \"offset\": 0}},\n)\nresp.raise_for_status()\nresult = resp.json()\nif \"errors\" in result:\n    raise RuntimeError(result[\"errors\"][0][\"message\"])\n\nfor wallet in result[\"data\"][\"wallets\"]:\n    scale = 10 ** wallet[\"currencyScale\"]\n    available = wallet[\"snapshot\"][\"availableBalanceMinor\"] / scale\n    print(f'{wallet[\"name\"]} ({wallet[\"currencyCode\"]}): {available:.2f} available')\n```\n\n---\n\n## Use cases\n\n**Check available balance before requesting a payout.** Query the wallet's `snapshot.availableBalanceMinor`, convert to decimal using `currencyScale`, and compare against the desired payout amount. If `availableBalanceMinor` is insufficient, the payout request will be rejected.\n\n**Reconcile balances against your own ledger.** Fetch `entries` with `limit`/`offset` pagination, ordering by `entrySeq`. Each entry's `balanceAfterMinor` should match your internal running balance. Use `reconciledAt` to distinguish entries already reconciled by Fynex.\n\n\u003e [!CAUTION]\n\u003e Balance amounts are always in **minor currency units** (e.g. cents). A value of `12345` with `currencyScale: 2` equals 123.45 in the nominal currency. Never display raw minor-unit amounts to end users without dividing by `10^currencyScale`.\n\n## See also\n\n- **[GraphQL Authentication](#tag/graphql-auth)** — How to obtain the dashboard_session cookie required for all GraphQL calls.\n- **[Reconciliation](#tag/reconciliation)** — Match Fynex settlement records against your own books.\n- **[Payouts](#tag/payouts)** — Request a wallet-to-payee transfer via REST or GraphQL.\n","name":"Wallets"},{"description":"A **virtual account** is an issued IBAN that lets customers pay a seller by bank transfer without exposing the seller's primary bank account.\n\n\u003e [!CAUTION]\n\u003e **The `virtualAccounts` and `virtualAccount` queries are not exposed on `/dashboard/graphql`.** They exist on the Fynex staff surface only, backed by the banking-provider integration; a `dashboard_session` calling them receives `Cannot query field \"virtualAccounts\" on type \"Query\"`. They are documented below for reference — ask your Fynex representative for account listings until a seller-facing read ships. The REST issuance preview described below creates a different account record and is not returned by those queries either.\n\n---\n\n## When to use virtual accounts\n\n- **Collect bank-transfer payments** without exposing the seller's primary IBAN.\n- **Isolate funds per customer** — issue one virtual account per customer and reconcile inflows by IBAN.\n- **Reconcile provider account activity** using the provider-specific account surface enabled for the seller.\n\n---\n\n## Provider boundaries\n\n- The GraphQL operations below list and inspect banking-provider-backed virtual accounts.\n- `POST /api/v1/accounts/virtual` is a tier-gated issuance preview. It currently returns only an internal Fynex UUID.\n- There is no seller-facing read/status endpoint for issued accounts in this API version, and the GraphQL operations below cannot resolve the UUID returned by REST issuance.\n- Provider settlement events update the issued account/payment records, but they do not currently create seller wallet-ledger entries. Do not use wallet polling as confirmation of an incoming transfer.\n\nUse the issuance preview only as part of a coordinated rollout with Fynex. Do not present its returned UUID as an IBAN or build automated account-status or incoming-funds reconciliation until the corresponding seller read and wallet-ledger surfaces are released.\n\n---\n\n## GraphQL operations\n\n\u003e [!CAUTION]\n\u003e Staff surface only — not callable with a `dashboard_session`. Shown for reference.\n\n| Operation | Signature |\n|-----------|-----------|\n| List virtual accounts | `virtualAccounts(first: Int, after: String, type: VirtualAccountType): VirtualAccountConnection!` |\n| Get a single account | `virtualAccount(id: ID!): VirtualAccount` |\n\n### `VirtualAccountType` enum\n\n| Value | Description |\n|-------|-------------|\n| `CLIENT` | Account issued for a specific customer / payer |\n| `OPERATING` | Account used for the seller's own operating funds |\n\n---\n\n## Type reference\n\n### `VirtualAccount`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `ID!` | Internal Fynex account ID |\n| `accountId` | `String!` | The issued IBAN or account number to share with the payer |\n| `virtualAccountId` | `String!` | Banking partner's own reference for this account |\n| `currency` | `Currency!` | Account currency |\n| `type` | `VirtualAccountType!` | `CLIENT` or `OPERATING` |\n| `status` | `String!` | Current provisioning status |\n| `balance` | `Float!` | Current balance (in major currency units) |\n| `createdAt` | `String!` | ISO timestamp of account creation |\n| `customerInfo` | `VirtualAccountCustomer` | Optional customer metadata (name, email, phone, address) |\n\n### `VirtualAccountConnection`\n\nCursor-based pagination is used for `virtualAccounts`:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `edges` | `[VirtualAccountEdge!]!` | Paginated account nodes |\n| `pageInfo` | `PageInfo!` | Cursor and page metadata |\n\n`PageInfo` fields: `hasNextPage`, `hasPreviousPage`, `startCursor`, `endCursor`, `totalCount`, `currentPage`, `totalPages`.\n\nAdvance pages by passing the `endCursor` value as the `after` argument on the next call.\n\n---\n\n## Requesting a virtual account\n\n\u003e [!CAUTION]\n\u003e **Tier-gated preview.** `POST /api/v1/accounts/virtual` may not be enabled for all seller tiers. It does not yet provide a public status/read flow. Confirm the rollout and reconciliation process with Fynex before using it.\n\n```http\nPOST /api/v1/accounts/virtual\nContent-Type: application/json\nAuthorization: Bearer \u003cseller-api-token\u003e\nIdempotency-Key: va_customer-jane_2026-08-08\n\n{\n\t\"name\": \"Jane Smith\"\n}\n```\n\nThe seller is derived from the bearer token; the request does not accept a merchant or seller ID. A successful request returns `201 Created`:\n\n```json\n{\n\t\"accountId\": \"0f834fa8-f5ab-4d80-96b1-fc2d9e4e1824\"\n}\n```\n\n`accountId` is the internal Fynex UUID for the issuance record, not the issued IBAN. Retrying the same request with the same `Idempotency-Key` returns the same UUID without issuing another account. Reusing that key with a different name is rejected. Keep the key stable until the request has a definitive response. The dashboard GraphQL queries continue to use the dashboard cookie, but they do not expose this issuance record; REST issuance uses seller bearer authentication.\n\n---\n\n## Code samples — list virtual accounts\n\n#### curl\n\n```bash\n# Step 1 — login\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Step 2 — list virtual accounts (first 20, CLIENT type)\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"# Not available on /dashboard/graphql — staff surface only\\nquery ListVAs($first: Int, $after: String, $type: VirtualAccountType) { virtualAccounts(first: $first, after: $after, type: $type) { pageInfo { hasNextPage endCursor totalCount } edges { cursor node { id accountId virtualAccountId currency type status balance createdAt customerInfo { name email } } } } }\",\n    \"variables\": { \"first\": 20, \"type\": \"CLIENT\" }\n  }'\n```\n\n#### JavaScript\n\n```js\nconst BASE = 'https://api.fynex.ai';\n\nasync function login(email, password) {\n  const res = await fetch(`${BASE}/api/v1/login/dashboard`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ email, password }),\n  });\n  if (!res.ok) throw new Error(`Login failed: ${res.status}`);\n}\n\nasync function gql(query, variables = {}) {\n  const res = await fetch(`${BASE}/dashboard/graphql`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ query, variables }),\n  });\n  const { data, errors } = await res.json();\n  if (errors?.length) throw new Error(errors[0].message);\n  return data;\n}\n\nawait login('you@example.com', 'your_password');\n\n// Fetch all CLIENT-type virtual accounts using cursor pagination\nlet after = undefined;\nlet allAccounts = [];\n\ndo {\n  const { virtualAccounts } = await gql(\n    `# Not available on /dashboard/graphql — staff surface only\n     query ListVAs($first: Int, $after: String, $type: VirtualAccountType) {\n       virtualAccounts(first: $first, after: $after, type: $type) {\n         pageInfo {\n           hasNextPage\n           endCursor\n           totalCount\n         }\n         edges {\n           node {\n             id\n             accountId\n             virtualAccountId\n             currency\n             type\n             status\n             balance\n             createdAt\n             customerInfo {\n               name\n               email\n             }\n           }\n         }\n       }\n     }`,\n    { first: 20, after, type: 'CLIENT' }\n  );\n\n  allAccounts = allAccounts.concat(virtualAccounts.edges.map((e) =\u003e e.node));\n  after = virtualAccounts.pageInfo.hasNextPage ? virtualAccounts.pageInfo.endCursor : undefined;\n} while (after);\n\nconsole.log(`Fetched ${allAccounts.length} virtual accounts`);\nfor (const va of allAccounts) {\n  console.log(`${va.accountId} (${va.currency}) — status: ${va.status}, balance: ${va.balance}`);\n}\n```\n\n#### Python\n\n```python\nimport requests\n\nBASE = \"https://api.fynex.ai\"\nsession = requests.Session()\n\nsession.post(\n    f\"{BASE}/api/v1/login/dashboard\",\n    json={\"email\": \"you@example.com\", \"password\": \"your_password\"},\n).raise_for_status()\n\nquery = \"\"\"\n  # Not available on /dashboard/graphql — staff surface only\n  query ListVAs($first: Int, $after: String, $type: VirtualAccountType) {\n    virtualAccounts(first: $first, after: $after, type: $type) {\n      pageInfo {\n        hasNextPage\n        endCursor\n        totalCount\n      }\n      edges {\n        node {\n          id\n          accountId\n          virtualAccountId\n          currency\n          type\n          status\n          balance\n          createdAt\n          customerInfo {\n            name\n            email\n          }\n        }\n      }\n    }\n  }\n\"\"\"\n\nall_accounts = []\nafter = None\n\nwhile True:\n    variables = {\"first\": 20, \"type\": \"CLIENT\"}\n    if after:\n        variables[\"after\"] = after\n\n    resp = session.post(\n        f\"{BASE}/dashboard/graphql\",\n        json={\"query\": query, \"variables\": variables},\n    )\n    resp.raise_for_status()\n    result = resp.json()\n    if \"errors\" in result:\n        raise RuntimeError(result[\"errors\"][0][\"message\"])\n\n    page = result[\"data\"][\"virtualAccounts\"]\n    all_accounts.extend(edge[\"node\"] for edge in page[\"edges\"])\n\n    if page[\"pageInfo\"][\"hasNextPage\"]:\n        after = page[\"pageInfo\"][\"endCursor\"]\n    else:\n        break\n\nprint(f\"Total virtual accounts: {len(all_accounts)}\")\nfor va in all_accounts:\n    print(f\"{va['accountId']} ({va['currency']}) — {va['status']}, balance: {va['balance']}\")\n```\n\n## See also\n\n- **[GraphQL Authentication](#tag/graphql-auth)** — Obtain the dashboard_session cookie required for all GraphQL calls.\n- **[Wallets](#tag/wallets)** — Inspect wallet-ledger activity for products that post funds to the seller wallet. The issuance preview does not currently create those entries.\n- **[Concepts](#tag/concepts)** — The object model behind virtual accounts: sellers, payees, wallets and how funds move between them.\n","name":"Virtual accounts"},{"description":"Fynex supports two complementary ways to distribute payment funds across multiple payees:\n\n1. **Persistent split rules** — a standing rule attached to a seller account that automatically distributes funds at settlement time. Managed via GraphQL mutations.\n2. **Per-payment inline splits** — a one-shot distribution specified at payment creation time via `orderData.payeeDistribution`. Documented in the Hosted Checkout and Server-to-Server guides.\n\n\u003e [!NOTE]\n\u003e **GraphQL only — cookie session required.** Split rule operations and execution queries live on `POST /dashboard/graphql`. A valid `dashboard_session` cookie is required. See [GraphQL Authentication](#tag/graphql-auth).\n\n\u003e [!IMPORTANT]\n\u003e **A split rule is not created active, and `status` cannot be set.** Every rule is created inactive, previewed against a real amount, and then activated with the receipt the preview returns. Sending `status: \"active\"` to `createSplitRule` or `updateSplitRule` is rejected:\n\u003e\n\u003e ```\n\u003e split rule status is lifecycle-controlled; create an inactive rule, preview it, then activate it\n\u003e ```\n\u003e\n\u003e The splits section of the Fynex dashboard implements exactly this sequence and is the reference implementation.\n\n---\n\n## The lifecycle\n\nThree steps, in order. Skipping the preview is not possible: activation requires a receipt that only a successful preview issues.\n\n```\ncreateSplitRule ──▶ previewSplit(ruleId:) ──▶ activateSplitRule(previewReceipt:, confirmed: true)\n   (inactive)          (returns receipt,           (atomic swap: activates this rule,\n                        valid 10 minutes)           deactivates the previous one)\n```\n\n1. **Create it inactive.** `createSplitRule` stores the rule and its lines. Omit `status` — the field is deprecated and any value other than `inactive` is rejected. Nothing runs against payments yet.\n\n2. **Preview the saved rule.** `previewSplit` with `ruleId` set runs the rule against an amount and a distribution you supply, and returns the exact allocations it would produce — plus an `activationReceipt` valid for **10 minutes**. Pass `ruleId` *or* an inline `rule`, never both; only the `ruleId` form issues a receipt. If the rule cannot run, `wouldFailReason` says why and no receipt is issued.\n\n3. **Activate with the receipt.** `activateSplitRule` takes the rule `id`, the `previewReceipt` and `confirmed: true`. The receipt is bound to that rule, that seller, the session that previewed it, and a **fingerprint of the exact rule snapshot that was previewed** — so editing the rule between preview and activation invalidates it:\n\n   ```\n   split rule changed after preview; run preview again\n   ```\n\n   Another rule's receipt, another seller's receipt or an expired one is refused the same way, and `confirmed: false` answers `activation confirmation is required`. Activating a rule that is already active answers `split rule is already active`.\n\n`activateSplitRule` returns `SplitRuleActivationResult`, whose `deactivated` array names every rule the activation stood down.\n\n### Three rules the API enforces\n\n- **One active rule per seller.** Activation is an atomic swap, not an addition. Whatever was active becomes inactive in the same transaction — that is what `deactivated` reports.\n- **Active rules are immutable.** `updateSplitRule`, `upsertSplitRuleLineByPayee` and `deleteSplitRuleLineByPayee` all refuse an active rule: *\"active split rules are immutable; clone the rule, edit the inactive copy, preview it, then activate it\"*. Use `cloneSplitRule` to get an editable inactive copy.\n- **Active rules cannot be deleted.** `deleteSplitRule` refuses while the rule is active. Deactivate it first, or activate its replacement — which deactivates it for you.\n\n\u003e [!NOTE]\n\u003e `scheduleSplitRule` is disabled and returns an error. To schedule, set `effectiveFrom` / `effectiveTo` on the inactive rule, preview the saved version, then activate it.\n\n---\n\n## GraphQL operations\n\n### Queries\n\n| Operation | Signature | Permission |\n|-----------|-----------|------------|\n| List rules | `splitRules(limit: Int, offset: Int, merchantId: ID): [SplitRule!]!` | `SPLITRULES_READ` |\n| Get rule | `splitRule(id: Int!): SplitRule` | `SPLITRULES_READ` |\n| Get the active rule | `activeSplitRule(merchantId: ID!): SplitRule` | `SPLITRULES_READ` |\n| Preview a split | `previewSplit(input: SplitRulePreviewInput!): SplitRulePreviewResult!` | `SPLITRULES_READ` |\n| List executions | `splitExecutions(dateFrom: Time, dateTo: Time, merchantId: ID): [SplitExecution!]!` | `SPLITRULES_READ` |\n\n### Mutations\n\n| Operation | Signature | Permission |\n|-----------|-----------|------------|\n| Create (inactive) | `createSplitRule(input: CreateSplitRuleInput!): SplitRule!` | `SPLITRULES_CREATE` |\n| Update (inactive only) | `updateSplitRule(id: Int!, merchantId: ID, input: UpdateSplitRuleInput!): SplitRule!` | `SPLITRULES_UPDATE` |\n| Activate | `activateSplitRule(id: Int, input: ActivateSplitRuleInput): SplitRuleActivationResult!` | `SPLITRULES_UPDATE` |\n| Deactivate | `deactivateSplitRule(id: Int!, merchantId: ID): SplitRule!` | `SPLITRULES_UPDATE` |\n| Clone (to edit an active rule) | `cloneSplitRule(id: Int!, merchantId: ID): SplitRule!` | `SPLITRULES_CREATE` |\n| Upsert one line by payee | `upsertSplitRuleLineByPayee(input: UpsertSplitRuleLineByPayeeInput!): SplitRule!` | `SPLITRULES_UPDATE` |\n| Delete one line by payee | `deleteSplitRuleLineByPayee(input: DeleteSplitRuleLineByPayeeInput!): SplitRule!` | `SPLITRULES_UPDATE` |\n| Delete (inactive only) | `deleteSplitRule(id: Int!, merchantId: ID): Boolean!` | `SPLITRULES_DELETE` |\n\n---\n\n## How a rule runs\n\n1. **Fynex applies the active rule** server-side when a payment settles. `amountBase` controls whether lines run against the `net_settled` or `gross_payment` amount.\n\n2. **Funds are transferred** from the seller's main wallet to each payee's wallet via `WalletTransfer` records. The split produces a `SplitExecution` record you can query later.\n\n3. **Inspect executions** via `splitExecutions(dateFrom, dateTo, merchantId)` to audit how funds were distributed. Each execution snapshots the `ruleVersion` it ran, so an execution stays readable after the rule is replaced.\n\n---\n\n## Type reference\n\n### `SplitRule`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Rule ID |\n| `merchantId` | `ID!` | Owning seller account |\n| `type` | `SplitRuleType!` | `fixed` or `custom` |\n| `status` | `SplitRuleStatus!` | `active` or `inactive`. Read-only — set by the activation lifecycle, never by input |\n| `amountBase` | `SplitRuleAmountBase!` | `net_settled` (the default) or `gross_payment` — the amount the lines run against. Commission is computed on net settled money unless the rule explicitly says gross |\n| `allocationMode` | `SplitRuleAllocationMode!` | `weight` or `absolute` — whether line allocations are relative shares or fixed claims |\n| `remainderPolicy` | `SplitRuleRemainderPolicy!` | Where leftover funds go: `to_main_wallet` or `to_remainder_wallet` |\n| `overAllocationPolicy` | `SplitRuleOverAllocationPolicy!` | How to handle over-allocation: `cap_by_priority`, `scale_down_percent`, `scale_down_all`, or `fail` |\n| `remainderWalletId` | `Int` | Target wallet for remainder (when `remainderPolicy` is `to_remainder_wallet`) |\n| `percentBps` | `Int` | Top-level percentage in basis points (1 bps = 0.01%) |\n| `fixedAmountMinor` | `Int` | Top-level fixed amount in minor currency units |\n| `effectiveFrom` | `Time` | Rule starts applying from this time |\n| `effectiveTo` | `Time` | Rule stops applying after this time |\n| `version` | `Int!` | Incremented on each saved edit; `SplitExecution.ruleVersion` records which version ran |\n| `lines` | `[SplitRuleLine!]!` | Per-payee allocation lines |\n\n### `SplitRuleLine`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Line ID |\n| `splitRuleId` | `Int!` | Parent rule |\n| `payeeId` | `Int!` | Recipient payee |\n| `allocationType` | `SplitRuleAllocationType!` | `percent_bps`, `fixed_minor`, or `mixed` |\n| `percentBps` | `Int` | Share in basis points (e.g. `1000` = 10%) |\n| `fixedAmountMinor` | `Int` | Fixed amount in minor units |\n| `allocationPercentBps` | `Int` | Percentage component when `allocationType` is `mixed` |\n| `allocationFixedAmountMinor` | `Int` | Fixed component when `allocationType` is `mixed` |\n| `priority` | `Int!` | Execution order when funds are insufficient |\n| `isEnabled` | `Boolean!` | Whether this line is active |\n| `minAmountMinor` | `Int` | Minimum transfer amount (clamps the allocation) |\n| `maxAmountMinor` | `Int` | Maximum transfer amount (caps the allocation) |\n\n### `SplitRulePreviewResult`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `ruleId` | `Int` | The previewed rule, when previewing a saved one |\n| `ruleVersion` | `Int!` | Version the preview ran against. Activation checks it, and the rule fingerprint, against the live rule |\n| `totalAmountMinor` | `Int!` | Amount the preview distributed |\n| `totalFeeMinor` | `Int!` | Total fee across allocations |\n| `allocations` | `[SplitRulePreviewAllocation!]!` | Per-payee `grossShareMinor`, `feeMinor`, `netAmountMinor` |\n| `wouldFailReason` | `String` | Non-null when the rule could not run; no receipt is issued |\n| `activationReceipt` | `String` | Opaque receipt for `activateSplitRule`. Only issued for a `ruleId` preview |\n| `activationReceiptExpiresAt` | `Time` | 10 minutes after the preview |\n\n### `SplitExecution`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Execution ID |\n| `genericPaymentId` | `Int` | Payment that triggered the split |\n| `splitRuleId` | `Int` | Rule that ran |\n| `sourceWalletId` | `Int!` | Wallet funds were distributed from |\n| `amountMinor` | `Int!` | Total amount distributed |\n| `currencyCode` | `CurrencyCode!` | Currency |\n| `status` | `String!` | Execution status |\n| `ruleVersion` | `Int!` | Snapshot of rule version at execution time |\n| `requestedAt` | `Time` | When the split was triggered |\n| `postedAt` | `Time` | When transfers were posted |\n| `walletTransfers` | `[WalletTransfer!]!` | Individual per-payee transfers |\n\n### Inputs\n\n```graphql\ninput CreateSplitRuleInput {\n  merchantId: ID!\n  type: SplitRuleType               # fixed | custom\n  percentBps: Int\n  fixedAmountMinor: Int\n  status: SplitRuleStatus           # deprecated — lifecycle-controlled, omit it\n  amountBase: SplitRuleAmountBase   # net_settled | gross_payment\n  allocationMode: SplitRuleAllocationMode   # weight | absolute\n  remainderPolicy: SplitRuleRemainderPolicy\n  overAllocationPolicy: SplitRuleOverAllocationPolicy\n  remainderWalletId: Int\n  effectiveFrom: Time\n  effectiveTo: Time\n  lines: [CreateSplitRuleLineInput!]\n}\n\ninput CreateSplitRuleLineInput {\n  payeeId: Int!\n  allocationType: SplitRuleAllocationType!   # percent_bps | fixed_minor | mixed\n  percentBps: Int\n  fixedAmountMinor: Int\n  priority: Int!\n  isEnabled: Boolean\n  minAmountMinor: Int\n  maxAmountMinor: Int\n}\n\ninput SplitRulePreviewInput {\n  merchantId: ID!\n  amountMinor: Int!\n  distribution: [SplitRulePreviewDistributionItem!]!   # { payeeRef: { id } | { externalId }, amountMinor }\n  ruleId: Int          # preview a SAVED rule — this is the form that issues a receipt\n  rule: SplitRulePreviewRuleInput   # preview an unsaved shape instead; no receipt\n}\n\ninput ActivateSplitRuleInput {\n  id: Int!\n  previewReceipt: String!\n  confirmed: Boolean!\n}\n```\n\n`UpdateSplitRuleInput` carries the same fields as `CreateSplitRuleInput` minus `merchantId`, plus three sub-operations on lines — `createLines`, `updateLines` (each entry needs its `lineId`), `deleteLineIds` — and explicit clear flags: `clearRemainderWallet`, `clearEffectiveFrom`, `clearEffectiveTo`.\n\n---\n\n## Worked example — create, preview, activate\n\nA rule that sends 30% of each net-settled payment to payee 101 and 70% to payee 102.\n\n\u003e [!NOTE]\n\u003e Split arithmetic is **minor units** throughout: `amountMinor: 10000` is €100.00. The inline `payeeDistribution` on a checkout payment takes major units instead — see the warning at the end of this guide.\n\n#### curl\n\n```bash\n# Step 0 — login\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Step 1 — create the rule. It is created INACTIVE; do not send `status`.\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"mutation CreateSplit($input: CreateSplitRuleInput!) { createSplitRule(input: $input) { id status version lines { id payeeId percentBps priority } } }\",\n    \"variables\": {\n      \"input\": {\n        \"merchantId\": \"42\",\n        \"amountBase\": \"net_settled\",\n        \"remainderPolicy\": \"to_main_wallet\",\n        \"overAllocationPolicy\": \"fail\",\n        \"lines\": [\n          { \"payeeId\": 101, \"allocationType\": \"percent_bps\", \"percentBps\": 3000, \"priority\": 1, \"isEnabled\": true },\n          { \"payeeId\": 102, \"allocationType\": \"percent_bps\", \"percentBps\": 7000, \"priority\": 2, \"isEnabled\": true }\n        ]\n      }\n    }\n  }'\n\n# Step 2 — preview the SAVED rule against a real amount. Returns the receipt.\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"query Preview($input: SplitRulePreviewInput!) { previewSplit(input: $input) { ruleId ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt activationReceiptExpiresAt allocations { payeeId grossShareMinor feeMinor netAmountMinor } } }\",\n    \"variables\": {\n      \"input\": {\n        \"merchantId\": \"42\",\n        \"ruleId\": 7,\n        \"amountMinor\": 10000,\n        \"distribution\": [\n          { \"payeeRef\": { \"id\": 101 }, \"amountMinor\": 3000 },\n          { \"payeeRef\": { \"id\": 102 }, \"amountMinor\": 7000 }\n        ]\n      }\n    }\n  }'\n\n# Step 3 — activate with the receipt from step 2 (valid 10 minutes).\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"mutation Activate($input: ActivateSplitRuleInput!) { activateSplitRule(input: $input) { activated { id status version } deactivated { id status } } }\",\n    \"variables\": {\n      \"input\": { \"id\": 7, \"previewReceipt\": \"\u003cactivationReceipt from step 2\u003e\", \"confirmed\": true }\n    }\n  }'\n```\n\n#### JavaScript\n\n```js\nconst BASE = 'https://api.fynex.ai';\n\nasync function login(email, password) {\n  const res = await fetch(`${BASE}/api/v1/login/dashboard`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ email, password }),\n  });\n  if (!res.ok) throw new Error(`Login failed: ${res.status}`);\n}\n\nasync function gql(query, variables = {}) {\n  const res = await fetch(`${BASE}/dashboard/graphql`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ query, variables }),\n  });\n  const { data, errors } = await res.json();\n  if (errors?.length) throw new Error(errors[0].message);\n  return data;\n}\n\nawait login('you@example.com', 'your_password');\nconst merchantId = '42';\n\n// 1. Create — inactive, no `status` field.\nconst { createSplitRule: rule } = await gql(\n  `mutation CreateSplit($input: CreateSplitRuleInput!) {\n     createSplitRule(input: $input) { id status version }\n   }`,\n  {\n    input: {\n      merchantId,\n      amountBase: 'net_settled',\n      remainderPolicy: 'to_main_wallet',\n      overAllocationPolicy: 'fail',\n      lines: [\n        { payeeId: 101, allocationType: 'percent_bps', percentBps: 3000, priority: 1, isEnabled: true },\n        { payeeId: 102, allocationType: 'percent_bps', percentBps: 7000, priority: 2, isEnabled: true },\n      ],\n    },\n  }\n);\n\n// 2. Preview the saved rule — this is what issues the activation receipt.\nconst { previewSplit: preview } = await gql(\n  `query Preview($input: SplitRulePreviewInput!) {\n     previewSplit(input: $input) {\n       ruleVersion\n       totalAmountMinor\n       totalFeeMinor\n       wouldFailReason\n       activationReceipt\n       allocations { payeeId grossShareMinor feeMinor netAmountMinor }\n     }\n   }`,\n  {\n    input: {\n      merchantId,\n      ruleId: rule.id,\n      amountMinor: 10000,\n      distribution: [\n        { payeeRef: { id: 101 }, amountMinor: 3000 },\n        { payeeRef: { id: 102 }, amountMinor: 7000 },\n      ],\n    },\n  }\n);\n\nif (preview.wouldFailReason) {\n  throw new Error(`Rule would not run: ${preview.wouldFailReason}`);\n}\n\n// 3. Activate. Deactivates whatever was active, in the same transaction.\nconst { activateSplitRule: result } = await gql(\n  `mutation Activate($input: ActivateSplitRuleInput!) {\n     activateSplitRule(input: $input) {\n       activated { id status version }\n       deactivated { id status }\n     }\n   }`,\n  { input: { id: rule.id, previewReceipt: preview.activationReceipt, confirmed: true } }\n);\n\nconsole.log('Active rule:', result.activated.id, '— stood down:', result.deactivated.map((r) =\u003e r.id));\n```\n\n#### Python\n\n```python\nimport requests\n\nBASE = \"https://api.fynex.ai\"\nMERCHANT_ID = \"42\"\nsession = requests.Session()\n\nsession.post(\n    f\"{BASE}/api/v1/login/dashboard\",\n    json={\"email\": \"you@example.com\", \"password\": \"your_password\"},\n).raise_for_status()\n\n\ndef gql(query, variables):\n    resp = session.post(f\"{BASE}/dashboard/graphql\", json={\"query\": query, \"variables\": variables})\n    resp.raise_for_status()\n    body = resp.json()\n    if \"errors\" in body:\n        raise RuntimeError(body[\"errors\"][0][\"message\"])\n    return body[\"data\"]\n\n\n# 1. Create — inactive. Sending `status` is rejected.\nrule = gql(\n    \"\"\"\n    mutation CreateSplit($input: CreateSplitRuleInput!) {\n      createSplitRule(input: $input) { id status version }\n    }\n    \"\"\",\n    {\n        \"input\": {\n            \"merchantId\": MERCHANT_ID,\n            \"amountBase\": \"net_settled\",\n            \"remainderPolicy\": \"to_main_wallet\",\n            \"overAllocationPolicy\": \"fail\",\n            \"lines\": [\n                {\"payeeId\": 101, \"allocationType\": \"percent_bps\", \"percentBps\": 3000, \"priority\": 1, \"isEnabled\": True},\n                {\"payeeId\": 102, \"allocationType\": \"percent_bps\", \"percentBps\": 7000, \"priority\": 2, \"isEnabled\": True},\n            ],\n        }\n    },\n)[\"createSplitRule\"]\n\n# 2. Preview the saved rule — returns the activation receipt.\npreview = gql(\n    \"\"\"\n    query Preview($input: SplitRulePreviewInput!) {\n      previewSplit(input: $input) {\n        ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt\n        allocations { payeeId grossShareMinor feeMinor netAmountMinor }\n      }\n    }\n    \"\"\",\n    {\n        \"input\": {\n            \"merchantId\": MERCHANT_ID,\n            \"ruleId\": rule[\"id\"],\n            \"amountMinor\": 10000,\n            \"distribution\": [\n                {\"payeeRef\": {\"id\": 101}, \"amountMinor\": 3000},\n                {\"payeeRef\": {\"id\": 102}, \"amountMinor\": 7000},\n            ],\n        }\n    },\n)[\"previewSplit\"]\n\nif preview[\"wouldFailReason\"]:\n    raise RuntimeError(f\"Rule would not run: {preview['wouldFailReason']}\")\n\n# 3. Activate within 10 minutes of the preview.\nresult = gql(\n    \"\"\"\n    mutation Activate($input: ActivateSplitRuleInput!) {\n      activateSplitRule(input: $input) {\n        activated { id status version }\n        deactivated { id status }\n      }\n    }\n    \"\"\",\n    {\"input\": {\"id\": rule[\"id\"], \"previewReceipt\": preview[\"activationReceipt\"], \"confirmed\": True}},\n)[\"activateSplitRule\"]\n\nprint(\"Active rule:\", result[\"activated\"][\"id\"],\n      \"— stood down:\", [r[\"id\"] for r in result[\"deactivated\"]])\n```\n\n---\n\n## Why did this payee get 90 and not 95?\n\nEvery split execution writes a **decision record** — a snapshot of the rule\nand lines as they were when the split ran, what each line contributed, which\nlines did not fire and the machine-readable reason (`line_disabled`,\n`no_distribution_weight`, `zero_allocation`), and which policy moved a number\nafterwards (`clamped_to_min`, `clamped_to_max`, `over_allocation_resolved`).\nRead it over REST with your seller key:\n\n```\nGET /payments-api/v1/payments/{paymentId}/split-decisions\n```\n\n`paymentId` is your `externalOrderRef`, the same value the other payment reads\ntake.\n\nRecords come back newest first, and **more than one is normal**: a re-split\nafter a correction is a second evaluation, and the first entry is the\nallocation in force. An empty list means no split has run for the payment —\nit is not a 404. Every amount in a record is an integer in minor units; the\npayment it explains still reports `amount` in major units, so trust the record\nfor arithmetic.\n\n---\n\n## Editing, replacing and removing rules\n\n**Editing the rule that is live.** You cannot. Clone it, edit the copy, preview the copy, activate the copy:\n\n```graphql\nmutation Replace($id: Int!) {\n  cloneSplitRule(id: $id) { id status version }   # a new INACTIVE copy\n}\n```\n\nThen `updateSplitRule` on the clone, `previewSplit(ruleId:)` on the clone, and `activateSplitRule` — which deactivates the original as part of the same transaction. There is no window in which the seller has two active rules, and none in which they have none.\n\n**Editing an inactive rule.** `updateSplitRule(id, input)` supports three sub-operations on lines in one call: `createLines`, `updateLines` (each needs its `lineId`), `deleteLineIds`. For single-line changes keyed by payee rather than line ID, use `upsertSplitRuleLineByPayee` / `deleteSplitRuleLineByPayee`. All of them refuse an active rule.\n\n**Turning splitting off.** `deactivateSplitRule(id:)`. Setting `status: inactive` through an update is *not* the way — the field is rejected.\n\n**Deleting.** `deleteSplitRule(id:)` works only on an inactive rule; an active one is refused. Deleting does not reverse split executions that have already posted — inspect `splitExecutions` before deleting a rule that has run.\n\n---\n\n## Per-payment inline splits\n\nFor one-off distributions that do not need a persistent rule, pass `payeeDistribution` inside `orderData` at payment creation time. Each element specifies a `payeeId` and an `amount`; the total must sum to the payment `amount`.\n\n```json\n{\n  \"amount\": 100.00,\n  \"currencyCode\": \"EUR\",\n  \"orderData\": {\n    \"payeeDistribution\": [\n      { \"payeeId\": 101, \"amount\": 30.00 },\n      { \"payeeId\": 102, \"amount\": 70.00 }\n    ]\n  }\n}\n```\n\n\u003e [!WARNING]\n\u003e **The two split surfaces do not agree on units.** Inline `payeeDistribution.amount` is in **major** units — `30.00` is €30.00 — while split rules, previews and executions are in **minor** units, where €30.00 is `3000`. Read the field name: a value ending in `Minor` is minor units, and everything else on the checkout payload is major. Neither surface will reject the wrong one; it is simply off by a hundred.\n\nThis is documented in detail in the [Hosted Checkout](#tag/hosted-checkout) guide. Use persistent split rules when the same distribution applies across many payments; use inline splits for ad-hoc, per-transaction control.\n\n## See also\n\n- **[GraphQL Authentication](#tag/graphql-auth)** — Obtain the dashboard_session cookie required for all GraphQL calls.\n- **[Payees](#tag/payees)** — Create and manage the payees that appear in your split rule lines.\n- **[Hosted Checkout](#tag/hosted-checkout)** — Pass payeeDistribution for per-payment inline splits.\n- **[Wallets](#tag/wallets)** — Inspect the wallet ledger entries produced by split executions.\n","name":"Splits"},{"description":"Hosted checkout is the **simplest, lowest-friction** way to accept a payment with Fynex. Card details are entered on a Fynex-hosted page, so your servers and frontend never touch raw PAN/CVV — your PCI obligations stay at the SAQ A level.\n\n## When to use it\n\n- You want to ship fast.\n- You don't have an existing card-collection UI.\n- You're OK redirecting customers to a Fynex domain to complete the payment.\n\nIf you need to keep the customer on your own domain or already manage card data securely, use [server-to-server](#tag/server-to-server) instead.\n\n## Lifecycle\n\n```\nYour backend                    Fynex                       Customer browser\n─────────────                    ─────                       ────────────────\nPOST /checkout    ─────────►    creates session\n                                returns sessionId,\n                                checkoutUrl, expiresAt\n                  ◄──── response\nredirect customer ────────────────────────────────────────►  fills card form\n                                                             on hosted page\n                                                             (3DS handled here)\n                                Fynex processes payment ◄──── submit\n                                                             redirect →\n                                                             returnUrls.success\n                                                             or .failure\npoll for status   ─────────►   (see Polling \u0026 SSE guide)\nverify, fulfil\n```\n\n\u003e [!NOTE]\n\u003e Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). To verify the final payment state — or as a backstop — poll the GraphQL `genericPayment` query or subscribe to SSE events. See the [Polling \u0026 SSE](#tag/polling-sse) guide.\n\n## Step-by-step\n\n1. **Create a checkout session from your backend**\n\n   Send `POST /payments-api/v1/checkout` with a Bearer token and a unique `Idempotency-Key` header.\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/checkout\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-1042\",\n    \"amount\": 49.00,\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"returnUrls\": {\n      \"success\": \"https://example.com/orders/1042/success\",\n      \"failure\": \"https://example.com/orders/1042/failure\"\n    },\n    \"description\": \"Order #1042\"\n  }'\n```\n\n#### JavaScript\n\n```js\nimport { randomUUID } from 'node:crypto';\n\nexport async function createCheckoutSession(order) {\n  const res = await fetch(\n    `${process.env.FYNEX_API}/payments-api/v1/checkout`,\n    {\n      method: 'POST',\n      headers: {\n        Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n        'Content-Type': 'application/json',\n        'Idempotency-Key': randomUUID(),\n      },\n      body: JSON.stringify({\n        externalOrderRef: order.id,\n        amount: order.amount,\n        currencyCode: order.currency,\n        countryCode: order.countryCode,\n        returnUrls: {\n          success: `https://example.com/orders/${order.id}/success`,\n          failure: `https://example.com/orders/${order.id}/failure`,\n        },\n        description: `Order #${order.id}`,\n      }),\n    }\n  );\n  if (!res.ok) throw new Error(await res.text());\n  return res.json();\n}\n```\n\n#### Python\n\n```python\nimport os\nimport uuid\nimport requests\n\ndef create_checkout_session(order: dict) -\u003e dict:\n    res = requests.post(\n        f\"{os.environ['FYNEX_API']}/payments-api/v1/checkout\",\n        headers={\n            \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n            \"Content-Type\": \"application/json\",\n            \"Idempotency-Key\": str(uuid.uuid4()),\n        },\n        json={\n            \"externalOrderRef\": order[\"id\"],\n            \"amount\": order[\"amount\"],\n            \"currencyCode\": order[\"currency\"],\n            \"countryCode\": order[\"country_code\"],\n            \"returnUrls\": {\n                \"success\": f\"https://example.com/orders/{order['id']}/success\",\n                \"failure\": f\"https://example.com/orders/{order['id']}/failure\",\n            },\n            \"description\": f\"Order #{order['id']}\",\n        },\n        timeout=10,\n    )\n    res.raise_for_status()\n    return res.json()\n```\n\n   You receive a `201 Created` response:\n\n   ```json\n   {\n     \"sessionId\": \"6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n     \"checkoutUrl\": \"https://pay.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n     \"expiresAt\": \"2026-04-29T13:30:00Z\"\n   }\n   ```\n\n   The TTL is set server-side; `expiresAt` tells you when the session will become invalid.\n\n2. **Redirect the customer**\n\n   Send a `302` to `checkoutUrl`, or render it as a button. Do not embed it in an iframe — browsers may block cross-origin iframe navigation.\n\n   ```js\n   res.redirect(302, session.checkoutUrl);\n   ```\n\n3. **Customer completes the payment on the hosted page**\n\n   3DS challenges, if required, are handled internally by the hosted page. Your integration does not need to manage any 3DS redirect.\n\n4. **Customer is redirected back**\n\n   On completion, Fynex redirects the customer to `returnUrls.success` (payment completed) or `returnUrls.failure` (payment failed or abandoned). The redirect itself carries no authoritative payment state — do not grant fulfilment based on it.\n\n5. **Verify the payment status**\n\n   Poll the GraphQL `genericPayment(id)` query or use SSE to confirm the final state before fulfilling the order.\n\n   See [Polling \u0026 SSE](#tag/polling-sse) for code samples.\n\n## Request fields reference\n\n### Required fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `externalOrderRef` | string | Your order reference. Stored on both the checkout session and the underlying payment. |\n| `amount` | float | Amount in major units (e.g., `49.00` for £49.00). |\n| `currencyCode` | string (3-letter) | ISO 4217 code, e.g. `GBP`. Normalized to upper-case server-side. Note: `/checkout` does not enforce a currency allowlist — an unsupported currency will be rejected at payment time, not at session creation. |\n| `countryCode` | string (2-letter) | ISO 3166-1 alpha-2. Required despite the `omitempty` JSON tag. |\n| `returnUrls.success` | string | URL the customer is redirected to on successful payment. |\n| `returnUrls.failure` | string | URL the customer is redirected to on failure. Note the field name is `failure`, not `cancel`. |\n\n### Optional fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `autoSettlement` | bool | When `true`, the payment auto-settles immediately. Default: `false`. |\n| `orderData.payeeDistribution` | array | Split funds across payees. Each element is `{ \"payeeId\": \u003cint64\u003e, \"amount\": \u003cfloat\u003e }`. The amounts must sum to `amount`. See [Split payments](#split-payments). |\n| `sellerMerchantName` | string | Business name displayed on the hosted checkout page. |\n| `logoUrl` | string | Logo URL displayed on the hosted checkout page. |\n| `locale` | string | Language code (e.g., `en`). Falls back to the seller account locale, then `en`. |\n| `description` | string | Order description shown to the customer (e.g., `\"Order #1042\"`). |\n\n\u003e [!CAUTION]\n\u003e **Fields that do NOT exist on this DTO:** `successUrl`, `cancelUrl`, `expiresInMinutes`, `metadata`, `customerEmail`, `payeeId` (top-level), `splitRules` (top-level). Do not send these — the server will ignore them silently or reject the request.\n\n## Split payments\n\nTo distribute the captured amount across multiple payees, include `orderData.payeeDistribution`. Retrieve payee IDs from `GET /payments-api/v1/payees`.\n\n```json\n{\n  \"externalOrderRef\": \"ORDER-1042\",\n  \"amount\": 100.00,\n  \"currencyCode\": \"GBP\",\n  \"countryCode\": \"GB\",\n  \"returnUrls\": {\n    \"success\": \"https://example.com/success\",\n    \"failure\": \"https://example.com/failure\"\n  },\n  \"orderData\": {\n    \"totalAmount\": 100.00,\n    \"payeeDistribution\": [\n      { \"payeeId\": 101, \"amount\": 85.00 },\n      { \"payeeId\": 102, \"amount\": 15.00 }\n    ]\n  }\n}\n```\n\nAll amounts are in major units. The distribution sum must equal `amount` (or `orderData.totalAmount` if provided).\n\n## 3DS handling\n\nThe hosted checkout page manages the entire 3DS flow internally. If the issuer requires a challenge, the customer completes it on the hosted page without ever leaving the Fynex-controlled flow. Your backend only sees the final outcome via polling/SSE — there is no `requiresAction` or `redirectUrl` on this path.\n\nIf you need direct control over the 3DS redirect (e.g., your own checkout UI), use [server-to-server](#tag/server-to-server) instead.\n\n## Common pitfalls\n\n\u003e [!WARNING]\n\u003e **Do not fulfil the order based on the redirect alone.** A user can navigate directly to `returnUrls.success` without paying. Always verify the payment state server-side before dispatching goods or services.\n\n\u003e [!CAUTION]\n\u003e **Misleading 401 error: \"sellerAccountId is missing in auth context\"**\n\u003e\n\u003e This error sounds like an authentication problem, but it is almost always caused by a **missing or malformed `Idempotency-Key` header**. The checkout handler reads the seller context via the same helper that validates the header — a missing or non-UUID key surfaces as this misleading 401 before the Bearer token is even checked. Verify your header first before investigating auth issues.\n\n- **`Idempotency-Key` reuse with a different body returns `409 Conflict`.** Use a fresh UUID per order.\n- **`returnUrls.failure` — not `cancelUrl`.** The field is named `failure`. Sending `cancelUrl` has no effect.\n- **`currencyCode` is not validated at session creation.** An unrecognized currency may be silently accepted at `/checkout` and only rejected when the payment is processed. Test end-to-end in staging.\n- **Amount is locked at session creation.** To change the amount, create a new session.\n\n## Test cards\n\nThe hosted checkout page manages 3DS internally and **always runs it** — unlike the\nserver-to-server flow, the hosted page does not expose the `skip3DS` flag. Because 3DS always\nruns, the PAN you use decides the authentication outcome, and a handle that does not\nauthenticate (`threeDResult` other than `Y`/`A`) **cannot be settled**.\n\nFor a happy-path success in staging use one of the frictionless-`Y` PANs, with any future\nexpiry date and any 3-digit CVV:\n\n| Card number | Brand |\n|-------------|-------|\n| `4000 0000 0000 2701` | Visa |\n| `5200 0000 0000 2235` | Mastercard |\n\n\u003e [!IMPORTANT]\n\u003e Do **not** use `4111 1111 1111 1111` here. It is not one of the authenticating PANs, so on\n\u003e the hosted checkout it always ends in a decline. It only works on flows that skip 3DS.\n\nTo exercise a decline or a failed 3DS challenge, use one of the challenge/failure PANs from the\n[Test cards \u0026 sandbox](#tag/test-cards) guide, which lists the full set.\n\n## See also\n\n- **[Checkout widget](#tag/checkout-widget)** — Want to embed Fynex\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Verify payment outcomes without webhooks.\n- **[Captures \u0026 refunds](#tag/captures-refunds)** — Capture, partially capture, or refund a payment after the fact.\n- **[Server-to-server payments](#tag/server-to-server)** — Take full control of the payment flow and 3DS redirect.\n","name":"Hosted checkout"},{"description":"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.\n\n\u003e [!CAUTION]\n\u003e 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](#tag/hosted-checkout) instead — it keeps you at SAQ A.\n\n## Lifecycle\n\n```\nCustomer            Your backend                    Fynex\n────────            ─────────────                   ─────\nfills card form ─►  POST /initialize-payment ──────► authorizes via processor\n                                                     returns status + actionUrl?\n                ◄── 202 response\n                    if requiresAction:\nredirect ──────────────────────────────────────────► customer completes 3DS\n                                                     browser bounces back\n                    POST /finalize-payment ─────────► captures / finalizes\n                                                     returns final status\n                ◄── 200 result\n                    verify status, fulfil order\n```\n\n\u003e [!NOTE]\n\u003e Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). You can also use the GraphQL `genericPayment(id)` query or SSE to poll for the final status — recommended as a backstop. See [Polling \u0026 SSE](#tag/polling-sse).\n\n## Step-by-step\n\n1. **Collect card details**\n\n   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.\n\n2. **Initialize the payment**\n\n   `POST /payments-api/v1/initialize-payment` with `Authorization: Bearer \u003ctoken\u003e` and a unique `Idempotency-Key` (UUID) header.\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-1042\",\n    \"amount\": 49.00,\n    \"paymentType\": \"card\",\n    \"paymentMethod\": \"card\",\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"autoSettlement\": false,\n    \"captureMode\": \"manual\",\n    \"cardData\": {\n      \"cardNumber\": \"4111111111111111\",\n      \"expMonth\": 12,\n      \"expYear\": 2028,\n      \"cvv\": \"123\",\n      \"holderName\": \"Jane Doe\"\n    },\n    \"billingDetails\": {\n      \"country\": \"GB\",\n      \"zip\": \"SW1A1AA\",\n      \"city\": \"London\",\n      \"street\": \"1 Example Street\"\n    },\n    \"returnLinks\": [\n      { \"rel\": \"on_completed\", \"href\": \"https://example.com/orders/1042/success\", \"method\": \"GET\" },\n      { \"rel\": \"on_failed\",    \"href\": \"https://example.com/orders/1042/failure\", \"method\": \"GET\" },\n      { \"rel\": \"default\",      \"href\": \"https://example.com/orders/1042/return\",  \"method\": \"GET\" }\n    ]\n  }'\n```\n\n#### JavaScript\n\n```js\nimport { randomUUID } from 'node:crypto';\n\nexport async function initializePayment(order, card) {\n  const res = await fetch(\n    `${process.env.FYNEX_API}/payments-api/v1/initialize-payment`,\n    {\n      method: 'POST',\n      headers: {\n        Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n        'Content-Type': 'application/json',\n        'Idempotency-Key': randomUUID(),\n      },\n      body: JSON.stringify({\n        externalOrderRef: order.id,\n        amount: order.amount,\n        paymentType: 'card',\n        paymentMethod: 'card',\n        currencyCode: order.currency,\n        countryCode: order.countryCode,\n        autoSettlement: false,\n        captureMode: 'manual',\n        cardData: {\n          cardNumber: card.number,\n          expMonth: card.expMonth,\n          expYear: card.expYear,\n          cvv: card.cvv,\n          holderName: card.holderName,\n        },\n        billingDetails: {\n          country: order.billingCountry,\n          zip: order.billingZip,\n          city: order.billingCity,\n          street: order.billingStreet,\n        },\n        returnLinks: [\n          { rel: 'on_completed', href: order.successUrl, method: 'GET' },\n          { rel: 'on_failed',    href: order.failureUrl, method: 'GET' },\n          { rel: 'default',      href: order.returnUrl,  method: 'GET' },\n        ],\n      }),\n    }\n  );\n  if (!res.ok) throw new Error(await res.text());\n  return res.json(); // 202 Accepted\n}\n```\n\n#### Python\n\n```python\nimport os\nimport uuid\nimport requests\n\ndef initialize_payment(order: dict, card: dict) -\u003e dict:\n    res = requests.post(\n        f\"{os.environ['FYNEX_API']}/payments-api/v1/initialize-payment\",\n        headers={\n            \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n            \"Content-Type\": \"application/json\",\n            \"Idempotency-Key\": str(uuid.uuid4()),\n        },\n        json={\n            \"externalOrderRef\": order[\"id\"],\n            \"amount\": order[\"amount\"],\n            \"paymentType\": \"card\",\n            \"paymentMethod\": \"card\",\n            \"currencyCode\": order[\"currency\"],\n            \"countryCode\": order[\"country_code\"],\n            \"autoSettlement\": False,\n            \"captureMode\": \"manual\",\n            \"cardData\": {\n                \"cardNumber\": card[\"number\"],\n                \"expMonth\": card[\"exp_month\"],\n                \"expYear\": card[\"exp_year\"],\n                \"cvv\": card[\"cvv\"],\n                \"holderName\": card[\"holder_name\"],\n            },\n            \"billingDetails\": {\n                \"country\": order[\"billing_country\"],\n                \"zip\": order[\"billing_zip\"],\n                \"city\": order[\"billing_city\"],\n                \"street\": order[\"billing_street\"],\n            },\n            \"returnLinks\": [\n                {\"rel\": \"on_completed\", \"href\": order[\"success_url\"], \"method\": \"GET\"},\n                {\"rel\": \"on_failed\",    \"href\": order[\"failure_url\"], \"method\": \"GET\"},\n                {\"rel\": \"default\",      \"href\": order[\"return_url\"],  \"method\": \"GET\"},\n            ],\n        },\n        timeout=15,\n    )\n    res.raise_for_status()\n    return res.json()  # 202 Accepted\n```\n\n   The response is `202 Accepted` for a new payment:\n\n   ```json\n   {\n     \"paymentId\": \"ORDER-1042\",\n     \"status\": \"provider_pending\",\n     \"amount\": 49.00,\n     \"currencyCode\": \"GBP\",\n     \"requiresAction\": false,\n     \"actionUrl\": \"\"\n   }\n   ```\n\n   When 3DS is required the response looks like:\n\n   ```json\n   {\n     \"paymentId\": \"ORDER-1042\",\n     \"status\": \"provider_pending\",\n     \"amount\": 49.00,\n     \"currencyCode\": \"GBP\",\n     \"requiresAction\": true,\n     \"actionUrl\": \"https://3ds.example.com/challenge/...\"\n   }\n   ```\n\n3. **Handle 3DS (if `requiresAction: true`)**\n\n   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.\n\n   When `requiresAction: false`, skip this step entirely.\n\n4. **Finalize the payment**\n\n   `POST /payments-api/v1/finalize-payment` with `paymentId` (your `externalOrderRef`) and a new unique `Idempotency-Key`.\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/finalize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{ \"paymentId\": \"ORDER-1042\" }'\n```\n\n#### JavaScript\n\n```js\nexport async function finalizePayment(paymentId) {\n  const res = await fetch(\n    `${process.env.FYNEX_API}/payments-api/v1/finalize-payment`,\n    {\n      method: 'POST',\n      headers: {\n        Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n        'Content-Type': 'application/json',\n        'Idempotency-Key': randomUUID(),\n      },\n      body: JSON.stringify({ paymentId }),\n    }\n  );\n  if (!res.ok) throw new Error(await res.text());\n  return res.json(); // 200 OK\n}\n```\n\n#### Python\n\n```python\ndef finalize_payment(payment_id: str) -\u003e dict:\n    res = requests.post(\n        f\"{os.environ['FYNEX_API']}/payments-api/v1/finalize-payment\",\n        headers={\n            \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n            \"Content-Type\": \"application/json\",\n            \"Idempotency-Key\": str(uuid.uuid4()),\n        },\n        json={\"paymentId\": payment_id},\n        timeout=15,\n    )\n    res.raise_for_status()\n    return res.json()\n```\n\n   The response is `200 OK`:\n\n   ```json\n   {\n     \"paymentId\": \"ORDER-1042\",\n     \"status\": \"provider_completed\",\n     \"providerPaymentId\": \"pay_01J2EXAMPLE\",\n     \"capturedAmount\": 49.00,\n     \"currencyCode\": \"GBP\"\n   }\n   ```\n\n5. **Verify and fulfil**\n\n   Mark the order paid only when `status` reaches a terminal success value. If `failureCode` is present, the payment was declined — surface the `failureDescription` to the customer as appropriate.\n\n   Poll `genericPayment(id)` if you need to check status asynchronously. See [Polling \u0026 SSE](#tag/polling-sse).\n\n## Request fields reference\n\n### `POST /initialize-payment` — required fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `externalOrderRef` | string | Your order reference. Returned as `paymentId` in all subsequent responses and used as the path param for capture/refund. |\n| `amount` | float | Major units (e.g., `49.00`). |\n| `paymentType` | string | `card`, `bank_transfer`, or `apm`. |\n| `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)](#tag/alternative-payment-methods-apm) for the redirect flow). |\n| `currencyCode` | string (3-letter) | Must be one of: `EUR`, `USD`, `GBP`, `DKK`, `NOK`, `SEK`. Upper-cased server-side. |\n| `countryCode` | string (2-letter) | ISO 3166-1 alpha-2. Length checked; no enum validation. |\n| `cardData.cardNumber` | string | Required when `paymentMethod=card`. Non-empty check only at this layer — length/Luhn validation occurs downstream. |\n| `cardData.expMonth` | int | Required when `paymentMethod=card`. Non-nil check only. |\n| `cardData.expYear` | int | Required when `paymentMethod=card`. Non-nil check only. |\n| `cardData.cvv` | string | Required when `paymentMethod=card`. Non-empty check only. |\n\n### `POST /initialize-payment` — optional fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `cardData.holderName` | string | Cardholder name. The upstream processor accepts Latin letters (`A-Z`), spaces, apostrophes, dots, and hyphens only. |\n| `autoSettlement` | bool | Must be `false` if you intend to use the manual capture flow (separate `POST /payments/{id}/capture`). Defaults to `false`. |\n| `captureMode` | string | `auto` (default) or `manual`. Set to `manual` for a separate capture step. |\n| `merchantCustomerId` | string | Your identifier for the customer. Stored on the payment as `SellerCustomerRef`. |\n| `billingDetails` | object | Cardholder billing address. Fields: `firstName`, `lastName`, `email`, `phone`, `addressLine1` (or `street`), `addressLine2`, `city`, `state`, `postalCode` (or `zip`), `countryCode` (or `country`). Optional at the API level, but **the upstream card processor requires `country` and `zip`**. Omitting them returns 502 with `{\"error\":\"\u003cprovider\u003e ... returned 400\"}`. Always populate at least country and zip. |\n| `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. |\n| `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\": \u003cenum\u003e, \"href\": \u003cabsolute http/https URL\u003e, \"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\"}`. |\n| `orderData.payeeDistribution` | array | Split the captured amount across payees. Each element: `{ \"payeeId\": \u003cint64\u003e, \"amount\": \u003cfloat\u003e }`. Amounts in major units; sum must equal `amount`. |\n| `holdPeriod` | int | Hours to keep the authorization (informational). |\n| `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`. |\n| `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`. |\n| `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. |\n\n\u003e [!CAUTION]\n\u003e **Fields that do NOT exist on `InitiatePaymentRequest`:** `saveCard`, `customerEmail`, `splitRules` (top-level), `metadata`, `successUrl`, `cancelUrl`. Do not send these.\n\u003e\n\u003e **`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)](#tag/alternative-payment-methods-apm). Card and bank_transfer payments do **not** use `returnUrl`; they use the `returnLinks` array instead.\n\u003e\n\u003e **`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\"}`.\n\n\u003e [!TIP]\n\u003e **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.\n\n### `POST /finalize-payment` — fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `paymentId` | string | Yes | The `externalOrderRef` from initialize. Used for lookup. |\n| `amount` | float | No | Partial capture: provide a value lower than the authorized amount to capture less. Omit to capture the full amount. |\n| `merchantReference` | string | No | Your internal reference for this capture. Stored at your discretion. |\n\n\u003e [!NOTE]\n\u003e **`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.\n\n## Partial capture\n\nIf you authorized £100 but only need to capture £80, pass `amount` on `/finalize-payment`:\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"amount\": 80.00\n}\n```\n\nThe remaining £20 authorization is released to the cardholder's available balance.\n\n## Split payments\n\nPass `orderData.payeeDistribution` on `/initialize-payment` to distribute funds across payees. Retrieve payee IDs from `GET /payments-api/v1/payees`.\n\n```json\n{\n  \"externalOrderRef\": \"ORDER-1042\",\n  \"amount\": 100.00,\n  \"paymentType\": \"card\",\n  \"paymentMethod\": \"card\",\n  \"currencyCode\": \"GBP\",\n  \"countryCode\": \"GB\",\n  \"cardData\": { \"...\" : \"...\" },\n  \"orderData\": {\n    \"totalAmount\": 100.00,\n    \"payeeDistribution\": [\n      { \"payeeId\": 101, \"amount\": 85.00 },\n      { \"payeeId\": 102, \"amount\": 15.00 }\n    ]\n  }\n}\n```\n\nAll amounts in major units. The sum of `payeeDistribution[*].amount` must equal `amount`.\n\n## 3DS handling\n\nWhen the processor requires a 3DS challenge, `/initialize-payment` returns:\n\n```json\n{\n  \"requiresAction\": true,\n  \"actionUrl\": \"https://3ds.example.com/challenge/...\"\n}\n```\n\n**Browser-based flow:**\n\n1. Redirect the customer's browser to `actionUrl` (full-page redirect, not an iframe).\n2. The issuer redirects back to a URL configured in your payment setup after the challenge.\n3. Once the customer is back, call `/finalize-payment` with the same `paymentId`.\n\n**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.\n\n\u003e [!NOTE]\n\u003e 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.\n\n## Skipping 3DS\n\nFor 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`:\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-1042\",\n    \"amount\": 49.00,\n    \"paymentType\": \"card\",\n    \"paymentMethod\": \"card\",\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"skip3DS\": true,\n    \"cardData\": {\n      \"cardNumber\": \"4111111111111111\",\n      \"expMonth\": 12,\n      \"expYear\": 2028,\n      \"cvv\": \"123\",\n      \"holderName\": \"Jane Doe\"\n    },\n    \"billingDetails\": {\n      \"country\": \"GB\",\n      \"zip\": \"SW1A1AA\",\n      \"city\": \"London\",\n      \"street\": \"1 Example Street\"\n    }\n  }'\n```\n\n#### JavaScript\n\n```js\nawait fetch(`${process.env.FYNEX_API}/payments-api/v1/initialize-payment`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n    'Content-Type': 'application/json',\n    'Idempotency-Key': randomUUID(),\n  },\n  body: JSON.stringify({\n    externalOrderRef: order.id,\n    amount: order.amount,\n    paymentType: 'card',\n    paymentMethod: 'card',\n    currencyCode: order.currency,\n    countryCode: order.countryCode,\n    skip3DS: true,\n    cardData: { /* ... */ },\n    billingDetails: { country: 'GB', zip: 'SW1A1AA', /* ... */ },\n  }),\n});\n```\n\n#### Python\n\n```python\nrequests.post(\n    f\"{os.environ['FYNEX_API']}/payments-api/v1/initialize-payment\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n        \"Content-Type\": \"application/json\",\n        \"Idempotency-Key\": str(uuid.uuid4()),\n    },\n    json={\n        \"externalOrderRef\": order[\"id\"],\n        \"amount\": order[\"amount\"],\n        \"paymentType\": \"card\",\n        \"paymentMethod\": \"card\",\n        \"currencyCode\": order[\"currency\"],\n        \"countryCode\": order[\"country_code\"],\n        \"skip3DS\": True,\n        \"cardData\": { ... },\n        \"billingDetails\": { \"country\": \"GB\", \"zip\": \"SW1A1AA\", ... },\n    },\n    timeout=15,\n)\n```\n\n**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 it typically reaches `provider_completed` within ~5 seconds of `/initialize-payment` returning. The `/initialize-payment` response will contain:\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"provider_pending\",\n  \"amount\": 49.00,\n  \"currencyCode\": \"GBP\",\n  \"requiresAction\": false\n}\n```\n\n`actionUrl` is absent. **You do not need to call `/finalize-payment`** — poll `genericPayment(id)` (or wait ~5s for one poller cycle) and the status will be `provider_completed`.\n\n\u003e [!CAUTION]\n\u003e **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.\n\n## Saved cards and recurring payments\n\nSaving cards for repeat charges is on the roadmap as a Wave 3 feature. When that guide ships it will cover the card-storage flow for this public API. 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.\n\n## Polling for status\n\n`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.\n\n```bash\ncurl -sS \"$FYNEX_API/payments-api/v1/payments/ORDER-1042\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\nThe response includes the Fynex lifecycle `status`, 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.) Treat the following statuses as **terminal**: `provider_completed`, `settled`, `deposit_confirmed`, `refunded`, `failed`, `cancelled`. Everything else is intermediate — keep polling, or wait for the next state-changing call on your side.\n\nAlongside 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 \u0026 SSE](#tag/polling-sse) for backoff schedules and resilience patterns.\n\n## Common pitfalls\n\n\u003e [!WARNING]\n\u003e **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 `status` before fulfilling.\n\n- **`autoSettlement` must be `false` for manual capture.** If you want to call `POST /payments/{id}/capture` separately, initialize with `autoSettlement: false` and `captureMode: \"manual\"`. The capture endpoint enforces `autoSettlement=false` as a pre-condition.\n- **`paymentType` and `paymentMethod` must be compatible.** `paymentType: \"card\"` with `paymentMethod: \"bank_account\"` returns a 400 validation error.\n- **`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.\n- **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.\n- **`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.\n- **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.\n- **`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.\n\n## See also\n\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Verify payment outcomes without webhooks.\n- **[Captures \u0026 refunds](#tag/captures-refunds)** — Capture, partially capture, or refund a payment after the fact.\n- **[Idempotency \u0026 retries](#tag/idempotency)** — Make S2S calls resilient to network failures.\n- **[Apple Pay](#tag/apple-pay)** — Accepting Apple Pay or Google Pay? Use the dedicated guides.\n- **[Google Pay](#tag/google-pay)** — Accepting Google Pay with server-to-server integration.\n- **[Alternative payment methods (APM)](#tag/alternative-payment-methods-apm)** — Accepting Bancontact, Multibanco, MB WAY, Wero, or Swish with `paymentType: \"apm\"`.\n","name":"Server-to-server"},{"description":"Apple Pay lets customers authorize payments with Face ID, Touch ID, or a paired Apple Watch without entering card details. The browser-side `ApplePaySession` API (or your own native app) returns a payment token whose `paymentData` is **encrypted by the device Secure Element** — you decrypt it server-side with your Apple Pay payment processing certificate, then send the decrypted fields to Fynex.\n\nFynex accepts Apple Pay two ways:\n\n- **Server-to-server API** — decrypt the Apple Pay token yourself, then submit the decrypted fields to `POST /payments-api/v1/initialize-payment` with `paymentMethod: \"apple_pay\"`. Bearer-authenticated, the same endpoint used for card and Google Pay. Recommended for direct integrations.\n- **Hosted-checkout (legacy)** — the `/api/v1/checkout/apple-pay-*` surface, session-cookie authenticated, where a Fynex checkout page drives the `ApplePaySession` handshake. Documented under [Hosted-checkout flow](#hosted-checkout-flow-legacy-surface) below.\n\nUnlike Google Pay — where you may hand Fynex the still-encrypted token — Apple Pay decryption is **always the integrator's responsibility**: Fynex performs no server-side Apple Pay token decryption. You decrypt the device-encrypted `paymentData` with your Apple Pay payment processing certificate and pass the resulting `decryptedData` block, which Fynex relays straight to the upstream processor's single-use-token endpoint.\n\n## Server-to-server API\n\n`POST /payments-api/v1/initialize-payment`, authenticated with your seller `Authorization: Bearer` token. Set `paymentMethod` to `apple_pay` and put the decrypted Apple Pay token under `applePayData`:\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-1042\",\n    \"amount\": 1.23,\n    \"paymentType\": \"card\",\n    \"paymentMethod\": \"apple_pay\",\n    \"currencyCode\": \"USD\",\n    \"countryCode\": \"GB\",\n    \"returnLinks\": [\n      { \"rel\": \"default\", \"href\": \"https://merchant.example/return\", \"method\": \"GET\" }\n    ],\n    \"billingDetails\": {\n      \"firstName\": \"John\", \"lastName\": \"Doe\",\n      \"street\": \"1 Example St\", \"city\": \"London\",\n      \"postalCode\": \"SW1A1AA\", \"countryCode\": \"GB\"\n    },\n    \"applePayData\": {\n      \"label\": \"Pay with Apple\",\n      \"requestBillingAddress\": true,\n      \"paymentToken\": {\n        \"transactionIdentifier\": \"\u003ctoken.transactionIdentifier\u003e\",\n        \"paymentMethod\": {\n          \"displayName\": \"MasterCard 1470\",\n          \"network\": \"MasterCard\",\n          \"type\": \"credit\"\n        },\n        \"decryptedData\": {\n          \"applicationPrimaryAccountNumber\": \"\u003cdevice PAN\u003e\",\n          \"applicationExpirationDate\": \"YYMMDD\",\n          \"currencyCode\": \"840\",\n          \"transactionAmount\": \"123\",\n          \"onlinePaymentCryptogram\": \"\u003ccryptogram\u003e\",\n          \"eciIndicator\": \"2\"\n        }\n      }\n    }\n  }'\n```\n\nThe `applePayData.paymentToken.decryptedData` fields map directly from Apple's `PKPaymentToken.paymentData` after decryption.\n\n### Apple Pay token\n\nEvery field marked required below returns `400` when it is absent. String fields are also rejected when blank; the object rows are presence checks only, so an empty object passes and fails instead on the required field inside it.\n\n| Field | Required | Notes |\n|-------|----------|-------|\n| `paymentToken.transactionIdentifier` | yes | From the Apple Pay token. |\n| `paymentToken.paymentMethod` | yes | Object presence check. |\n| `paymentToken.paymentMethod.network` | yes | `Visa`, `MasterCard`, `Amex`, … |\n| `paymentToken.decryptedData` | yes | Object presence check. |\n| `decryptedData.applicationPrimaryAccountNumber` | yes | Device PAN (DPAN). |\n| `decryptedData.applicationExpirationDate` | yes | `YYMMDD`. |\n| `decryptedData.onlinePaymentCryptogram` | yes | The 3-D Secure cryptogram. |\n| `decryptedData.version` | no | Apple's `PKPaymentToken` cryptogram version (`EC_v1` / `EC_v2` / `RSA_v1`). Forwarded to the upstream processor when set; not validated. |\n| `decryptedData.eciIndicator` | no | Apple Pay populates the ECI only for some networks (typically Visa); Mastercard / Amex tokens routinely omit it. The validator deliberately does not require it. |\n| `billingDetails` | recommended | Include a `postalCode` — if your upstream account runs an AVS check it rejects the payment when the postal code is absent. An absent block is dropped from the outbound request, not sent as empty strings. |\n\n### Header + common body (shared with every paymentMethod)\n\nThese layers run before the Apple Pay-specific checks:\n\n| Field | Format |\n|-------|--------|\n| `Idempotency-Key` (HTTP header) | UUID, non-zero |\n| `externalOrderRef` | non-empty string |\n| `amount` | number \u003e 0 |\n| `paymentType` | `\"card\"` |\n| `paymentMethod` | `\"apple_pay\"` |\n| `currencyCode` | 3-letter ISO, must pass `IsValidCurrencyCode` |\n| `countryCode` | 2-letter ISO |\n\n`paymentType` and `paymentMethod` are additionally checked against each other — an incompatible pair returns `400`.\n\n`accountId` is **not** sent by the caller — Fynex resolves the upstream merchant account from the seller's terminal routing.\n\nA fresh request returns `202 Accepted`:\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"provider_pending\",\n  \"amount\": 1.23,\n  \"currencyCode\": \"USD\",\n  \"requiresAction\": false\n}\n```\n\nWhen `requiresAction` is `true`, redirect the customer to the returned action URL for the 3-D Secure step-up. Submitting the same `Idempotency-Key` returns the existing payment instead of creating a duplicate.\n\n## Upstream outbound mapping (for debugging processor rejections)\n\nThe Fynex DTO is flatter than the upstream processor's shape: `decryptedData.onlinePaymentCryptogram` and `decryptedData.eciIndicator` sit at the same level as `applicationPrimaryAccountNumber` in the inbound request, but Fynex re-wraps them into the processor's nested `decryptedData.paymentData.{onlinePaymentCryptogram, eciIndicator}` block on the way out. Use the mapping below when comparing a processor reject reason to your outbound request body.\n\n| Fynex DTO path | Upstream outbound path |\n|----------------|-----------------------|\n| `applePayData.paymentToken.transactionIdentifier` | `applePay.applePayPaymentToken.token.transactionIdentifier` |\n| `applePayData.paymentToken.paymentMethod.network` | `applePay.applePayPaymentToken.token.paymentMethod.network` |\n| `applePayData.paymentToken.decryptedData.version` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.version` |\n| `applePayData.paymentToken.decryptedData.applicationPrimaryAccountNumber` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.applicationPrimaryAccountNumber` |\n| `applePayData.paymentToken.decryptedData.applicationExpirationDate` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.applicationExpirationDate` |\n| `applePayData.paymentToken.decryptedData.onlinePaymentCryptogram` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.paymentData.onlinePaymentCryptogram` (one level deeper) |\n| `applePayData.paymentToken.decryptedData.eciIndicator` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.paymentData.eciIndicator` (one level deeper) |\n\n### Caveats\n\n1. **The processor does not mark fields as required vs optional explicitly** — there is only a sample. Fynex's validator picks the minimum set without which the processor rejects. Stricter upstream requirements may exist for specific networks or 3DS levels.\n2. **`paymentToken.paymentMethod`** is required as an object (Fynex validates `network`), but `displayName` and `type` are optional in the Fynex DTO. They are accepted upstream when present.\n3. **Passthrough fields not validated by Fynex** but present in the processor's sample: `decryptedData.currencyCode` (numeric ISO 4217, e.g. `\"840\"`), `transactionAmount`, `cardholderName`, `deviceManufacturerIdentifier`, `paymentDataType`. They round-trip when set on the inbound request.\n4. **`billingDetails`** is a top-level object, not part of the Apple Pay token block. When it is not set on the inbound request the field is dropped rather than sent as empty strings, so the processor never sees a populated-with-empty-strings billing block. Include a `postalCode` if your upstream account runs an AVS check.\n\n## Hosted-checkout flow (legacy surface)\n\nThe remainder of this guide covers the legacy `/api/v1/checkout/apple-pay-*` surface, where a Fynex-hosted checkout page drives the browser `ApplePaySession` handshake. It is session-cookie authenticated and remains available for hosted-checkout integrations.\n\n## Prerequisites\n\nBefore showing the Apple Pay button you need two things in place:\n\n1. **The Apple Pay JS SDK** — load `https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js` before your checkout code runs.\n2. **Browser support** — check `window.ApplePaySession` before rendering the button. Apple Pay is only available in Safari on Apple devices (or macOS + Safari + an Apple Pay-enrolled card).\n\n```html\n\u003c!-- Load the Apple Pay SDK in your page head --\u003e\n\u003cscript\n  src=\"https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js\"\n  crossorigin=\"anonymous\"\n\u003e\u003c/script\u003e\n```\n\n## Flow overview\n\n```\nCustomer browser            Your backend               Fynex\n─────────────────           ─────────────              ─────\ncheck ApplePaySession\n  available?\nshow \"Apple Pay\" button\ncustomer clicks button\n  ──────────────────────►  validate merchant session  (call Apple from\n                           (production: call Apple     your own server —\n                           from your server)           no Fynex endpoint)\n                           ◄── merchantSession\n  completeMerchantValidation\n  (Apple Pay sheet opens)\n  customer authorizes\n  onpaymentauthorized fires\n  applePaymentToken\n  ──────────────────────►  POST /api/v1/checkout/     apple-pay-submit\n                           ◄── { success, status,\n                                 requiresAction?,\n                                 actionUrl? }\n                           if requiresAction (3DS):\n                             redirect browser to\n                             actionUrl (customer\n                             authenticates); the\n                             hosted page finalizes\n                             through the shared\n                             checkout contract\n  session.completePayment(ApplePaySession.STATUS_SUCCESS)\n```\n\n## API calls in this flow\n\n| Step | Method | Path |\n|------|--------|------|\n| 1 | POST | Your own server-side merchant-validation endpoint (calls Apple — there is no Fynex endpoint for this) |\n| 2 | POST | `/api/v1/checkout/apple-pay-submit` — initializes the payment through the shared checkout pipeline; there is no separate completion call |\n\n## Step-by-step\n\n1. **Check availability and show the button**\n\n   Gate the Apple Pay button on `ApplePaySession` being present in the browser window.\n\n   ```js\n   function isApplePayAvailable() {\n     return (\n       typeof window !== 'undefined' \u0026\u0026\n       'ApplePaySession' in window \u0026\u0026\n       ApplePaySession.canMakePayments()\n     );\n   }\n\n   if (isApplePayAvailable()) {\n     document.getElementById('apple-pay-button').style.display = 'block';\n   }\n   ```\n\n2. **Handle the button click — open an `ApplePaySession`**\n\n   When the customer clicks the Apple Pay button, open a session. The `countryCode` is currently hardcoded to `US` in the Fynex dashboard — see the limitation note below.\n\n   ```js\n   async function startApplePayPayment({ amount, currencyCode }) {\n     const paymentRequest = {\n       countryCode: 'US',          // current limitation — see note below\n       currencyCode,\n       merchantCapabilities: ['supports3DS'],\n       supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'],\n       total: {\n         label: 'Your business name',\n         amount: String(amount),\n       },\n     };\n\n     const session = new ApplePaySession(3, paymentRequest);\n\n     // Step 3: merchant validation\n     session.onvalidatemerchant = async (event) =\u003e {\n       try {\n         const merchantSession = await validateMerchantWithServer(\n           event.validationURL\n         );\n         session.completeMerchantValidation(merchantSession);\n       } catch (err) {\n         session.abort();\n         throw err;\n       }\n     };\n\n     // Step 4: customer authorizes — submit to Fynex\n     session.onpaymentauthorized = async (event) =\u003e {\n       const applePayToken = event.payment.token;\n       try {\n         await submitAndCapture({ applePayToken, amount, currencyCode });\n         session.completePayment(ApplePaySession.STATUS_SUCCESS);\n       } catch (err) {\n         session.completePayment(ApplePaySession.STATUS_FAILURE);\n         throw err;\n       }\n     };\n\n     session.begin();\n   }\n   ```\n\n\u003e [!CAUTION]\n\u003e    **Country code limitation.** The Fynex dashboard currently hardcodes `countryCode: 'US'` in the `ApplePaySession` request. This means the payment request always presents as a US transaction. Custom integrations should pass the correct country code for their merchant account.\n\n3. **Validate the merchant session (server-side)**\n\n   Apple requires your server to contact Apple's servers and validate the merchant session before the payment sheet is shown to the customer. In the Fynex dashboard this step is currently faked — it calls `completeMerchantValidation({})` with an empty object, which will fail against real Apple Pay in production.\n\n   For a real integration you must implement a server-side endpoint that:\n   - Accepts the `validationURL` from the browser\n   - Calls Apple's merchant validation endpoint using your Apple Pay merchant certificate and private key\n   - Returns the opaque merchant session object to the browser\n\n   ```js\n   // Browser: call your own server-side validation endpoint\n   async function validateMerchantWithServer(validationURL) {\n     const res = await fetch('/api/apple-pay/validate-merchant', {\n       method: 'POST',\n       headers: { 'Content-Type': 'application/json' },\n       body: JSON.stringify({ validationURL }),\n     });\n     if (!res.ok) throw new Error('Merchant validation failed');\n     return res.json(); // returns the opaque merchant session from Apple\n   }\n   ```\n\n   \u003e [!NOTE]\n   \u003e Fynex does **not** expose a merchant-session-validation endpoint. Merchant validation must call Apple's `validationURL` directly from your own server using your Apple Pay merchant certificate and private key — the browser cannot make this call due to CORS. Return the opaque merchant session from your endpoint (as shown in `validateMerchantWithServer` above) and hand it to `session.completeMerchantValidation(...)`.\n\n4. **Submit the Apple Pay token to Fynex**\n\n   Once the customer authorizes the payment, `onpaymentauthorized` fires with an encrypted `applePayToken`. Submit it to Fynex:\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_LEGACY_API/api/v1/checkout/apple-pay-submit\" \\\n  -H \"Content-Type: application/json\" \\\n  -b \"session_id=$SESSION_COOKIE\" \\\n  -d '{\n    \"sessionId\": \"6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n    \"token\": { \"...\": \"encrypted token from Apple\" }\n  }'\n```\n\n#### JavaScript\n\n```js\nasync function submitApplePay({ sessionId, token }) {\n  const res = await fetch(\n    `${FYNEX_LEGACY_API}/api/v1/checkout/apple-pay-submit`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      credentials: 'include',\n      body: JSON.stringify({ sessionId, token }),\n    }\n  );\n  if (!res.ok) throw new Error(`Submit failed: ${res.status}`);\n  return res.json();\n  // Response: { success, message, status, failureCode, failureDescription, requiresAction?, actionUrl? }\n}\n```\n\n   The response contains:\n\n   | Field | Type | Notes |\n   |-------|------|-------|\n   | `success` | boolean | Whether the payment was initialized |\n   | `status` | string | The payment's lifecycle status after initialization |\n   | `failureCode` / `failureDescription` | int / string | Set when initialization failed — see [Errors](#tag/errors) |\n   | `requiresAction` | boolean? | `true` when the issuer requires 3DS |\n   | `actionUrl` | string? | Where to send the browser when `requiresAction` is true |\n\n5. **Let the hosted page finish**\n\n   There is no completion call. `apple-pay-submit` already initialized the payment through\n   the same pipeline a card payment takes, and the response's `status` tells you where it\n   stands. If `requiresAction` is `true`, send the browser to `actionUrl` for 3DS; the hosted\n   checkout page finalizes the payment on return through the shared checkout contract and\n   redirects to your `returnUrls`. The former `complete-apple-pay-payment` route bypassed the\n   platform's antifraud and compliance gates and has been removed.\n\n```js\n// Putting it together\nasync function submitAndFinish({ sessionId, token }) {\n  const result = await submitApplePay({ sessionId, token });\n  if (!result.success) {\n    throw new Error(`Apple Pay failed (${result.failureCode}): ${result.failureDescription}`);\n  }\n  if (result.requiresAction \u0026\u0026 result.actionUrl) {\n    window.location.href = result.actionUrl; // 3DS — the hosted page completes the payment on return\n    return;\n  }\n  return result.status; // e.g. provider_pending / provider_completed — confirm via webhook or GET /payments/{id}\n}\n```\n\n## Common pitfalls\n\n\u003e [!WARNING]\n\u003e **Merchant validation must be server-side in production.** The Fynex dashboard fakes merchant validation with an empty object. This is a development shortcut that will fail against real Apple Pay. Real merchant validation requires an Apple Pay merchant certificate and a server-side call to Apple's validation URL. Your browser cannot make this call directly due to CORS restrictions.\n\n- **Apple Pay only shows in Safari on Apple devices.** Always gate the button on `isApplePayAvailable()`. Chrome and Firefox do not support `ApplePaySession`.\n- **The country code is currently hardcoded to `US`.** If your merchant account is registered in another country, override `countryCode` in the `ApplePaySession` request.\n- **3DS happens on the hosted page.** If `requiresAction` is `true`, the browser is sent to `actionUrl`; the hosted checkout page finishes the payment on return through the shared checkout contract and redirects to your `returnUrls`. There is nothing to store client-side and no completion call to make. See the [3DS guide](#tag/3ds) for the full pattern.\n- **Call `session.completePayment()` always.** Whether the payment succeeds or fails, you must call `session.completePayment(STATUS_SUCCESS)` or `session.completePayment(STATUS_FAILURE)` to dismiss the Apple Pay sheet gracefully.\n\n## See also\n\n- **[Hosted Checkout](#tag/hosted-checkout)** — The lowest-PCI path to accepting payments — Fynex-hosted page handles card entry.\n- **[Server-to-Server Payments](#tag/server-to-server)** — Direct card submission for integrations that collect card data on their own infrastructure.\n- **[Errors](#tag/errors)** — HTTP status codes and error response shapes.\n","name":"Apple Pay"},{"description":"Fynex supports three ways to accept Google Pay:\n\n- **Hosted Checkout** — Fynex renders the Google Pay button on its own checkout page. Your servers never see the token. Lowest PCI scope and the recommended path for most integrations.\n- **Encrypted Direct API** — you render the Google Pay button on your own page, capture the signed token from Google's JS SDK, and submit it **as-is** (still encrypted) to `POST /payments-api/v1/initialize-payment`. Fynex hands the token to the upstream processor for server-side decryption. You don't hold private keys.\n- **Decrypted Direct API** — you decrypt the Google Pay token on your server using your own DIRECT-mode key pair, then send Fynex the decrypted card data. Use this only if you already operate ECv2 keys.\n\n\u003e [!CAUTION]\n\u003e **Availability today (2026-05-13)**\n\u003e\n\u003e All three flows are enabled on `staging-api.fynex.ai`. **Production** (`api.fynex.ai`) is not yet running for Direct API; contact Fynex support before you cut over.\n\n## Choosing an integration model\n\n#### Hosted Checkout (recommended)\n\n- You call `POST /payments-api/v1/checkout`, redirect the customer, receive the outcome by webhook or polling.\n- Fynex handles `isReadyToPay`, the GP button, the encrypted token, decryption, and the 3DS step-up.\n- Lowest PCI scope.\n- No Google Pay Console registration required on your side — Fynex's platform merchant ID is used.\n\n#### Encrypted Direct API\n\n- You render the Google Pay button using the Google Pay JS SDK on your own page.\n- You forward `paymentData.paymentMethodData.tokenizationData.token` (a signed string) verbatim to `POST /initialize-payment`.\n- Fynex hands the signed token to the upstream processor, which decrypts it server-side.\n- You don't run ECv2 decryption and don't hold private keys.\n- Recommended when Hosted Checkout doesn't fit your UX.\n\n#### Decrypted Direct API\n\n- You register your own Google Pay merchant in the Google Pay Business Console and own the DIRECT-mode key pair.\n- Your frontend calls `paymentsClient.loadPaymentData(...)` with `tokenizationSpecification.type = 'DIRECT'`; your backend decrypts the JWE.\n- You send the **decrypted** payload (PAN, cryptogram, expiry, …) to `POST /initialize-payment`.\n- For integrators who already run ECv2 decryption pipelines.\n\nIf you're not sure which to pick, use Hosted Checkout. If you need a non-hosted UI without running decryption yourself, use Encrypted Direct API.\n\n### Why Encrypted is the default Direct API recommendation\n\nBoth Direct API flows authorize the same card payment. The split between Encrypted and Decrypted is really a split between *who decrypts the Google Pay wallet token*, and that one decision changes the integration cost dramatically:\n\n| Concern | Encrypted | Decrypted |\n|---|---|---|\n| Card-data PCI scope | No PAN exposure — opaque ciphertext only (typically SAQ A-EP) | PAN/cryptogram pass through your process memory (SAQ D-Merchant) |\n| Private-key management | None | You generate, store, rotate an ECv2 EC P-256 key pair |\n| Server-side crypto code | None — forward the signed string verbatim | You implement ECv2 decryption (signed-then-encrypted, AES-CTR + HMAC-SHA256) |\n| Google Pay Console setup | Use Fynex's registered gateway (`fondyeu`) — fast | Register your own merchant + public key — slower |\n| Breach blast radius | Attacker can't decrypt without the gateway's key | Attacker can read decrypted PANs from memory / heap dumps |\n| Time to integrate | Forward one string, get a payment handle | Build + audit a crypto pipeline |\n\nThe Decrypted flow only earns its overhead when you already carry a full PCI DSS compliance assessment (ROC) with on-premise key management and want explicit control over the PAN — typically a large merchant doing per-network routing or a payment orchestrator. Anyone smaller should prefer Encrypted.\n\n## Hosted Checkout\n\nThe seller-facing API is just `POST /payments-api/v1/checkout` — exactly the same call you would make for a card-only hosted checkout. Google Pay availability is a property of the deploy, not the request.\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/checkout\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-1042\",\n    \"amount\": 49.99,\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"description\": \"Order #1042\",\n    \"returnUrls\": {\n      \"success\": \"https://merchant.example/orders/1042/success\",\n      \"failure\": \"https://merchant.example/orders/1042/failure\"\n    }\n  }'\n```\n\nResponse (truncated to the field you care about):\n\n```json\n{\n  \"sessionId\": \"6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n  \"checkoutUrl\": \"https://pay.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n  \"expiresAt\": \"...\"\n}\n```\n\nRedirect the customer to `checkoutUrl`. The page that loads will display:\n\n- A card-entry form (always).\n- A **Google Pay** button — only if all of the following are true:\n  - Fynex has Google Pay enabled for the deploy your checkout session was created on.\n  - The customer's browser supports Google Pay (`PaymentsClient.isReadyToPay()` returns true).\n  - The customer has at least one saved card in their Google account that Google Pay can tokenize.\n- An **Apple Pay** button — only if Apple Pay is enabled on the deploy. Apple Pay availability requires Apple Developer certificates plus an upstream-processor-issued payment processing certificate to be wired in. Until that's done the button stays hidden.\n\nSee [Hosted Checkout](#tag/hosted-checkout) for the full request reference, return-URL behavior, and webhook delivery.\n\n### Customer-side flow\n\n1. **Customer arrives at `https://pay.fynex.ai/checkout/{session_id}`.** Fynex serves an HTML page rendered server-side; the page includes the Google Pay JS SDK and a hidden GP button.\n\n2. **The page runs `isReadyToPay()` in the customer's browser.** If the result is positive *and* the deploy has Google Pay configured, the button becomes visible. Otherwise the page hides it and shows only the card form — there's no \"broken button\" state.\n\n3. **Customer clicks Google Pay.** The native Google Pay sheet opens; the customer picks a card and authorizes.\n\n4. **The page POSTs the encrypted GP token to `/api/v1/checkout/google-pay-submit`** along with the `sessionId`. The handler:\n   - Resolves the **authoritative `amount`, `currencyCode`, and upstream-provider `accountId`** server-side from the CheckoutSession + GenericPayment that back the session. Body-supplied amount/currency are **ignored** to defeat a class of browser-extension / XSS attacks that would otherwise pay $0.01 for a $19.99 order.\n   - Sends the encrypted JWE token to the upstream card processor via its Single-Use Token endpoint, using credentials that the processor has enrolled the seller's Google Pay merchant against. The token never leaves the Fynex backend in plaintext.\n   - Receives back a `paymentHandleToken` and `merchantRefNum`.\n\n5. **The page completes the payment through the shared checkout contract** — the same initialize/finalize path a card payment takes, using the `paymentHandleToken` and `merchantRefNum` from step 4. There is no separate `complete-google-pay-payment` call: that legacy route bypassed the platform's antifraud and compliance gates and has been removed. The outcome is mirrored onto the payment (`provider_completed` on success, with `providerPaymentId` set).\n\n6. **Customer is redirected** to your `returnUrls.success` or `.failure` URL, exactly as for a card payment.\n\n\u003e [!NOTE]\n\u003e Steps 4 and 5 are internal to the Fynex-hosted page. As a seller integration you never call those endpoints; you only see the final state via webhooks, `GET /payments-api/v1/payments/{payment_id}`, or the `returnUrls` redirect.\n\n## Encrypted Direct API\n\nThis is the recommended Direct API path. You take ownership of the Google Pay button but **not** of the token decryption — Fynex passes the signed token to the upstream processor, which decrypts it server-side. You don't need private keys, ECv2 libraries, or PCI scope on the card data.\n\n### Prerequisites\n\n1. Your seller account has `google_pay` in `allowedPaymentMethods` (check via `GET /payments-api/v1/payment-methods`).\n2. A Google Pay merchant ID in the [Google Pay Business Console](https://pay.google.com/business/console), with domains whitelisted where you render the button.\n3. Checkout currency supported (`USD`, `EUR`, `GBP`).\n\n### Step 1: Get a token from Google\n\nIn your frontend, load the Google Pay JS SDK and request a payment data with `PAYMENT_GATEWAY` tokenization (Fynex's registered gateway is `fondyeu` — that exact string is what Google's tokenization spec requires):\n\n```javascript\nconst paymentsClient = new google.payments.api.PaymentsClient({ environment: 'PRODUCTION' });\n\nconst paymentDataRequest = {\n  apiVersion: 2,\n  apiVersionMinor: 0,\n  allowedPaymentMethods: [{\n    type: 'CARD',\n    parameters: {\n      allowedAuthMethods: ['PAN_ONLY', 'CRYPTOGRAM_3DS'],\n      allowedCardNetworks: ['VISA', 'MASTERCARD', 'AMEX', 'DISCOVER'],\n    },\n    tokenizationSpecification: {\n      type: 'PAYMENT_GATEWAY',\n      parameters: {\n        gateway: 'fondyeu',\n        gatewayMerchantId: '\u003cyour-fynex-merchant-id\u003e',\n      },\n    },\n  }],\n  transactionInfo: {\n    totalPriceStatus: 'FINAL',\n    totalPrice: '49.99',\n    currencyCode: 'GBP',\n  },\n  merchantInfo: {\n    merchantId: '\u003cyour-google-pay-merchant-id\u003e',\n    merchantName: 'Your Store',\n  },\n};\n\nconst paymentData = await paymentsClient.loadPaymentData(paymentDataRequest);\n// paymentData.paymentMethodData.tokenizationData.token is the JSON-encoded\n// signed-and-encrypted payload — pass it through verbatim.\nconst signedToken = paymentData.paymentMethodData.tokenizationData.token;\n```\n\n### Step 2: Call `/initialize-payment` with the signed token\n\n`POST /payments-api/v1/initialize-payment` (headers: `Authorization: Bearer \u003cseller_token\u003e`, `Idempotency-Key: \u003cUUID v4\u003e`):\n\n```json\n{\n  \"externalOrderRef\": \"ORDER-100106\",\n  \"amount\": 49.99,\n  \"paymentType\": \"card\",\n  \"paymentMethod\": \"google_pay\",\n  \"currencyCode\": \"GBP\",\n  \"countryCode\": \"GB\",\n  \"autoSettlement\": true,\n  \"googlePayData\": {\n    \"paymentToken\": {\n      \"apiVersion\": 2,\n      \"apiVersionMinor\": 0,\n      \"paymentMethodData\": {\n        \"type\": \"CARD\",\n        \"description\": \"Visa •••• 1234\",\n        \"info\": {\n          \"cardNetwork\": \"VISA\",\n          \"cardDetails\": \"1234\"\n        },\n        \"tokenizationData\": {\n          \"type\": \"PAYMENT_GATEWAY\",\n          \"token\": \"\u003cpaymentData.paymentMethodData.tokenizationData.token verbatim\u003e\"\n        }\n      }\n    }\n  },\n  \"billingDetails\": {\n    \"firstName\": \"John\",\n    \"lastName\": \"Doe\",\n    \"email\": \"john.doe@example.com\",\n    \"addressLine1\": \"1 Example Street\",\n    \"city\": \"London\",\n    \"countryCode\": \"GB\",\n    \"postalCode\": \"SW1A1AA\"\n  },\n  \"returnLinks\": [\n    { \"rel\": \"default\", \"href\": \"https://merchant.example/payments/return\", \"method\": \"GET\" }\n  ]\n}\n```\n\nValidation rules:\n\n- Exactly one of `tokenizationData.token` (encrypted) or `tokenizationData.decryptedToken` (decrypted, see below) must be present. Providing both returns `400`.\n- `tokenizationData.type` is required and must match the payload kind: `\"PAYMENT_GATEWAY\"` when `token` is set, `\"DIRECT\"` when `decryptedToken` is set. Empty or mismatched values return `400`.\n- Top-level `billingDetails` is required for AVS scoring. Minimum: `countryCode` and `postalCode`. You may use the aliases `country` / `zip`. Omitting them returns a `502` with body `{\"error\":\"upstream card processor returned 400\"}`.\n\nResponse and capture flow are the same as the decrypted variant — see [Common response shape](#common-response-shape) below.\n\n## Decrypted Direct API\n\nUse this only if you already operate your own Google Pay DIRECT-mode key pair and prefer to decrypt server-side. Fynex receives the decrypted card data and forwards it to the upstream processor's *decrypted-handle* endpoint.\n\n### Prerequisites (in addition to Encrypted)\n\n- Your own Google Pay merchant in the Google Pay Business Console.\n- A DIRECT-mode key pair you registered with Google. Fynex never holds your private key.\n- A server-side ECv2 decryption library.\n\n### Step 1: Get and decrypt a token from Google\n\nSame JS SDK call as the Encrypted variant, but with `tokenizationSpecification.type = 'DIRECT'` and your `publicKey`:\n\n```javascript\ntokenizationSpecification: {\n  type: 'DIRECT',\n  parameters: {\n    protocolVersion: 'ECv2',\n    publicKey: '\u003cyour-base64-encoded-public-key\u003e',\n  },\n}\n```\n\nDecrypt `paymentData.paymentMethodData.tokenizationData.token` on your server using the [Google Pay payment-data cryptography spec](https://developers.google.com/pay/api/web/guides/resources/payment-data-cryptography). The decrypted payload looks like:\n\n```json\n{\n  \"messageId\": \"AH2EjtfX...\",\n  \"messageExpiration\": \"1715620000000\",\n  \"paymentMethod\": \"CARD\",\n  \"paymentMethodDetails\": {\n    \"authMethod\": \"CRYPTOGRAM_3DS\",\n    \"pan\": \"4111111111111111\",\n    \"expirationMonth\": 12,\n    \"expirationYear\": 2028,\n    \"cryptogram\": \"AgAAAAAABk4...\",\n    \"eciIndicator\": \"05\"\n  }\n}\n```\n\n### Step 2: Call `/initialize-payment` with the decrypted payload\n\n```json\n{\n  \"externalOrderRef\": \"ORDER-100106\",\n  \"amount\": 49.99,\n  \"paymentType\": \"card\",\n  \"paymentMethod\": \"google_pay\",\n  \"currencyCode\": \"GBP\",\n  \"countryCode\": \"GB\",\n  \"autoSettlement\": true,\n  \"googlePayData\": {\n    \"paymentToken\": {\n      \"apiVersion\": 2,\n      \"apiVersionMinor\": 0,\n      \"paymentMethodData\": {\n        \"type\": \"CARD\",\n        \"description\": \"Visa •••• 1234\",\n        \"info\": { \"cardNetwork\": \"VISA\", \"cardDetails\": \"1234\" },\n        \"tokenizationData\": {\n          \"type\": \"DIRECT\",\n          \"decryptedToken\": {\n            \"messageId\": \"AH2EjtfX...\",\n            \"messageExpiration\": \"1715620000000\",\n            \"paymentMethod\": \"CARD\",\n            \"paymentMethodDetails\": {\n              \"authMethod\": \"CRYPTOGRAM_3DS\",\n              \"pan\": \"4111111111111111\",\n              \"expirationMonth\": 12,\n              \"expirationYear\": 2028,\n              \"cryptogram\": \"AgAAAAAABk4...\",\n              \"eciIndicator\": \"05\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"billingDetails\": { \"...\": \"same as Encrypted\" },\n  \"returnLinks\": [ { \"rel\": \"default\", \"href\": \"https://merchant.example/payments/return\", \"method\": \"GET\" } ]\n}\n```\n\nServer-side validation for the decrypted variant:\n\n- `pan` is **required**.\n- `cryptogram` is **required when `authMethod` is `CRYPTOGRAM_3DS`**. `PAN_ONLY` tokens carry no network cryptogram and are accepted on this flow without one.\n- `expirationMonth` and `expirationYear` are **required**.\n- `eciIndicator` is recommended for `CRYPTOGRAM_3DS` but not enforced by Fynex.\n\n## Common response shape\n\nBoth Direct API models share the same response. `POST /initialize-payment` returns `202 Accepted`:\n\n```json\n{\n  \"paymentId\": \"ORDER-100106\",\n  \"status\": \"provider_pending\",\n  \"amount\": 49.99,\n  \"currencyCode\": \"GBP\",\n  \"requiresAction\": true,\n  \"actionUrl\": \"https://example-acs.test/challenge\"\n}\n```\n\n- When `requiresAction = true`, redirect the customer to `actionUrl` to complete the 3DS challenge. After the challenge, the payment moves through `provider_pending` to `provider_completed`.\n- When `requiresAction = false`, the handle is ready for capture.\n\nThen `POST /finalize-payment` (reuse the same `Idempotency-Key` or generate a fresh UUID — both work):\n\n```json\n{ \"paymentId\": \"ORDER-100106\" }\n```\n\n`amount` is optional. Omitting it captures the full authorized amount; passing a value must be **less than or equal to** the originally authorized amount (partial capture).\n\nResponse `200 OK`:\n\n```json\n{\n  \"paymentId\": \"ORDER-100106\",\n  \"status\": \"provider_completed\",\n  \"providerPaymentId\": \"pay_01J2EXAMPLE\",\n  \"providerCode\": \"pp_01\",\n  \"capturedAmount\": 49.99,\n  \"currencyCode\": \"GBP\",\n  \"failureCode\": 0,\n  \"failureDescription\": \"\"\n}\n```\n\n## `googlePayData` field reference\n\n### `tokenizationData` — Encrypted (model B)\n\n| Field   | Type   | Required | Description                                                              |\n|---------|--------|----------|--------------------------------------------------------------------------|\n| `type`  | string | yes      | Must be `\"PAYMENT_GATEWAY\"` for the encrypted flow.                       |\n| `token` | string | yes      | Raw signed token from `paymentData.paymentMethodData.tokenizationData.token`. JSON-encoded string. |\n\n### `tokenizationData` — Decrypted (model C)\n\n| Field             | Type   | Required | Description                                |\n|-------------------|--------|----------|--------------------------------------------|\n| `type`            | string | yes      | Must be `\"DIRECT\"` for the decrypted flow.  |\n| `decryptedToken`  | object | yes      | Decrypted Google Pay payload — see below.   |\n\nSend **exactly one** of `token` or `decryptedToken` per request. `type` is required and must match the payload kind — mismatches return `400`.\n\n### `decryptedToken`\n\n| Field                      | Type   | Required | Description                                                  |\n|----------------------------|--------|----------|--------------------------------------------------------------|\n| `gatewayMerchantId`        | string | no       | Echoed verbatim to the upstream processor.                   |\n| `messageId`                | string | yes (processor contract) | `messageId` from the decrypted payload. Forwarded, but not enforced by the Fynex validator. |\n| `messageExpiration`        | string | yes (processor contract) | Epoch milliseconds as a string. Not checked by the request validator, but it must parse as an integer later — see caveat 2. |\n| `paymentMethod`            | string | yes (processor contract) | Always `\"CARD\"`. Forwarded, but not enforced by the Fynex validator. |\n| `paymentMethodDetails`     | object | yes      | See below. Missing returns `400`.                             |\n\n### `paymentMethodDetails`\n\n| Field             | Type   | Required | Description                                                       |\n|-------------------|--------|----------|-------------------------------------------------------------------|\n| `authMethod`      | string | yes (processor contract) | `\"PAN_ONLY\"` or `\"CRYPTOGRAM_3DS\"`. Matched case-insensitively and forwarded upstream in the spec spelling; a value that is neither is passed through unchanged, and the Fynex validator does NOT enforce the enum today. |\n| `pan`             | string | yes      | Decrypted card PAN. Missing returns `400`.                        |\n| `expirationMonth` | int    | yes (\u003e 0) | 1–12. Zero or missing returns `400`.                             |\n| `expirationYear`  | int    | yes (\u003e 0) | Full year (e.g. `2028`). Zero or missing returns `400`.          |\n| `cryptogram`      | string | yes when `authMethod` is `CRYPTOGRAM_3DS` | The network cryptogram. Omitting it on a `CRYPTOGRAM_3DS` token returns `400`. `PAN_ONLY` tokens carry no cryptogram and are accepted without one. |\n| `eciIndicator`    | string | no       | Recommended for `CRYPTOGRAM_3DS` (usually `\"05\"`/`\"06\"`). Not enforced by Fynex; gated to CRYPTOGRAM_3DS at the outbound builder so PAN_ONLY tokens don't get rejected with \"Invalid field when authMethod is not CRYPTOGRAM_3DS\". |\n\n### `billingDetails` (top level of `InitiatePaymentRequest`)\n\nRequired for AVS. Minimum fields: `countryCode` (alias `country`) and `postalCode` (alias `zip`).\n\n### Parent-object presence checks\n\nBefore the field-level checks above, the validator walks the request shape and rejects any missing parent. Each `nil` returns `400` with a clear path-rooted message:\n\n- `googlePayData`\n- `googlePayData.paymentToken`\n- `googlePayData.paymentToken.paymentMethodData`\n- `googlePayData.paymentToken.paymentMethodData.tokenizationData`\n- `tokenizationData.token` xor `tokenizationData.decryptedToken` — send exactly one; neither or both returns `400`\n- `tokenizationData.type == \"PAYMENT_GATEWAY\"` when `token` is set\n- `tokenizationData.type == \"DIRECT\"` when `decryptedToken` is set\n\n### Header + common body (shared with every paymentMethod)\n\n| Field | Format |\n|-------|--------|\n| `Idempotency-Key` (HTTP header) | UUID, non-zero |\n| `externalOrderRef` | non-empty string |\n| `amount` | number \u003e 0 |\n| `paymentType` | `\"card\"` |\n| `paymentMethod` | `\"google_pay\"` |\n| `currencyCode` | 3-letter ISO, must pass `IsValidCurrencyCode` |\n| `countryCode` | 2-letter ISO |\n\n`paymentType` and `paymentMethod` are additionally checked against each other — an incompatible pair returns `400`.\n\n## Upstream outbound mapping (for debugging processor rejections)\n\nUnlike Apple Pay, the Google Pay DTO mirrors the upstream processor's shape closely — the only nesting difference is at the root: Fynex's `googlePayData` becomes the processor's `googlePay.googlePayPaymentToken`.\n\n| Fynex DTO path | Upstream outbound path |\n|----------------|-----------------------|\n| `googlePayData.paymentToken.apiVersion` | `googlePay.googlePayPaymentToken.apiVersion` |\n| `googlePayData.paymentToken.apiVersionMinor` | `googlePay.googlePayPaymentToken.apiVersionMinor` |\n| `googlePayData.paymentToken.paymentMethodData.description` | `googlePay.googlePayPaymentToken.paymentMethodData.description` |\n| `…tokenizationData.type` | `…tokenizationData.type` |\n| `…tokenizationData.decryptedToken.paymentMethodDetails.pan` | `…tokenizationData.decryptedToken.paymentMethodDetails.pan` |\n| `…paymentMethodDetails.expirationMonth` | `…paymentMethodDetails.expirationMonth` |\n| `…paymentMethodDetails.expirationYear` | `…paymentMethodDetails.expirationYear` |\n| `…paymentMethodDetails.cryptogram` | `…paymentMethodDetails.cryptogram` (sent only when `authMethod` is `CRYPTOGRAM_3DS`, matched case-insensitively) |\n| `…paymentMethodDetails.eciIndicator` | `…paymentMethodDetails.eciIndicator` (sent only when `authMethod` is `CRYPTOGRAM_3DS`, matched case-insensitively) |\n\n### Caveats\n\n1. **`authMethod` enum is not enforced.** The validator does not check that `authMethod` is one of `PAN_ONLY` / `CRYPTOGRAM_3DS`. An empty or arbitrary string passes through to the processor, which will reject it with its own (less helpful) error.\n2. **`paymentMethod`, `messageId`, `messageExpiration` are required by the processor's sample** but the Fynex validator does not enforce them, and each behaves differently when omitted. `messageId` round-trips exactly as sent, including empty. `paymentMethod` defaults to `\"CARD\"`. `messageExpiration` must parse as an epoch-milliseconds integer — a missing or malformed value fails the payment **during processing, after the `202`**, with a Fynex error rather than an upstream one. Send all three.\n3. **`cryptogram` gating on `authMethod`.** Fynex requires `cryptogram` only when `authMethod` is `CRYPTOGRAM_3DS`, so a `PAN_ONLY` token validates without one — do **not** send a placeholder value. The outbound `cryptogram` + `eciIndicator` are additionally gated on `authMethod` being `CRYPTOGRAM_3DS` (matched case-insensitively) before the request leaves Fynex, which avoids the processor's \"Invalid field when authMethod is not CRYPTOGRAM_3DS\" error. A value that is **neither** enum neither requires a `cryptogram` nor forwards one, so nothing is demanded of you that Fynex would discard — but the payment will still fail upstream on the unrecognised value. Send one of the two documented values.\n4. **`gatewayMerchantId` is forwarded, not ignored.** It is not part of the processor's decrypted-token request sample, but Fynex passes it through verbatim when set. If you send it, send the same value your Google Pay client used — the processor matches this field against the merchant behind the tokenization key, and an unrelated value can be rejected upstream.\n\n## 3DS behavior\n\nGoogle Pay tokens carry one of two auth methods:\n\n| Auth method | Source | What you'll see |\n|---|---|---|\n| `CRYPTOGRAM_3DS` | Tokenized device-bound card (Android phone, Wear OS) | The upstream processor **may** waive the customer-facing 3DS challenge since the GP cryptogram already proves cardholder presence. Fynex always sends the `threeDs` block, so you should still handle `requiresAction: true` in your client code in case it doesn't waive. |\n| `PAN_ONLY` | Server-tokenized card stored in the Google account (no device cryptogram) | May trigger a 3DS step-up. Direct API: redirect the customer to `actionUrl` and resume on return. Hosted Checkout handles the redirect inside the page. |\n\n\u003e [!CAUTION]\n\u003e Do not set `skip3DS` for Google Pay in live traffic. SCA exemptions are tied to MIT/MOTO flows, which Google Pay does not match.\n\n## Payment lifecycle (simplified)\n\n```\ndraft -\u003e provider_pending -\u003e provider_completed -\u003e settled\n                          -\u003e failed\n                          -\u003e cancelled\n```\n\n| Status                | Meaning                                                                  |\n|-----------------------|--------------------------------------------------------------------------|\n| `draft`               | Payment created, not yet submitted to the provider.                      |\n| `provider_pending`    | Submitted to the provider; awaiting result, 3DS, or capture.             |\n| `provider_completed`  | Authorized and captured (or auth+capture in auto-settlement mode).       |\n| `settled`             | Funds reached settlement.                                                |\n| `failed`              | Declined by the provider, the bank, or the 3DS issuer.                   |\n| `cancelled`           | Cancelled by the customer (e.g. abandoned 3DS).                          |\n\nDirect API payments start at `provider_pending`; `draft` is used internally by Hosted Checkout before submission. Intermediate statuses like `new`, `routed`, `authorized`, `provider_risk_review`, `funds_in_flight`, and `deposit_confirmed` may also surface on polling. Post-settlement refund flow has its own statuses (`refund_pending`, `refunded`, `refund_failed`, `refund_cancelled`) — see [Captures \u0026 Refunds](#tag/captures-refunds).\n\n## Verifying the outcome\n\nSame as any payment — three options, in decreasing order of recommendation:\n\n1. **Webhook** (`POST /payments-api/v1/webhooks` to register a URL once; Fynex will post `payment.*` events when state changes). See [Webhooks](#tag/webhooks).\n2. **REST poll** — `GET /payments-api/v1/payments/{payment_id}` with your bearer token. The `payment_id` path parameter is your `externalOrderRef`. Terminal statuses: `provider_completed`, `settled`, `deposit_confirmed`, `refunded`, `failed`, `cancelled`. See [Polling \u0026 SSE](#tag/polling-sse).\n3. **GraphQL** — `genericPayment(id: Int!)` on `/dashboard/graphql` (cookie-auth only — for dashboard integrations, not server-to-server).\n\nThe `paymentMethod` field on the response will be `card` regardless of whether the customer used Google Pay or a typed card; the underlying card is always charged via the upstream card processor. To distinguish Google Pay payments at the data level, check the `providerCode` field on the payment response together with any internal markers your dashboard surfaces.\n\n## Testing\n\n- **Sandbox**: set `environment: 'TEST'` on the Google Pay JS side. On Fynex's side the seller account must be in `Demo` operational mode (capital D — that's the canonical value, though the routing layer is case-insensitive).\n- **3DS**: the upstream sandbox lets you replay both success and failure challenges.\n\n### Test cards\n\nGoogle Pay's TEST environment returns a sandbox PAN that is then authorized on the upstream\ncard processor's sandbox. Google Pay tokens are network-tokenized, so the upstream handle is\ncreated with 3DS skipped — Google Pay payments authorize without a separate 3DS redirect.\n\nTokenize any PAN the upstream sandbox recognises; for example the universal Visa test PAN\n`4111 1111 1111 1111`, or any of the PANs listed in [Test cards \u0026 sandbox](#tag/test-cards).\nExpiry: any future month/year (4-digit year recommended, e.g. `2028`). CVV: any 3-digit value\n(4-digit for AmEx).\n\n\u003e [!CAUTION]\n\u003e The card number does not select the authorization outcome on this sandbox. To simulate a\n\u003e decline, use the processor's sandbox simulation rules — ask your Fynex contact for the\n\u003e current values. Do not assume a particular PAN maps to \"frictionless\" vs \"challenge\" vs\n\u003e \"decline\".\n\n## Troubleshooting\n\n### The Google Pay button doesn't appear on the hosted checkout page\n\nIn order of frequency:\n\n1. **Google Pay isn't enabled on this deploy yet.** The platform's upstream-processor credentials for Google Pay haven't been provisioned, or the per-deploy GP merchant ID is missing. Sellers can't fix this — contact Fynex support.\n2. **The customer's browser or device doesn't support Google Pay.** `isReadyToPay()` returns `false` and the page (correctly) leaves the button hidden. Nothing to fix — the customer can use the card form.\n3. **The customer has no saved cards in their Google account.** `isReadyToPay()` may still return `true`, but the GP sheet shows an empty list when the customer opens it. Not a configuration bug.\n4. **Environment mismatch.** The deploy points at Google Pay's `PRODUCTION` environment but the merchant ID is only enrolled in `TEST`, or vice versa. Google's SDK silently treats this as \"GP unavailable.\" Contact Fynex support to confirm the configured environment.\n\n### `/initialize-payment` returns 400 \"...either token or decryptedToken, not both\"\n\nYou supplied both `tokenizationData.token` and `tokenizationData.decryptedToken`. Pick one path and remove the other.\n\n### `/initialize-payment` returns 400 \"...token or .decryptedToken is required\"\n\nYou supplied a `tokenizationData` block but neither `token` nor `decryptedToken`. Add the matching field for the model you're using.\n\n### `/initialize-payment` returns 400 \"cryptogram is required\"\n\nYou're on the Decrypted Direct API flow, `authMethod` is `CRYPTOGRAM_3DS`, and the token carries no cryptogram. Send the cryptogram, or — if the token really is a server-tokenized card — set `authMethod` to `PAN_ONLY` (matched case-insensitively), which is accepted without one.\n\n### `/initialize-payment` returns 502 \"upstream card processor returned 400\"\n\nMost often: missing `countryCode` or `postalCode` in the top-level `billingDetails`. AVS scoring is performed against `billingDetails`, not the address embedded inside `googlePayData`.\n\n### `/initialize-payment` returns 409\n\n`Idempotency-Key` reused with different business intent (`amount`, `currencyCode`, `externalOrderRef`, …). Use a fresh UUID v4.\n\n### The customer authorized Google Pay, but the order page says \"failed\"\n\nLook at the payment's `failureStage`:\n\n- `failureStage: \"authorization\"` — the encrypted token reached the upstream processor but the issuer declined the card. Normal card decline; the customer should try a different card or method.\n- `failureStage: \"settlement\"` — the handle was authorized but the settlement call failed. Usually a transient upstream issue; retry the order with a fresh `externalOrderRef`.\n- Empty `failureStage` with `status: \"cancelled\"` — the customer dismissed the GP sheet. The page treats this as a soft exit and does not create a payment.\n\n## Production checklist\n\nFor the Encrypted Direct API (recommended for non-hosted UIs):\n\n1. Google Pay merchant ID registered in the Google Pay Business Console.\n2. Domains that render the Google Pay button whitelisted in the Google Pay Console.\n3. Production seller API token issued (`Authorization: Bearer ...`).\n4. `google_pay` enabled for the seller account in the Fynex dashboard.\n5. `returnLinks` configured (success / failure URLs).\n6. Webhook registered for final status delivery, or polling implemented via `GET /payments/{payment_id}`.\n\nFor the Decrypted Direct API, additionally:\n\n7. Server-side DIRECT-mode decryption library in place that can handle ECv2 payloads, and your own DIRECT-mode key pair registered with Google.\n\n## See also\n\n- **[Hosted Checkout](#tag/hosted-checkout)** — The seller-facing API that creates the checkout session Google Pay runs inside.\n- **[Apple Pay](#tag/apple-pay)** — Apple Pay is decrypted-flow today — Fynex receives the decrypted token. See the Apple Pay guide for the payload shape.\n- **[3DS Authentication](#tag/3ds)** — How 3DS step-up works inside Hosted Checkout and Direct API flows.\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Verify the payment outcome from your backend.\n- **[Errors](#tag/errors)** — HTTP status codes and the per-seller rate-limit headers.\n","name":"Google Pay"},{"description":"Alternative payment methods (APMs) are the local, non-card ways buyers pay in their own\nmarket — Bancontact in Belgium, Multibanco and MB WAY in Portugal, Wero in Belgium, Germany,\nand France, and Swish in Sweden. Fynex offers them through a single payment provider that\nfronts all of these schemes, on a dedicated payment rail (`apm`) that is distinct from the\n`card` rail that carries card, Apple Pay, and Google Pay.\n\nAPMs are a sibling to Apple Pay and Google Pay in that they are wallet-like, buyer-initiated\nmethods you surface at checkout — but they are **architecturally different**. There is no\ncard token to decrypt and no card payload to forward. Most methods send the buyer to the\nscheme (a bank-selection page, an app handoff, or a QR code) to authorize. Multibanco can\ninstead return an Entity and Reference that your own checkout renders for the buyer.\n\nFynex accepts APMs two ways:\n\n- **Server-to-server API** — call `POST /payments-api/v1/initialize-payment` with\n  `paymentType: \"apm\"` and the APM instrument from your own backend. Follow the redirect\n  action Fynex returns, or render the structured payment instructions returned for\n  Multibanco. Bearer-authenticated, the same endpoint used for card / Apple Pay / Google Pay.\n  See [Server-to-server API](#server-to-server-api) below.\n- **Fynex-hosted checkout** — create a checkout session as you would for a card checkout, and\n  the Fynex-hosted page renders whichever APM tiles are available; the redirect handshake\n  happens inside that page. See [Hosted checkout](#hosted-checkout) below.\n\nBoth paths use the same asynchronous-settlement behavior. Redirect flows also converge on\nthe same Fynex-hosted return step.\n\n## Instruments and availability\n\nFive instruments are supported. Each is only offered for the currency (and, where the scheme\nis country-bound, country) combinations the provider actually supports it on:\n\n| Instrument | Wire value | Currency | Country | Notes |\n|------------|-----------|----------|---------|-------|\n| Bancontact | `bancontact` | `EUR` | `BE` | Belgium's domestic card/bank scheme. |\n| Multibanco | `multibanco` | `EUR` | `PT` | Portuguese reference-number / voucher payment. |\n| MB WAY | `mbway` | `EUR` | `PT` | Portuguese mobile-app payment. Offered alongside Multibanco for `EUR`/`PT` — the two cannot be told apart by currency/country, so the buyer picks between them at checkout. |\n| Wero | `wero` | `EUR` | `BE`, `DE`, `FR` | European account-to-account wallet. Requests outside these three countries are rejected before routing. |\n| Swish | `swish` | `SEK` | `SE` | Swedish mobile payment. |\n\nA `EUR`/`BE` checkout therefore offers Bancontact **and** Wero; `EUR`/`DE` and `EUR`/`FR`\noffer Wero; `EUR`/`PT` offers Multibanco and MB WAY; a `SEK`/`SE` checkout offers Swish.\nWero is not offered for other EUR countries, including Italy.\n\n\u003e [!NOTE]\n\u003e This currency/country matrix is the *ceiling* of what a checkout can show. The actual set a\n\u003e given buyer sees is narrower — it is also gated on your account being routable for that\n\u003e method. See [How hosted-checkout availability is decided](#how-hosted-checkout-availability-is-decided).\n\n### How the buyer completes each method\n\nThe buyer action differs by method and integration style. It matters especially when you test:\n\n- **Bancontact** completes on the redirect page itself — the buyer authorizes there and comes\n  straight back.\n- **MB WAY** completes on the redirect page by default. Server-to-server, you can instead keep\n  the buyer on your own checkout: send `apmData.phone` and the authorization is pushed to that\n  number's MB WAY app, so there is nothing to redirect to. See\n  [Keeping the buyer on your checkout with MB WAY](#keeping-the-buyer-on-your-checkout-with-mb-way).\n- **Multibanco** server-to-server initiation can return `paymentInstructions` containing an\n  Entity and Reference. Render both with the response's top-level amount and currency; the\n  buyer completes the payment through Portuguese online banking or an ATM. Fynex-hosted\n  checkout continues to handle the provider redirect flow for you.\n- **Wero hands off to a Wero code / QR flow.** In live, the buyer approves in their Wero\n  app. In sandbox, you can complete the flow with the Wero UAT test site — see\n  [Testing APMs in sandbox](#testing-apms-in-sandbox).\n- **Swish hands off to the buyer's Swish app and BankID.** There is no card-number-entry\n  fallback. Sandbox completion needs Swish test credentials provisioned by Fynex / the\n  provider; otherwise the payment can remain `provider_pending` even though your redirect\n  integration is working.\n\n\u003e [!IMPORTANT]\n\u003e **Returned to your site is not the same as paid.** APMs are asynchronous. The buyer can land\n\u003e back on your `returnUrl` while the scheme is still processing, or while an app-based method\n\u003e is still waiting for approval. Treat the return as a UX step only; confirm the final outcome\n\u003e with webhooks or `GET /payments-api/v1/payments/{payment_id}`.\n\n## Testing APMs in sandbox\n\nUse a **Demo** account and its token. Whether APM sandboxes are reached is decided by the account's operational mode, not the host — Demo accounts exist on both `https://staging-api.fynex.ai` and `https://api.fynex.ai`. Call the base URL your token was issued for: the two environments keep separate credential stores, so a token only authenticates against its own host.\nDo not send these test values to production. Your seller account still needs APMs enabled and\nroutable: `GET /payments-api/v1/payment-methods` should include `apm` in\n`allowedPaymentRails` and the specific instrument in `allowedPaymentMethods`.\n\nYou can test with either integration style:\n\n1. **Server-to-server:** call `POST /payments-api/v1/initialize-payment` with\n   `paymentType: \"apm\"`, a unique `externalOrderRef`, a unique `Idempotency-Key`, and the\n   method/currency/country from the table below. The response should be `202 Accepted` with\n   `requiresAction: true`. Redirect methods include `redirectFullPage: true` and an\n   `actionUrl`; Multibanco includes `paymentInstructions` and can also include the redirect.\n2. **Hosted checkout:** create a checkout session in staging, open the hosted checkout URL,\n   and choose the APM tile. The tile only appears if the checkout's currency/country and your\n   seller routing both support the method.\n3. **Complete the buyer action:** for redirect methods, open `actionUrl` in a top-level browser\n   window (not an iframe). For Multibanco, render the returned Entity and Reference. Then wait\n   for the webhook or poll `GET /payments-api/v1/payments/{payment_id}` until terminal.\n\n| Method | Request values | How to complete a sandbox payment | Failure-path test |\n|--------|----------------|-----------------------------------|-------------------|\n| Bancontact | `paymentMethod: \"bancontact\"`, `currencyCode: \"EUR\"`, `countryCode: \"BE\"` | On the provider-hosted Bancontact page, choose the card/PAN entry flow and use PAN `60600599999899023` with expiry `01/2035` and any CVV. | Use PAN `60600599999899080` with expiry `01/2035` to deny authentication. |\n| Multibanco | `paymentMethod: \"multibanco\"`, `currencyCode: \"EUR\"`, `countryCode: \"PT\"` | For server-to-server integration, verify that `paymentInstructions.entity` and `.reference` render on your checkout with the top-level amount/currency. The provider sandbox can also return a mock redirect; approve there if you exercise that fallback. Then wait for webhook/polling because the method is asynchronous. | Abandon the instructions/redirect and keep polling to verify your pending-state handling, or choose the deny/fail option on the mock page when offered. |\n| MB WAY | `paymentMethod: \"mbway\"`, `currencyCode: \"EUR\"`, `countryCode: \"PT\"` | MB WAY has no scheme sandbox — the provider simulates it. **Redirect flow:** approve on the provider's mock-authorization page; if it asks for a phone number use `+11111111112`, and for a verification code use `777123`. **Inline flow:** send `apmData.phone: \"+11111111112\"` and verify your page renders the waiting state from `paymentInstructions`, then that the payment reaches a terminal status without any redirect being followed. | Choose the deny/fail option on the mock page when offered, or abandon the flow and keep polling to verify your pending-state handling. For the inline flow, send a well-formed number that is not enrolled and confirm your page handles the payment expiring. |\n| Wero | `paymentMethod: \"wero\"`, `currencyCode: \"EUR\"`, `countryCode: \"BE\"`, `\"DE\"`, or `\"FR\"` | On the provider page, select **Pay with Wero code**, copy the displayed code, open `https://example-consumer-psps.werouat.eu`, choose **Give consent**, paste the code, and choose **Approve**. | Repeat the same Wero UAT flow and choose **Deny**. |\n| Swish | `paymentMethod: \"swish\"`, `currencyCode: \"SEK\"`, `countryCode: \"SE\"` | Swish sandbox completion requires provider-issued Swish test credentials / app access. If your sandbox account has them, complete the Swish app / BankID flow after redirect. | Use the provider-issued Swish failure path, or abandon the flow and verify that your integration handles `provider_pending` correctly. |\n\n\u003e [!NOTE]\n\u003e Bancontact is the only APM with static public card-like test PANs. Multibanco and MB WAY\n\u003e run on the provider's mock authorizer (approve/deny buttons, plus the `777123` /\n\u003e `+11111111112` test values above where prompted), Wero uses the Wero UAT consent site, and\n\u003e Swish test credentials are provisioned separately — the scheme publishes none for sandbox.\n\u003e If you need Swish credentials, contact Fynex support before scheduling your sandbox test.\n\u003e The underlying scheme and provider sandboxes are hosted outside Fynex and can be\n\u003e intermittently flaky. If the API times out or returns `502` before you receive a definitive\n\u003e payment result, retry the unchanged request with the same `externalOrderRef` and\n\u003e `Idempotency-Key`; Fynex reconciles the same provider attempt and re-surfaces the current\n\u003e buyer action (a redirect and/or Multibanco payment instructions). While that reconciliation\n\u003e is still active, the unchanged retry can return `503`\n\u003e with `provider create is pending reconciliation`; continue retrying the same request rather\n\u003e than minting a new key. A `409` with `provider create requires manual reconciliation` means\n\u003e automated recovery has stopped and Fynex support must resolve the provider charge. After a\n\u003e definitive terminal `failed` result, start a new payment attempt with a fresh pair.\n\n### Suggested sandbox amounts\n\nUse small, realistic amounts such as `10.00` in the method's currency. Avoid huge values in\nsandbox unless Fynex support has asked you to test a specific limit or edge case. For each\nscenario, use a fresh `externalOrderRef` and `Idempotency-Key`; reusing an idempotency key is\nonly for retrying the exact same payment attempt.\n\n### What a successful test proves\n\nA complete APM sandbox test proves that:\n\n- your seller token is valid in staging;\n- your seller account is configured for the `apm` rail and the chosen instrument;\n- your `returnUrl` host is allow-listed when you send one;\n- your frontend follows `actionUrl` as a full-page redirect, or renders complete Multibanco\n  Entity/Reference instructions when returned; and\n- your system waits for a terminal status via webhook or polling instead of treating the\n  browser return as final payment success.\n\nIf a method does not appear on hosted checkout or `initialize-payment` rejects it, first\ncheck the method/currency/country combination and `GET /payments-api/v1/payment-methods`.\nAn unsupported combination is rejected with `400` before Fynex creates or routes a payment;\nfor example, Wero with `countryCode: \"IT\"` is invalid. If\nthe buyer returns to your site with `status=pending`, keep polling or wait for your webhook.\nThat is normal for asynchronous APMs.\n\n## Server-to-server API\n\nSubmit the payment directly from your backend to\n`POST /payments-api/v1/initialize-payment`, authenticated with your seller\n`Authorization: Bearer` token. Set `paymentType` to `apm` and `paymentMethod` to one of the\nfive APM instruments. No card, wallet, or billing data is required — the buyer authenticates\nat the scheme, not on your form.\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-1042\",\n    \"amount\": 49.99,\n    \"paymentType\": \"apm\",\n    \"paymentMethod\": \"bancontact\",\n    \"currencyCode\": \"EUR\",\n    \"countryCode\": \"BE\",\n    \"returnUrl\": \"https://merchant.example/checkout/return\"\n  }'\n```\n\n`paymentMethod` must be one of `bancontact`, `multibanco`, `mbway`, `wero`, or `swish`, and it\nmust be compatible with the currency/country you send — Fynex routes on the currency/country\nmatrix (see [Instruments and availability](#instruments-and-availability)) and on your\n`apm`-rail terminals. An instrument that isn't compatible with `paymentType: \"apm\"` returns\n`400`. An otherwise valid APM whose currency/country is outside its supported matrix also\nreturns `400`, for example `paymentMethod wero is not supported for currency EUR and country IT`.\n\nThe example's `returnUrl` host (`merchant.example`) must be allow-listed on your seller\naccount first — see the note below.\n\n\u003e [!IMPORTANT]\n\u003e **`returnUrl` is optional only when another valid return-link source exists.** You may omit\n\u003e it when you send an explicit `returnLinks` array or your seller account already has default\n\u003e return links. If the request has neither and the seller has no defaults, initialization\n\u003e returns `400` (`\"valid returnLinks are required\"`). The Fynex-hosted status page is a\n\u003e fallback after a valid return configuration has been resolved; omitting `returnUrl` alone\n\u003e does not create that configuration.\n\u003e\n\u003e **When you do send `returnUrl`, this path fails closed.** Your seller account must have an\n\u003e **allowed-return-hosts whitelist** configured, and the host of `returnUrl` must be on it. A\n\u003e `returnUrl` whose host isn't allow-listed — *or any `returnUrl` sent before you've configured\n\u003e any allowed hosts at all* — returns `400` (`\"returnUrl cannot be used until allowed return\n\u003e hosts are configured\"`). This fail-closed rule (stricter than the hosted-checkout\n\u003e success/failure URLs, which are opt-in) is what keeps the later return-step redirect a safe,\n\u003e pre-validated one rather than an open redirect. Configure your allowed return hosts before\n\u003e going live.\n\n\u003e [!NOTE]\n\u003e **`returnUrl` alone is all you need.** Fynex derives a single default return link\n\u003e (`{ \"rel\": \"default\", \"href\": \u003creturnUrl\u003e, \"method\": \"GET\" }`) from it, so the redirect\n\u003e provider sends the buyer back to that URL for every outcome. If you need per-outcome\n\u003e destinations — a different landing page for completed vs. failed vs. cancelled — send an\n\u003e explicit `returnLinks` array instead, each entry a `{ \"rel\", \"href\", \"method\": \"GET\" }`\n\u003e object with `rel` one of `default`, `on_completed`, `on_failed`, or `on_cancelled`. When you\n\u003e send `returnLinks`, it takes precedence and `returnUrl` is not used to derive a link (it still\n\u003e drives the Fynex-hosted status-page fallback). You don't need both: `returnUrl` covers the\n\u003e common single-destination case.\n\n### Response — follow the returned buyer action\n\nA fresh request returns `202 Accepted`:\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"provider_pending\",\n  \"amount\": 49.99,\n  \"currencyCode\": \"EUR\",\n  \"requiresAction\": true,\n  \"actionUrl\": \"https://redirect.provider.example/checkout/abc123\",\n  \"redirectFullPage\": true\n}\n```\n\n| Field | Type | Meaning for APMs |\n|-------|------|------------------|\n| `requiresAction` | boolean | `true` for a successful APM initiation while the buyer still needs to act. |\n| `actionUrl` | string | Provider redirect URL when available. Send the buyer's browser here for redirect flows. |\n| `paymentInstructions` | object | Structured inline instructions when available, so the buyer can complete on your own checkout. `type` names the format: `payment_reference` (Multibanco) adds `entity` and `reference`; `app_notification` (MB WAY, when you send `apmData.phone`) adds `phone`. Absent when the only action is the redirect. |\n| `redirectFullPage` | boolean | When `true`, redirect the whole page (navigate the top-level window), not an iframe. |\n| `status` | string | `provider_pending` until the scheme reports back. |\n| `paymentId` | string | Your `externalOrderRef`; use it to poll the outcome. |\n\nFor a redirect response, your UI drives the redirect — Fynex does not redirect the buyer for\nyou on this path. Send the browser to `actionUrl` using a full-page navigation when\n`redirectFullPage` is true. After the buyer completes or abandons at the scheme, they pass\nback through a Fynex-hosted return step that 302s them to your `returnUrl`. See\n[The return leg](#the-return-leg) below.\n\nFor Multibanco, the same response can include inline instructions:\n\n```json\n{\n  \"paymentId\": \"ORDER-1043\",\n  \"status\": \"provider_pending\",\n  \"amount\": 49.99,\n  \"currencyCode\": \"EUR\",\n  \"requiresAction\": true,\n  \"paymentInstructions\": {\n    \"type\": \"payment_reference\",\n    \"entity\": \"11854\",\n    \"reference\": \"999999964\"\n  },\n  \"actionUrl\": \"https://redirect.provider.example/checkout/def456\",\n  \"redirectFullPage\": true\n}\n```\n\nDisplay `entity` and `reference` as plain text, alongside the top-level `amount` and\n`currencyCode`; never interpret provider values as HTML. Fynex only returns a structured\ninstruction after validating the Multibanco Entity as five ASCII digits and the Reference as\nnine ASCII digits. Do not treat displaying or copying the instructions as payment success;\nkeep the payment pending until a webhook or payment lookup reports a terminal state. If both\ninstructions and `actionUrl` are present, you may keep the redirect as a fallback.\n\n### Keeping the buyer on your checkout with MB WAY\n\nBy default MB WAY sends the buyer to a provider-hosted page. To keep them on your own\ncheckout instead, collect their MB WAY phone number and send it as `apmData.phone`:\n\n```json\n{\n  \"externalOrderRef\": \"ORDER-100106\",\n  \"amount\": 10.50,\n  \"paymentType\": \"apm\",\n  \"paymentMethod\": \"mbway\",\n  \"currencyCode\": \"EUR\",\n  \"countryCode\": \"PT\",\n  \"apmData\": { \"phone\": \"+351912345678\" }\n}\n```\n\nThe authorization is then pushed to that number's MB WAY app and the response carries an\n`app_notification` instruction:\n\n```json\n{\n  \"paymentId\": \"ORDER-100106\",\n  \"status\": \"provider_pending\",\n  \"requiresAction\": true,\n  \"paymentInstructions\": {\n    \"type\": \"app_notification\",\n    \"phone\": \"+351912345678\"\n  },\n  \"actionUrl\": \"https://redirect.provider.example/checkout/ghi789\",\n  \"redirectFullPage\": true\n}\n```\n\nThere is nothing to render but a waiting state — the buyer approves in their app. Rules:\n\n- **`apmData.phone` must be a valid E.164 number** (`+` and 8–15 digits). A malformed value is\n  rejected with `400` before any payment is created. Omitting it is not an error: the payment\n  simply keeps the redirect flow.\n- **`apmData.phone` is the only source of the number.** `billingDetails.phone` is never used\n  for the push, even a Portuguese one — it is a contact detail collected for something else,\n  and the buyer whose handset rings has not agreed to that by giving you a billing contact.\n  The push goes to the number registered with the buyer's MB WAY account, which need not be\n  your billing contact, so ask for it explicitly. No `apmData.phone`, no push.\n- **`actionUrl` is still returned** — keep it as a fallback. The push has a short window\n  (below) and the same charge is payable either way.\n- **The window is 4 minutes.** MB WAY is not a voucher method: if the buyer does not approve\n  in time the payment moves to `failed` and you must start a new one.\n- **Do not treat the instruction as payment success.** Wait for a terminal status via your\n  webhook or by polling `GET /payments-api/v1/payments/{payment_id}`.\n- **Send a number the buyer gave you for this payment.** Nothing upstream validates it: a\n  number that is not enrolled with MB WAY is accepted and simply never authorizes, so the\n  only symptom is a payment that expires. Do not push to a number the buyer supplied for\n  some other purpose.\n\nThis is a server-to-server capability. Fynex-hosted checkout continues to drive MB WAY through\nthe provider redirect, and `apmData` is ignored there.\n\nSubmitting the same `Idempotency-Key` with the same business intent returns the existing\npayment (`200 OK`) rather than creating a duplicate; reusing it with a different `amount` /\n`currencyCode` / `externalOrderRef` / `apmData.phone` returns `409`.\n\n`apmData.phone` is part of that intent because it decides where the authorization goes. So if\nthe buyer mistyped their number, **retrying with a corrected `apmData.phone` under the same\n`Idempotency-Key` is a `409`, not a correction** — the original payment is already pushing to\nthe original number. Start a new payment with a fresh `Idempotency-Key` and `externalOrderRef`\ninstead, and let the first one expire.\n\n## Hosted checkout\n\nIf you already use Fynex-hosted checkout, you get APMs with no extra integration work. Create\nthe checkout session exactly as you would for a card-only checkout; the Fynex-hosted page\nrenders whichever APM tiles are available for that session (see [How hosted-checkout\navailability is decided](#how-hosted-checkout-availability-is-decided)), and drives the\nredirect handshake inside the page. Your integration never sees a provider token and never\ncalls a provider endpoint — you only observe the final outcome via webhook, polling, or the\nreturn redirect. See [Hosted checkout](#tag/hosted-checkout) for the session-creation\nreference.\n\n## The return leg\n\nBoth entry paths — server-to-server and hosted checkout — return the buyer through the same\nFynex-hosted return step once they finish authorizing at the scheme:\n\n```\nBuyer browser              Scheme / provider        Fynex\n─────────────              ─────────────────        ─────\n(server-to-server: your\n UI sends the buyer to\n actionUrl; hosted: the\n Fynex page does it)\n  ─────────────────────►  authorize\n                            (bank page / app / QR)\n                          ─── redirect back ────►  Fynex return handler\n                                                   (signed token resolved\n                                                   server-side; looks up the\n                                                   payment's pre-validated\n                                                   destination)\n  ◄─────────────────────────────────────────────  302 to that destination\nlands on:\n  • hosted-checkout success / failure URL, or\n  • caller returnUrl with ?status=\u0026paymentId=, or\n  • Fynex-hosted status page (no caller destination)\n```\n\nThe return step carries a signed token Fynex issued when it started the payment; Fynex\nresolves it **server-side** and 302s to a destination it reads from already-persisted,\nalready-validated payment data:\n\n1. the hosted-checkout **success / failure URL** configured on the payment, or\n2. the caller's **`returnUrl`** with `?status=` and `?paymentId=` query params appended, or\n3. a **Fynex-hosted status page** when the payment has neither of the above.\n\nThe destination is never taken from the incoming request beyond the signed token, so the step\ncannot be turned into an open redirect. A missing, invalid, or expired token renders an\n\"Invalid Link\" page rather than redirecting anywhere.\n\n\u003e [!NOTE]\n\u003e An APM payment can still be *pending* when the buyer is returned — account-to-account and\n\u003e voucher schemes settle asynchronously. The status page (and the `?status=` on your\n\u003e `returnUrl`) can therefore read `pending`; the payment is finalized later. The return\n\u003e query uses one of `status=pending`, `status=succeeded`, or `status=failed`, and includes\n\u003e `paymentId=\u003cexternalOrderRef\u003e`. Confirm the real outcome the same way you would for any\n\u003e payment: register a webhook or poll\n\u003e `GET /payments-api/v1/payments/{payment_id}` (the returned `?paymentId=` is your\n\u003e `externalOrderRef`). Do not treat \"returned to my URL\" as \"paid\" — wait for a terminal\n\u003e status. See [Polling \u0026 SSE](#tag/polling-sse) and [Webhooks](#tag/webhooks).\n\n## How hosted-checkout availability is decided\n\nOn **hosted checkout**, whether an APM tile appears on a given session is the **intersection**\nof two gates:\n\n1. **The currency/country matrix** above — a method is only a candidate when the checkout's\n   currency (and country, for the country-bound schemes) matches the table.\n2. **Routability** — your account must have at least one active `apm`-rail terminal whose\n   currency / country / operational-mode can carry the checkout. Fynex never renders a tile it\n   cannot route, so a buyer can't pick a method that would then fail on submit.\n\nIf nothing survives both gates, the hosted checkout simply doesn't show an APM block — the\nbuyer falls back to card (or whatever else is configured). There is no broken-tile state.\n\nThe **server-to-server** path is gated by the same two constraints, but you learn about a\nmismatch differently: an instrument that doesn't match the currency/country you sent, or that\nyour account can't route, is rejected at `initialize-payment` (`400`, or a routing `502`)\nrather than silently hidden. Either way, don't hard-code which APMs to offer — discover what\nyour account supports with the payment-methods endpoint, exactly as you would for any other\ninstrument.\n\n## Discovering what's enabled\n\n`GET /payments-api/v1/payment-methods` returns the instruments and rails configured on your\nseller account. Check `allowedPaymentRails` for `apm` and `allowedPaymentMethods` for the\nspecific instrument values before you rely on APMs being available:\n\n```bash\ncurl -sS \"$FYNEX_API/payments-api/v1/payment-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n```json\n{\n  \"sellerAccountId\": 42,\n  \"allowedPaymentMethods\": [\"card\", \"bancontact\", \"wero\"],\n  \"allowedCurrencies\": [\"EUR\"],\n  \"allowedPaymentRails\": [\"card\", \"apm\"]\n}\n```\n\nSee [Payment methods \u0026 capabilities](#tag/payment-methods) for the full response shape.\n\nTwo things to keep in mind when reading this response for APMs:\n\n- **`allowedPaymentMethods` is the account-level ceiling.** It tells you which instruments\n  your account is configured for. It does **not** guarantee a given instrument works on every\n  payment — the per-checkout currency/country matrix and terminal routability still apply (see\n  above).\n- **Use it to decide what to offer, on either path.** For hosted checkout, it tells you\n  whether to route a buyer to a Fynex-hosted page at all (the tiles themselves are rendered by\n  Fynex). For the server-to-server path, it tells you which `paymentMethod` values are worth\n  presenting in your own UI before you call `initialize-payment`.\n\n## See also\n\n- **[Hosted checkout](#tag/hosted-checkout)** — create the checkout session that renders APM tiles for the hosted path.\n- **[Payment methods \u0026 capabilities](#tag/payment-methods)** — discover the `apm` rail and enabled instruments for your account.\n- **[Apple Pay](#tag/apple-pay)** / **[Google Pay](#tag/google-pay)** — the other wallet-like instruments, on the `card` rail; like APMs they also offer a server-to-server path.\n- **[Polling \u0026 SSE](#tag/polling-sse)** / **[Webhooks](#tag/webhooks)** — confirm the final outcome of an asynchronously-settling APM payment.\n","name":"Alternative payment methods (APM)"},{"description":"3DS (3D Secure) is an additional authentication step issued by the card-holder's bank before a payment can be authorized. Fynex exposes 3DS only on the **server-to-server card flow** — when you call `POST /initialize-payment` directly from your backend. If you use the hosted checkout (`POST /checkout`), the checkout page handles 3DS internally and you never see a `requiresAction` response.\n\n---\n\n## When 3DS is triggered\n\nAfter `POST /initialize-payment`, inspect the response:\n\n```json\n{\n  \"paymentId\": \"ORDER-100106\",\n  \"status\": \"provider_pending\",\n  \"amount\": 49.99,\n  \"currencyCode\": \"GBP\",\n  \"requiresAction\": true,\n  \"actionUrl\": \"https://acs.issuer-bank.example/challenge?token=XYZ\"\n}\n```\n\nWhen `requiresAction` is `true`, the payment is in `provider_pending` state. The customer must complete the issuer's challenge at `actionUrl` before the payment can be authorized. If `requiresAction` is `false` (frictionless flow), the payment may already be `authorized` or `provider_completed` and no redirect is needed.\n\n---\n\n## 3DS challenge types\n\n| Type | Description |\n|------|-------------|\n| 3DS 2.0 frictionless | The issuer approves silently based on risk data. `requiresAction` is `false`; no redirect needed. |\n| 3DS 2.0 step-up | The issuer requires an OTP or biometric. Customer is redirected to `actionUrl` for challenge completion. |\n| 3DS 1.x fallback | Legacy challenge for cards not enrolled in 3DS 2.0. Same redirect mechanism. |\n\n---\n\n## Integration flow\n\n```\nYour Backend                   Your Frontend              Issuer ACS\n    │                               │                          │\n    │── POST /initialize-payment ──►│                          │\n    │◄── { requiresAction: true,    │                          │\n    │      actionUrl: \"https://...\" }│                         │\n    │                               │                          │\n    │── return actionUrl ──────────►│                          │\n    │                               │── redirect browser ─────►│\n    │                               │                          │\n    │                               │  (customer completes     │\n    │                               │   OTP / biometric)       │\n    │                               │                          │\n    │                               │◄── redirect to returnUrl ─┤\n    │                               │                          │\n    │◄── signal to finalize ────────│                          │\n    │                               │                          │\n    │── POST /finalize-payment ─────►                          │\n    │◄── { status: \"provider_completed\", capturedAmount: ... } │\n```\n\n### Step-by-step\n\n1. **Your backend** calls `POST /initialize-payment` with the customer's card data, amount, and currency.\n2. If `requiresAction: true`, extract `actionUrl` from the response and return it to your frontend.\n3. **Your frontend** performs a full-page redirect of the customer's browser to `actionUrl`. Do not use an iframe — issuers reject embedded challenges.\n4. The customer completes the 3DS challenge (OTP, biometric, or frictionless silent approval) on the issuer's page.\n5. The issuer redirects the customer back to your `returnUrl` (or your app's resume page).\n6. **Your frontend** signals your backend to call `POST /finalize-payment` using the same `paymentId` (the `externalOrderRef` from step 1).\n7. Your backend calls `POST /finalize-payment` and returns the result to the customer.\n\n\u003e [!NOTE]\n\u003e `POST /finalize-payment` looks up the payment by `paymentId` (which is the `externalOrderRef` you originally passed to `/initialize-payment`). You do not need a separate payment lookup between steps 6 and 7.\n\n---\n\n## State continuity across the redirect\n\nThe 3DS challenge is a **full-page redirect** that bounces the customer's browser away from your origin and back. Any in-memory state your frontend holds is lost during that hop.\n\nBridge the gap using `localStorage` (or a server-side session/cookie):\n\n**Before the redirect — save what you need:**\n```js\nlocalStorage.setItem('checkout_payment_id', paymentId);\nlocalStorage.setItem('checkout_amount', amount.toString());\nlocalStorage.setItem('checkout_currency', currencyCode);\n// add any other fields your resume page needs\n```\n\n**After the redirect lands on your resume page — restore and finalize:**\n```js\nconst paymentId = localStorage.getItem('checkout_payment_id');\nconst amount    = parseFloat(localStorage.getItem('checkout_amount') || '0');\n\n// signal backend to finalize\nawait finalizePayment(paymentId, amount);\n\n// clean up\nlocalStorage.removeItem('checkout_payment_id');\nlocalStorage.removeItem('checkout_amount');\nlocalStorage.removeItem('checkout_currency');\n```\n\n\u003e [!CAUTION]\n\u003e The Fynex hosted dashboard uses `checkout_*` keys in `localStorage` for exactly this purpose. If you are building a custom integration on the same origin as the dashboard, use distinct key names to avoid collisions.\n\n---\n\n## Double-completion guard (React strict mode)\n\nIn React 18+ strict mode, effects fire twice in development. If your resume page calls `/finalize-payment` inside a `useEffect`, it may fire twice — resulting in a double-capture attempt.\n\nGuard against this with a ref:\n\n```jsx\nimport { useEffect, useRef } from 'react';\n\nfunction PaymentResumePage() {\n  const hasSubmitted = useRef(false);\n\n  useEffect(() =\u003e {\n    if (hasSubmitted.current) return;\n    hasSubmitted.current = true;\n\n    const paymentId = localStorage.getItem('checkout_payment_id');\n    if (!paymentId) return;\n\n    finalizePayment(paymentId).then((result) =\u003e {\n      // handle success or failure\n    });\n  }, []);\n\n  return \u003cdiv\u003eProcessing payment…\u003c/div\u003e;\n}\n```\n\n---\n\n## Code samples\n\n#### curl — initialize\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/initialize-payment\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"externalOrderRef\": \"ORDER-100106\",\n    \"amount\": 49.99,\n    \"paymentType\": \"card\",\n    \"paymentMethod\": \"card\",\n    \"currencyCode\": \"GBP\",\n    \"countryCode\": \"GB\",\n    \"autoSettlement\": false,\n    \"captureMode\": \"manual\",\n    \"cardData\": {\n      \"cardNumber\": \"4111111111111111\",\n      \"expMonth\": 12,\n      \"expYear\": 2028,\n      \"holderName\": \"Jane Smith\",\n      \"cvv\": \"123\"\n    }\n  }'\n# If requiresAction is true, redirect customer to actionUrl.\n# Then call /finalize-payment once they return.\n```\n\n#### JavaScript — browser redirect + resume\n\n```js\n// ── Step 1: initialize (call from your backend and return actionUrl to the browser) ──\n// On your frontend, after receiving actionUrl from your server:\n\nasync function startPayment({ paymentId, amount, currencyCode, actionUrl }) {\n  if (actionUrl) {\n    // Save state before the full-page redirect\n    localStorage.setItem('checkout_payment_id', paymentId);\n    localStorage.setItem('checkout_amount', String(amount));\n    localStorage.setItem('checkout_currency', currencyCode);\n\n    // Redirect customer to the 3DS challenge page\n    window.location.href = actionUrl;\n    return; // execution stops here; browser navigates away\n  }\n\n  // No 3DS required — proceed directly\n  await notifyBackendToFinalize(paymentId, amount);\n}\n\n// ── Step 2: resume page (your returnUrl lands here) ──\n// Runs after the issuer redirects the customer back to your site.\n\nasync function onPaymentReturn() {\n  const paymentId = localStorage.getItem('checkout_payment_id');\n  const amount    = parseFloat(localStorage.getItem('checkout_amount') || '0');\n\n  if (!paymentId) {\n    console.error('No pending payment found in localStorage');\n    return;\n  }\n\n  try {\n    const result = await notifyBackendToFinalize(paymentId, amount);\n    // result.status will be 'provider_completed' on success\n    displaySuccessUI(result);\n  } catch (err) {\n    displayErrorUI(err);\n  } finally {\n    localStorage.removeItem('checkout_payment_id');\n    localStorage.removeItem('checkout_amount');\n    localStorage.removeItem('checkout_currency');\n  }\n}\n```\n\n#### Python — finalize\n\n```python\nimport os\nimport uuid\nimport requests\n\nFYNEX_API   = os.environ[\"FYNEX_API\"]   # e.g. https://api.fynex.ai/payments-api/v1\nFYNEX_TOKEN = os.environ[\"FYNEX_TOKEN\"]\n\ndef finalize_payment(payment_id: str, amount: float | None = None) -\u003e dict:\n    \"\"\"\n    Call after the customer completes the 3DS challenge.\n    payment_id is the externalOrderRef from /initialize-payment.\n    amount is optional; omit to capture the full authorized amount.\n    \"\"\"\n    body: dict = {\"paymentId\": payment_id}\n    if amount is not None:\n        body[\"amount\"] = amount\n\n    response = requests.post(\n        f\"{FYNEX_API}/finalize-payment\",\n        json=body,\n        headers={\n            \"Authorization\": f\"Bearer {FYNEX_TOKEN}\",\n            \"Content-Type\": \"application/json\",\n            \"Idempotency-Key\": str(uuid.uuid4()),\n        },\n        timeout=30,\n    )\n    response.raise_for_status()\n    return response.json()\n\n# Example:\n# result = finalize_payment(\"ORDER-100106\", amount=49.99)\n# print(result[\"status\"])   # \"provider_completed\"\n# print(result[\"capturedAmount\"])  # 49.99\n```\n\n---\n\n## Triggering (and skipping) 3DS\n\nOn this sandbox, whether a payment goes through a 3DS challenge is controlled by the\n**`skip3DS` request flag — not by the card number**:\n\n| Request | Behaviour |\n|---------|-----------|\n| `\"skip3DS\": false` (or omitted) | 3DS requested → `requiresAction: true` + `actionUrl`. Complete the challenge, then `/finalize-payment`. |\n| `\"skip3DS\": true` | No 3DS → `requiresAction: false`, the payment authorizes without a redirect. |\n\nUse any of the sandbox Visa PANs from the [Test cards \u0026 sandbox](#tag/test-cards) guide (e.g.\n`4111 1111 1111 1111`) with any future expiry — the same card works for both the 3DS and the\nno-3DS flow depending on `skip3DS`.\n\n---\n\n## Failure cases\n\nIf the customer cancels the 3DS challenge, fails the OTP, or the issuer declines, the payment transitions to `failed` or `cancelled`. Your resume page should handle these gracefully.\n\nThe `/finalize-payment` response will carry a non-success `status` plus `failureCode` and `failureDescription`:\n\n```json\n{\n  \"paymentId\": \"ORDER-100106\",\n  \"status\": \"failed\",\n  \"failureCode\": 2001,\n  \"failureDescription\": \"Card declined by issuer\"\n}\n```\n\nCommon failure scenarios:\n\n| Scenario | Resulting status | Recommended UX |\n|----------|-----------------|----------------|\n| Customer clicks \"Cancel\" on issuer page | `cancelled` | Show a \"Payment cancelled\" message with option to retry |\n| Wrong OTP / too many attempts | `failed` | Show error; allow customer to try a different card |\n| Issuer hard decline | `failed` | Show generic decline message; do not expose issuer reason verbatim |\n| Session timeout | `failed` | Prompt customer to start checkout again |\n\n\u003e [!NOTE]\n\u003e Detect user-cancellation from the error shape rather than treating it as a generic failure. The `status` field will be `cancelled` rather than `failed`. Show a neutral \"Payment not completed\" message rather than an error for cancellations.\n\n## See also\n\n- **[Server-to-Server](#tag/server-to-server)** — Full reference for POST /initialize-payment and POST /finalize-payment.\n- **[Payment Lifecycle](#tag/payment-lifecycle)** — All payment statuses and how they transition.\n- **[Errors](#tag/errors)** — HTTP status codes and error response shapes.\n","name":"3DS"},{"description":"A **capture** settles the funds reserved by an authorization. A **refund** returns captured funds to the customer after the processor confirms the payment. Both operations act on an existing payment identified by your original `externalOrderRef` string.\n\n## Prerequisites\n\n- The payment must have been created with `autoSettlement: false` (for capture).\n- You need the same `externalOrderRef` string you sent on `/initialize-payment` — this is the `{externalOrderRef}` path parameter.\n\n\u003e [!TIP]\n\u003e The `{externalOrderRef}` path parameter is the **string you chose** when you created the payment (e.g. `ORDER-1042`). It is not a numeric ID.\n\n---\n\n## Captures\n\n### When to capture\n\nCapture is only available when:\n\n- `autoSettlement` was `false` on the original `/initialize-payment` request, **and**\n- The payment status is `authorized` or `provider_completed`.\n\nAny other status returns `409 Conflict` with `\"invalid status transition\"`.\n\n### Full capture\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\"\n```\n\n#### JavaScript\n\n```js\nimport { randomUUID } from 'node:crypto';\n\nconst res = await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/capture`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n    'Idempotency-Key': randomUUID(),\n    'Content-Type': 'application/json',\n  },\n});\nconst data = await res.json();\n```\n\n#### Python\n\n```python\nimport os, uuid, requests\n\nres = requests.post(\n    f\"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/capture\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n        \"Idempotency-Key\": str(uuid.uuid4()),\n        \"Content-Type\": \"application/json\",\n    },\n)\ndata = res.json()\n```\n\n**Response (200 OK):**\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"provider_completed\",\n  \"providerCode\": \"\u003cprocessor\u003e\",\n  \"providerPaymentId\": \"pay_01J2EXAMPLE\",\n  \"amount\": 49.99,\n  \"currencyCode\": \"GBP\"\n}\n```\n\n### Partial capture\n\nPass an `amount` smaller than the authorized total. The remainder is released automatically.\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"amount\": 30.00 }'\n```\n\n#### JavaScript\n\n```js\nimport { randomUUID } from 'node:crypto';\n\nawait fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/capture`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n    'Idempotency-Key': randomUUID(),\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ amount: 30.00 }),\n});\n```\n\n#### Python\n\n```python\nimport os, uuid, requests\n\nrequests.post(\n    f\"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/capture\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n        \"Idempotency-Key\": str(uuid.uuid4()),\n        \"Content-Type\": \"application/json\",\n    },\n    json={\"amount\": 30.00},\n)\n```\n\n\u003e [!CAUTION]\n\u003e Capture once for the final amount. You cannot issue a second capture on the same payment.\n\u003e Capture is also blocked while a refund is pending or after any successful refund on that payment.\n\n### Capture request body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `amount` | `float64` | No | Major units (e.g. `30.00`). Omit for full-amount capture. Must be `\u003e 0` and `\u003c=` authorized amount. |\n\n### Capture response fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `paymentId` | `string` | Your `externalOrderRef` |\n| `status` | `string` | `GenericPaymentStatus` post-capture |\n| `providerCode` | `string` | Upstream processor identifier |\n| `providerPaymentId` | `string` | Provider's reference for the transaction |\n| `amount` | `float64` | Captured amount in major units |\n| `currencyCode` | `string` | 3-letter ISO currency |\n| `failureCode` | `int` | Present if processor declined |\n| `failureDescription` | `string` | Human-readable decline reason |\n\n---\n\n## Refunds\n\n### When to refund\n\nRefund is only available when:\n\n- Payment status is `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled`, **and**\n- The payment is not already `refunded` or `refund_pending`.\n\nAttempting a new refund outside these states returns `409 Conflict`.\nIf your original `POST /refund` timed out while the refund is still pending,\nretry with the **same** `Idempotency-Key` to replay the existing refund row.\nUsing a different key is treated as a new refund attempt and stays blocked until\nthe pending refund reaches `succeeded`, `failed`, or `cancelled`.\n\n### Full refund\n\n#### curl\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\"\n```\n\n#### JavaScript\n\n```js\nimport { randomUUID } from 'node:crypto';\n\nawait fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/refund`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,\n    'Idempotency-Key': randomUUID(),\n    'Content-Type': 'application/json',\n  },\n});\n```\n\n#### Python\n\n```python\nimport os, uuid, requests\n\nrequests.post(\n    f\"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/refund\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\",\n        \"Idempotency-Key\": str(uuid.uuid4()),\n        \"Content-Type\": \"application/json\",\n    },\n)\n```\n\n### Partial refund\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"amount\": 10.00 }'\n```\n\nIssue multiple partial refunds as long as the running total of successful refunds stays at or below the captured amount. Omit `amount` to refund the remaining refundable balance.\n\n### What customers see\n\nRefunds typically reach the customer's bank within 3–10 business days, depending on the issuer. The funds appear as a separate credit transaction — the original charge is not reversed.\n\n### Refund response (200 OK)\n\n```json\n{\n  \"id\": \"6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\",\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"pending\",\n  \"amount\": 10.00,\n  \"currencyCode\": \"GBP\",\n  \"providerRefundId\": \"rfnd_01J2EXAMPLE\",\n  \"createdAt\": \"2026-05-11T12:34:56Z\",\n  \"updatedAt\": \"2026-05-11T12:34:56Z\"\n}\n```\n\nThe status will be `pending` immediately after the request. Once the provider processes it, the refund moves to `succeeded` or `failed`.\n\n### Refund response fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `string` | Unique ID of this refund ledger row |\n| `paymentId` | `string` | Your `externalOrderRef` for the parent payment |\n| `status` | `string` | `pending`, `succeeded`, `failed`, or `cancelled` |\n| `amount` | `float64` | Refunded amount in major units |\n| `currencyCode` | `string` | 3-letter ISO currency |\n| `providerRefundId` | `string` | Provider's reference for the refund (present once issued) |\n| `failureCode` | `int` | Present if the provider rejected the refund |\n| `failureDescription` | `string` | Human-readable failure reason |\n| `createdAt` | `string` | When the refund was created |\n| `updatedAt` | `string` | When the refund row was last updated |\n| `completedAt` | `string` | When the refund reached a terminal state (present once complete) |\n\n---\n\n## Status reference\n\nThe full `GenericPaymentStatus` enum, in lifecycle order:\n\n| Status | Description |\n|--------|-------------|\n| `draft` | Payment record created but not yet submitted |\n| `new` | Submitted to the routing engine |\n| `routed` | Assigned to a processor terminal |\n| `provider_pending` | Submitted to the upstream processor |\n| `authorized` | Funds reserved — ready to capture |\n| `provider_completed` | Provider confirmed capture — ready to refund |\n| `funds_in_flight` | Settlement in progress |\n| `settled` | Funds settled — ready to refund |\n| `deposit_confirmed` | Deposit confirmed — ready to refund |\n| `refund_pending` | Refund submitted to the processor |\n| `refunded` | Refund completed |\n| `refund_failed` | Processor rejected the refund |\n| `refund_cancelled` | Refund was cancelled |\n| `failed` | Payment failed |\n| `cancelled` | Payment was cancelled |\n\n**Capture window:** `authorized` → `provider_completed` → *(capture)* → `funds_in_flight` → `settled`\n\n**Refund window:** `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled` → `refund_pending` → `refunded` when fully refunded, back to a refundable captured state after a successful partial refund, or `refund_failed`/`refund_cancelled` when the attempt fails/cancels.\n\n\u003e [!CAUTION]\n\u003e The statuses `captured`, `partially_captured`, and `partially_refunded` do **not** exist. If you see these names in older code or documentation, they are incorrect.\n\n---\n\n## Idempotency\n\nBoth endpoints require an `Idempotency-Key` UUID header. Always send one — network glitches can cause duplicate submissions without it.\n\nThe key is forwarded to the upstream processor as its own capture/refund idempotency key, so provider-side duplicates are also prevented.\n\nSee [Idempotency \u0026 retries](#tag/idempotency) for the full retry pattern.\n\n---\n\n## Dynamic webhook URL\n\nBoth `POST /payments/{payment_id}/capture` and `POST /payments/{payment_id}/refund` accept an optional `webhookUrl` field on the request body. When set, the resulting webhook event is delivered to that URL **in addition** to the seller's configured `SellerWebhookConfig` URLs.\n\nThe dynamic URL must:\n\n- Be HTTPS (`http://` rejected with `webhook_url_not_https`)\n- Be ≤ 1024 chars (`webhook_url_too_long`)\n- Resolve via DNS (`webhook_url_dns_failed`)\n- Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`)\n- Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth)\n\nThe seller must have at least one active `SellerWebhookConfig` row before `webhookUrl` is accepted — otherwise the request fails with `webhook_url_requires_configured_webhook` (422).\n\nManage the per-seller allowlist via `GET` / `POST` / `DELETE /payments-api/v1/webhooks/allowlist`. Outgoing deliveries to the dynamic URL are signed with the lexicographically-first active config's secret using HMAC-SHA256 (`X-Fynex-Signature: sha256=\u003chex\u003e`, `X-Fynex-Timestamp: \u003cunix-seconds\u003e`).\n\nSee the [Webhooks tag](#tag/webhooks) for the full signature-verification flow.\n\n### Example\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"amount\": 10.00,\n    \"webhookUrl\": \"https://merchant.example.com/webhooks/refunds/ORDER-1042\"\n  }'\n```\n\n---\n\n## Status codes\n\n| Status | When |\n|--------|------|\n| `200` | Success |\n| `400` | Missing/invalid `Idempotency-Key`; bad body or amount |\n| `401` | Missing or invalid bearer token |\n| `404` | Payment not found for this `externalOrderRef` and seller |\n| `409` | Status precondition not met; already refunded; capture attempted while/after refunding; `autoSettlement` was `true` |\n| `502` | Upstream processor error |\n\n---\n\n## Verify the result\n\nCapture and refund events fire **outbound webhooks** — they're delivered to every active `SellerWebhookConfig` URL (plus any per-request `webhookUrl` you set, see the section above). The fastest way to learn the terminal status is to receive that webhook and verify its `X-Fynex-Signature` header.\n\nIf you'd rather poll, query the payment via REST or GraphQL:\n\n```graphql\n# id is Int! — use the numeric internal ID.\n# genericPayment looks a payment up by its numeric id; there is no\n# externalOrderRef argument on the GraphQL query.\nquery {\n  genericPayment(id: 42) {\n    status\n    amount\n  }\n}\n```\n\nOr use the SSE stream on the hosted checkout page. See [Polling \u0026 SSE](#tag/polling-sse).\n\n## See also\n\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Poll payment status or subscribe to server-sent events.\n- **[Errors](#tag/errors)** — Interpret 409s and provider failure codes.\n- **[Disputes \u0026 chargebacks](#tag/disputes)** — If a customer disputes a payment, see the Disputes \u0026 chargebacks guide.\n","name":"Captures \u0026 refunds"},{"description":"\u003e [!CAUTION]\n\u003e **There is no dedicated recurring-billing API yet.** The *first* card payment can opt into the saved-card / merchant-initiated (MIT) flow via the `subscription` block on `POST /initialize-payment` (see [Server-to-Server → Saved cards and recurring payments](#tag/server-to-server)). What's missing is the lifecycle surface: no public endpoint to charge a previously-stored card by reference, and no subscription create/manage/cancel API. If you need full recurring/subscription billing now, contact your Fynex representative — and do not rely on any `/api/v1/checkout/*` endpoints; they are not part of this API.\n\n---\n\n## What recurring payments will do\n\nRecurring payments let you charge a returning customer without presenting a card form again. The customer provides their card once; Fynex stores an encrypted token on its side and returns a reference your backend uses for future charges — your servers never hold raw card data.\n\n| Concept | What it is |\n|---------|------------|\n| MIT | Merchant-Initiated Transaction — a charge your server triggers without the customer present. |\n| Saved card | An encrypted, PCI-scoped card token held on Fynex's infrastructure. |\n| `merchantCustomerId` | A string you choose (typically your internal customer/user ID) that future charges reference. |\n\n\u003e [!NOTE]\n\u003e **PCI scope.** Even though raw card data stays on Fynex's infrastructure, instructing Fynex to store credentials and triggering MIT charges moves your integration into a higher-scrutiny PCI DSS category (SAQ D / Merchant Level 2). Consult your QSA before going live with recurring billing.\n\n---\n\n## Current state\n\n- `POST /initialize-payment` **does** accept a `subscription` block (`subscription.enabled`, `subscription.allowSubsequentMerchantInitiated`) plus a `merchantCustomerId`. Setting these on the first payment opts it into the upstream processor's saved-card / MIT flow. See [Server-to-Server](#tag/server-to-server).\n- What's **not** yet available: a public endpoint to charge a previously-stored card *by reference* (no \"charge saved card\" call, no `storeCredential` field), and no subscription lifecycle API (create / update schedule / cancel). Storage is driven by the `subscription` block above, not a separate call.\n- The dedicated recurring-billing surface is in development.\n\n**To use recurring billing today:** contact your Fynex representative to discuss options for your account.\n\n---\n\n## What to prepare (before the API ships)\n\nYou can make the eventual migration smoother by structuring your data now:\n\n| Data point | Why |\n|------------|-----|\n| A stable `merchantCustomerId` per customer | Will key the customer's saved cards once the API ships. |\n| First-payment reference (`externalOrderRef`) | Links the initial card capture to the customer record. |\n| Customer consent record | MIT charging requires documented cardholder agreement for stored-credential reuse. |\n\n---\n\n## See also\n\n- **[Server-to-Server](#tag/server-to-server)** — Take the initial card payment today via `POST /initialize-payment`.\n- **[Hosted Checkout](#tag/hosted-checkout)** — Collect the first payment without handling card data.\n- **[Concepts](#tag/concepts)** — Key domain concepts: GenericPayment, SellerAccount, operationalMode, and more.\n","name":"Recurring"},{"description":"A **payout method** is a bank account or virtual IBAN registered as a destination for a specific payee. When you request a payout, you target a payout method — it tells Fynex where to send the money.\n\n\u003e [!NOTE]\n\u003e Payout methods are **bank accounts only** (`type: bank_account`). Card destinations are not supported. IBAN methods in EUR, GBP, and USD are executable through the current banking-provider mapping. GBP `uk_local` methods are executable through Faster Payments. US-local and SWIFT formats can be onboarded and approved, but execution is blocked before funds are held until their mappings are enabled.\n\nPayout methods have two API surfaces:\n\n| Surface | Auth | Operations |\n|---------|------|------------|\n| REST `/payments-api/v1/payees/{payee_id}/payout-methods[/{method_id}]` | Bearer token | Full CRUD: list, create, update, delete |\n| GraphQL `/dashboard/graphql` | `dashboard_session` cookie | Full CRUD: create, update, delete |\n\n\u003e [!NOTE]\n\u003e Integrators with a Bearer token have full REST CRUD over payout methods. `update` and `delete` operate on a single method at `…/payout-methods/{method_id}`; the method must belong to the payee in the path and to your seller account.\n\n---\n\n## Prerequisite ordering\n\n1. **Create a payee** — a payout method must be attached to an existing payee. See [Payees](#tag/payees).\n\n2. **Create a payout method** on that payee — supply the bank account details documented below.\n\n3. **Request a payout** — pass the `payoutMethodId` and a `payeeId` when calling `POST /payments-api/v1/payouts`. See [Payouts](#tag/payouts).\n\n\u003e [!NOTE]\n\u003e `payee_id` in the REST path is the **numeric integer ID** returned by `createPayee` or from the payee list — not a UUID or string.\n\n---\n\n## Payout method fields (from `schema.graphql`)\n\n### `type PayoutMethod`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Numeric payout method ID — pass this as `payoutMethodId` when creating a payout |\n| `payeeId` | `Int!` | ID of the payee this method belongs to |\n| `type` | `String!` | Account type identifier. The only value the platform uses is `bank_account` (the default). Card destinations are not supported. |\n| `bankAccountType` | `String!` | Destination identifier format: `iban`, `uk_local`, `us_local`, or `swift`. Fynex selects the eventual payout rail; clients do not select it. |\n| `currency` | `String!` | Three-letter ISO 4217 currency code (e.g. `EUR`, `GBP`) |\n| `bankCountry` | `String` | Two-letter ISO country code for the destination bank. |\n| `iban` | `String` | IBAN for SEPA / SWIFT destinations |\n| `bic` | `String` | BIC / SWIFT code |\n| `accountNumber` | `String` | Account number for domestic rails (eight digits for UK Faster Payments) |\n| `sortCode` | `String` | Six-digit sort code for UK Faster Payments |\n| `routingNumber` | `String` | ABA routing number for US local accounts. |\n| `bankName` | `String` | Bank name for reference |\n| `accountName` | `String` | Account holder name |\n| `status` | `String!` | Account status (e.g. `active`) |\n| `createdAt` | `Time!` | Creation timestamp |\n| `updatedAt` | `Time!` | Last update timestamp |\n\n---\n\n## REST — List payout methods for a payee\n\n**Auth: Bearer token** (`Authorization: Bearer \u003ctoken\u003e`)\n\n```\nGET /payments-api/v1/payees/{payee_id}/payout-methods\n```\n\nReturns a list of active payout methods for the specified payee. Use this before creating a payout to confirm that a method exists and to retrieve its `id`.\n\n#### curl\n\n```bash\ncurl -sS \"https://api.fynex.ai/payments-api/v1/payees/7/payout-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n#### JavaScript\n\n```js\nconst payeeId = 7;\nconst res = await fetch(\n  `${process.env.FYNEX_API}/payees/${payeeId}/payout-methods`,\n  {\n    headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` },\n  }\n);\nconst data = await res.json();\nconsole.log(data); // { items: [...], totalCount: N }\n```\n\n#### Python\n\n```python\nimport os, requests\n\npayee_id = 7\nres = requests.get(\n    f\"{os.environ['FYNEX_API']}/payees/{payee_id}/payout-methods\",\n    headers={\"Authorization\": f\"Bearer {os.environ['FYNEX_TOKEN']}\"},\n)\nres.raise_for_status()\nprint(res.json())\n```\n\n---\n\n## REST — Create a payout method for a payee\n\n**Auth: Bearer token** (`Authorization: Bearer \u003ctoken\u003e`)\n\n```\nPOST /payments-api/v1/payees/{payee_id}/payout-methods\n```\n\nRegisters a new payout destination for the payee. The owning payee is taken from the path and pinned to your seller account, so `payeeId` is **not** accepted in the body. Only `bank_account` methods are supported. `bankAccountType` describes the supplied account identifiers; Fynex selects the rail. IBAN destinations can be used for EUR, GBP, and USD payouts. GBP `uk_local` destinations use Faster Payments. US-local and SWIFT formats can be saved and reviewed but payout execution is rejected before funds are held until their provider mappings are enabled.\n\nReturns `201 Created` with the payout method. A payee that does not exist, or belongs to another seller, returns `404`.\n\n### Request body fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `currency` | `String` | Yes | ISO 4217 currency code: `EUR`, `GBP`, or `USD`. |\n| `type` | `String` | No | Defaults to `bank_account` (the only supported value). |\n| `bankAccountType` | `String` | No | `iban` (default), `uk_local`, `us_local`, or `swift`. |\n| `bankCountry` | `String` | Conditional | Required for local and SWIFT formats. Use `GB` for `uk_local` and `US` for `us_local`. |\n| `iban` | `String` | Conditional | Required when `bankAccountType` is `iban`; optional account identifier for `swift`. |\n| `bic` | `String` | No | BIC / SWIFT code. |\n| `accountNumber` | `String` | No | Account number for domestic rails; exactly eight digits for `uk_local`. |\n| `sortCode` | `String` | No | Sort code for UK Faster Payments; exactly six digits after spaces/hyphens are removed. |\n| `routingNumber` | `String` | No | Nine-digit ABA routing number for `us_local`. |\n| `bankName` | `String` | No | Bank name for reference. |\n| `accountName` | `String` | No | Account holder name. |\n\n#### curl\n\n```bash\ncurl -sS -X POST \"https://api.fynex.ai/payments-api/v1/payees/7/payout-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"currency\": \"EUR\",\n    \"bankAccountType\": \"iban\",\n    \"iban\": \"DE89370400440532013000\",\n    \"bic\": \"DEUTDEFF\",\n    \"accountName\": \"Acme Supplies Ltd\"\n  }'\n```\n\n---\n\n## REST — Update a payout method\n\n**Auth: Bearer token** (`Authorization: Bearer \u003ctoken\u003e`)\n\n```\nPATCH /payments-api/v1/payees/{payee_id}/payout-methods/{method_id}\n```\n\nUpdates only the fields present in the body; omitted fields are left unchanged. `currency` cannot be changed (it is fixed at creation). The method must belong to the payee in the path and to your seller account, otherwise `404`. Set `status` to `inactive` to retire a method (it is then excluded from the list and can no longer be used for new payouts) or `active` to restore it.\n\n### Request body fields (all optional)\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `bankAccountType` | `String` | New destination identifier format. |\n| `bankCountry` | `String` | New two-letter bank country. |\n| `iban` | `String` | New IBAN. |\n| `bic` | `String` | New BIC. |\n| `accountNumber` | `String` | New account number. |\n| `sortCode` | `String` | New sort code. |\n| `routingNumber` | `String` | New ABA routing number. |\n| `bankName` | `String` | New bank name. |\n| `accountName` | `String` | New account holder name. |\n| `status` | `String` | `active` or `inactive`. |\n\n#### curl\n\n```bash\ncurl -sS -X PATCH \"https://api.fynex.ai/payments-api/v1/payees/7/payout-methods/501\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"accountName\": \"Acme Supplies Ltd\" }'\n```\n\n---\n\n## REST — Delete a payout method\n\n**Auth: Bearer token** (`Authorization: Bearer \u003ctoken\u003e`)\n\n```\nDELETE /payments-api/v1/payees/{payee_id}/payout-methods/{method_id}\n```\n\nDeletes a payout method your seller account owns. The method must belong to the payee in the path, otherwise `404`. Returns `{ \"deleted\": true }`.\n\n#### curl\n\n```bash\ncurl -sS -X DELETE \"https://api.fynex.ai/payments-api/v1/payees/7/payout-methods/501\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n---\n\n## GraphQL — Full CRUD\n\n**Auth: `dashboard_session` cookie** — see [GraphQL Authentication](#tag/graphql-auth) for the login flow.\n\n| Operation | Type | Permission |\n|-----------|------|------------|\n| `payoutMethods(payeeId, limit, offset)` | Query | `PAYOUTMETHODS_READ` |\n| `payoutMethod(id: Int!)` | Query | `PAYOUTMETHODS_READ` |\n| `createPayoutMethod(input: CreatePayoutMethodInput!)` | Mutation | `PAYOUTMETHODS_CREATE` |\n| `updatePayoutMethod(id: Int!, input: UpdatePayoutMethodInput!)` | Mutation | `PAYOUTMETHODS_UPDATE` |\n| `deletePayoutMethod(id: Int!)` | Mutation | `PAYOUTMETHODS_DELETE` |\n\n### `CreatePayoutMethodInput` fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `payeeId` | `Int!` | Yes | ID of the payee to attach this method to |\n| `currency` | `String!` | Yes | ISO 4217 currency code: `EUR`, `GBP`, or `USD`. |\n| `type` | `String` | No | Account type. Defaults to `bank_account`, the only value the platform uses — it does not affect routing. |\n| `bankAccountType` | `String` | No | Destination identifier format: `iban` (default), `uk_local`, `us_local`, or `swift`. |\n| `bankCountry` | `String` | Conditional | Required for local and SWIFT formats. |\n| `iban` | `String` | Conditional | Required for `iban`; may identify a `swift` destination instead of `accountNumber`. |\n| `bic` | `String` | No | BIC/SWIFT code |\n| `accountNumber` | `String` | No | Account number — required for domestic rails |\n| `sortCode` | `String` | No | Six-digit sort code — required for UK Faster Payments |\n| `routingNumber` | `String` | No | Nine-digit ABA routing number — required for `us_local`. |\n| `bankName` | `String` | No | Bank name |\n| `accountName` | `String` | No | Account holder name |\n\n### `UpdatePayoutMethodInput` fields\n\nAll fields are optional. Supply only the fields you want to change.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `bankAccountType` | `String` | New destination identifier format |\n| `bankCountry` | `String` | New two-letter bank country |\n| `iban` | `String` | New IBAN |\n| `bic` | `String` | New BIC |\n| `accountNumber` | `String` | New account number |\n| `sortCode` | `String` | New sort code |\n| `routingNumber` | `String` | New ABA routing number |\n| `bankName` | `String` | New bank name |\n| `accountName` | `String` | New account holder name |\n| `status` | `String` | Update status (e.g. set to `inactive`) |\n\n---\n\n## Create a payout method — code samples\n\n#### curl\n\n```bash\n# Step 1 — login and save the cookie\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Step 2 — create the payout method (IBAN example)\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"mutation CreatePayoutMethod($input: CreatePayoutMethodInput!) { createPayoutMethod(input: $input) { id payeeId currency iban isDefault status } }\",\n    \"variables\": {\n      \"input\": {\n        \"payeeId\": 7,\n        \"currency\": \"EUR\",\n        \"type\": \"bank_account\",\n        \"iban\": \"DE89370400440532013000\",\n        \"bic\": \"COBADEFFXXX\",\n        \"bankName\": \"Commerzbank AG\",\n        \"accountName\": \"Acme Supplies Limited\"\n      }\n    }\n  }'\n```\n\n#### JavaScript\n\n```js\nconst BASE = 'https://api.fynex.ai';\n\n// Assumes login was already called and the dashboard_session cookie is present\nasync function createPayoutMethod(input) {\n  const res = await fetch(`${BASE}/dashboard/graphql`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({\n      query: `\n        mutation CreatePayoutMethod($input: CreatePayoutMethodInput!) {\n          createPayoutMethod(input: $input) {\n            id\n            payeeId\n            currency\n            iban\n            isDefault\n            status\n          }\n        }\n      `,\n      variables: { input },\n    }),\n  });\n  const { data, errors } = await res.json();\n  if (errors?.length) throw new Error(errors[0].message);\n  return data.createPayoutMethod;\n}\n\nconst method = await createPayoutMethod({\n  payeeId: 7,\n  currency: 'EUR',\n  type: 'bank_account',\n  iban: 'DE89370400440532013000',\n  bic: 'COBADEFFXXX',\n  bankName: 'Commerzbank AG',\n  accountName: 'Acme Supplies Limited',\n});\nconsole.log(method);\n// { id: 3, payeeId: 7, currency: 'EUR', iban: 'DE89370400440532013000', isDefault: true, status: 'active' }\n```\n\n#### Python\n\n```python\nimport requests\n\nBASE = \"https://api.fynex.ai\"\nsession = requests.Session()\n\n# Login first\nsession.post(\n    f\"{BASE}/api/v1/login/dashboard\",\n    json={\"email\": \"you@example.com\", \"password\": \"your_password\"},\n).raise_for_status()\n\nmutation = \"\"\"\n  mutation CreatePayoutMethod($input: CreatePayoutMethodInput!) {\n    createPayoutMethod(input: $input) {\n      id\n      payeeId\n      currency\n      iban\n      isDefault\n      status\n    }\n  }\n\"\"\"\nvariables = {\n    \"input\": {\n        \"payeeId\": 7,\n        \"currency\": \"EUR\",\n        \"type\": \"bank_account\",\n        \"iban\": \"DE89370400440532013000\",\n        \"bic\": \"COBADEFFXXX\",\n        \"bankName\": \"Commerzbank AG\",\n        \"accountName\": \"Acme Supplies Limited\",\n    }\n}\nresp = session.post(\n    f\"{BASE}/dashboard/graphql\",\n    json={\"query\": mutation, \"variables\": variables},\n)\nresp.raise_for_status()\nbody = resp.json()\nif \"errors\" in body:\n    raise RuntimeError(body[\"errors\"][0][\"message\"])\nprint(body[\"data\"][\"createPayoutMethod\"])\n```\n\n---\n\n## Other operations\n\n### Update a payout method\n\n```graphql\nmutation UpdatePayoutMethod($id: Int!, $input: UpdatePayoutMethodInput!) {\n  updatePayoutMethod(id: $id, input: $input) {\n    id\n    iban\n    bic\n    isDefault\n    status\n    updatedAt\n  }\n}\n```\n\n### Delete a payout method\n\n```graphql\nmutation DeletePayoutMethod($id: Int!) {\n  deletePayoutMethod(id: $id)\n}\n```\n\nReturns `true` on success. You cannot delete a method that is currently referenced by a pending payout — cancel or complete the payout first.\n\n### List payout methods via GraphQL\n\n```graphql\nquery ListPayoutMethods($payeeId: Int!, $limit: Int, $offset: Int) {\n  payoutMethods(payeeId: $payeeId, limit: $limit, offset: $offset) {\n    items {\n      id\n      currency\n      type\n      iban\n      accountNumber\n      isDefault\n      status\n    }\n    totalCount\n  }\n}\n```\n\n---\n\n## Common pitfalls\n\n| Pitfall | Resolution |\n|---------|------------|\n| `payee_id` treated as UUID | The `payee_id` path parameter is a **numeric integer** from the `Payee.id` field. |\n| Creating a method before the payee exists | `createPayoutMethod` requires a valid `payeeId`. Create the payee first (see [Payees](#tag/payees)). |\n| Required identifiers missing | For `iban`, provide `iban`; for `uk_local`, provide a GBP account with `GB`, account number, and sort code; for `us_local`, provide a USD account with `US`, account number, and ABA routing number; for `swift`, provide bank country, BIC, and either account number or IBAN. |\n| US-local or SWIFT method selected for payout | These formats can be onboarded and approved, but execution is not enabled yet. Fynex rejects the payout before funds are held. GBP `uk_local` methods are enabled through Faster Payments. |\n| Using Bearer auth on `/dashboard/graphql` | The GraphQL endpoint only accepts the `dashboard_session` cookie. Bearer tokens are for the REST surface only. |\n\n## See also\n\n- **[GraphQL Authentication](#tag/graphql-auth)** — Obtain a dashboard_session cookie before calling any GraphQL mutation.\n- **[Payees](#tag/payees)** — Create and manage the payees that payout methods are attached to.\n- **[Payouts](#tag/payouts)** — Send money from a seller wallet to a payee\n","name":"Payout methods"},{"description":"Fynex delivers **outbound webhooks** for payment and refund events — see [Webhooks](#tag/webhooks) for the live signature-verified contract. **Polling** and **Server-Sent Events (SSE)**, documented here, are complementary: they let you learn when a payment succeeded, a refund completed, or a payout settled, and they make a solid backstop in case a webhook delivery is missed (payouts in particular do not yet emit webhooks, so poll for those). Fynex's own dashboard uses polling at 5-second intervals, so the pattern is battle-tested.\n\n## Choose your verification path\n\n| Scenario | Best fit | Why |\n|---|---|---|\n| You're a backend integrating server-to-server | **Polling** `GET /payments-api/v1/payments/{payment_id}` | Bearer-token REST, same auth as `/initialize-payment`. No cookie session needed. |\n| You're running a hosted checkout and want instant UI feedback | **SSE** stream `/checkout/{session_id}/events` | Pushed transitions, no busy loop, scoped to one session |\n| You're sending payouts | **Polling** `GET /payouts/{id}` | Settlement is asynchronous (minutes for SEPA, hours for SWIFT) |\n| You're listing transactions or refunds for a dashboard | **Polling** `transactions` / `refunds` GraphQL queries | Fits batch UI patterns; the Fynex dashboard polls these every 5s |\n\n## Polling pattern\n\n### Recommended cadence\n\n- **Right after a state-changing action** (initialize, finalize, capture, refund, payout-create): poll frequently for the first few seconds, then back off to 5–10 seconds.\n- **Steady-state monitoring**: then progressively slower depending on urgency.\n- **Stop polling** once the resource reaches a terminal state (`succeeded`, `failed`, `cancelled`, `refunded`, `completed`).\n- **Stay under the per-seller rate limit.** Every authorized `/payments-api/v1/*` response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`; the budget differs by environment, so drive your interval from those headers rather than a fixed rate. Polling faster than the remaining budget allows will trip 429 with a `Retry-After` header — honor it. See [Rate limiting](#tag/errors).\n\n### Payment status (REST, recommended for server-to-server)\n\n`GET /payments-api/v1/payments/{payment_id}` is fully bearer-token authenticated — same credential and same surface as `/initialize-payment`, `/capture`, and `/refund`. The `payment_id` path segment is the seller's **`externalOrderRef`** (the value you supplied on `/initialize-payment`); if multiple payment attempts share an `externalOrderRef`, the **latest** attempt for the authenticated seller is returned.\n\nThe response carries the Fynex lifecycle `status`, the originally authorized `amount` in major units, currency/country, payment type/method, `externalOrderRef`, and the latest `failureCode` / `failureDescription` / `failureStage` (if any). (The raw upstream `providerStatus` is not part of this REST response — it is exposed only on the GraphQL `genericPayment` type.)\n\n#### curl\n\n```bash\nwhile :; do\n  RES=$(curl -sS \"$FYNEX_API/payments-api/v1/payments/ORDER-1042\" \\\n    -H \"Authorization: Bearer $FYNEX_TOKEN\")\n  STATUS=$(echo \"$RES\" | jq -r '.status')\n  echo \"$(date -u +%H:%M:%S) status=$STATUS\"\n  case \"$STATUS\" in\n    provider_completed|settled|deposit_confirmed|refunded|failed|cancelled) break;;\n  esac\n  sleep 5\ndone\n```\n\n#### JavaScript\n\n```js\nasync function waitForPayment(externalOrderRef, { intervalMs = 5000, maxMs = 10 * 60 * 1000 } = {}) {\n  const terminal = new Set([\n    'provider_completed', 'settled', 'deposit_confirmed',\n    'refunded', 'failed', 'cancelled',\n  ]);\n  const deadline = Date.now() + maxMs;\n  while (Date.now() \u003c deadline) {\n    const res = await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/${encodeURIComponent(externalOrderRef)}`, {\n      headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` },\n    });\n    if (!res.ok) throw new Error(`HTTP ${res.status}`);\n    const payment = await res.json();\n    if (terminal.has(payment.status)) return payment;\n    await new Promise(r =\u003e setTimeout(r, intervalMs));\n  }\n  throw new Error(`Payment ${externalOrderRef} did not reach a terminal state in time`);\n}\n```\n\n#### Python\n\n```python\nimport time\nimport requests\n\nTERMINAL = {\n    \"provider_completed\", \"settled\", \"deposit_confirmed\",\n    \"refunded\", \"failed\", \"cancelled\",\n}\n\ndef wait_for_payment(external_order_ref: str, *, interval: float = 5.0, max_seconds: int = 10 * 60):\n    deadline = time.monotonic() + max_seconds\n    while time.monotonic() \u003c deadline:\n        res = requests.get(\n            f\"{FYNEX_API}/payments-api/v1/payments/{external_order_ref}\",\n            headers={\"Authorization\": f\"Bearer {FYNEX_TOKEN}\"},\n            timeout=10,\n        )\n        res.raise_for_status()\n        payment = res.json()\n        if payment[\"status\"] in TERMINAL:\n            return payment\n        time.sleep(interval)\n    raise TimeoutError(f\"Payment {external_order_ref} did not settle in time\")\n```\n\n`status` follows the [Payment Lifecycle](#tag/captures-refunds) state machine. Stop polling once you see a terminal value; treat intermediate states like `provider_pending` as \"keep waiting.\"\n\n### Payment status (GraphQL, for cookie-auth contexts)\n\nIf you're calling from the customer dashboard or another context that already holds a `dashboard_session` cookie, you can use the GraphQL `genericPayment(id: Int!)` query instead. The lookup key is the **numeric internal ID** (not the `externalOrderRef`) and it requires the dashboard cookie — see [GraphQL Authentication](#tag/graphql-auth) for the login flow.\n\n```graphql\nquery GenericPayment($id: Int!) {\n  genericPayment(id: $id) {\n    id\n    externalOrderRef\n    status\n    providerPaymentId\n    failureCode\n    failureDescription\n    amount\n    currencyCode\n  }\n}\n```\n\nFor pure server-to-server backends, prefer the REST endpoint above — no extra session flow, same bearer token you already have.\n\n### Payout status (REST)\n\n`GET /payments-api/v1/payouts/{id}` is fully bearer-token authenticated. Poll until `status` is one of `completed` / `failed` / `cancelled`.\n\n#### curl\n\n```bash\nwhile :; do\n  RES=$(curl -sS \"$FYNEX_API/payments-api/v1/payouts/503\" \\\n    -H \"Authorization: Bearer $FYNEX_TOKEN\")\n  STATUS=$(echo \"$RES\" | jq -r '.status')\n  echo \"$(date -u +%H:%M:%S) status=$STATUS\"\n  case \"$STATUS\" in\n    completed|failed|cancelled) break;;\n  esac\n  sleep 5\ndone\n```\n\n#### JavaScript\n\n```js\nasync function waitForPayout(id, { intervalMs = 5000, maxMs = 30 * 60 * 1000 } = {}) {\n  const deadline = Date.now() + maxMs;\n  while (Date.now() \u003c deadline) {\n    const res = await fetch(`${process.env.FYNEX_API}/payments-api/v1/payouts/${id}`, {\n      headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` },\n    });\n    if (!res.ok) throw new Error(`HTTP ${res.status}`);\n    const payout = await res.json();\n    if (['completed', 'failed', 'cancelled'].includes(payout.status)) {\n      return payout;\n    }\n    await new Promise(r =\u003e setTimeout(r, intervalMs));\n  }\n  throw new Error(`Payout ${id} did not reach a terminal state in time`);\n}\n```\n\n#### Python\n\n```python\nimport time\nimport requests\n\ndef wait_for_payout(payout_id: int, *, interval: float = 5.0, max_seconds: int = 30 * 60):\n    deadline = time.monotonic() + max_seconds\n    while time.monotonic() \u003c deadline:\n        res = requests.get(\n            f\"{FYNEX_API}/payments-api/v1/payouts/{payout_id}\",\n            headers={\"Authorization\": f\"Bearer {FYNEX_TOKEN}\"},\n            timeout=10,\n        )\n        res.raise_for_status()\n        payout = res.json()\n        if payout[\"status\"] in {\"completed\", \"failed\", \"cancelled\"}:\n            return payout\n        time.sleep(interval)\n    raise TimeoutError(f\"Payout {payout_id} did not settle in time\")\n```\n\n### Polling resilience\n\n- **Use HTTPS keep-alive** to avoid TLS handshake on every poll.\n- **Add jitter** (e.g. ±20% of the interval) if you have many concurrent pollers.\n- **Surface a deadline** to your operator. A payout that's still `processing` after an hour deserves a human look.\n- **Cache the last status** locally so you only re-render UI on state changes.\n- **Honor `Retry-After` on 429.** When a poll returns 429, sleep for at least `Retry-After` seconds (the header is always whole seconds) before the next attempt — don't tight-retry. A poller that ignores 429 will stay rate-limited indefinitely. See [Rate limiting](#tag/errors).\n\n## SSE: real-time browser updates for hosted checkout\n\nWhen a customer is sitting on the hosted checkout page, you don't want them to wait on a poll loop. The hosted-checkout backend exposes a Server-Sent Events stream:\n\n```\nGET https://staging-api.fynex.ai/checkout/{session_id}/events\n```\n\nThe stream emits one event per state transition (e.g. when the upstream processor returns, when 3DS completes, when capture finalises). Every message is sent with the SSE event name `status`. The browser-side checkout page subscribes to it and reacts immediately.\n\n### Browser snippet\n\n```js\nconst evt = new EventSource(\n  `https://staging-api.fynex.ai/checkout/${sessionId}/events`,\n);\n\nevt.addEventListener('status', (e) =\u003e {\n  const data = JSON.parse(e.data);\n  console.log('Payment status changed:', data.status);\n  // SSE status values: pending | payable | completed | failed | timeout\n  if (['completed', 'failed', 'timeout'].includes(data.status)) {\n    evt.close();\n    handleTerminal(data);\n  }\n});\n\nevt.onerror = () =\u003e {\n  // EventSource auto-reconnects with exponential backoff.\n  console.warn('SSE disconnected; auto-reconnecting');\n};\n```\n\n\u003e [!NOTE]\n\u003e SSE is **only available from a browser context** that already has the checkout session URL. It is not authenticated with a bearer token — the session ID itself is the credential. Don't expose session IDs publicly.\n\n### When to use SSE vs polling\n\n- **SSE** if you control the customer's browser session and want sub-second feedback on a single payment.\n- **Polling** for backend services, batch jobs, payouts, and any case where a long-lived HTTP connection is awkward (mobile networks, serverless functions with execution-time limits, etc.).\n\n## Common pitfalls\n\n- **Don't trust the redirect alone.** When a hosted checkout redirects the customer to your `returnUrls.success`, the redirect URL is *not authoritative*. Verify by polling or via the SSE stream before granting fulfilment.\n- **Don't poll forever.** Set a deadline. A payment stuck at `provider_pending` for hours signals an upstream issue that needs human attention, not more polling.\n- **Don't forget terminal-state caching.** Once a payment reaches `succeeded` or `failed`, the state is permanent — write it to your own database and stop polling that record.\n- **Keep dedupe in mind.** If you re-trigger a flow with the same `Idempotency-Key`, polling could pick up a previously-completed payment. Make sure your \"is this a new transaction?\" check happens before you start polling.\n\n## See also\n\n- **[Captures \u0026 refunds](#tag/captures-refunds)** — Trigger state transitions, then poll to confirm them.\n- **[Idempotency \u0026 retries](#tag/idempotency)** — Make your retry logic safe so polling never replays a charge.\n","name":"Polling \u0026 SSE"},{"description":"The Fynex reconciliation surface lets sellers compare what Fynex has recorded as settled funds against what actually arrived in their bank account. Each reconciliation record represents a settlement deposit event with three amounts: **reported** (what the processor claimed), **expected** (what Fynex calculated), and **actual** (what was received).\n\n\u003e [!NOTE]\n\u003e **GraphQL only — cookie session required.** All reconciliation queries are on `POST /dashboard/graphql` and require permission `RECONCILIATIONS_READ` on the session. See [GraphQL Authentication](#tag/graphql-auth).\n\n---\n\n## Use cases\n\n- **Daily settlement file matching** — pull all reconciliation records for the previous day and compare reported vs actual amounts to detect shortfalls.\n- **Dispute investigation** — fetch a single `reconciliation(id)` to see the full fact list (fees reported vs expected) for a specific deposit event.\n- **Dispute reserve calculation** — use `reconciliationStatistics` to get aggregate counts of pending, successful, and discrepancy records over a date range.\n\n---\n\n## GraphQL operations\n\n| Operation | Signature | Permission |\n|-----------|-----------|------------|\n| List reconciliations | `reconciliations(limit: Int, offset: Int, status: String, dateFrom: Time, dateTo: Time): ReconciliationConnection!` | `RECONCILIATIONS_READ` |\n| Get single record | `reconciliation(id: Int!): ReconciliationSettlementDeposit` | `RECONCILIATIONS_READ` |\n| Aggregate statistics | `reconciliationStatistics(dateFrom: Time, dateTo: Time): ReconciliationStatistics!` | `RECONCILIATIONS_READ` |\n\n\u003e [!NOTE]\n\u003e Raw processor-side settlement records (the underlying settlement file contents) are not part of the seller-facing API. Their shape follows whichever processor is configured for the seller, so the field names are not a stable public contract and are not suitable to build against. The reconciliation surfaces documented on this page are the supported way to reconcile.\n\n---\n\n## Type reference\n\n### `ReconciliationConnection`\n\nThe list query returns a connection type rather than a plain array:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `items` | `[ReconciliationSettlementDeposit!]!` | Page of reconciliation records |\n| `totalCount` | `Int!` | Total number of records matching the filter |\n\n### `ReconciliationSettlementDeposit`\n\nThe primary reconciliation entity — one record per settlement deposit event.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Record ID |\n| `bankingTransferReference` | `String!` | Reference of the corresponding bank transfer |\n| `depositAmountReported` | `Float!` | Amount the processor reported in the settlement file |\n| `depositAmountExpected` | `Float!` | Amount Fynex calculated based on processed payments |\n| `depositAmountActual` | `Float!` | Amount actually received in the banking deposit |\n| `status` | `String!` | Reconciliation status (`pending`, `success`, `discrepancy`, etc.) |\n| `createdAt` | `Time!` | When the record was created |\n| `updatedAt` | `Time!` | Last update time |\n| `genericSettlementReport` | `ReconciliationSettlementReport` | Linked processor settlement report |\n| `bankingDeposit` | `ReconciliationBankingDeposit` | Linked banking deposit |\n| `facts` | `[ReconciliationFact!]!` | Line-item fee discrepancy details |\n\n### `ReconciliationSettlementReport`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Report ID |\n| `paymentProviderCode` | `String!` | Processor identifier |\n| `operationalMode` | `String!` | `demo` or `live` |\n| `periodStart` | `Time` | Settlement period start |\n| `periodEnd` | `Time` | Settlement period end |\n| `bankingTransferReference` | `String!` | Bank transfer reference |\n| `fundingPaymentCurrency` | `String!` | Currency of the funding payment |\n| `totalFundingPaymentAmountReported` | `Float!` | Total reported funding amount |\n| `totalFundingPaymentAmountExpected` | `Float!` | Total expected funding amount |\n| `totalPurchaseFeeAmountReported` | `Float!` | Total purchase fees as reported |\n| `totalOtherFeeAmountReported` | `Float!` | Other fees as reported |\n| `status` | `String!` | Report status |\n\n### `ReconciliationFact`\n\nEach fact represents a single line-item discrepancy found during reconciliation.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | `Int!` | Fact ID |\n| `factType` | `String!` | Category of discrepancy |\n| `discrepancyPlace` | `String!` | Where in the flow the discrepancy was detected |\n| `feeAmountReported` | `Float!` | Fee amount from processor file |\n| `feeAmountExpected` | `Float!` | Fee amount Fynex expected |\n| `feeAmountActual` | `Float!` | Fee amount actually applied |\n| `normalizedRecord` | `ReconciliationNormalizedRecord` | The payment transaction this fact relates to |\n\n### `ReconciliationStatistics`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `successCount` | `Int!` | Reconciliations with no discrepancy |\n| `pendingCount` | `Int!` | Reconciliations not yet matched |\n| `discrepancyCount` | `Int!` | Reconciliations where amounts do not match |\n| `totalReportedAmount` | `Float!` | Sum of all reported deposit amounts |\n| `totalActualAmount` | `Float!` | Sum of all actual deposit amounts |\n\n---\n\n## Code samples\n\n#### curl\n\n```bash\n# Step 1 — login\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Step 2 — list reconciliations for a date range\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"query Recons($dateFrom: Time, $dateTo: Time, $limit: Int, $offset: Int) { reconciliations(dateFrom: $dateFrom, dateTo: $dateTo, limit: $limit, offset: $offset) { totalCount items { id status depositAmountReported depositAmountExpected depositAmountActual bankingTransferReference createdAt } } }\",\n    \"variables\": {\n      \"dateFrom\": \"2025-01-01T00:00:00Z\",\n      \"dateTo\": \"2025-01-31T23:59:59Z\",\n      \"limit\": 50,\n      \"offset\": 0\n    }\n  }'\n```\n\n#### JavaScript\n\n```js\nconst BASE = 'https://api.fynex.ai';\n\nasync function login(email, password) {\n  const res = await fetch(`${BASE}/api/v1/login/dashboard`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ email, password }),\n  });\n  if (!res.ok) throw new Error(`Login failed: ${res.status}`);\n}\n\nasync function gql(query, variables = {}) {\n  const res = await fetch(`${BASE}/dashboard/graphql`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ query, variables }),\n  });\n  const { data, errors } = await res.json();\n  if (errors?.length) throw new Error(errors[0].message);\n  return data;\n}\n\nawait login('you@example.com', 'your_password');\n\nconst { reconciliations } = await gql(\n  `query Recons($dateFrom: Time, $dateTo: Time, $limit: Int, $offset: Int) {\n     reconciliations(dateFrom: $dateFrom, dateTo: $dateTo, limit: $limit, offset: $offset) {\n       totalCount\n       items {\n         id\n         status\n         depositAmountReported\n         depositAmountExpected\n         depositAmountActual\n         bankingTransferReference\n         createdAt\n         facts {\n           factType\n           discrepancyPlace\n           feeAmountReported\n           feeAmountExpected\n         }\n       }\n     }\n   }`,\n  {\n    dateFrom: '2025-01-01T00:00:00Z',\n    dateTo: '2025-01-31T23:59:59Z',\n    limit: 50,\n    offset: 0,\n  }\n);\n\nconsole.log(`Total: ${reconciliations.totalCount}`);\nfor (const r of reconciliations.items) {\n  const diff = r.depositAmountActual - r.depositAmountExpected;\n  console.log(`${r.id} [${r.status}] diff: ${diff.toFixed(2)}`);\n}\n```\n\n#### Python\n\n```python\nimport requests\n\nBASE = \"https://api.fynex.ai\"\nsession = requests.Session()\n\nsession.post(\n    f\"{BASE}/api/v1/login/dashboard\",\n    json={\"email\": \"you@example.com\", \"password\": \"your_password\"},\n).raise_for_status()\n\nquery = \"\"\"\n  query Recons($dateFrom: Time, $dateTo: Time, $limit: Int, $offset: Int) {\n    reconciliations(\n      dateFrom: $dateFrom\n      dateTo: $dateTo\n      limit: $limit\n      offset: $offset\n    ) {\n      totalCount\n      items {\n        id\n        status\n        depositAmountReported\n        depositAmountExpected\n        depositAmountActual\n        bankingTransferReference\n        createdAt\n        facts {\n          factType\n          discrepancyPlace\n          feeAmountReported\n          feeAmountExpected\n          feeAmountActual\n        }\n      }\n    }\n  }\n\"\"\"\n\nresp = session.post(\n    f\"{BASE}/dashboard/graphql\",\n    json={\n        \"query\": query,\n        \"variables\": {\n            \"dateFrom\": \"2025-01-01T00:00:00Z\",\n            \"dateTo\": \"2025-01-31T23:59:59Z\",\n            \"limit\": 50,\n            \"offset\": 0,\n        },\n    },\n)\nresp.raise_for_status()\nresult = resp.json()\nif \"errors\" in result:\n    raise RuntimeError(result[\"errors\"][0][\"message\"])\n\ndata = result[\"data\"][\"reconciliations\"]\nprint(f\"Total records: {data['totalCount']}\")\n\nfor r in data[\"items\"]:\n    diff = r[\"depositAmountActual\"] - r[\"depositAmountExpected\"]\n    print(f\"ID {r['id']} [{r['status']}]: diff={diff:+.2f}, ref={r['bankingTransferReference']}\")\n    if r[\"facts\"]:\n        for fact in r[\"facts\"]:\n            print(f\"  Fact: {fact['factType']} @ {fact['discrepancyPlace']}\")\n```\n\n---\n\n## Reconciliation workflow\n\n1. **Fetch daily statistics** with `reconciliationStatistics(dateFrom, dateTo)` to get a quick health check: `successCount`, `pendingCount`, and `discrepancyCount`.\n\n2. **List pending and discrepancy records** by passing `status: \"pending\"` or `status: \"discrepancy\"` to `reconciliations`. Focus your investigation on these.\n\n3. **Inspect a specific record** with `reconciliation(id)` to get the full `facts` list showing where fees diverge between reported, expected, and actual.\n\n4. **Cross-reference with wallet entries** — each settled payment that contributed to the deposit will have a corresponding `WalletEntry` with `kind: INCOMING_PAYMENT_PROCESSED`. Compare `depositAmountActual` against the sum of wallet credit entries for the same period.\n\n5. **Escalate discrepancies** to your Fynex account manager if `depositAmountActual` differs from `depositAmountExpected` and the `facts` list does not explain the gap.\n\n---\n\n## Filtering tips\n\n- `dateFrom` and `dateTo` accept RFC 3339 timestamps (`2025-01-15T00:00:00Z`).\n- Use `status` to filter by reconciliation outcome. Common values include `pending`, `success`, and `discrepancy` — confirm the exact values with your Fynex contact as the list may expand.\n- Combine `limit` + `offset` for pagination. The `totalCount` field tells you how many pages to expect.\n\n## See also\n\n- **[GraphQL Authentication](#tag/graphql-auth)** — Obtain the dashboard_session cookie required for all GraphQL calls.\n- **[Wallets](#tag/wallets)** — Inspect wallet ledger entries that correspond to reconciled settlements.\n- **[Payment Lifecycle](#tag/payment-lifecycle)** — Understand the status transitions a payment goes through before it settles.\n","name":"Reconciliation"},{"description":"\u003e [!CAUTION]\n\u003e **This feature is not yet available via the API.** The Fynex dashboard includes a disputes \u0026 chargebacks view, but it currently directs merchants to the manual support flow — there are no live API endpoints for dispute management today. For any active dispute or chargeback, contact your Fynex representative directly.\n\n---\n\n## What are disputes and chargebacks?\n\nA **dispute** is raised when a cardholder questions a charge with their issuing bank. If the issuer sides with the cardholder, the charge is reversed — this is called a **chargeback**. A chargeback results in:\n\n- The captured amount being returned to the cardholder.\n- A chargeback fee charged to the merchant by the card scheme.\n- A formal process in which you may submit evidence to contest the reversal.\n\nChargebacks have strict response deadlines (typically 7–30 days depending on the card scheme). Acting quickly with strong evidence is the most effective defence.\n\n---\n\n## Current state\n\nThe Fynex disputes surface is **under active development**. Today:\n\n- The `/online-payments/disputes-and-chargebacks` dashboard page exists but is an informational/manual-support surface only.\n- There is no `disputes` GraphQL query or REST endpoint available.\n- There is no evidence-submission or lifecycle-management API.\n\n**To handle a dispute now:** email or message your Fynex representative as soon as you are notified of a chargeback. Include the payment reference, order details, customer contact, and any supporting evidence you hold.\n\n---\n\n## What to prepare (before the API ships)\n\nEven without an API, you can reduce chargeback risk and improve dispute outcomes by preserving the following data at payment time:\n\n| Data point | How to capture it |\n|------------|-------------------|\n| Order details | Store item names, quantities, and amounts tied to the Fynex payment reference. |\n| Customer email | Collect at checkout; include in your order record. |\n| Customer IP address | Log from your server at the point the payment is submitted. |\n| Device fingerprint | Forward the `X-Device-Fingerprint` header to Fynex at payment time — it is passed to the upstream processor for risk scoring and becomes available for dispute evidence. |\n| Delivery / fulfilment proof | Keep shipping tracking numbers, download logs, or service-completion records linked to each order. |\n\n---\n\n## Planned API surface\n\nWhen the disputes API ships, expect the following shape (subject to change):\n\n```graphql\n# Planned — not yet available\nquery disputes($limit: Int, $offset: Int) {\n  disputes(limit: $limit, offset: $offset) {\n    id\n    status          # under_review | accepted | contested | won | lost\n    amount\n    currencyCode\n    reason\n    deadline\n    genericPayment { id externalOrderRef }\n    createdAt\n  }\n}\n\nmutation submitDisputeEvidence($disputeId: ID!, $evidence: DisputeEvidenceInput!) {\n  submitDisputeEvidence(disputeId: $disputeId, evidence: $evidence) {\n    id\n    status\n  }\n}\n```\n\nPlanned lifecycle states:\n\n| State | Meaning |\n|-------|---------|\n| `under_review` | Fynex has received the chargeback notification and is reviewing. |\n| `accepted` | You have accepted the chargeback (amount returned to cardholder). |\n| `contested` | Evidence submitted; awaiting card scheme ruling. |\n| `won` | Card scheme ruled in your favour; funds retained. |\n| `lost` | Card scheme ruled for the cardholder; funds reversed. |\n\n---\n\n## Interim resolution path\n\nWhile the API is unavailable, a **full refund** is sometimes the fastest way to resolve a pre-chargeback dispute directly with the customer — it avoids chargeback fees and the scheme process entirely. See [Captures \u0026 Refunds](#tag/captures-refunds) for how to issue a refund programmatically.\n\n## See also\n\n- **[Captures \u0026 Refunds](#tag/captures-refunds)** — Issue partial or full refunds as an interim resolution before a chargeback escalates.\n- **[Errors](#tag/errors)** — Understand Fynex error shapes and status codes.\n- **[Concepts](#tag/concepts)** — Key domain concepts: GenericPayment, SellerAccount, operationalMode, and more.\n","name":"Disputes"},{"description":"Every integration has to answer the same question: _when did my payment actually succeed?_ Fynex provides three verification patterns — polling, SSE, and outbound webhooks — all available today. This page helps architects choose the right combination for their use case.\n\n---\n\n## The three options\n\n### 1. Polling\n\nYour backend calls the Fynex API on a schedule and inspects the resource's `status` field until it reaches a terminal state (`succeeded`, `failed`, `cancelled`, `completed`).\n\n- **Entry points**: `GET /payments-api/v1/payments/{payment_id}` and `GET /payments-api/v1/payouts/{id}` (REST, bearer token — recommended for server-to-server backends). The `genericPayment(id)` GraphQL query (dashboard session) is the alternative when you already hold a cookie-auth context.\n- **Available today**: yes — the Fynex dashboard itself uses 5-second polling for payments lists.\n- **Works from**: any server, scheduled job, or serverless function.\n\n### 2. SSE (Server-Sent Events)\n\nThe customer's browser opens a persistent HTTP connection to `/checkout/{session_id}/events`. The server pushes a `status` event for every state transition. The connection closes when the terminal event arrives (or the tab closes).\n\n- **Entry point**: `GET https://pay.fynex.ai/checkout/{session_id}/events`\n- **Available today**: yes — used internally by the hosted checkout page.\n- **Works from**: browser only. The session ID is the credential; bearer tokens are not used.\n\n### 3. Outbound webhooks\n\nFynex POSTs a signed JSON event to a seller-controlled HTTPS endpoint when a payment or refund changes state.\n\n\u003e [!NOTE]\n\u003e **Available today.** Outbound webhooks are live and signature-verified — see the [Webhooks](#tag/webhooks) guide for the full contract (configuration, HMAC-SHA256 signature, allowlist, retries). Payout events do not yet emit webhooks, so keep polling for payouts.\n\n---\n\n## Decision matrix\n\nRows are integration use cases. Columns are the three patterns. **Recommended** = the right default; **OK** = works but not ideal; **Not suitable** = do not use; **N/A** = not applicable.\n\n| Use case | Polling | SSE | Webhooks |\n|---|---|---|---|\n| Real-time hosted-checkout result on the customer's browser | OK | **Recommended** | N/A |\n| Server-to-server payment authorization → finalize sequence | **Recommended** | Not suitable | OK |\n| Long-running payout settlement (minutes to hours) | **Recommended** | Not suitable | OK |\n| Daily reconciliation batch run | **Recommended** | Not suitable | Not suitable |\n| Webhook backup / dead-letter queue | **Recommended** | Not suitable | OK |\n| Mobile app payment status update | **Recommended** | Not suitable | OK |\n| Background fraud review handling | **Recommended** | Not suitable | **Recommended** |\n| Failed-payment alerting in operations dashboard | **Recommended** | Not suitable | **Recommended** |\n\n**Why SSE is browser-only**: the SSE connection is scoped to a single checkout session UUID (that UUID is the credential). It is not authenticated with a bearer token and is not suitable for server-side code that handles many concurrent sessions or survives process restarts.\n\n---\n\n## Tradeoff analysis\n\n### Polling\n\n**Latency**: 1–30 seconds depending on interval. Right after a state-changing call (initialize, capture, refund) you can poll every 1–2 seconds; back off to 5–30 seconds for steady-state monitoring.\n\n**Cost**: one HTTP round-trip per interval per resource being watched. With HTTPS keep-alive the marginal cost per poll is low. With many concurrent pollers, add ±20% jitter to avoid thundering herd.\n\n**Rate limit**: Fynex applies a per-seller token-bucket limit to `/payments-api/v1/*`. The budget differs by environment, so watch `X-RateLimit-Remaining` on every response and back off as it approaches zero rather than assuming a fixed polling interval is safe. 429 responses carry `Retry-After`. See [Rate limiting](#tag/errors).\n\n**Reliability**: very high. Polling is stateless — a process restart, a network blip, or a pod reschedule loses nothing because the next poll picks up the current state. The only reliability concern is setting a deadline so a stuck resource gets human attention instead of polling forever.\n\n**Implementation complexity**: low. A loop with a sleep, a deadline, and a terminal-state check is a dozen lines in any language.\n\n**Operational burden**: none at rest. You own the retry logic; there is no queue to drain, no connection to keep alive, no dead-letter store to maintain.\n\n---\n\n### SSE\n\n**Latency**: sub-second. Events are pushed as they are emitted by the server, with no polling interval in between.\n\n**Cost**: very low. A single persistent connection replaces dozens of poll requests. The server fan-out is one event per status transition per session, which is minimal.\n\n**Reliability**: moderate. `EventSource` auto-reconnects with browser-managed exponential backoff, but connection drops are common on mobile networks and across certain reverse proxies. Some corporate HTTP proxies buffer chunked responses, which silences SSE events silently. A closed tab terminates the stream unconditionally.\n\n**Implementation complexity**: moderate. `EventSource` is a standard browser API, but you need to handle: message parsing, terminal-state detection and `close()`, reconnection edge cases, and a polling fallback for cases where SSE is unavailable (see Section 5).\n\n**Operational burden**: the persistent connection itself. Load balancers and API gateways must be configured to support long-lived HTTP connections (disable response buffering, increase idle timeouts). This is a one-time infrastructure concern, not a per-integration burden.\n\n---\n\n### Webhooks\n\n**Latency**: seconds from the state transition. Push eliminates the polling gap entirely, and the event arrives without the customer's browser being open.\n\n**Cost**: the lowest of the three for high-volume integrations. Fynex bears the delivery cost; you bear the ingestion cost (one HTTP handler). No long-lived connections on your side.\n\n**Reliability**: depends on your implementation. Webhook delivery is inherently at-least-once with retries; without idempotency handling, you will process duplicate events. A dead-letter queue is essential for production. Reliability also depends on Fynex's retry policy (expected: backoff for at least 24 hours).\n\n**Implementation complexity**: the highest of the three. A correct webhook handler must: verify the HMAC-SHA256 signature on every request, deduplicate by `X-Fynex-Event-Id`, return a fast 200 (process async), handle retries gracefully, and monitor the dead-letter queue.\n\n**Operational burden**: significant. You must expose a public HTTPS endpoint, keep TLS certificates valid, monitor delivery failures, and operate a dead-letter queue for events that exhaust retries.\n\n---\n\n## Recommended starting point per integration profile\n\n### \"I'm building a one-page checkout for my web shop\"\n\nUse **hosted checkout + SSE** for the customer-facing result: instant feedback while the customer is on the page. Use **polling on your backend** for the order-fulfillment trigger — never grant fulfilment based on the redirect URL alone; the redirect is advisory, not authoritative.\n\n### \"I have a B2B integration where a sales team calls the API directly\"\n\nUse **server-to-server + polling on the backend**. Initialize and finalize payments from your own server; poll `genericPayment(id)` or the payout endpoint until terminal state. SSE is irrelevant here (no customer browser session). When webhooks ship, add them as a supplement.\n\n### \"I'm running a marketplace with thousands of payouts a day\"\n\nUse **polling at a reasonable cadence** — 60-second intervals work for SEPA payouts (minutes to hours). Implement a circuit-breaker: if a payout stays in `processing` for more than N hours (pick a threshold appropriate to the rail), alert your operations team rather than keep polling silently.\n\n### \"I have a serverless architecture (Lambda, Cloud Functions) and don't want long-running pollers\"\n\nSchedule polls via your platform's scheduler (AWS EventBridge, GCP Cloud Scheduler, etc.). A Lambda triggered every 30 seconds that checks open payouts is equivalent to a persistent poller at a fraction of the cost. Avoid SSE entirely — it requires a persistent connection that serverless functions cannot hold.\n\n---\n\n## Hybrid patterns\n\n### Polling + SSE for hosted checkout\n\nThe most robust hosted-checkout implementation uses both patterns in parallel:\n\n1. **Browser**: opens `EventSource` on `/checkout/{session_id}/events` for instant UI feedback.\n2. **Backend**: polls `genericPayment(id)` (or receives the finalize response and verifies it) before writing the order-fulfilled record.\n\nThe two paths are independent. If SSE drops, the customer's UI can fall back to the `/checkout/{session_id}/poll` REST endpoint. The backend's truth comes from its own poll, not from what the browser reported.\n\n### Polling with exponential backoff\n\nA practical backoff schedule for payout settlement:\n\n```\n0–10 s     →  poll every 1 s   (immediate confirmation window)\n10–70 s    →  poll every 5 s   (typical fast-path settlement)\n70 s–30 m  →  poll every 30 s  (slow provider / queued)\n\u003e 30 m     →  circuit-break, alert, stop polling\n```\n\n#### JavaScript\n\n```js\nasync function pollWithBackoff(id, fetchFn, isTerminal) {\n  const schedule = [\n    { until: 10_000,      interval: 1_000  },\n    { until: 70_000,      interval: 5_000  },\n    { until: 30 * 60_000, interval: 30_000 },\n  ];\n  const start = Date.now();\n  while (true) {\n    const elapsed = Date.now() - start;\n    const stage = schedule.find(s =\u003e elapsed \u003c s.until);\n    if (!stage) throw new Error(`Circuit-break: ${id} not terminal after 30 min`);\n    const resource = await fetchFn(id);\n    if (isTerminal(resource.status)) return resource;\n    await new Promise(r =\u003e setTimeout(r, stage.interval));\n  }\n}\n```\n\n#### Python\n\n```python\nimport time\n\nSCHEDULE = [\n    (10,       1),    # 0–10 s: every 1 s\n    (70,       5),    # 10–70 s: every 5 s\n    (30 * 60,  30),   # 70 s–30 m: every 30 s\n]\nTERMINAL = {\"completed\", \"failed\", \"cancelled\", \"succeeded\"}\n\ndef poll_with_backoff(resource_id, fetch_fn):\n    start = time.monotonic()\n    while True:\n        elapsed = time.monotonic() - start\n        interval = next(\n            (iv for limit, iv in SCHEDULE if elapsed \u003c limit),\n            None,\n        )\n        if interval is None:\n            raise TimeoutError(f\"Circuit-break: {resource_id} not terminal after 30 min\")\n        resource = fetch_fn(resource_id)\n        if resource[\"status\"] in TERMINAL:\n            return resource\n        time.sleep(interval)\n```\n\n### Polling backed by webhook fallback\n\nWith outbound webhooks live, the recommended pattern is: **webhook delivers fast, polling catches what the webhook missed**.\n\n- Webhook arrives: update your local record immediately.\n- Polling runs on a slow interval (e.g. 5 minutes) as a safety net for events the webhook failed to deliver within the retry window.\n- Deduplicate webhook deliveries so a late webhook and a poll that already updated the record don't conflict.\n\n---\n\n## Webhooks today\n\nOutbound webhooks are live and align with the status state machines. See the [Webhooks](#tag/webhooks) guide for the authoritative contract; in summary:\n\n- **Payment / refund events** are delivered as Fynex fires them on state transitions (e.g. `PaymentCompleted`).\n- **Payout events** do not yet emit webhooks — poll for payout status.\n- **Delivery semantics**: at-least-once with retry-and-backoff; your receiver must return HTTP `200`.\n- **Signature verification**: each request carries an `X-Fynex-Signature` header containing an HMAC-SHA256 digest of the raw request body keyed with your webhook signing secret, plus an `X-Fynex-Timestamp`.\n- **Deduplication**: store and check the delivery's event identifier before processing.\n\n\u003e [!NOTE]\n\u003e The recommended default for server-side integrations is **\"webhook + polling fallback\"**: take webhooks as the fast path and poll as a backstop for any delivery that was missed. Polling remains fully production-ready on its own (and is the only option for payouts).\n\n## See also\n\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Implementation guide: code snippets, cadence recommendations, and common pitfalls for both patterns.\n- **[Webhooks](#tag/webhooks)** — The live outbound-webhook contract: configuration, HMAC-SHA256 signature verification, allowlist, and retries.\n- **[Idempotency \u0026 retries](#tag/idempotency)** — Make your polling and retry logic safe so re-checks never replay a charge.\n- **[Errors](#tag/errors)** — HTTP status codes and error shapes returned by the Fynex API.\n","name":"Verification patterns"},{"description":"The Fynex checkout widget lets you embed the hosted checkout form **directly on your own page** for **test/demo-mode checkout sessions**. Instead of redirecting the customer to `https://pay.fynex.ai/checkout/{session_id}`, you load a small JavaScript SDK that renders the form inside an iframe — on your domain, in your layout.\n\n\u003e [!IMPORTANT]\n\u003e Widget iframe embedding is currently enabled only for test/demo-mode payments. Live cardholder checkout pages are protected with same-origin frame headers; use the full-page hosted checkout redirect for live payments.\n\nThe widget exposes two modes:\n\n| Mode | Method | Use case |\n|------|--------|----------|\n| **Embed** | `Fynex.embed()` | Inline form inside a container element on your page. |\n| **Popup** | `Fynex.popup()` | Modal overlay on top of your page. |\n\n\u003e [!NOTE]\n\u003e For test/demo-mode sessions, the widget is an alternative to the full-page redirect described in [Hosted Checkout](#tag/hosted-checkout). Both modes share the same checkout session lifecycle — you still create the session server-side and verify the outcome via polling or SSE. For live payments, use hosted checkout redirect.\n\n---\n\n## How it works\n\n```\nYour backend          Fynex API               Customer browser\n────────────          ─────────               ────────────────\nPOST /checkout  ────► creates session\n                      returns sessionId  ◄────\npass sessionId\nto frontend\n\n                                         load widget.js\n                                         Fynex.embed({ sessionId })\n                                              │\n                                         iframe → /checkout/{id}?mode=embed\n                                              │\n                                         customer fills form\n                                         (3DS handled inside iframe)\n                                              │\n                                         postMessage to your page\n                                         onSuccess(data) / onFailure(data)\n\nYour backend    ────► poll genericPayment(id) to verify final state\n```\n\n---\n\n## Step-by-step\n\n1. **Create a test/demo checkout session from your backend**\n\n   This is identical to the Hosted Checkout flow. `POST /payments-api/v1/checkout` with your Bearer token for a test/demo seller or terminal. See [Hosted Checkout — Step 1](#tag/hosted-checkout) for the full request reference.\n\n   ```bash\n   curl -sS -X POST https://api.fynex.ai/payments-api/v1/checkout \\\n     -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n     -H \"Content-Type: application/json\" \\\n     -H \"Idempotency-Key: $(uuidgen)\" \\\n     -d '{\n       \"externalOrderRef\": \"ORDER-2099\",\n       \"amount\": 79.00,\n       \"currencyCode\": \"GBP\",\n       \"countryCode\": \"GB\",\n       \"returnUrls\": {\n         \"success\": \"https://example.com/orders/2099/success\",\n         \"failure\": \"https://example.com/orders/2099/failure\"\n       }\n     }'\n   # Response: { \"sessionId\": \"6f9b84e1-...\", \"checkoutUrl\": \"...\", \"expiresAt\": \"...\" }\n   ```\n\n   Pass the `sessionId` to your frontend — do **not** expose your Bearer token to the browser.\n\n2. **Include the widget script**\n\n   Add the script tag to your page. It must load before you call `Fynex.embed()` or `Fynex.popup()`. The script is served with `Cache-Control: public, max-age=3600` and `Access-Control-Allow-Origin: *`, so it can be loaded from any domain.\n\n   ```html\n   \u003cscript src=\"https://api.fynex.ai/widget/checkout.js\"\u003e\u003c/script\u003e\n   ```\n\n   No API key or auth header is needed for the script itself — it is a public static asset.\n\n3. **Launch the widget**\n\n   Choose **embed** (inline) or **popup** (modal) depending on your UX preference.\n\n   **Embed mode** — renders the checkout form inside a container element you control:\n\n   ```html\n   \u003cdiv id=\"payment-form\"\u003e\u003c/div\u003e\n\n   \u003cscript\u003e\n     var checkout = Fynex.embed({\n       sessionId: SESSION_ID,      // string — from your backend\n       container: '#payment-form', // CSS selector or DOM element\n       onSuccess: function(data) {\n         console.log('Payment succeeded', data);\n         // Do NOT fulfil the order here — verify server-side first\n       },\n       onFailure: function(data) {\n         console.log('Payment failed', data);\n       }\n     });\n\n     // To remove the widget later (e.g. after navigating away):\n     // checkout.destroy();\n   \u003c/script\u003e\n   ```\n\n   **Popup mode** — opens a modal overlay over your page:\n\n   ```html\n   \u003cbutton id=\"pay-btn\"\u003ePay now\u003c/button\u003e\n\n   \u003cscript\u003e\n     document.getElementById('pay-btn').addEventListener('click', function() {\n       var popup = Fynex.popup({\n         sessionId: SESSION_ID,\n         onSuccess: function(data) {\n           console.log('Payment succeeded', data);\n         },\n         onFailure: function(data) {\n           console.log('Payment failed', data);\n         },\n         onClose: function() {\n           console.log('Customer closed the popup');\n         }\n       });\n\n       // To close programmatically:\n       // popup.close();\n     });\n   \u003c/script\u003e\n   ```\n\n4. **Verify the payment server-side**\n\n   The `onSuccess` callback is a client-side signal only — it fires when the iframe posts a `success` message to your page. A determined user could fire a `postMessage` manually. Always verify the final payment state on your backend before fulfilling the order.\n\n   ```js\n   // On your server, after onSuccess fires and the browser POSTs your endpoint:\n   const { genericPayment } = await gql(\n     `query ($id: Int!) { genericPayment(id: $id) { id status } }`,\n     { id: paymentId }\n   );\n   if (genericPayment.status === 'settled' || genericPayment.status === 'provider_completed') {\n     fulfillOrder();\n   }\n   ```\n\n   See [Polling \u0026 SSE](#tag/polling-sse) for a complete server-side verification reference.\n\n---\n\n## API reference\n\n### `Fynex.embed(opts)` → `{ destroy() }`\n\n| Option | Type | Required | Description |\n|--------|------|----------|-------------|\n| `sessionId` | string | Yes | The checkout session ID returned by `POST /payments-api/v1/checkout`. |\n| `container` | string \\| Element | Yes | CSS selector (e.g. `'#payment-form'`) or a DOM element reference. |\n| `onSuccess` | function(data) | No | Called when the payment completes successfully. |\n| `onFailure` | function(data) | No | Called when the payment fails. |\n\nReturns an object with a `destroy()` method that removes the iframe and cleans up event listeners.\n\n### `Fynex.popup(opts)` → `{ close() }`\n\n| Option | Type | Required | Description |\n|--------|------|----------|-------------|\n| `sessionId` | string | Yes | The checkout session ID. |\n| `onSuccess` | function(data) | No | Called when the payment completes successfully. |\n| `onFailure` | function(data) | No | Called when the payment fails. |\n| `onClose` | function() | No | Called when the overlay is dismissed (close button or click-outside). |\n\nReturns an object with a `close()` method that dismisses the overlay programmatically.\n\n### Events (postMessage)\n\nThe widget communicates via `window.postMessage`. The widget SDK handles these internally; you interact through the callbacks above. Messages carry `{ type: 'fynex-checkout', event: 'success' | 'failure' | 'close' | 'resize', data: {} }`.\n\n---\n\n## Common pitfalls\n\n\u003e [!WARNING]\n\u003e **Do not fulfil orders from `onSuccess` alone.** The callback fires on a client-side `postMessage`. Always verify the payment state server-side via the GraphQL `genericPayment` query before dispatching goods or services.\n\n\u003e [!CAUTION]\n\u003e **Session expiry.** Checkout sessions have a server-side TTL (returned as `expiresAt`). If the customer takes too long, the iframe will show an expiry error. Create a fresh session and re-initialise the widget.\n\n- **`container` not found.** `Fynex.embed` throws `\"Fynex.embed: container not found\"` if the selector matches no element. Ensure the DOM node exists before calling `embed()`.\n- **`sessionId` is required.** Both `embed` and `popup` throw immediately if `sessionId` is missing or falsy.\n- **Widget behind a strict CSP.** The iframe loads `https://api.fynex.ai`. Ensure your Content-Security-Policy `frame-src` directive allows `https://api.fynex.ai`.\n- **Live checkout sessions.** Live payment pages are not iframe-embeddable by default. If you pass a live `sessionId` to the widget, browser frame protections can block the iframe. Use the hosted checkout redirect for live payments.\n- **3DS inside the iframe.** For test/demo widget sessions, 3DS challenges are handled entirely within the embedded iframe — your page does not need to manage any redirect. The `onSuccess` / `onFailure` callback fires once the full payment flow (including any 3DS step) is complete.\n\n## See also\n\n- **[Hosted Checkout](#tag/hosted-checkout)** — Full-page redirect alternative — lowest integration effort.\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Server-side payment verification after onSuccess fires.\n- **[Server-to-server payments](#tag/server-to-server)** — Full control over the payment flow without any hosted page.\n","name":"Checkout widget"},{"description":"Before you can accept live payments you must complete three sequential phases: **sign-up** (a short REST wizard that ends with a Demo account), optionally **additional company entities** (a GraphQL chain), and **KYB** (identity verification via our KYB provider). At the end of KYB approval your account's `operationalMode` flips from `Demo` to `Live`.\n\n\u003e [!NOTE]\n\u003e This guide mixes REST calls and GraphQL mutations. REST calls hit `https://api.fynex.ai` and use no auth header — they rely on an HttpOnly session cookie. GraphQL calls hit `https://api.fynex.ai/dashboard/graphql` and also use the cookie. See [GraphQL Authentication](#tag/graphql-auth) for a standalone reference.\n\n---\n\n## Overview\n\n```\nPhase 1 — REST                     Phase 2 — GraphQL (optional)        Phase 3 — REST + KYB\n──────────────                     ────────────────────────────        ────────────────────\nPOST /onboarding/start             createOrganization (staff only)     POST /api/v1/kyb/initiate\n    ↓ (cookie set)                      ↓                                  ↓ (KYB token)\nPATCH /onboarding/step ×N          createLegalEntityByUuid             KYB Web SDK (customer fills form)\n    ↓                                   ↓ (legalEntityId)                  ↓\nPOST /verify-email                 createOrganizationProfileByUuid     Fynex staff review + approve\n    ↓                                   ↓                                  ↓\nPOST /onboarding/complete          additional entities registered      operationalMode → Live\n    ↓ (org + legal entity + profile + Demo seller, sandbox provisioned)\n```\n\n---\n\n## Phase 1 — Sign-up (draft → verified → complete)\n\nSign-up is a short, resumable wizard over REST. Four calls, all on\n`https://api.fynex.ai`, all cookie-authenticated after the first:\n\n| # | Call | What it does |\n|---|------|--------------|\n| 1 | `POST /api/v1/onboarding/start` | Creates a **draft** user from `email` + `password`, sets the HttpOnly `dashboard_session` cookie, and e-mails a verification code. No auth header. |\n| 2 | `PATCH /api/v1/onboarding/step` | Saves one wizard step: `{ \"step\": \"\u003cname\u003e\", \"data\": { … } }`. Steps must be submitted in order; a step may be re-submitted to edit it. |\n| 3 | `POST /api/v1/verify-email` | Consumes the e-mailed code: `{ \"code\": \"123456\" }`. `POST /api/v1/verify-email/resend` sends a fresh one. |\n| 4 | `POST /api/v1/onboarding/complete` | Turns the draft into a real account: organization, legal entity, organization profile and a **Demo** seller account, pre-provisioned as a sandbox. Refused with `403 emailNotVerified` until step 3 is done. |\n\n\u003e [!NOTE]\n\u003e `start` already sets the session cookie — there is no separate sign-in step\n\u003e during sign-up. `POST /api/v1/login/dashboard` is for **returning** sessions.\n\n### Step names and what each collects\n\nThe step sequence depends on `legalEntityType`, chosen in the first step:\n\n```\nlegal_entity_type → personal_details → business_details → business_solutions → extra_info   (Company)\nlegal_entity_type → personal_details → individual_details → extra_info                       (Individual)\n```\n\nEvery `data` object is a partial of one form; send only the fields the step\ncollects. The fields are camelCase and match the wizard: `legalEntityType`\n(`Company` or `Individual`), `firstName`, `lastName`, `phone`, `role`,\n`businessName`, `industry`, `businessModel`, `country` (ISO 3166-1 alpha-2),\n`businessWebsite`, `registrationNumber`, `registeredAddress`,\n`estimatedMonthlyVolume`, `averageTransactionSize`, `paymentFlowsDescription`.\n`country` is screened against the supported jurisdictions when it is submitted;\nan unsupported country answers `422 countryNotSupported`.\n\n#### curl\n\n```bash\n# 1 — start the draft; -c saves the session cookie, -b sends it on later calls\ncurl -sS -c cookies.txt -X POST https://api.fynex.ai/api/v1/onboarding/start \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"alice@example.com\", \"password\": \"s3cur3-P@ssw0rd\"}'\n\n# 2 — the wizard steps, in order\ncurl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"step\": \"legal_entity_type\", \"data\": {\"legalEntityType\": \"Company\"}}'\n\ncurl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"step\": \"personal_details\", \"data\": {\"firstName\": \"Alice\", \"lastName\": \"Smith\", \"phone\": \"+447700900000\", \"role\": \"owner\"}}'\n\ncurl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"step\": \"business_details\", \"data\": {\"businessName\": \"Acme Ltd\", \"industry\": \"SaaS\", \"country\": \"GB\", \"registrationNumber\": \"12345678\"}}'\n\ncurl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"step\": \"business_solutions\", \"data\": {\"businessModel\": \"saas\"}}'\n\ncurl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"step\": \"extra_info\", \"data\": {\"businessWebsite\": \"https://acme.example\"}}'\n\n# 3 — the code from the verification e-mail\ncurl -sS -b cookies.txt -X POST https://api.fynex.ai/api/v1/verify-email \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"code\": \"123456\"}'\n\n# 4 — complete: organization, legal entity, profile and a Demo seller account\ncurl -sS -b cookies.txt -X POST https://api.fynex.ai/api/v1/onboarding/complete\n# Response: { \"userId\": \"42\", \"organizationUuid\": \"…\", \"legalEntityIds\": [77],\n#             \"organizationProfileIds\": [12], \"permissionTypes\": [...] }\n```\n\n#### JavaScript\n\n```js\nconst BASE = 'https://api.fynex.ai';\nconst post = (path, body, method = 'POST') =\u003e fetch(`${BASE}${path}`, {\n  method,\n  credentials: 'include',            // stores and sends the HttpOnly cookie\n  headers: { 'Content-Type': 'application/json' },\n  body: body === undefined ? undefined : JSON.stringify(body),\n});\n\nawait post('/api/v1/onboarding/start', { email: 'alice@example.com', password: 's3cur3-P@ssw0rd' });\nfor (const [step, data] of [\n  ['legal_entity_type', { legalEntityType: 'Company' }],\n  ['personal_details', { firstName: 'Alice', lastName: 'Smith', phone: '+447700900000', role: 'owner' }],\n  ['business_details', { businessName: 'Acme Ltd', industry: 'SaaS', country: 'GB', registrationNumber: '12345678' }],\n  ['business_solutions', { businessModel: 'saas' }],\n  ['extra_info', { businessWebsite: 'https://acme.example' }],\n]) {\n  await post('/api/v1/onboarding/step', { step, data }, 'PATCH');\n}\nawait post('/api/v1/verify-email', { code: codeFromEmail });\nconst { organizationUuid, legalEntityIds } = await (await post('/api/v1/onboarding/complete')).json();\n```\n\nWhat `complete` leaves you with is a working **Demo** account: the sandbox\nauto-provisioning that runs on completion adds wallets, a demo card terminal, a\ndemo counterparty payee, a pre-set split rule and three test customers, and\nissues the account's first API key pair (retrieve the secret from\n**Integration → API keys** in the dashboard — see [Getting your API token](#getting-your-api-token)).\nNothing here moves real money; Phase 3 is what takes the account live.\n\n---\n\n## Phase 2 — Company creation (GraphQL chain)\n\n\u003e [!IMPORTANT]\n\u003e **Most integrations don't need this phase.** `POST /api/v1/onboarding/complete` (Phase 1)\n\u003e **already creates** the organization, legal entity, and organization profile for you in a\n\u003e single transaction — its response returns `organizationUuid`, `legalEntityIds` and\n\u003e `organizationProfileIds`. The mutations below are only for advanced cases where you manage\n\u003e **additional** legal entities or profiles under one account. To get an **API token**, you\n\u003e don't run these — see [Getting your API token](#getting-your-api-token) below.\n\nCompany creation runs as sequential GraphQL mutations on `/dashboard/graphql`. Each step depends on the identifier returned by the previous one. The organization is addressed by its **UUID** (`organizationUuid` from `complete`); the legal entity by its numeric `legalEntityId`.\n\n```\ncreateOrganization  ──► organizationUuid    (staff surface only — see Step 1)\n                              │\n                              ▼\ncreateLegalEntity   ──► legalEntityId (takes organizationUuid)\n                              │\n                              ▼\ncreateOrganizationProfile  (takes both organizationUuid + legalEntityId)\n```\n\n\u003e [!CAUTION]\n\u003e **Partial rollback:** if `createOrganizationProfile` fails after the first two mutations have succeeded, you are left with an orphaned `Organization` and `LegalEntity`. There is no automatic rollback. Contact your Fynex representative to clean up and retry.\n\n### Helper: send a GraphQL mutation\n\nAll three calls below share the same shape. Use this wrapper or your preferred GraphQL client.\n\n#### curl (reusable)\n\n```bash\ngql() {\n  curl -sS -b cookies.txt \\\n    -X POST https://api.fynex.ai/dashboard/graphql \\\n    -H \"Content-Type: application/json\" \\\n    -d \"$1\"\n}\n```\n\n#### JavaScript (reusable)\n\n```js\nasync function gql(query, variables = {}) {\n  const res = await fetch('https://api.fynex.ai/dashboard/graphql', {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ query, variables }),\n  });\n  const body = await res.json();\n  if (body.errors?.length) throw new Error(body.errors[0].message);\n  return body.data;\n}\n```\n\n#### Python (reusable)\n\n```python\ndef gql(session, query, variables=None):\n    resp = session.post(\n        'https://api.fynex.ai/dashboard/graphql',\n        json={'query': query, 'variables': variables or {}},\n    )\n    resp.raise_for_status()\n    body = resp.json()\n    if 'errors' in body:\n        raise RuntimeError(body['errors'][0]['message'])\n    return body['data']\n```\n\n---\n\n### Step 1 — Create an organization\n\n\u003e [!CAUTION]\n\u003e **`createOrganization` is not exposed on `/dashboard/graphql`.** The mutation exists on the\n\u003e Fynex staff surface only; a dashboard session calling it receives\n\u003e `Cannot query field \"createOrganization\" on type \"Mutation\"`. Your organization is created by\n\u003e `POST /api/v1/onboarding/complete` (Phase 1), which returns its `organizationUuid`. To add an\n\u003e **additional** organization under the same account, ask your Fynex representative for it and\n\u003e continue from Step 2 with the `organizationUuid` they give you.\n\nFor reference, the operation as it exists on the staff surface — it takes a single\n`displayName` and returns the `organizationUuid` the next two mutations need:\n\n```graphql\n# Not available on /dashboard/graphql — staff surface only. Shown for reference.\nmutation CreateOrganization($input: CreateOrganizationInput!) {\n  createOrganization(input: $input) {\n    organizationUuid\n    displayName\n  }\n}\n```\n\n**Input: `CreateOrganizationInput`**\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `displayName` | String | Yes | Your company trading name as it appears in Fynex. |\n\nThe samples below address the organization two ways. **Prefer the `…ByUuid` mutations**,\nwhich take the `organizationUuid` that `complete` returns:\n\n```graphql\nmutation CreateLegalEntityByUuid($input: CreateLegalEntityByUuidInput!) {\n  createLegalEntityByUuid(input: $input) {\n    id\n    name\n    status\n  }\n}\n```\n\n```graphql\nmutation CreateOrganizationProfileByUuid($input: CreateOrganizationProfileByUuidInput!) {\n  createOrganizationProfileByUuid(input: $input) {\n    id\n    statementDescriptor\n  }\n}\n```\n\nThe legacy `createLegalEntity` and `createOrganizationProfile` mutations were removed.\nUse the UUID-only `…ByUuid` mutations above and pass the `organizationUuid` returned by\nonboarding (or supplied by your Fynex representative).\n\n---\n\n### Step 2 — Create a legal entity\n\n`createLegalEntity` registers the incorporated company behind the organization. Requires the `organizationUuid` from step 1.\n\n```graphql\nmutation CreateLegalEntity($input: CreateLegalEntityByUuidInput!) {\n  createLegalEntityByUuid(input: $input) {\n    id\n    name\n    legalName\n    status\n  }\n}\n```\n\n**Input: `CreateLegalEntityByUuidInput`**\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `organizationUuid` | ID | Yes | UUID returned by `createOrganization`. |\n| `name` | String | Yes | Short display name for the legal entity. |\n| `type` | LegalEntityType | Yes | Corporate form, e.g. `limited_company`, `sole_trader`. |\n| `legalName` | String | Yes | Full registered legal name. |\n| `registrationCountry` | CountryCode | Yes | ISO 3166-1 alpha-2, e.g. `GB`. |\n| `registrationNumber` | String | Yes | Companies House / state registration number. |\n| `incorporationDate` | Time | No | RFC 3339 date, e.g. `\"2019-03-15T00:00:00Z\"`. |\n| `globalTaxId` | String | No | VAT / EIN / tax identification number. |\n\n#### curl\n\n```bash\ngql \"{\n  \\\"query\\\": \\\"mutation (\\$input: CreateLegalEntityByUuidInput!) { createLegalEntityByUuid(input: \\$input) { id name status } }\\\",\n  \\\"variables\\\": {\n    \\\"input\\\": {\n      \\\"organizationUuid\\\": \\\"$ORG_UUID\\\",\n      \\\"name\\\": \\\"Acme Ltd\\\",\n      \\\"type\\\": \\\"limited_company\\\",\n      \\\"legalName\\\": \\\"Acme Limited\\\",\n      \\\"registrationCountry\\\": \\\"GB\\\",\n      \\\"registrationNumber\\\": \\\"12345678\\\",\n      \\\"incorporationDate\\\": \\\"2019-03-15T00:00:00Z\\\"\n    }\n  }\n}\"\n# Response: { \"data\": { \"createLegalEntity\": { \"id\": 3, \"name\": \"Acme Ltd\", \"status\": \"pending\" } } }\nLEGAL_ENTITY_ID=3\n```\n\n#### JavaScript\n\n```js\nconst { createLegalEntityByUuid } = await gql(\n  `mutation ($input: CreateLegalEntityByUuidInput!) {\n     createLegalEntityByUuid(input: $input) { id name status }\n   }`,\n  {\n    input: {\n      organizationUuid,\n      name: 'Acme Ltd',\n      type: 'limited_company',\n      legalName: 'Acme Limited',\n      registrationCountry: 'GB',\n      registrationNumber: '12345678',\n      incorporationDate: '2019-03-15T00:00:00Z',\n    },\n  }\n);\nconst legalEntityId = createLegalEntityByUuid.id; // e.g. 3\n```\n\n#### Python\n\n```python\ndata = gql(session, '''\n  mutation($input: CreateLegalEntityByUuidInput!) {\n    createLegalEntityByUuid(input: $input) { id name status }\n  }\n''', {\n    'input': {\n        'organizationUuid': organization_uuid,\n        'name': 'Acme Ltd',\n        'type': 'limited_company',\n        'legalName': 'Acme Limited',\n        'registrationCountry': 'GB',\n        'registrationNumber': '12345678',\n        'incorporationDate': '2019-03-15T00:00:00Z',\n    }\n})\nlegal_entity_id = data['createLegalEntityByUuid']['id']  # e.g. 3\n```\n\n---\n\n### Step 3 — Create an organization profile\n\n`createOrganizationProfile` links the legal entity to the organization and sets operational parameters. This completes the company-creation chain.\n\n```graphql\nmutation CreateOrganizationProfile($input: CreateOrganizationProfileByUuidInput!) {\n  createOrganizationProfileByUuid(input: $input) {\n    id\n    countryCode\n    statementDescriptor\n    timezone\n  }\n}\n```\n\n**Input: `CreateOrganizationProfileByUuidInput`**\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `organizationUuid` | ID | Yes | From step 1. |\n| `legalEntityId` | Int | Yes | From step 2. |\n| `countryCode` | CountryCode | Yes | Country of primary operation, e.g. `GB`. |\n| `statementDescriptor` | String | Yes | Text that appears on the customer's bank statement (max ~22 chars). |\n| `timezone` | String | Yes | IANA timezone, e.g. `Europe/London`. The dashboard defaults to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. |\n| `industry` | String | No | Business industry / MCC category. |\n| `platformRoleId` | Int | No | Assigned by Fynex — leave unset unless instructed. |\n\n#### curl\n\n```bash\ngql \"{\n  \\\"query\\\": \\\"mutation (\\$input: CreateOrganizationProfileByUuidInput!) { createOrganizationProfileByUuid(input: \\$input) { id statementDescriptor } }\\\",\n  \\\"variables\\\": {\n    \\\"input\\\": {\n      \\\"organizationUuid\\\": \\\"$ORG_UUID\\\",\n      \\\"legalEntityId\\\": $LEGAL_ENTITY_ID,\n      \\\"countryCode\\\": \\\"GB\\\",\n      \\\"statementDescriptor\\\": \\\"ACME LTD\\\",\n      \\\"timezone\\\": \\\"Europe/London\\\"\n    }\n  }\n}\"\n```\n\n#### JavaScript\n\n```js\nconst { createOrganizationProfileByUuid } = await gql(\n  `mutation ($input: CreateOrganizationProfileByUuidInput!) {\n     createOrganizationProfileByUuid(input: $input) { id statementDescriptor }\n   }`,\n  {\n    input: {\n      organizationUuid,\n      legalEntityId,\n      countryCode: 'GB',\n      statementDescriptor: 'ACME LTD',\n      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    },\n  }\n);\n```\n\n#### Python\n\n```python\nimport datetime\n\ndata = gql(session, '''\n  mutation($input: CreateOrganizationProfileByUuidInput!) {\n    createOrganizationProfileByUuid(input: $input) { id statementDescriptor }\n  }\n''', {\n    'input': {\n        'organizationUuid': organization_uuid,\n        'legalEntityId': legal_entity_id,\n        'countryCode': 'GB',\n        'statementDescriptor': 'ACME LTD',\n        'timezone': 'Europe/London',\n    }\n})\nprofile_id = data['createOrganizationProfile']['id']\n```\n\n---\n\n## Getting your API token\n\nYour API bearer token belongs to a **seller account**. Create one with the `createSellerAccount`\nmutation — it mints and returns the token immediately. You need a `legalEntityId` and an\n`organizationProfileId` (both came back in your login response), plus a `name` and `currency`.\n\n```bash\ncurl -sS -b cookies.txt -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"mutation Create($input: CreateSellerAccountInput!) { createSellerAccount(input: $input) { id name status operationalMode authorizationToken } }\",\n    \"variables\": { \"input\": { \"legalEntityId\": 3, \"organizationProfileId\": 5, \"name\": \"Acme Ltd\", \"currency\": \"GBP\" } }\n  }'\n# data.createSellerAccount.authorizationToken is your /payments-api/v1 Bearer token\n```\n\nThe dashboard equivalent is the **Integration** page (reveal / copy, or **Generate Token**). To\nrotate, call `regenerateSellerAccountToken(id:)` or use the dashboard's **Regenerate** button.\n\n\u003e [!IMPORTANT]\n\u003e A new seller account is created in `Demo` mode and **activated automatically**, so the token\n\u003e works immediately for **sandbox** payments. **KYB approval** is what flips the account to `Live`\n\u003e for real-money processing (with a Fynex-assigned live terminal). If a sandbox call returns\n\u003e `403 seller account is not active`, the token is still valid — the account just isn't active\n\u003e yet. See [Authentication](#tag/authentication).\n\n---\n\n## Phase 3 — KYB (Know Your Business)\n\nFynex uses a third-party KYB provider. Your integration obtains a short-lived KYB access token from Fynex, embeds the provider's Web SDK in your UI, and lets the customer fill the verification form. Fynex staff then review the submission and approve the account.\n\nAll three KYB endpoints require the `dashboard_session` cookie.\n\n| Endpoint | Purpose |\n|----------|---------|\n| `POST /api/v1/kyb/initiate` | Start a KYB application. Returns `{ token, status }`. |\n| `GET /api/v1/kyb/token` | Refresh an existing KYB access token (tokens expire). |\n| `GET /api/v1/kyb/status` | Poll the current KYB state. |\n\n1. **Initiate KYB and obtain the KYB access token**\n\n#### curl\n\n```bash\ncurl -sS -b cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/kyb/initiate \\\n  -H \"Content-Type: application/json\"\n# Response: { \"token\": \"_act-...\", \"status\": \"pending\" }\n```\n\n#### JavaScript\n\n```js\nconst res = await fetch('https://api.fynex.ai/api/v1/kyb/initiate', {\n  method: 'POST',\n  credentials: 'include',\n});\nconst { token, status } = await res.json();\n```\n\n#### Python\n\n```python\nresp = session.post('https://api.fynex.ai/api/v1/kyb/initiate')\nresp.raise_for_status()\nkyb = resp.json()\nkyb_token = kyb['token']\n```\n\n2. **Embed the KYB provider's Web SDK in your UI**\n\n   Pass the token to the [provider's Web SDK](https://developers.sumsub.com/web-sdk/) to render the verification form. When the token expires (these tokens are short-lived), refresh it:\n\n   ```js\n   // When the SDK calls your token refresh handler:\n   const refreshRes = await fetch('https://api.fynex.ai/api/v1/kyb/token', {\n     credentials: 'include',\n   });\n   const { token: newToken } = await refreshRes.json();\n   return newToken; // return to the SDK\n   ```\n\n   A minimal SDK integration:\n\n   ```html\n   \u003cdiv id=\"sumsub-websdk-container\"\u003e\u003c/div\u003e\n   \u003cscript src=\"https://static.sumsub.com/idensic/static/sns-websdk-builder.js\"\u003e\u003c/script\u003e\n   \u003cscript\u003e\n     const snsWebSdkInstance = snsWebSdk\n       .init(KYB_TOKEN, () =\u003e\n         fetch('/api/kyb/token', { credentials: 'include' })\n           .then(r =\u003e r.json())\n           .then(d =\u003e d.token)\n       )\n       .withConf({ lang: 'en' })\n       .withOptions({ addViewportTag: false, adaptIframeHeight: true })\n       .on('idCheck.onApplicantSubmitted', () =\u003e console.log('Submitted'))\n       .build();\n\n     snsWebSdkInstance.launch('#sumsub-websdk-container');\n   \u003c/script\u003e\n   ```\n\n3. **Poll KYB status**\n\n   After the customer submits the form, Fynex staff review the application. Poll `/api/v1/kyb/status` until the status is terminal.\n\n   ```js\n   const statusRes = await fetch('https://api.fynex.ai/api/v1/kyb/status', {\n     credentials: 'include',\n   });\n   const { status } = await statusRes.json();\n   // status values: \"init\" | \"pending\" | \"approved\" | \"rejected\"\n   ```\n\n   When approved, your `SellerAccount.operationalMode` flips from `Demo` to `Live`.\n\n\u003e [!CAUTION]\n\u003e **KYB token expiry.** KYB access tokens are short-lived. If you get a `401` or `invalid token` error from the SDK, call `GET /api/v1/kyb/token` to refresh. Pass the refresh function directly to the SDK's token-provider callback so it can renew tokens automatically without user interaction.\n\n---\n\n## Common pitfalls\n\n| Pitfall | Fix |\n|---------|-----|\n| `complete` answers `403 emailNotVerified` | Consume the e-mailed code with `POST /api/v1/verify-email` first; `POST /api/v1/verify-email/resend` sends a fresh one. |\n| `step` answers `409 invalidDraftState` | Steps must be submitted in order; you skipped one, or the draft is already completed. |\n| `createOrganizationProfile` fails mid-chain | No automatic rollback. Contact your Fynex representative to clean up orphaned records before retrying. |\n| KYB token expired | Call `GET /api/v1/kyb/token` and pass the refreshed token to the SDK. |\n| Payments still in `Demo` mode after approval | Check `kybStatus` via GraphQL; if `approved`, allow a few minutes for the `operationalMode` flip. If it persists, contact support. |\n| `timezone` missing from `createOrganizationProfile` | Required field. Use `Intl.DateTimeFormat().resolvedOptions().timeZone` in a browser or an IANA string server-side. |\n\n## See also\n\n- **[GraphQL Authentication](#tag/graphql-auth)** — Full reference for session cookies and the /dashboard/graphql endpoint.\n- **[Going Live](#tag/going-live)** — Checklist and final steps once KYB is approved and operationalMode is live.\n- **[Hosted Checkout](#tag/hosted-checkout)** — Start accepting payments immediately after your account is active.\n","name":"Onboarding"},{"description":"You've built and tested your integration in staging. Before processing real payments, work\nthrough every section of this checklist. Each item maps to a specific system behavior;\nnone of the items here are aspirational.\n\nJust signed up? Start with [Onboarding \u0026 KYB](#tag/onboarding) before working through this checklist.\n\n## 1. Token management\n\nGet your production bearer token yourself from the **Integration** page of the production\ndashboard (`https://dashboard.fynex.ai`) — log in, select your seller account, reveal the\ntoken, and copy it (see [Authentication](#tag/authentication)). Production and staging tokens\nare independent; the production token becomes available once your KYB approval is complete\n(see [section 7](#7-kyb-and-operational-mode)). If you don't have a production dashboard login\nyet, ask your Fynex representative to set you up.\n\nOnce you have a production token:\n\n- [ ] Store it in a secrets manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret\n  Manager, or equivalent). Never commit it to source control or write it to application\n  logs.\n- [ ] Load it into your application via an environment variable (`FYNEX_TOKEN`). Keep\n  staging and production tokens in separate secret paths/namespaces.\n- [ ] Document a token rotation procedure. Rotation is atomic — you replace the token in\n  one step with no grace period. Plan for a brief maintenance window or use a blue/green\n  secret-swap pattern so in-flight requests finish before the old token is retired.\n\n#### curl (env-var loading)\n\n```bash\n# Verify the token resolves before deploying\ncurl -sS \"$FYNEX_API/payments-api/v1/payment-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" | jq .\n```\n\n#### JavaScript\n\n```js\n// Load token from environment — never hard-code\nconst token = process.env.FYNEX_TOKEN;\nif (!token) throw new Error('FYNEX_TOKEN is not set');\n```\n\n#### Python\n\n```python\nimport os\n\ntoken = os.environ.get(\"FYNEX_TOKEN\")\nif not token:\n    raise RuntimeError(\"FYNEX_TOKEN is not set\")\n```\n\nToken rotation is available via the GraphQL mutation\n`regenerateSellerAccountToken(id: Int!)` on `/dashboard/graphql`. The mutation returns a\nnew `SellerAccount` with the updated `authorizationToken`. Update your secret store\nimmediately after calling it — the old token stops working at the same moment.\n\n## 2. Endpoint switch-over\n\nUpdate `FYNEX_API` (and any dashboard URLs) across your codebase:\n\n| | Staging | Production |\n|---|---------|------------|\n| API base | `https://staging-api.fynex.ai/payments-api/v1` | `https://api.fynex.ai/payments-api/v1` |\n| Customer dashboard | `https://staging-dashboard.fynex.ai` | `https://dashboard.fynex.ai` |\n\nDrive these from a single environment variable so you can switch environments without\nchanging application code:\n\n```bash\n# staging\nFYNEX_API=https://staging-api.fynex.ai\n\n# production\nFYNEX_API=https://api.fynex.ai\n```\n\n- [ ] Confirm `FYNEX_API` is set to the production base URL in your production\n  environment.\n- [ ] Confirm no staging URL is hard-coded anywhere in your payment flow code.\n- [ ] Smoke-test `GET $FYNEX_API/payments-api/v1/payment-methods` with the production\n  token before routing real traffic.\n\n## 3. Status verification (polling / SSE)\n\nFynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller\naccount (your receiver must return HTTP 200). Polling or SSE remain available — and are\nrecommended as a backstop — to verify the final payment state before fulfilling orders or\nreleasing goods. (Payouts do not yet emit webhooks; poll for payout status.)\n\n- [ ] Your fulfilment logic reads payment status from the API — it does not rely solely\n  on a redirect URL or a query parameter that the customer could manipulate.\n- [ ] Your polling loop has a maximum number of attempts and a fallback (e.g., mark the\n  order as \"pending review\" after 10 minutes of inconclusive polling).\n- [ ] SSE (`/checkout/{session_id}/events`) is only available while the customer's\n  browser is on the hosted checkout page. For server-side verification, use the GraphQL\n  `genericPayment(id)` query.\n\nSee the [Polling \u0026 SSE](#tag/polling-sse) guide for code samples and status\ntransition reference.\n\n## 4. Idempotency persistence\n\n- [ ] Every `POST` request (`/initialize-payment`, `/finalize-payment`, `/checkout`,\n  `/payments/{id}/capture`, `/payments/{id}/refund`, `/payouts`) sends a unique\n  `Idempotency-Key` UUID.\n- [ ] Idempotency keys are **persisted with the order** in your database before the\n  request is sent. A pod restart, crash, or retry must reuse the original key — not\n  generate a new one.\n- [ ] Keys are generated from a cryptographically secure source (e.g., `crypto.randomUUID()`\n  in Node, `uuid.uuid4()` in Python). Do not use `Math.random()` or sequential IDs.\n- [ ] You treat `200 OK` from a repeated create as a successful idempotency replay. A\n  `409 Conflict` means the key was reused with different financial fields and must be\n  handled as an error, not as success.\n\nSee [Idempotency \u0026 retries](#tag/idempotency) for the full replay contract.\n\n## 5. Logging and monitoring\n\nWire alerts on these signals:\n\n- [ ] HTTP error rate on Fynex API calls above 1% sustained over 5 minutes.\n- [ ] `502 Bad Gateway` rate above 0.5% (signals an upstream provider issue).\n- [ ] Payment status remaining in a non-terminal state (e.g., `new`, `routed`,\n  `provider_pending`) for more than 15 minutes.\n- [ ] Capture-to-refund ratio outside your expected range.\n- [ ] Decline rate rising more than 5% week-over-week.\n\n\u003e [!CAUTION]\n\u003e Do not log the raw card number (PAN), CVV, or full track data at any point in your\n\u003e pipeline. Audit your log pipeline — structured logging frameworks can inadvertently\n\u003e serialize entire request bodies.\n\nLog the Fynex `paymentId` (the numeric ID on the `GenericPayment` object) alongside\nyour internal order ID on every payment event. This is what Fynex support will ask for\nwhen you raise an issue.\n\n\u003e [!NOTE]\n\u003e There are no `X-Fynex-Request-Id` or `X-Fynex-Trace-Id` response headers in the\n\u003e current API. Use the `paymentId` from the response body as your primary correlation key.\n\n## 6. Customer experience\n\n- [ ] **Decline messaging** — use the human-readable `failureDescription` field from\n  the payment response to show the customer why a payment failed. Do not display the\n  numeric `failureCode` directly; treat it as an opaque internal identifier.\n- [ ] **Retry with a different card** — after a decline, your UI should offer a clear\n  path to re-enter card details. Generate a fresh `Idempotency-Key` for the retry\n  attempt.\n- [ ] **3DS handling** — if your server-to-server integration receives\n  `requiresAction: true` with a `redirectUrl`, redirect the customer immediately and\n  preserve the original `Idempotency-Key` for the finalize call. Do not generate a new\n  key after the 3DS redirect. See [Server-to-server](#tag/server-to-server) for the\n  full flow.\n- [ ] **Error state UI** — distinguish between \"payment failed\" (terminal) and \"payment\n  status unknown\" (polling timed out). Show different messages and offer appropriate\n  next steps.\n\nSee [Errors](#tag/errors) for the full error shape and status code reference.\n\n## 7. KYB and operational mode\n\nEach seller account has an `OperationalMode` field that is either `Demo` or `Live`\n(the GraphQL `SellerAccountOperationalMode` enum is case-sensitive — capitalized).\nPayments processed in `Demo` mode do not move real funds.\n\n1. **Complete KYB** — your Fynex representative will guide you through the KYB\n   (Know Your Business) submission via the KYB provider's flow. You can check KYB status via\n   the GraphQL `kybStatus` query.\n\n2. **Wait for approval** — Fynex staff review and approve the KYB submission.\n\n3. **Flip operational mode** — after KYB approval, contact your Fynex representative\n   to switch your seller account from `Demo` to `Live`. This flip is staff-mediated\n   today; there is no self-service control for it in the seller dashboard.\n\n4. **Confirm the mode** — query your seller account via GraphQL to verify\n   `operationalMode` is `Live` before routing real traffic.\n\n\u003e [!WARNING]\n\u003e Do not route real customer payments to an account still in `Demo` mode. The API will\n\u003e accept the request but no real funds will be processed or settled.\n\n## 8. PCI scope\n\n| Integration type | PCI scope |\n|-----------------|-----------|\n| Hosted checkout | SAQ A — card data never touches your servers |\n| Server-to-server | SAQ D — your servers handle card numbers; full PCI assessment required |\n\n- [ ] PCI scope confirmed with your compliance team.\n- [ ] Customer-facing privacy policy mentions Fynex as a payment processor.\n- [ ] Logs never contain PAN, CVV, or full track data (see [section 5](#5-logging-and-monitoring)).\n\n## 9. Sandbox test cards\n\n\u003e [!CAUTION]\n\u003e Test cards work **only on a Demo account** — that is decided by the account's operational\n\u003e mode, not by which host you call. Do not ship code that hard-codes them; on a Live account\n\u003e they are declined, or worse, charged if the number happens to belong to a real card.\n\nFynex's sandbox is backed by an upstream card processor's test environment, where the PAN\nselects the 3DS authentication outcome. `4000 0000 0000 2701` (Visa) and\n`5200 0000 0000 2235` (Mastercard) authenticate frictionlessly and settle — use those for a\nsuccess. On any flow that runs 3DS (the hosted checkout always does), a PAN that does not\nauthenticate cannot be settled. See the [Test cards \u0026 sandbox](#tag/test-cards) guide for the\nfull set of sandbox card numbers and decline codes.\n\nUse any future expiry date and any 3-digit CVV in the sandbox.\n\n## 10. Cutover plan\n\nRun a cautious rollout rather than flipping all traffic at once:\n\n1. **Internal soft-launch** — route only your own team's test orders through production.\n   Verify a real card payment, a capture, and a refund end-to-end.\n\n2. **5% canary** — route 5% of live traffic to Fynex for 24 hours. Monitor decline rate,\n   error rate, and latency p99.\n\n3. **Ramp to 50%** — after 48 hours of clean canary metrics, increase to 50%.\n\n4. **Full cut-over** — after a further 72 hours of clean data, move to 100%.\n\nKeep a feature flag that can re-route payments back to your previous processor. Leave\nit in place for at least two weeks post-cutover. If anything goes wrong during ramp-up,\nthe rollback is a single config change.\n\n## 11. Post-launch review\n\nSchedule a review 7 days after full cut-over:\n\n- [ ] Decline rate — compare to your baseline from staging and industry benchmarks.\n- [ ] Dispute / chargeback rate — should be near zero in the first week; investigate any\n  spike immediately.\n- [ ] Refund rate — track against your expected return rate.\n- [ ] Support ticket volume — identify any friction in the payment flow from customer\n  complaints.\n- [ ] Reconciliation — verify settlement amounts match your expected revenue.\n\n\u003e [!NOTE]\n\u003e If anything looks off, reach out at **support@fynex.ai** with your seller account ID\n\u003e and the relevant `paymentId` values and we'll investigate.\n\n## See also\n\n- **[Authentication](#tag/authentication)** — How bearer tokens work, how to rotate them, and auth failure modes.\n- **[Polling \u0026 SSE](#tag/polling-sse)** — Verify payment outcomes without webhooks.\n- **[Errors](#tag/errors)** — Status codes, error shapes, and how to handle declines.\n- **[Idempotency \u0026 retries](#tag/idempotency)** — Safe retry patterns and key persistence requirements.\n","name":"Going live"},{"description":"This page aggregates the most common integration mistakes and questions across all Fynex guides into one scannable reference. Use your browser's Find (Ctrl+F / Cmd+F) to search for an error message, HTTP status code, or symptom.\n\n\u003e [!NOTE]\n\u003e Still stuck after checking here? Email **support@fynex.ai** with your `externalOrderRef` (or payout `id`), the HTTP status and response body, and the approximate time of the request.\n\n---\n\n## 1. Authentication \u0026 tokens\n\n### Q: I'm getting `401 Unauthorized` on every request\n\nCheck the `Authorization` header format. The correct form is:\n\n```http\nAuthorization: Bearer \u003cyour_token\u003e\n```\n\nCommon mistakes: omitting the word `Bearer`, adding extra whitespace, wrapping the token in quotes, or using a staging token against the production base URL (`https://api.fynex.ai`) or vice versa. Staging tokens only work against `https://staging-api.fynex.ai`. A quick smoke-test is `GET /payment-methods` — a `200 OK` confirms the token is valid and the seller account is active.\n\n### Q: The 401 response body is plain text, not JSON — my client is crashing trying to parse it\n\nThat is expected. Errors thrown by the `SellerAccountAuthMiddleware` use Go's `http.Error()`, which returns `text/plain`. Examples: `seller authorization token is required`, `unauthorized`. Once a request passes authentication and enters a handler, all subsequent error responses are JSON `{\"error\": \"...\"}`. Handle the 401 case in your HTTP client before you attempt JSON parsing.\n\nSee [Errors](#tag/errors) for the full error-shape reference.\n\n### Q: I get a 401 with the message `\"sellerAccountId is missing in auth context\"` on `POST /checkout` even though my Bearer token is correct\n\nThis message is misleading. It almost always means the **`Idempotency-Key` header is missing or not a valid UUID**, not that authentication failed. The checkout handler reads the seller context via the same helper that validates the idempotency key — a missing or malformed key surfaces as this 401 before the Bearer token check completes. Verify your `Idempotency-Key` header first.\n\nSee [Request Headers](#tag/headers) and [Idempotency \u0026 retries](#tag/idempotency).\n\n### Q: How do I rotate my token?\n\nUse the GraphQL mutation `regenerateSellerAccountToken` on `/dashboard/graphql` (cookie-session authenticated). Supply your seller account numeric ID:\n\n```graphql\nmutation RotateToken($merchantId: ID!) {\n  regenerateSellerAccountToken(merchantId: $merchantId) {\n    id\n    authorizationToken\n  }\n}\n```\n\n\u003e [!CAUTION]\n\u003e Rotation **atomically replaces** the existing token. The old token is invalidated immediately — there is no two-token overlap window. Update your secret manager and restart affected services before calling this mutation in production.\n\nSee [Authentication \u0026 Tokens](#tag/authentication) for the full rotation steps.\n\n### Q: Do tokens expire?\n\nNo. Tokens have no expiry by default. The only way to invalidate a token is to rotate it via `regenerateSellerAccountToken`.\n\n---\n\n## 2. Payment creation — `/initialize-payment` and `/checkout`\n\n### Q: I sent `successUrl` and `cancelUrl` but they were silently ignored\n\nTop-level `successUrl`, `cancelUrl`, and `failureUrl` are not part of either endpoint's DTO. The correct field name and **shape depend on which endpoint you're calling**:\n\n- **`POST /checkout`** (hosted checkout) — nest URLs under `returnUrls` as an object:\n\n  ```json\n  {\n    \"returnUrls\": {\n      \"success\": \"https://example.com/orders/123/success\",\n      \"failure\": \"https://example.com/orders/123/failure\"\n    }\n  }\n  ```\n\n- **`POST /initialize-payment`** (server-to-server) — pass `returnLinks` as an **array** of `{rel, href, method}`:\n\n  ```json\n  {\n    \"returnLinks\": [\n      { \"rel\": \"on_completed\", \"href\": \"https://example.com/orders/123/success\", \"method\": \"GET\" },\n      { \"rel\": \"on_failed\",    \"href\": \"https://example.com/orders/123/failure\", \"method\": \"GET\" },\n      { \"rel\": \"default\",      \"href\": \"https://example.com/orders/123/return\",  \"method\": \"GET\" }\n    ]\n  }\n  ```\n\n  Sending `returnLinks` as an object on `/initialize-payment` fails JSON decoding and returns `400 {\"error\":\"invalid request body\"}`. Valid `rel` values: `default`, `on_completed`, `on_failed`, `on_cancelled`.\n\nSee [Hosted Checkout](#tag/hosted-checkout) and [Server-to-Server](#tag/server-to-server).\n\n### Q: `/initialize-payment` returns `400 {\"error\":\"valid returnLinks are required\"}`\n\nThe endpoint resolves return links in this order: (1) the `returnLinks` array on the request body, (2) the return links configured on the seller account in the Dashboard. If neither source provides at least one valid link, the request is rejected with this error. Either pass `returnLinks` explicitly in the request body, or configure defaults on the seller account.\n\n### Q: I sent a `metadata` field on `/initialize-payment` but it doesn't appear in the response\n\n`metadata` does not exist on `InitiatePaymentRequest` or its response DTO. Other fields that also do not exist on this DTO: `saveCard`, `customerEmail`, `splitRules` (top-level), `returnUrl` (top-level), `returnUrls` (the `/checkout` shape — `/initialize-payment` uses `returnLinks` instead), `successUrl`, `cancelUrl`. The server silently discards unknown JSON fields.\n\n### Q: `/initialize-payment` rejects my currency code, but `/checkout` accepts it\n\n`POST /initialize-payment` enforces a strict currency allowlist: `EUR`, `USD`, `GBP`, `DKK`, `NOK`, `SEK`. An unrecognized code returns `400`. By contrast, `POST /checkout` does not validate the currency at session-creation time — an unsupported currency may be accepted at that stage and only rejected when the payment is actually processed. Test end-to-end in staging to catch currency issues before going live.\n\n### Q: I'm getting `502 {\"error\":\"upstream card processor returned 409\"}` (the payment's `failureCode` is `2002` with an upstream \"Duplicate merchant reference\" detail) — but I sent a fresh `Idempotency-Key`\n\nThe upstream card processor deduplicates independently of Fynex, on its `merchantRefNum` field — which Fynex sends as your `externalOrderRef`. Re-using the same `externalOrderRef` returns `409` from the processor, regardless of your Fynex `Idempotency-Key`. Use a unique `externalOrderRef` per attempt — typically your internal order ID plus an attempt counter, e.g. `ORDER-1042`, `ORDER-1042-r1`, `ORDER-1042-r2`.\n\nThe Fynex `Idempotency-Key` (deduplicates Fynex API calls) and the upstream `merchantRefNum` (deduplicates upstream transactions) are **separate** keys — both must be fresh on a genuinely new attempt; both must be reused identically when retrying after a network failure.\n\nSee [Idempotency \u0026 retries](#tag/idempotency) for the full retry semantics.\n\n### Q: My idempotency key gets a `409` — can I reuse it?\n\nIdempotency keys are per-endpoint and per-seller. Same key + same body = the existing operation is returned (the status code changes from `201`/`202` to `200` on replay). For an active redirect APM, Fynex rehydrates the existing provider redirect in that response.\n\nSame key + different body on `/initialize-payment` returns `409 Conflict` with a concrete reason — for example:\n\n```json\n{ \"error\": \"Idempotency-Key reused with a different currencyCode: original=USD, request=EUR\" }\n```\n\nThe check covers `amount`, `currencyCode`, `countryCode`, `externalOrderRef`, `paymentType`, and `paymentMethod`. If you need to retry with corrected parameters on any of those fields, use a fresh UUID. If you are retrying an unchanged request after a network failure, reuse the original key — that is the intended behavior.\n\nSee [Idempotency \u0026 retries / Reusing a key with a different body](#tag/idempotency).\n\nSee [Idempotency \u0026 retries](#tag/idempotency).\n\n### Q: Do I need to call `/finalize-payment` even when `requiresAction` is `false`?\n\nAlmost always: yes. `/initialize-payment` returns `202 Accepted` for a new payment but does not confirm capture. Call `/finalize-payment` and check the `status` field before fulfilling the order.\n\nThe single exception is the `skip3DS: true` server-to-server path. There the upstream payment handle is created already in `PAYABLE` state, and Fynex's upstream status poller auto-finalizes the payment on its next tick (~5 seconds). For those payments you do not need to call `/finalize-payment` yourself — poll `genericPayment(id)` until `status` reaches `provider_completed` or a terminal failure. See [Server-to-Server / Skipping 3DS](#tag/server-to-server).\n\n### Q: I'm getting `502 {\"error\":\"upstream card processor returned 400\"}` (the payment's `failureCode` is `2002`) — what's missing?\n\nThe upstream card processor requires `billingDetails.country` (or `countryCode`) and `billingDetails.zip` (or `postalCode`). The Fynex DTO marks `billingDetails` as optional and forwards an empty object as empty strings, which the upstream rejects. Add at minimum:\n\n```json\n\"billingDetails\": {\n  \"country\": \"GB\",\n  \"zip\": \"SW1A1AA\"\n}\n```\n\nIf you also see `fieldErrors` in the upstream body for `billingDetails.state`, that field is required by the upstream processor for US/CA cards.\n\n### Q: I get `502 {\"error\":\"no active terminal found for seller account\"}` even though my seller account is active\n\nThe HTTP response carries only the error string. On the payment row, Fynex also stamps `failure_code: 1003` and `failure_stage: routing` — visible to platform operators via the database / dashboard, but **not** returned in the HTTP body.\n\nFirst distinguish an unsupported method market from missing seller routing. Fynex rejects an unsupported APM combination with `400` before creating a payment — for example, Wero supports `EUR` in `BE`, `DE`, and `FR`, so Wero with `countryCode: \"IT\"` returns `paymentMethod wero is not supported for currency EUR and country IT`.\n\nIf you instead receive `no active terminal found`, the combination is supported but no active terminal matches the **payment method**, **operational mode**, **country**, and **currency**. Check the seller's attached terminals (in the dashboard or via `seller_account_terminals` → `terminals`) and confirm at least one active link supports the requested instrument and matches its `currencyCode`, `countryCode`, and `operationalMode` (Demo vs. Live).\n\n---\n\n## 3. 3DS handling (server-to-server)\n\n### Q: After the 3DS redirect I'm losing my checkout state — the resume page has no payment ID\n\nThe 3DS challenge is a full-page browser redirect away from your origin. All in-memory JavaScript state (React state, module-level variables) is destroyed during that navigation. Persist the `paymentId` and any UI state you need to `localStorage` before redirecting, then read it back on your return page:\n\n```js\n// Before redirect\nlocalStorage.setItem('checkout_payment_id', paymentId);\nlocalStorage.setItem('checkout_amount', String(amount));\n\n// On return page (your returnUrl)\nconst paymentId = localStorage.getItem('checkout_payment_id');\nawait finalizePayment(paymentId);\nlocalStorage.removeItem('checkout_payment_id');\nlocalStorage.removeItem('checkout_amount');\n```\n\nThe Fynex hosted dashboard uses `checkout_*` keys for this purpose. If you share an origin with the dashboard, use distinct key names to avoid collisions.\n\nSee [3DS Authentication](#tag/3ds).\n\n### Q: React strict-mode is firing my finalize call twice and I'm getting a double-capture error\n\nReact 18+ strict mode invokes effects twice in development. Guard your `/finalize-payment` call with a `useRef` flag:\n\n```jsx\nconst hasSubmitted = useRef(false);\n\nuseEffect(() =\u003e {\n  if (hasSubmitted.current) return;\n  hasSubmitted.current = true;\n  finalizePayment(paymentId);\n}, []);\n```\n\nSee [3DS Authentication](#tag/3ds).\n\n### Q: How do I know whether 3DS will be triggered before I redirect the customer?\n\nYou don't know in advance — it depends on the card issuer's risk decision. After calling `POST /initialize-payment`, inspect the response: if `requiresAction` is `true`, the customer must complete the 3DS challenge at `actionUrl`. If `requiresAction` is `false`, the payment may already be in `authorized` or `provider_completed` state and you can proceed directly to `/finalize-payment`.\n\n### Q: Can I use an iframe for the 3DS challenge page?\n\nNo. Card issuers reject embedded challenges. The redirect to `actionUrl` must be a full-page browser navigation (`window.location.href = actionUrl`).\n\n### Q: I get `failureCode: 2002` and a 502 `{\"error\":\"upstream card processor returned 400\"}` after calling `/finalize-payment`\n\nThe upstream response (visible in the platform-side payment exchange logs) carries a field-level\nerror on `paymentHandle`, reporting that the handle is in a state from which a payment cannot be\ntaken.\n\nThis means `/finalize-payment` was called **before** the customer completed the 3DS challenge at `actionUrl`. The upstream payment handle is still in `INITIATED` state and cannot be used to authorize a payment. Two common ways to hit this:\n\n- A test script that calls `/initialize-payment` and immediately calls `/finalize-payment` without visiting `actionUrl` in a browser. The sandbox 3DS page has a \"complete\" button — open `actionUrl` first, click through the challenge, then call `/finalize-payment`.\n- A frontend that signals the backend to finalize too early — before the issuer redirects the customer back to your `returnUrl`. Always wait for the post-redirect signal (server-rendered return page or an explicit message from your frontend).\n\nIf you want to test the no-3DS path, the 3DS challenge is controlled by the **`skip3DS` request flag, not by the card number** — send `\"skip3DS\": true` on `/initialize-payment` and the payment authorizes without a challenge (`requiresAction: false`), so there is no `actionUrl` to visit and a separate `/finalize-payment` step is not needed. Any sandbox PAN works. See [Test cards \u0026 sandbox](#tag/test-cards).\n\n---\n\n## 4. Captures \u0026 refunds\n\n### Q: Capture returns `409 \"invalid status transition from \u003cstatus\u003e to capture\"`\n\nCapture has two hard pre-conditions:\n\n1. The payment must have been created with `autoSettlement: false` (and `captureMode: \"manual\"`). If `autoSettlement` was `true`, a `409` with `\"manual settlement required\"` is returned.\n2. The current payment status must be `authorized` or `provider_completed`. Any other status — including `settled`, `provider_pending`, or `failed` — returns the invalid-transition 409.\n3. No refund can be pending or already successful for the payment. If a refund is pending, capture returns `409 \"capture is not allowed while a refund is in progress\"`; if any refund succeeded, capture returns `409 \"capture is not allowed after a successful refund\"`.\n\nCheck the payment's current status before calling capture. Use the GraphQL `genericPayment(id)` query to fetch it.\n\n### Q: Refund returns `409 \"refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments\"`\n\nThe payment has not been captured or settled yet, or it is in a non-retryable terminal state. Refunds are available in `provider_completed`, `settled`, or `deposit_confirmed`; failed/cancelled refund attempts can also be retried from `refund_failed` or `refund_cancelled`.\n\n### Q: What is `provider_completed`? I expected `captured`\n\nThere is no `captured` status in the Fynex payment lifecycle. After a successful capture, the payment moves to `provider_completed`. The statuses `captured`, `partially_captured`, and `partially_refunded` do not exist. See the [Payment Lifecycle](#tag/payment-lifecycle) guide for the full status enum.\n\n### Q: The capture/refund path parameter takes my numeric internal ID, right?\n\nNo. The `{id}` path parameter on `POST /payments/{id}/capture` and `POST /payments/{id}/refund` is the **string `externalOrderRef`** you passed to `/initialize-payment` — for example, `ORDER-1042`. It is not a numeric ID.\n\n```bash\n# Correct — use your externalOrderRef string\ncurl -X POST \"$FYNEX_API/payments/ORDER-1042/capture\" ...\n\n# Wrong — do not use a numeric internal ID\ncurl -X POST \"$FYNEX_API/payments/12345/capture\" ...\n```\n\n### Q: I issued a partial refund successfully, but a second partial refund returns `409`\n\nAfter the first refund call, the payment moves to `refund_pending`. You cannot issue another refund until the first one completes. A successful partial refund returns the parent payment to a refundable captured state; a full cumulative refund reaches `refunded`; failed/cancelled attempts move to `refund_failed`/`refund_cancelled` and can be retried.\n\n---\n\n## 5. Payouts\n\n### Q: My `Idempotency-Key` header is being ignored on `POST /payouts`\n\n`POST /payouts` does not read the `Idempotency-Key` HTTP header for deduplication. Pass idempotency as a body field named `idempotencyKey` instead:\n\n```json\n{\n  \"walletId\": 15,\n  \"amountMinor\": 199900,\n  \"currencyCode\": \"GBP\",\n  \"idempotencyKey\": \"6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b\"\n}\n```\n\nAll other POST endpoints in this API use the `Idempotency-Key` header. Payouts are the sole exception.\n\nSee [Payouts](#tag/payouts) and [Idempotency \u0026 retries](#tag/idempotency).\n\n### Q: I'm getting `409 \"insufficient balance\"` but I can see funds in the wallet\n\nCheck the wallet's `availableBalanceMinor`, not its total balance. Held, pending, or reserved funds are not available for payout. Top up the wallet or wait for in-flight transactions to clear, then retry with the **same** `idempotencyKey` value.\n\n### Q: Payout amounts — should I send major or minor units?\n\nPayouts use **minor units** (`amountMinor`). For £19.99 send `1999`. This differs from every other endpoint in the API, which use major units. Double-check the field name: the payout body field is `amountMinor`, not `amount`.\n\n### Q: The `failureMessage` field on `GET /payouts/{id}` is always empty — is that a bug?\n\nYes, this is a known implementation gap. `failureMessage` appears in the response schema but is never populated by the server. Use `failureCode` to detect and classify payout failures; do not rely on `failureMessage` for message text.\n\n---\n\n## 6. Verification \u0026 polling\n\n### Q: How do I know when a payment succeeds if there are no webhooks?\n\nOutbound webhooks are not yet available. Two options:\n\n- **Polling:** query the GraphQL `genericPayment(id)` endpoint (requires a dashboard session cookie) until `status` reaches a terminal value.\n- **SSE:** if the customer is sitting on a hosted checkout page, subscribe to the server-sent events stream at `GET /checkout/{session_id}/events` from the browser.\n\nThe Fynex dashboard polls every 5 seconds. See [Polling \u0026 SSE](#tag/polling-sse).\n\n### Q: I'm polling for `status === \"succeeded\"` and never getting a match\n\nThe status `succeeded` does not exist in the `GenericPaymentStatus` enum. Terminal success statuses are `settled` and `deposit_confirmed` (after settlement) or `provider_completed` (immediately after capture, before settlement). Check for these values instead. The full status enum is documented in [Payment Lifecycle](#tag/payment-lifecycle).\n\n### Q: The customer was redirected to my `returnUrls.success` page — can I fulfil the order now?\n\nNo. A user can navigate directly to your success URL without paying. The redirect is not authoritative. Always verify the payment state server-side via polling or SSE before dispatching goods or services.\n\n### Q: How long should I poll before giving up?\n\nFor card payments: 10–15 minutes is a reasonable outer bound. Bank transfers may take longer. If the payment stays in a non-terminal state beyond your deadline, surface a `pending_review` UX state and alert your operations team — do not keep polling indefinitely. Once a payment reaches a terminal state (`settled`, `deposit_confirmed`, `failed`, `cancelled`, `refunded`), write it to your database and stop polling that record.\n\nSee [Polling \u0026 SSE](#tag/polling-sse).\n\n---\n\n## 7. GraphQL / Dashboard API\n\n### Q: GraphQL returns `401` with my Bearer token\n\n`POST /dashboard/graphql` does not support Bearer token authentication. It requires an **HttpOnly session cookie** named `dashboard_session`. Obtain one by calling `POST /api/v1/login/dashboard` first:\n\n```bash\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Then send GraphQL requests with the saved cookie\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"query\": \"{ payees(limit: 5) { id displayName } }\"}'\n```\n\nSee [GraphQL Authentication](#tag/graphql-auth).\n\n### Q: Sign-up succeeded but my next GraphQL request gets `401`\n\n`POST /api/v1/onboarding/start` creates a **draft** user and sets the `dashboard_session` cookie, but the account has no organization until the wizard finishes with `POST /api/v1/onboarding/complete`. GraphQL operations that need an organization fail until then — complete the onboarding first (see [Account setup \u0026 onboarding](#tag/onboarding)). For an existing account, `POST /api/v1/login/dashboard` obtains the cookie.\n\nSee [GraphQL Authentication](#tag/graphql-auth).\n\n### Q: My dashboard session disappeared and all GraphQL requests started returning `401`\n\nDashboard sessions are held in-memory on a single server node with a 24-hour TTL. A server restart invalidates all active sessions — you must re-authenticate. Your integration should handle the `401` response from `/dashboard/graphql` by re-running the login flow automatically.\n\n### Q: Where is the GraphQL Playground?\n\nA playground is available at `GET /dashboard/playground`. Note that the playground page itself has no auth gate — the authentication requirement applies to actual query execution, not to loading the playground UI.\n\n---\n\n## 8. Apple Pay \u0026 Google Pay (legacy surface)\n\n### Q: Apple Pay merchant validation is failing in production\n\nThe Fynex dashboard fakes merchant validation by calling `completeMerchantValidation({})` with an empty object — this is a development shortcut that will not work against real Apple Pay. For production you must implement a **server-side** endpoint that contacts Apple's merchant validation URL using your Apple Pay merchant certificate and private key, and returns the opaque merchant session to the browser. Browsers cannot make this call directly due to CORS restrictions.\n\nSee [Apple Pay](#tag/apple-pay).\n\n### Q: Apple Pay only shows in Safari — is that expected?\n\nYes. `ApplePaySession` is available only in Safari on Apple devices (macOS + Safari or iOS/iPadOS). Chrome, Firefox, and other browsers do not support it. Always gate the Apple Pay button on `isApplePayAvailable()`:\n\n```js\nfunction isApplePayAvailable() {\n  return typeof window !== 'undefined' \u0026\u0026\n    'ApplePaySession' in window \u0026\u0026\n    ApplePaySession.canMakePayments();\n}\n```\n\n### Q: Google Pay tokenization is being rejected by Fynex\n\nAs of the recent Google Pay refactor, the `gateway` and `gatewayMerchantId` parameters are no longer chosen by integrators — Fynex's hosted checkout sets them server-side from per-deploy configuration so they always match what the upstream processor has enrolled. If you're seeing tokenization rejections on the hosted page, contact Fynex support with the GP merchant ID you registered in the Google Pay Business Console; if you're driving Google Pay outside the hosted page, that integration path is not supported as a public API today.\n\n### Q: `isReadyToPay()` returns `true` but the Google Pay sheet shows no payment methods\n\n`isReadyToPay()` checks whether the Google Pay API is available, not whether the user has saved cards. The sheet can open and show no cards. Gate showing the Google Pay button on `isReadyToPay()` but handle the empty-sheet case gracefully — do not treat it as an error.\n\n### Q: The Google Pay flow returned `authorizationLink` — what do I do with it?\n\nA 3DS step-up is required. Save `merchantRefNum`, `paymentHandleToken`, and `amount` to `localStorage`, redirect the customer's browser to the `authorizationLink`, and call `complete-google-pay-payment` once the issuer redirects them back to your return page. This is the same localStorage-bridge pattern used by the card 3DS flow.\n\nSee [Google Pay](#tag/google-pay) and [3DS Authentication](#tag/3ds).\n\n---\n\n## Cross-reference: common error messages\n\n| HTTP status | Message | Cause | Guide |\n|-------------|---------|-------|-------|\n| `400` | `Idempotency-Key header is required` | Missing `Idempotency-Key` header on a POST endpoint | [Headers](#tag/headers) |\n| `400` | `Idempotency-Key must be a valid UUID` | Header value is not a UUID v4 | [Idempotency](#tag/idempotency) |\n| `400` | `currencyCode is required` | Missing `currencyCode` field | [Server-to-Server](#tag/server-to-server) |\n| `400` | `paymentMethod wero is not supported for currency EUR and country IT` | Wero is limited to EUR in Belgium, Germany, and France | [Alternative payment methods](#tag/alternative-payment-methods-apm) |\n| `400` | `invalid request body` | JSON parse failed — check `Content-Type: application/json` and body syntax | [Errors](#tag/errors) |\n| `401` | `seller authorization token is required` (plain text) | Missing or malformed `Authorization` header | [Authentication](#tag/authentication) |\n| `401` | `sellerAccountId is missing in auth context` | Missing or invalid `Idempotency-Key` on `POST /checkout` | [Hosted Checkout](#tag/hosted-checkout) |\n| `403` | `resource does not belong to this seller` | Token is valid but the resource belongs to a different seller account | [Authentication](#tag/authentication) |\n| `409` | `invalid status transition from \u003cstatus\u003e to capture` | Payment not in `authorized` or `provider_completed` state | [Captures \u0026 Refunds](#tag/captures-refunds) |\n| `409` | `capture is not allowed while a refund is in progress` | A refund has been reserved/submitted and has not terminalized yet | [Captures \u0026 Refunds](#tag/captures-refunds) |\n| `409` | `capture is not allowed after a successful refund` | A refund already succeeded for this payment, so capture is closed | [Captures \u0026 Refunds](#tag/captures-refunds) |\n| `409` | `refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments` | Payment is not in a refundable captured state, or the previous refund attempt is still pending/already fully refunded | [Captures \u0026 Refunds](#tag/captures-refunds) |\n| `409` | `insufficient balance` | Wallet `availableBalanceMinor` is below the requested payout amount | [Payouts](#tag/payouts) |\n| `409` | `payout with this idempotency key already exists` | Replay of a successful payout — fetch the existing payout instead of creating a new one | [Payouts](#tag/payouts) |\n| `502` | `upstream card processor returned 400` (payment row: `failureCode: 2002`, missing billing fields) | Missing `billingDetails.country` and/or `billingDetails.zip` on a card payment | [Server-to-Server](#tag/server-to-server) |\n| `failureCode: 2002` | `upstream card processor returned 400` after `/finalize-payment` (payment row: `failureStage: authorization`) | `/finalize-payment` called before the customer completed the 3DS challenge at `actionUrl` | [3DS Authentication](#tag/3ds) |\n| `502` | (any other) | Upstream processor error — safe to retry with the same idempotency key | [Errors](#tag/errors) |\n\n## See also\n\n- **[Errors](#tag/errors)** — Full HTTP status code and error body reference with recovery guidance.\n- **[Polling \u0026 SSE](#tag/polling-sse)** — How to verify payment and payout status without webhooks.\n- **[Idempotency \u0026 retries](#tag/idempotency)** — Make all mutating calls safe to retry with idempotency keys.\n- **[Authentication \u0026 Tokens](#tag/authentication)** — Bearer token provisioning, rotation, and common auth errors.\n","name":"Troubleshooting"},{"description":"The Fynex GraphQL API lives at **`/dashboard/graphql`**. Unlike the REST API — which uses a bearer token — this endpoint is authenticated exclusively with an **HttpOnly session cookie** named `dashboard_session`. There is no bearer-token path to `/dashboard/graphql`.\n\nThis endpoint is the right choice when you need operations that have no REST equivalent: full payee CRUD, payout method management, wallet queries, split rules, reconciliation data, and token rotation.\n\n---\n\n## When to use the GraphQL endpoint\n\nUse `/dashboard/graphql` (cookie-session auth) when you need to:\n\n- Create, update, or delete payees and their payout methods.\n- Query wallets, reconciliation statements, or split executions.\n- Rotate the bearer token that your server-to-server REST integration uses (`regenerateSellerAccountToken`).\n- Access any query or mutation not exposed on the REST surface.\n\n\u003e [!NOTE]\n\u003e Sellers who build **custom dashboard integrations** — or who need programmatic access to payee/wallet data — are the primary audience for this guide.\n\n---\n\n## Authentication flow\n\n1. **Obtain a session cookie** by posting credentials to the login endpoint:\n\n   ```http\n   POST /api/v1/login/dashboard\n   Content-Type: application/json\n\n   { \"email\": \"you@example.com\", \"password\": \"your_password\" }\n   ```\n\n   On success (`200 OK`) the server sets an HttpOnly cookie named `dashboard_session`. The response body also returns the user ID, permissions, and onboarding status — you can discard those fields for a pure API integration.\n\n2. **Send GraphQL requests** to `/dashboard/graphql` with the cookie attached. Set `Content-Type: application/json` and include the cookie on every request.\n\n3. **Log out** when done (optional but recommended for server-side scripts):\n\n   ```http\n   POST /api/v1/logout\n   ```\n\n   This clears the cookie server-side.\n\n\u003e [!CAUTION]\n\u003e **A new account is a draft until `complete`.** `POST /api/v1/onboarding/start` creates the user and **does** set the `dashboard_session` cookie, but the account has no organization yet — GraphQL calls that need one fail until `POST /api/v1/onboarding/complete` has run (see [Account setup \u0026 onboarding](#tag/onboarding)). For an existing account, `POST /api/v1/login/dashboard` is the way to a cookie.\n\n---\n\n## Code samples — login and first query\n\n#### curl\n\n```bash\n# Step 1 — login and save the cookie\ncurl -sc cookies.txt \\\n  -X POST https://api.fynex.ai/api/v1/login/dashboard \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"you@example.com\", \"password\": \"your_password\"}'\n\n# Step 2 — send a GraphQL query using the saved cookie\ncurl -b cookies.txt \\\n  -X POST https://api.fynex.ai/dashboard/graphql \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"query\": \"query ListPayees($limit: Int, $offset: Int) { payees(limit: $limit, offset: $offset) { id displayName role status } }\",\n    \"variables\": { \"limit\": 20, \"offset\": 0 }\n  }'\n```\n\n#### JavaScript\n\n```js\nconst BASE = 'https://api.fynex.ai';\n\n// Step 1 — login; browser (or same-origin server) sends/receives the cookie automatically\nasync function login(email, password) {\n  const res = await fetch(`${BASE}/api/v1/login/dashboard`, {\n    method: 'POST',\n    credentials: 'include', // required — sends and stores the HttpOnly cookie\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ email, password }),\n  });\n  if (!res.ok) throw new Error(`Login failed: ${res.status}`);\n  return res.json(); // { userId, permissions, onboardingStatus }\n}\n\n// Step 2 — send any GraphQL operation\nasync function gql(query, variables = {}) {\n  const res = await fetch(`${BASE}/dashboard/graphql`, {\n    method: 'POST',\n    credentials: 'include', // cookie is attached automatically\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ query, variables }),\n  });\n  const { data, errors } = await res.json();\n  if (errors?.length) throw new Error(errors[0].message);\n  return data;\n}\n\n// Usage\nawait login('you@example.com', 'your_password');\nconst { payees } = await gql(\n  `query ($limit: Int, $offset: Int) {\n     payees(limit: $limit, offset: $offset) { id displayName role status }\n   }`,\n  { limit: 20, offset: 0 }\n);\nconsole.log(payees);\n```\n\n#### Python\n\n```python\nimport requests\n\nBASE = \"https://api.fynex.ai\"\n\n# requests.Session persists cookies across calls automatically\nsession = requests.Session()\n\n# Step 1 — login\nresp = session.post(\n    f\"{BASE}/api/v1/login/dashboard\",\n    json={\"email\": \"you@example.com\", \"password\": \"your_password\"},\n)\nresp.raise_for_status()  # raises on 4xx/5xx\n\n# Step 2 — send a GraphQL query\nquery = \"\"\"\n  query ListPayees($limit: Int, $offset: Int) {\n    payees(limit: $limit, offset: $offset) {\n      id\n      displayName\n      role\n      status\n    }\n  }\n\"\"\"\nresp = session.post(\n    f\"{BASE}/dashboard/graphql\",\n    json={\"query\": query, \"variables\": {\"limit\": 20, \"offset\": 0}},\n)\nresp.raise_for_status()\ndata = resp.json()\nif \"errors\" in data:\n    raise RuntimeError(data[\"errors\"][0][\"message\"])\nprint(data[\"data\"][\"payees\"])\n```\n\n---\n\n## Session properties\n\n| Property | Value |\n|----------|-------|\n| Cookie name | `dashboard_session` |\n| Cookie flags | HttpOnly, SameSite (not accessible from JavaScript) |\n| Session TTL | 24 hours (in-memory; not clustered — a server restart invalidates all sessions) |\n| Auth required | Every request to `/dashboard/graphql` must carry the cookie |\n| Multiple sessions | Each login creates a new session; logout clears both `dashboard_session` and `backoffice_session` |\n\n---\n\n## Receipts are bound to the session\n\nSome mutations refuse to act on a bare id and require a **receipt** issued by\nan earlier call — `activateSplitRule` takes the `activationReceipt` that\n`previewSplit(ruleId:)` returned. That receipt is signed with a key derived\nfrom the `dashboard_session` that ran the preview, and it also names the\nactor, the rule and a fingerprint of the exact rule snapshot previewed. It is\nvalid for **10 minutes**.\n\nThe consequence for a scripted integration: preview and activate must happen\n**inside the same session**. A receipt presented from a different cookie —\nafter a re-login, from a second worker, or after a server restart invalidated\nthe session — is refused with *\"invalid split activation preview receipt\"*,\nand an expired one with *\"split activation preview receipt expired; run\npreview again\"*. Editing the rule in between invalidates it too. Hold one\nsession for the whole preview → activate sequence, and treat any receipt error\nas \"preview again\", never as \"retry the activation\".\n\nSee **[Split payments](#tag/splits)** for the full lifecycle.\n\n---\n\n## GraphQL endpoint details\n\n| Property | Value |\n|----------|-------|\n| URL | `https://api.fynex.ai/dashboard/graphql` |\n| Method | `POST` |\n| Content-Type | `application/json` |\n| Body shape | `{ \"query\": \"...\", \"variables\": { ... } }` |\n| Error shape | `{ \"errors\": [{ \"message\": \"...\" }] }` |\n\nA GraphQL Playground is available at `GET /dashboard/playground` (no auth gate on the playground page itself — useful for manual exploration).\n\n---\n\n## Rotating your bearer token from GraphQL\n\n`regenerateSellerAccountToken` is the one mutation that bridges the GraphQL session world back to the REST bearer-token world. Call it to rotate the `authorizationToken` used by your server-to-server integration.\n\n```graphql\nmutation RotateToken($merchantId: ID!) {\n  regenerateSellerAccountToken(merchantId: $merchantId) {\n    id\n    authorizationToken\n  }\n}\n```\n\nSupply your **seller account ID** as `$merchantId` — the argument is a GraphQL `ID`, so `\"42\"` and `42` are both accepted. The response contains the new bearer token. The previous token is invalidated immediately — update your secret manager before calling this.\n\n\u003e [!CAUTION]\n\u003e There is no overlap window between the old and new token. Plan a brief service restart or atomic secret rotation before calling this mutation in production.\n\n---\n\n## Common pitfalls\n\n| Pitfall | Resolution |\n|---------|------------|\n| Calling `/dashboard/graphql` with a Bearer token | This endpoint does not support bearer auth. Use the `dashboard_session` cookie instead. |\n| GraphQL fails right after sign-up | A draft account (after `POST /api/v1/onboarding/start`) has no organization yet. Finish the wizard and call `POST /api/v1/onboarding/complete` first. |\n| Session lost on server restart | Sessions are held in-memory on a single node. A restart invalidates all active sessions — clients must re-authenticate. |\n| Cookie not sent by the browser | Ensure you use `credentials: 'include'` on every `fetch` call (or the equivalent in your HTTP client). |\n| `401` on the GraphQL endpoint | Either the cookie is absent, expired (\u003e24 h), or was invalidated by a server restart. Re-login to get a fresh cookie. |\n| `activateSplitRule` rejects a receipt that `previewSplit` just issued | The two calls ran under different sessions (a re-login or a second worker in between), or more than 10 minutes passed. Receipts are session-bound — see **Receipts are bound to the session** above. Run the preview again in the session that will activate. |\n\n## See also\n\n- **[Authentication \u0026 Tokens (Bearer)](#tag/authentication)** — Bearer token auth for the server-to-server REST API.\n- **[Payees](#tag/payees)** — Create and manage payees via REST and GraphQL.\n- **[Payout Methods](#tag/payout-methods)** — Register bank accounts as payout destinations for your payees.\n","name":"GraphQL auth"}],"webhooks":{"KYBVerificationApproved":{"post":{"description":"The payee passed. This is the event that unlocks payouts to them.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-KYBVerificationApproved","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KYBWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"KYBVerificationApproved","tags":["Webhooks"]}},"KYBVerificationLevelChanged":{"post":{"description":"The verification level changed — usually a raised limit tier, which can require new documents.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-KYBVerificationLevelChanged","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KYBWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"KYBVerificationLevelChanged","tags":["Webhooks"]}},"KYBVerificationManualReview":{"post":{"description":"The provider escalated the case to a human reviewer.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-KYBVerificationManualReview","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KYBWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"KYBVerificationManualReview","tags":["Webhooks"]}},"KYBVerificationPending":{"post":{"description":"The applicant's documents are with the provider and under automated review.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-KYBVerificationPending","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KYBWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"KYBVerificationPending","tags":["Webhooks"]}},"KYBVerificationRejected":{"post":{"description":"The payee failed. `reviewRejectType` distinguishes a retryable request for more documents from a final refusal.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-KYBVerificationRejected","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KYBWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"KYBVerificationRejected","tags":["Webhooks"]}},"KYBVerificationStarted":{"post":{"description":"A verification was opened for a payee.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-KYBVerificationStarted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KYBWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"KYBVerificationStarted","tags":["Webhooks"]}},"MarketplaceVendorActivated":{"post":{"description":"The vendor's screening passed and `payoutsEnabled` became true: its payee can now be a split or payout target, under the cumulative limit.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-MarketplaceVendorActivated","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceVendorWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"MarketplaceVendorActivated","tags":["Webhooks"]}},"MarketplaceVendorBlocked":{"post":{"description":"The vendor was refused at screening or verification, or blocked manually. No funds can reach it; `verification.reason` says which.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-MarketplaceVendorBlocked","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceVendorWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"MarketplaceVendorBlocked","tags":["Webhooks"]}},"MarketplaceVendorPayoutAllowanceChanged":{"post":{"description":"`payoutAllowance.limitMinor`, `enforcement` or `unlimited` changed.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-MarketplaceVendorPayoutAllowanceChanged","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceVendorWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"MarketplaceVendorPayoutAllowanceChanged","tags":["Webhooks"]}},"MarketplaceVendorVerificationComplete":{"post":{"description":"Verification passed; the cumulative limit no longer applies (`payoutAllowance.unlimited`).\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-MarketplaceVendorVerificationComplete","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceVendorWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"MarketplaceVendorVerificationComplete","tags":["Webhooks"]}},"MarketplaceVendorVerificationInProgress":{"post":{"description":"The vendor submitted; the provider is reviewing. Nothing to do.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-MarketplaceVendorVerificationInProgress","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceVendorWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"MarketplaceVendorVerificationInProgress","tags":["Webhooks"]}},"MarketplaceVendorVerificationPendingReview":{"post":{"description":"The case is with a human reviewer, at Fynex or the provider. Nothing to do.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-MarketplaceVendorVerificationPendingReview","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceVendorWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"MarketplaceVendorVerificationPendingReview","tags":["Webhooks"]}},"MarketplaceVendorVerificationRequired":{"post":{"description":"The vendor must complete a verification step -- it crossed the cumulative payout limit, or more is needed. The payload carries `actionUrl` / `actionExpiresAt` when a link was issued; otherwise fetch one with POST /vendors/{vendor_id}/verification-link.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-MarketplaceVendorVerificationRequired","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceVendorWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"MarketplaceVendorVerificationRequired","tags":["Webhooks"]}},"PaymentCompleted":{"post":{"description":"A payment reached a terminal state. `status` says which one — a completed event is not by itself a successful payment, and `failureCode` / `failureDescription` carry the reason when it is not.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentCompleted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentCompleted","tags":["Webhooks"]}},"PaymentLinkCancelled":{"post":{"description":"It was cancelled before payment.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentLinkCancelled","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentLinkCancelled","tags":["Webhooks"]}},"PaymentLinkCreated":{"post":{"description":"A link was created and is payable.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentLinkCreated","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentLinkCreated","tags":["Webhooks"]}},"PaymentLinkEmailFailed":{"post":{"description":"The invitation email could not be sent. The link itself was created and is payable — send the URL another way rather than recreating it.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentLinkEmailFailed","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkEmailFailedWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentLinkEmailFailed","tags":["Webhooks"]}},"PaymentLinkExpired":{"post":{"description":"It passed its expiry unpaid and can no longer be paid.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentLinkExpired","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentLinkExpired","tags":["Webhooks"]}},"PaymentLinkPaid":{"post":{"description":"Someone paid it. A multi-use link stays active and reports the new `usageCount`; a single-use link moves to `paid`.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentLinkPaid","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentLinkPaid","tags":["Webhooks"]}},"PaymentLinkUpdated":{"post":{"description":"Its amount, title, expiry or recipient changed.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentLinkUpdated","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentLinkUpdated","tags":["Webhooks"]}},"PaymentRefunded":{"post":{"description":"A refund succeeded. `partial` is true when this refund is smaller than the payment's original captured amount — it is per-refund, not cumulative. A FAILED refund emits nothing; it surfaces as a seller email.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentRefunded","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefundWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PaymentRefunded","tags":["Webhooks"]}},"PromoCodeRedeemed":{"post":{"description":"A promotion code was redeemed. The context ids say what against — the ones that do not apply are null.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PromoCodeRedeemed","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromoCodeWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"PromoCodeRedeemed","tags":["Webhooks"]}},"SubscriptionCancelled":{"post":{"description":"It was cancelled — either on request, or because a trial ended with cancel-on-trial-end behaviour.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionCancelled","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionCancelled","tags":["Webhooks"]}},"SubscriptionCharged":{"post":{"description":"A term was charged and the period advanced.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionCharged","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionCharged","tags":["Webhooks"]}},"SubscriptionCommitmentCompleted":{"post":{"description":"The final installment of an annual commitment was charged; the commitment is served.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionCommitmentCompleted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionCommitmentCompleted","tags":["Webhooks"]}},"SubscriptionCreated":{"post":{"description":"A recurring subscription was created on a payment link.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionCreated","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionCreated","tags":["Webhooks"]}},"SubscriptionInstallmentCharged":{"post":{"description":"One monthly installment of an annual commitment was charged.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionInstallmentCharged","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionInstallmentCharged","tags":["Webhooks"]}},"SubscriptionPastDue":{"post":{"description":"A charge failed on its last permitted attempt. Collection stops until the subscription recovers.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionPastDue","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionPastDue","tags":["Webhooks"]}},"SubscriptionTrialEnded":{"post":{"description":"The trial ended and the subscription became active.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionTrialEnded","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionTrialEnded","tags":["Webhooks"]}},"SubscriptionTrialWillEnd":{"post":{"description":"A trial ends within a day — the last chance to collect a payment method before the first charge. Emitted for BOTH products, with a different payload each: a billing subscription's carries `contractId` and nothing else does. Branch on its presence rather than assuming the payment-link shape.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionTrialWillEnd","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionTrialWillEndWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"SubscriptionTrialWillEnd","tags":["Webhooks"]}},"TaxLocationResolved":{"post":{"description":"A checkout's tax jurisdiction was resolved and the evidence stored. Emitted so the trail can be archived beside your own records rather than requested later.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-TaxLocationResolved","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaxLocationWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"TaxLocationResolved","tags":["Webhooks"]}}},"x-tagGroups":[{"name":"Getting started","tags":["Getting started","Quickstart","Authentication","Headers","Idempotency","Errors","Test cards"]},{"name":"Concepts","tags":["Concepts","Payment lifecycle","Payment methods","Wallets","Virtual accounts","Splits"]},{"name":"Payment flows","tags":["Hosted checkout","Server-to-server","Apple Pay","Google Pay","Alternative payment methods (APM)","3DS","Captures \u0026 refunds","Recurring"]},{"name":"Payouts","tags":["Payouts","Payees","Vendors","Payout methods"]},{"name":"Operations","tags":["Polling \u0026 SSE","Webhooks","Reconciliation","Disputes","Verification patterns","Checkout widget","Onboarding","Going live","Troubleshooting"]},{"name":"Reference","tags":["Payments","Payment Methods","Webhooks","Payees","Vendors","Payouts","Checkout","Authentication"]},{"name":"GraphQL","tags":["GraphQL auth"]}]}