{"openapi":"3.1.0","info":{"description":"Fynex Payments API for seller-authenticated payment operations. Read the [Payments API Discovery Guide](https://docs.fynex.ai/payments-api/v2/docs/overview.md) for the public reference, guides, and examples. Authenticate with SellerAuth; see the [Authentication guide](https://docs.fynex.ai/payments-api/v2/docs/authentication.md).","title":"Fynex Payments API","version":"1.0.0"},"servers":[{"description":"Fynex Payments API","url":"https://api.fynex.ai"}],"security":[{"SellerAuth":[]}],"components":{"securitySchemes":{"SellerAuth":{"bearerFormat":"Fynex seller secret key (sk_test_… / sk_live_…)","description":"Seller API token, sent as a bearer credential. In an interactive client's\nauth field, enter the token value alone — no `Bearer ` prefix — the client\nadds it for you. As a raw header, the request must include:\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","scheme":"bearer","type":"http"}},"schemas":{"APMDataInput":{"additionalProperties":false,"properties":{"phone":{"examples":["+351912345678"],"type":"string"}},"type":"object"},"AccountAPIKey":{"additionalProperties":false,"properties":{"access":{"description":"full: every route. restricted: only the routes its permissions cover.","enum":["full","restricted"],"examples":["restricted"],"type":"string"},"expiresAt":{"description":"When the key stops working; null when it never expires.","format":"date-time","type":["string","null"]},"id":{"description":"The key's id in the dashboard's key list.","examples":[42],"format":"int64","type":"integer"},"name":{"description":"What the key is for, as named when it was created; null when unnamed.","examples":["CI"],"type":["string","null"]},"permissions":{"description":"A restricted key's permissions, resource:read or resource:write (write includes read); empty for a full-access key.","examples":[["payments:read","refunds:write"]],"items":{"type":"string"},"type":["array","null"]}},"required":["id","name","access","permissions","expiresAt"],"type":"object"},"AccountFeatures":{"additionalProperties":false,"properties":{"payeePayoutAutoFunding":{"description":"POST /payees/{payee_id}/payouts may top up the payee wallet from the main wallet when it is short. Always on for a Demo account.","type":"boolean"},"refunds":{"description":"Refunds are enabled. On by default.","type":"boolean"},"walletCreation":{"description":"POST /wallets is enabled. Always on for a Demo account; on a Live account off by default, Fynex switches it on.","type":"boolean"}},"required":["walletCreation","payeePayoutAutoFunding","refunds"],"type":"object"},"AccountResponse":{"additionalProperties":false,"properties":{"apiKey":{"$ref":"#/components/schemas/AccountAPIKey","description":"The sk_ key that made this call, absent for any other credential: whether it is restricted, and to what."},"currency":{"description":"The account's settlement currency, ISO 4217.","examples":["EUR"],"type":"string"},"features":{"$ref":"#/components/schemas/AccountFeatures"},"name":{"description":"The account's display name.","examples":["Acme Marketplace"],"type":"string"},"operationalMode":{"description":"Demo: test mode — payments use test cards and no real money moves, on any host. Live: real money.","enum":["Demo","Live"],"examples":["Demo"],"type":"string"},"selfServeSandbox":{"description":"True for an anonymous sandbox created through POST /sandbox/accounts. Routes with third-party side effects (virtual IBANs, KYC) refuse such an account.","type":"boolean"},"sellerAccountUuid":{"description":"The seller account this credential acts for.","examples":["3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"],"type":"string"},"status":{"description":"The account's operating status.","examples":["Active"],"type":"string"}},"required":["sellerAccountUuid","name","operationalMode","status","currency","selfServeSandbox","features"],"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":{"description":"Billing postcode, at most 10 characters after trimming surrounding spaces (a US ZIP+4 such as 12345-6789 is 10). A longer value is rejected with 400 billingDetails.postalCode must be at most 10 characters. Not checked when paymentType is apm.","examples":["SW1A1AA"],"type":"string"},"state":{"examples":["Greater London"],"type":"string"},"street":{"examples":["1 Example Street"],"type":"string"},"zip":{"description":"Alias of postalCode, used only when postalCode is empty. Same limit: at most 10 characters after trimming; a longer value is rejected with 400 billingDetails.zip must be at most 10 characters.","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"},"CancelPromotionRequest":{"additionalProperties":false,"properties":{"reason":{"description":"Why the order is cancelled; kept with the cancellation and sent in the PaymentPromotionCancelled webhook. 1 to 512 characters.","examples":["customer changed their mind"],"maxLength":512,"minLength":1,"type":"string"}},"required":["reason"],"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":{"description":"Per-payment input to a weight-mode split rule: it takes effect only when the seller's active split rule is in weight mode, where these shares decide each payee's cut and the rule adds its fee policy on top. Ignored when the active rule is in absolute mode — the rule's own lines decide every share. With no active split rule nothing is split and the payment settles unsplit. Give every share in amountMinor (integer minor units, preferred) or every share in the deprecated amount (MAJOR units); the shares must add up to the payment amount. See Per-payment inline splits in the Splits guide: /payments-api/v2/docs/splits.md","items":{"$ref":"#/components/schemas/CheckoutOrderPayeeDistributionInput"},"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"},"CheckoutOrderPayeeDistributionInput":{"additionalProperties":false,"properties":{"amount":{"deprecated":true,"description":"Deprecated: send amountMinor instead. Still accepted. This payee's share of the payment, in MAJOR units (49.99 is £49.99) like the payment amount — unlike amountMinor, split rules, previews and executions, which are integer minor units. Every item sets exactly one of amountMinor or amount, and all items in one distribution use the same one.","examples":[49.99],"format":"double","type":"number"},"amountMinor":{"description":"This payee's share of the payment, in integer minor units of the payment currency (4999 is £49.99) — the same unit as split rules, previews and executions. Preferred over the deprecated amount. Every item sets exactly one of amountMinor or amount, and all items in one distribution use the same one. Must be greater than zero.","examples":[4999],"format":"int64","type":"integer"},"payeeId":{"examples":[101],"format":"int64","type":"integer"}},"required":["payeeId"],"type":"object"},"CreateCheckoutRequest":{"additionalProperties":false,"properties":{"allowPromotions":{"description":"Let the buyer apply a coupon or Fynex wallet credit on the hosted checkout page. The discount is funded by the coupon's funder; the charged amount is then lower than amount. Default false.","examples":[false],"type":"boolean"},"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"},"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"},"description":{"description":"Optional. Your own note, shown back to you; the bank statement text of a payout from your main wallet is set by Fynex (e.g. SR-UK2609AA-261031 261007-261010: the settlement report number, then the dates). For a payout from a payee cashout wallet this text is what the beneficiary sees on their bank statement. When supplied it is trimmed and whitespace-collapsed and must then be 1-140 characters. Allowed characters: letters a-z A-Z, digits 0-9, space and / - ? : ( ) . , ' + — anything else is rejected, never transliterated. An empty or blank value is rejected rather than treated as omitted. A retry with the same idempotencyKey and a different description returns 409.","examples":["Invoice 2026-091 September commission"],"type":"string"},"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"},"description":{"description":"Optional. Your own note, shown back to you; the bank statement text of a payout from your main wallet is set by Fynex (e.g. SR-UK2609AA-261031 261007-261010: the settlement report number, then the dates). For a payout from a payee cashout wallet this text is what the beneficiary sees on their bank statement. When supplied it is trimmed and whitespace-collapsed and must then be 1-140 characters. Allowed characters: letters a-z A-Z, digits 0-9, space and / - ? : ( ) . , ' + — anything else is rejected, never transliterated. An empty or blank value is rejected rather than treated as omitted. A retry with the same idempotencyKey and a different description returns 409.","examples":["Invoice 2026-091 September commission"],"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, but not used for payouts yet: payout events go only to registered webhook endpoints that subscribe to them.","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":["16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117"],"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":{"code":{"description":"Machine-readable reason, on every error. A specific code where the API names one — for example card_expired or card_brand_not_supported on a 422 from initialize-payment — and otherwise one derived from the status: bad_request, unauthorized, forbidden, not_found, conflict, unprocessable_entity, rate_limited, internal_error and so on. Branch on code, never on the error text.","examples":["card_brand_not_supported"],"type":"string"},"error":{"type":"string"}},"required":["error"],"type":"object"},"Event":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of the event; the same id a webhook delivery of it carries.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"The event's type, as in the webhook catalogue.","examples":["PaymentCompleted"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC.","examples":["2026-10-16T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"description":"The event body exactly as a webhook delivery carries it; its shape per eventType is in the webhook catalogue."},"sellerAccountUuid":{"description":"The seller account the event belongs to — you.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventId","eventType","sellerAccountUuid","occurredAt","payload"],"type":"object"},"EventsResponse":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/components/schemas/Event"},"type":["array","null"]},"hasMore":{"description":"More events are available right now; call again with nextCursor.","type":"boolean"},"nextCursor":{"description":"Pass back as cursor. Present whenever there is a position to resume from — also on the last page, so a stored cursor later returns only newer events.","examples":[918204],"format":"int64","type":"integer"}},"required":["events","hasMore"],"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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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,1008,2001,2002,2005,2006,2003,2004,3001,3002,3003,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"},"legacyStatus":{"deprecated":true,"description":"Deprecated: the previous `status` value (e.g. provider_completed, settled). Kept for one notice period after `status` switches to the lifecycle enum; read `lifecycleStatus` instead.","enum":["draft","new","routed","compliance_review","provider_pending","authorized","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refunded","refund_failed","refund_cancelled","failed","cancelled"],"examples":["provider_completed"],"type":"string"},"lifecycleStatus":{"description":"The payment's lifecycle status — the same value on every endpoint and in the PaymentCompleted webhook. **pending** — created, routing, or waiting on the provider or the customer (3-D Secure, a redirect); nothing is reserved yet. **under_review** — held by a compliance or risk review before authorization. **authorized** — funds reserved, waiting for capture (captureMode manual). **captured** — money was taken; stays captured through settlement, partial refunds, and a refund in flight or failed. **refunded** — the whole captured amount was refunded. **failed** — declined or errored; no money was taken (see failureCode). **cancelled** — called off before money was taken, including an expired checkout.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled"],"examples":["captured"],"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":{"description":"The payment's status. Until the switch announced in the changelog it carries the previous values (the ones `legacyStatus` carries, e.g. provider_completed); after it, the lifecycle value `lifecycleStatus` carries. Read `lifecycleStatus` now to be unaffected by the switch.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled","draft","new","routed","compliance_review","provider_pending","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refund_failed","refund_cancelled"],"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":{"allowedActions":{"description":"What you can request on this payment next, by the same rules the endpoints enforce: a listed action is accepted, an unlisted one is refused. Empty when nothing is possible. **capture** / **partial_capture** — `POST /payments/{payment_id}/capture` without / with a lower `amount`; **refund** / **partial_refund** — `POST /payments/{payment_id}/refund` without / with a lower `amount`.","examples":[["capture","partial_capture"]],"items":{"enum":["capture","partial_capture","refund","partial_refund"],"type":"string"},"type":["array","null"]},"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"},"captureMode":{"description":"How this payment is captured: **auto** — captured as part of the payment; **manual** — rests at authorized until you call `POST /payments/{payment_id}/capture`.","enum":["auto","manual"],"examples":["manual"],"type":"string"},"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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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,1008,2001,2002,2005,2006,2003,2004,3001,3002,3003,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"},"legacyStatus":{"deprecated":true,"description":"Deprecated: the previous `status` value (e.g. provider_completed, settled). Kept for one notice period after `status` switches to the lifecycle enum; read `lifecycleStatus` instead.","enum":["draft","new","routed","compliance_review","provider_pending","authorized","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refunded","refund_failed","refund_cancelled","failed","cancelled"],"examples":["settled"],"type":"string"},"lifecycleStatus":{"description":"The payment's lifecycle status — the same value on every endpoint and in the PaymentCompleted webhook. **pending** — created, routing, or waiting on the provider or the customer (3-D Secure, a redirect); nothing is reserved yet. **under_review** — held by a compliance or risk review before authorization. **authorized** — funds reserved, waiting for capture (captureMode manual). **captured** — money was taken; stays captured through settlement, partial refunds, and a refund in flight or failed. **refunded** — the whole captured amount was refunded. **failed** — declined or errored; no money was taken (see failureCode). **cancelled** — called off before money was taken, including an expired checkout.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled"],"examples":["captured"],"type":"string"},"paymentId":{"examples":["ORDER-100106"],"type":"string"},"paymentMethod":{"examples":["card"],"type":"string"},"paymentType":{"examples":["card"],"type":"string"},"promotion":{"$ref":"#/components/schemas/PaymentPromotion","description":"The order's promotion breakdown: what a coupon and wallet credit took off it, who funded each part, and where the Fynex-funded part owed to you stands. Present only when the payment carried a promotion."},"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"},"settlementStatus":{"description":"How far captured money has travelled. Absent until money is captured. **pending** — captured, not yet picked up for settlement; **in_flight** — moving through settlement; **settled** — received into the operational account; **deposit_confirmed** — confirmed at the safeguarding layer.","enum":["pending","in_flight","settled","deposit_confirmed"],"examples":["settled"],"type":"string"},"status":{"description":"The payment's status. Until the switch announced in the changelog it carries the previous values (the ones `legacyStatus` carries, e.g. provider_completed); after it, the lifecycle value `lifecycleStatus` carries. Read `lifecycleStatus` now to be unaffected by the switch.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled","draft","new","routed","compliance_review","provider_pending","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refund_failed","refund_cancelled"],"examples":["settled"],"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","captureMode","allowedActions"],"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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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,1008,2001,2002,2005,2006,2003,2004,3001,3002,3003,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"},"legacyStatus":{"deprecated":true,"description":"Deprecated: the previous `status` value (e.g. provider_completed, settled). Kept for one notice period after `status` switches to the lifecycle enum; read `lifecycleStatus` instead.","enum":["draft","new","routed","compliance_review","provider_pending","authorized","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refunded","refund_failed","refund_cancelled","failed","cancelled"],"examples":["provider_pending"],"type":"string"},"lifecycleStatus":{"description":"The payment's lifecycle status — the same value on every endpoint and in the PaymentCompleted webhook. **pending** — created, routing, or waiting on the provider or the customer (3-D Secure, a redirect); nothing is reserved yet. **under_review** — held by a compliance or risk review before authorization. **authorized** — funds reserved, waiting for capture (captureMode manual). **captured** — money was taken; stays captured through settlement, partial refunds, and a refund in flight or failed. **refunded** — the whole captured amount was refunded. **failed** — declined or errored; no money was taken (see failureCode). **cancelled** — called off before money was taken, including an expired checkout.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled"],"examples":["pending"],"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":{"description":"The payment's status. Until the switch announced in the changelog it carries the previous values (the ones `legacyStatus` carries, e.g. provider_completed); after it, the lifecycle value `lifecycleStatus` carries. Read `lifecycleStatus` now to be unaffected by the switch.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled","draft","new","routed","compliance_review","provider_pending","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refund_failed","refund_cancelled"],"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"},"ListPaymentsResponse":{"additionalProperties":false,"properties":{"hasMore":{"description":"More payments are available; call again with nextCursor.","type":"boolean"},"nextCursor":{"description":"Pass back as cursor to fetch the next page. Present only when hasMore.","examples":[20481],"format":"int64","type":"integer"},"payments":{"items":{"$ref":"#/components/schemas/PaymentSummary"},"type":["array","null"]}},"required":["payments","hasMore"],"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":{"description":"Per-payment input to a weight-mode split rule: it takes effect only when the seller's active split rule is in weight mode, where these shares decide each payee's cut and the rule adds its fee policy on top. Ignored when the active rule is in absolute mode — the rule's own lines decide every share. With no active split rule nothing is split and the payment settles unsplit. Give every share in amountMinor (integer minor units, preferred) or every share in the deprecated amount (MAJOR units); the shares must add up to the payment amount. See Per-payment inline splits in the Splits guide: /payments-api/v2/docs/splits.md","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":{"deprecated":true,"description":"Deprecated: send amountMinor instead. Still accepted. This payee's share of the payment, in MAJOR units (49.99 is £49.99) like the payment amount — unlike amountMinor, split rules, previews and executions, which are integer minor units. Every item sets exactly one of amountMinor or amount, and all items in one distribution use the same one.","examples":[49.99],"format":"double","type":"number"},"amountMinor":{"description":"This payee's share of the payment, in integer minor units of the payment currency (4999 is £49.99) — the same unit as split rules, previews and executions. Preferred over the deprecated amount. Every item sets exactly one of amountMinor or amount, and all items in one distribution use the same one. Must be greater than zero.","examples":[4999],"format":"int64","type":"integer"},"payeeId":{"examples":[101],"format":"int64","type":"integer"}},"required":["payeeId"],"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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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,1008,2001,2002,2005,2006,2003,2004,3001,3002,3003,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"},"legacyStatus":{"deprecated":true,"description":"Deprecated: the previous `status` value (e.g. provider_completed, settled). Kept for one notice period after `status` switches to the lifecycle enum; read `lifecycleStatus` instead.","enum":["draft","new","routed","compliance_review","provider_pending","authorized","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refunded","refund_failed","refund_cancelled","failed","cancelled"],"examples":["provider_completed"],"type":"string"},"lifecycleStatus":{"description":"The payment's lifecycle status — the same value on every endpoint and in the PaymentCompleted webhook. **pending** — created, routing, or waiting on the provider or the customer (3-D Secure, a redirect); nothing is reserved yet. **under_review** — held by a compliance or risk review before authorization. **authorized** — funds reserved, waiting for capture (captureMode manual). **captured** — money was taken; stays captured through settlement, partial refunds, and a refund in flight or failed. **refunded** — the whole captured amount was refunded. **failed** — declined or errored; no money was taken (see failureCode). **cancelled** — called off before money was taken, including an expired checkout.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled"],"examples":["captured"],"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":{"description":"The payment's status. Until the switch announced in the changelog it carries the previous values (the ones `legacyStatus` carries, e.g. provider_completed); after it, the lifecycle value `lifecycleStatus` carries. Read `lifecycleStatus` now to be unaffected by the switch.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled","draft","new","routed","compliance_review","provider_pending","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refund_failed","refund_cancelled"],"examples":["provider_completed"],"type":"string"}},"required":["paymentId","status","amount","currencyCode"],"type":"object"},"PaymentCompletedWebhookPayload":{"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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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,1008,2001,2002,2005,2006,2003,2004,3001,3002,3003,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"},"legacyStatus":{"deprecated":true,"description":"Deprecated: the previous `status` value (e.g. provider_completed, settled). Kept for one notice period after `status` switches to the lifecycle enum; read `lifecycleStatus` instead.","enum":["draft","new","routed","compliance_review","provider_pending","authorized","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refunded","refund_failed","refund_cancelled","failed","cancelled"],"examples":["provider_completed"],"type":"string"},"lifecycleStatus":{"description":"The payment's lifecycle status — the same value on every endpoint and in the PaymentCompleted webhook. **pending** — created, routing, or waiting on the provider or the customer (3-D Secure, a redirect); nothing is reserved yet. **under_review** — held by a compliance or risk review before authorization. **authorized** — funds reserved, waiting for capture (captureMode manual). **captured** — money was taken; stays captured through settlement, partial refunds, and a refund in flight or failed. **refunded** — the whole captured amount was refunded. **failed** — declined or errored; no money was taken (see failureCode). **cancelled** — called off before money was taken, including an expired checkout.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled"],"examples":["captured"],"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"},"promotion":{"$ref":"#/components/schemas/PaymentPromotion","description":"The order's promotion breakdown, the same object GET /payments/{payment_id} returns: what a coupon and wallet credit took off it, who funded each part, and where the Fynex-funded part owed to you stands. Present only when the payment carried a promotion."},"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 payment's status. Until the switch announced in the changelog it carries the previous values (the ones `legacyStatus` carries, e.g. provider_completed); after it, the lifecycle value `lifecycleStatus` carries. Read `lifecycleStatus` now to be unaffected by the switch.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled","draft","new","routed","compliance_review","provider_pending","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refund_failed","refund_cancelled"],"examples":["provider_completed"],"type":"string"}},"required":["status","lifecycleStatus","legacyStatus","genericPaymentId","paymentId","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"},"PaymentLinkSubscriptionChargedPayload":{"additionalProperties":false,"properties":{"amountMinor":{"description":"What this term was charged before tax, in integer minor units: grossAmountMinor less discountMinor. 0 when the coupon paid the term in full.","examples":[3999],"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"]},"coupon":{"$ref":"#/components/schemas/SubscriptionChargedCoupon","description":"The coupon that discounted this term, or null."},"currency":{"description":"ISO 4217 currency of the amounts.","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"},"discountMinor":{"description":"What the subscription's coupon took off this term, in integer minor units; 0 without one.","examples":[1000],"format":"int64","type":"integer"},"grossAmountMinor":{"description":"The term's price before the coupon, in integer minor units.","examples":[4999],"format":"int64","type":"integer"},"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","grossAmountMinor","discountMinor","coupon"],"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"},"PaymentPromotion":{"additionalProperties":false,"properties":{"campaignId":{"description":"The promotion campaign behind the coupon or credit; null when none.","type":["string","null"]},"campaignName":{"description":"The campaign's name; null when none.","examples":["Spring sale"],"type":["string","null"]},"cancelled":{"description":"True when the order was cancelled and its promotions given back (a promotional-order cancellation, or a payment that ended without cash).","type":"boolean"},"cashMinor":{"description":"What the buyer paid in cash, in integer minor units; 0 for an order promotions paid in full.","examples":[3500],"format":"int64","type":"integer"},"code":{"description":"The promotion code the buyer used; null when the order used none.","examples":["SPRING10"],"type":["string","null"]},"couponDiscountMinor":{"description":"What the coupon took off the order, in integer minor units.","examples":[1000],"format":"int64","type":"integer"},"couponFundingParty":{"description":"Who funds the coupon discount: seller (your own campaign) or platform (Fynex). Null when the order carried no coupon.","enum":["seller","platform"],"examples":["platform"],"type":["string","null"]},"creditsMinor":{"description":"Wallet credit the buyer spent, platform and seller funded together, in integer minor units.","examples":[500],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of every amount above.","examples":["GBP"],"type":"string"},"originalAmountMinor":{"description":"The order total before the coupon and credits, in integer minor units: cashMinor + creditsMinor + couponDiscountMinor.","examples":[5000],"format":"int64","type":"integer"},"platformCreditsMinor":{"description":"The platform-funded part of creditsMinor, in integer minor units.","examples":[500],"format":"int64","type":"integer"},"platformFundedMinor":{"description":"What Fynex funded (a platform coupon and platform credit), owed to you on top of the cash, in integer minor units.","examples":[1500],"format":"int64","type":"integer"},"reservationId":{"description":"The checkout reservation that priced the promotion.","type":"string"},"sellerCreditsMinor":{"description":"The seller-funded part of creditsMinor, in integer minor units.","examples":[0],"format":"int64","type":"integer"},"sellerEntitlementMinor":{"description":"What you are owed for the order before fees, in integer minor units: cashMinor + platformFundedMinor.","examples":[5000],"format":"int64","type":"integer"},"subsidyStatus":{"description":"Where platformFundedMinor stands: not_applicable (nothing platform funded), owed, held_for_review, paid (credited to your wallet) or reversed (will not be paid: the order was refunded or cancelled, or a review rejected it).","enum":["not_applicable","owed","held_for_review","paid","reversed"],"examples":["owed"],"type":"string"}},"required":["originalAmountMinor","couponDiscountMinor","creditsMinor","platformCreditsMinor","sellerCreditsMinor","cashMinor","currency","couponFundingParty","platformFundedMinor","sellerEntitlementMinor","subsidyStatus","campaignId","campaignName","code","reservationId","cancelled"],"type":"object"},"PaymentPromotionCancelledPayload":{"additionalProperties":false,"properties":{"campaignId":{"description":"The campaign of the coupon applied, or null when only credit paid.","examples":["6f739c44-b75f-4f30-bf6f-1dc67cae08ef"],"type":["string","null"]},"cancelledAt":{"description":"When the order was cancelled, RFC 3339 UTC.","format":"date-time","type":"string"},"code":{"description":"The coupon code applied, or null when only credit paid.","examples":["SPRING25"],"type":["string","null"]},"couponDiscountMinor":{"description":"The coupon discount the order had, in integer minor units; the coupon is given back to the buyer when its terms allow.","examples":[1000],"format":"int64","type":"integer"},"creditsMinor":{"description":"Promotional credit the order spent, in integer minor units; restored to the buyer's wallet.","examples":[3999],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of every amount.","examples":["EUR"],"type":"string"},"externalOrderRef":{"description":"Your order reference for the payment.","examples":["ORDER-100106"],"type":"string"},"paymentId":{"description":"Fynex's own payment id for the order.","examples":[88214],"format":"int64","type":"integer"},"reason":{"description":"Why the order was cancelled: your own reason when you cancelled it, or \"cancelled by Fynex\" when Fynex support did.","examples":["customer changed their mind"],"type":"string"}},"required":["paymentId","externalOrderRef","couponDiscountMinor","creditsMinor","currency","campaignId","code","cancelledAt","reason"],"type":"object"},"PaymentPromotionCancelledWebhookEvent":{"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 PaymentPromotionCancelled.","enum":["PaymentPromotionCancelled"],"examples":["PaymentPromotionCancelled"],"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/PaymentPromotionCancelledPayload"},"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"},"PaymentPromotionCompletedPayload":{"additionalProperties":false,"properties":{"campaignId":{"description":"The campaign of the coupon applied, or null when only credit paid.","examples":["6f739c44-b75f-4f30-bf6f-1dc67cae08ef"],"type":["string","null"]},"couponDiscountMinor":{"description":"Discount the coupon took off, in integer minor units.","examples":[1000],"format":"int64","type":"integer"},"couponFundingParty":{"description":"Who pays the coupon discount (seller or platform), or null without a coupon.","examples":["seller"],"type":["string","null"]},"creditsMinor":{"description":"Promotional credit the buyer spent, in integer minor units; equals totalMinor, since the buyer paid nothing.","examples":[3999],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of every amount.","examples":["EUR"],"type":"string"},"fundedAt":{"description":"When the order was funded, RFC 3339 UTC.","format":"date-time","type":"string"},"genericPaymentId":{"description":"Fynex's own payment id for the order.","examples":[88214],"format":"int64","type":"integer"},"promotionFundedMinor":{"description":"The value promotion delivered: the coupon discount plus the credit, in integer minor units.","examples":[4999],"format":"int64","type":"integer"},"reservationId":{"description":"The promotion reservation that priced the order.","examples":["0b7f4c1e-8d2a-4c55-9a51-3f0f6f2a9c10"],"type":"string"},"sellerEntitlementMinor":{"description":"What you are owed for the order before fees: the platform-funded part, which Fynex pays into your balance, in integer minor units.","examples":[3999],"format":"int64","type":"integer"},"subtotalMinor":{"description":"The order value before the coupon, tax excluded, in integer minor units.","examples":[4999],"format":"int64","type":"integer"},"taxMinor":{"description":"Tax on the discounted value, in integer minor units.","examples":[0],"format":"int64","type":"integer"},"totalMinor":{"description":"What was due after the coupon, tax included, in integer minor units; promotional credit paid all of it.","examples":[3999],"format":"int64","type":"integer"}},"required":["genericPaymentId","reservationId","campaignId","currency","subtotalMinor","couponDiscountMinor","couponFundingParty","taxMinor","totalMinor","creditsMinor","promotionFundedMinor","sellerEntitlementMinor","fundedAt"],"type":"object"},"PaymentPromotionCompletedWebhookEvent":{"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 PaymentPromotionCompleted.","enum":["PaymentPromotionCompleted"],"examples":["PaymentPromotionCompleted"],"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/PaymentPromotionCompletedPayload"},"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"},"PaymentSummary":{"additionalProperties":false,"properties":{"amount":{"description":"The payment amount, in MAJOR units (49.99 is £49.99), paired with currencyCode — as on GET /payments/{payment_id}.","examples":[49.99],"format":"double","type":"number"},"captureMode":{"examples":["auto"],"type":"string"},"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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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,1008,2001,2002,2005,2006,2003,2004,3001,3002,3003,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"},"legacyStatus":{"deprecated":true,"description":"Deprecated: the previous `status` value (e.g. provider_completed, settled). Kept for one notice period after `status` switches to the lifecycle enum; read `lifecycleStatus` instead.","enum":["draft","new","routed","compliance_review","provider_pending","authorized","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refunded","refund_failed","refund_cancelled","failed","cancelled"],"examples":["settled"],"type":"string"},"lifecycleStatus":{"description":"The payment's lifecycle status — the same value on every endpoint and in the PaymentCompleted webhook. **pending** — created, routing, or waiting on the provider or the customer (3-D Secure, a redirect); nothing is reserved yet. **under_review** — held by a compliance or risk review before authorization. **authorized** — funds reserved, waiting for capture (captureMode manual). **captured** — money was taken; stays captured through settlement, partial refunds, and a refund in flight or failed. **refunded** — the whole captured amount was refunded. **failed** — declined or errored; no money was taken (see failureCode). **cancelled** — called off before money was taken, including an expired checkout.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled"],"examples":["captured"],"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":{"description":"The payment's status. Until the switch announced in the changelog it carries the previous values (the ones `legacyStatus` carries, e.g. provider_completed); after it, the lifecycle value `lifecycleStatus` carries. Read `lifecycleStatus` now to be unaffected by the switch.","enum":["pending","under_review","authorized","captured","refunded","failed","cancelled","draft","new","routed","compliance_review","provider_pending","provider_completed","provider_risk_review","funds_in_flight","settled","deposit_confirmed","refund_pending","refund_failed","refund_cancelled"],"examples":["captured"],"type":"string"},"updatedAt":{"examples":["2026-05-11T12:35:10Z"],"format":"date-time","type":"string"}},"required":["paymentId","externalOrderRef","status","amount","currencyCode","countryCode","paymentType","paymentMethod","captureMode","createdAt","updatedAt"],"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/PaymentCompletedWebhookPayload"},"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"},"description":{"description":"Your own note for a payout from your main wallet (its bank statement text is set by Fynex); for a payout from a payee cashout wallet, the text sent to the beneficiary's bank. Omitted when none.","examples":["Invoice 2026-091 September commission"],"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":"Seller-safe explanation of failureCode. Present on a failed payout and on a cancelled one; absent otherwise.","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"},"referenceNumber":{"description":"The payout number. A payout from your main wallet carries its settlement report number on the beneficiary's bank statement instead, or this number when no agreement number is on file. Omitted for payouts created before numbering.","examples":["PO-000100"],"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."},"PayoutWebhookEvent":{"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 terminal transition fired.","enum":["PayoutCompleted","PayoutFailed"],"examples":["PayoutCompleted"],"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/PayoutWebhookPayload"},"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"},"PayoutWebhookPayload":{"additionalProperties":false,"properties":{"amount":{"description":"The same amount in MAJOR units at the currency's scale.","examples":["12.50"],"type":"string"},"amountMinor":{"description":"Payout amount in the currency's minor units.","examples":[1250],"format":"int64","type":"integer"},"currencyCode":{"description":"ISO 4217 currency of the amounts.","examples":["GBP"],"type":"string"},"description":{"description":"The remittance text sent to the beneficiary's bank. Absent for a payout created without one.","examples":["Invoice 2026-091 September commission"],"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":["payout_method_inactive"],"type":"string"},"failureMessage":{"description":"Seller-safe explanation of failureCode. PayoutFailed only.","examples":["This payout method is not currently available. Please contact support."],"type":"string"},"payoutId":{"description":"The payout's id, as returned by POST /payouts and read by GET /payouts/{id}.","examples":[9001],"format":"int64","type":"integer"},"payoutMethodId":{"description":"The payout method the payout was sent to.","examples":[501],"format":"int64","type":"integer"},"providerReference":{"description":"Upstream provider's reference for this payout, when it has one. Opaque — match, do not parse.","examples":["ref_9f2c1a4b"],"type":"string"},"status":{"description":"The terminal status the payout reached.","enum":["completed","failed"],"examples":["completed"],"type":"string"},"walletId":{"description":"The wallet the payout was paid from.","examples":[15],"format":"int64","type":"integer"}},"required":["payoutId","status","walletId","payoutMethodId","amountMinor","amount","currencyCode"],"type":"object"},"PromoCodeRedeemedPayload":{"additionalProperties":false,"properties":{"campaignId":{"description":"The promotion campaign the code belongs to, or null for a standalone promo code.","examples":["6f739c44-b75f-4f30-bf6f-1dc67cae08ef"],"type":["string","null"]},"code":{"description":"The code's text, as the buyer entered or held it.","examples":["SPRING25"],"type":"string"},"creditsMinor":{"description":"Promotional credit the buyer spent on the same order, in integer minor units; 0 when none.","examples":[0],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of discountMinor and creditsMinor, or null when the redemption was not priced in one (a subscription promo).","examples":["EUR"],"type":["string","null"]},"discountMinor":{"description":"The discount it granted, in integer minor units.","examples":[500],"format":"int64","type":"integer"},"fundingParty":{"description":"Who pays the discount: seller (deducted from your proceeds) or platform (paid to you by Fynex).","enum":["seller","platform"],"examples":["seller"],"type":"string"},"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","code","campaignId","fundingParty","currency","creditsMinor"],"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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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,1008,2001,2002,2005,2006,2003,2004,3001,3002,3003,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":["16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117"],"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"},"SplitRuleActivateRequest":{"additionalProperties":false,"properties":{"confirmed":{"description":"Must be true. Anything else is refused with 400: holding a receipt is not on its own a decision to activate.","examples":[true],"type":"boolean"},"previewReceipt":{"description":"The activationReceipt from POST /split-rules/{id}/preview-activation on this same rule. A missing, expired or foreign receipt is refused with 409.","type":"string"}},"required":["previewReceipt","confirmed"],"type":"object"},"SplitRuleActivateResponse":{"additionalProperties":false,"properties":{"activated":{"$ref":"#/components/schemas/SplitRuleResponse","description":"The rule, now active."},"deactivated":{"description":"Every rule this activation stood down, in the same transaction. Empty, never null, when no rule was active.","items":{"$ref":"#/components/schemas/SplitRuleResponse"},"type":"array"}},"required":["activated","deactivated"],"type":"object"},"SplitRuleCalculateAllocation":{"additionalProperties":false,"properties":{"feeMinor":{"description":"The fee withheld from the share, in minor units.","examples":[600],"format":"int64","type":"integer"},"grossShareMinor":{"description":"The payee's share before fees, in minor units.","examples":[60000],"format":"int64","type":"integer"},"netAmountMinor":{"description":"What the payee receives: grossShareMinor minus feeMinor, in minor units.","examples":[59400],"format":"int64","type":"integer"},"payeeExternalId":{"description":"The payee's externalId (payeeContractId), when it has one.","examples":["contractor-42"],"type":"string"},"payeeId":{"description":"The payee.","examples":[101],"format":"int64","type":"integer"}},"required":["payeeId","grossShareMinor","feeMinor","netAmountMinor"],"type":"object"},"SplitRuleCalculateDistributionItem":{"additionalProperties":false,"properties":{"amount":{"description":"This payee's weight, as an integer in minor units (60000 = 600.00): the same unit as amountMinor, and unlike orderData.payeeDistribution[].amount on a payment, which is in major units. Weight mode splits amountMinor in proportion to these values, so amounts that sum to amountMinor come back unchanged.","examples":[60000],"format":"int64","type":"integer"},"payeeRef":{"$ref":"#/components/schemas/SplitRulePayeeRef","description":"The payee. A reference that is empty, names both id and externalId, or resolves to no payee of yours is refused with 422."}},"required":["payeeRef","amount"],"type":"object"},"SplitRuleCalculateRequest":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The hypothetical payment amount to split, in minor units (100000 = 1000.00). Must be greater than 0.","examples":[100000],"format":"int64","type":"integer"},"currencyCode":{"description":"Informational: the calculation does not read it.","examples":["EUR"],"type":"string"},"distribution":{"description":"The per-payment input to a weight-mode rule, as orderData.payeeDistribution would carry it on a real payment. Required, with at least one item, when the rule is in weight mode. An absolute-mode rule does not use the amounts, but the payee references are still checked.","items":{"$ref":"#/components/schemas/SplitRuleCalculateDistributionItem"},"type":["array","null"]},"rule":{"$ref":"#/components/schemas/SplitRuleCreateRequest","description":"A rule to calculate with instead of your active one, for a dry run before saving it. Nothing is stored, and its status and effective window are not read."}},"required":["amountMinor"],"type":"object"},"SplitRuleCalculateResponse":{"additionalProperties":false,"properties":{"allocationMode":{"description":"weight or absolute: the mode of the rule used. Absent when wouldFailReason is set.","examples":["weight"],"type":"string"},"allocations":{"description":"One entry per payee share. null when wouldFailReason is set.","items":{"$ref":"#/components/schemas/SplitRuleCalculateAllocation"},"type":["array","null"]},"overAllocated":{"description":"True when an absolute-mode rule's lines claimed more than the amount and overAllocationPolicy cut them back.","type":"boolean"},"remainderMinor":{"description":"What no line allocates, in minor units. It goes to your main wallet, or to the rule's remainderWalletId when remainderPolicy is to_remainder_wallet. Always present, 0 included; a weight-mode rule always reports 0.","examples":[2000],"format":"int64","type":"integer"},"remainderPolicy":{"description":"to_main_wallet or to_remainder_wallet: where remainderMinor goes. Absent when wouldFailReason is set.","examples":["to_main_wallet"],"type":"string"},"ruleId":{"description":"The active rule the calculation used. Absent when an inline rule was sent.","format":"int64","type":"integer"},"ruleVersion":{"description":"Version of the active rule used; 0 for an inline rule.","examples":[4],"format":"int64","type":"integer"},"totalAmountMinor":{"description":"The amountMinor you sent, in minor units.","examples":[100000],"format":"int64","type":"integer"},"totalFeeMinor":{"description":"Sum of the fees withheld from the payees' shares, in minor units.","examples":[1000],"format":"int64","type":"integer"},"wouldFailReason":{"description":"Present when the split would fail, saying why. The fee, remainder and allocation fields are then empty; ruleId, ruleVersion and totalAmountMinor are still set. The request itself answers 200.","type":"string"}},"required":["ruleVersion","totalAmountMinor","totalFeeMinor","remainderMinor","allocations"],"type":"object"},"SplitRuleCreateRequest":{"additionalProperties":false,"properties":{"allocationMode":{"description":"Where the shares come from. weight (the default): the payment's orderData.payeeDistribution, split in proportion. absolute: this rule's lines, which then need an allocation amount each; the payment's distribution is ignored.","enum":["weight","absolute"],"examples":["weight"],"type":"string"},"amountBase":{"description":"The amount the rule splits. net_settled (the default) is the only value accepted; gross_payment is refused with 400.","enum":["net_settled"],"examples":["net_settled"],"type":"string"},"effectiveFrom":{"description":"Start of the window in which the rule applies once active, RFC 3339. Activation is still immediate: the rule it replaces stops at once, and until this moment no rule applies.","examples":["2026-05-01T00:00:00Z"],"format":"date-time","type":"string"},"effectiveTo":{"description":"End of the window, RFC 3339. Absent means open-ended.","format":"date-time","type":"string"},"fixedAmountMinor":{"description":"Rule-level flat fee on each payee's share, in minor units (1500 = 15.00). Read only when type is fixed.","format":"int64","type":"integer"},"lines":{"description":"One line per payee; a payee may appear once.","items":{"$ref":"#/components/schemas/SplitRuleLineInput"},"type":["array","null"]},"overAllocationPolicy":{"description":"What an absolute-mode rule does when its lines claim more than the amount. fail (the default) fails the split; scale_down_percent needs at least one line with allocationPercentBps.","enum":["fail","cap_by_priority","scale_down_percent","scale_down_all"],"examples":["fail"],"type":"string"},"percentBps":{"description":"Rule-level fee on each payee's share, in basis points (100 = 1%). Read only when type is fixed.","format":"int64","type":"integer"},"remainderPolicy":{"description":"Where an absolute-mode rule's remainder (what no line allocates) goes. to_main_wallet is the default; to_remainder_wallet needs remainderWalletId.","enum":["to_main_wallet","to_remainder_wallet"],"examples":["to_main_wallet"],"type":"string"},"remainderWalletId":{"description":"The wallet that receives the remainder. Required when remainderPolicy is to_remainder_wallet.","format":"int64","type":"integer"},"status":{"deprecated":true,"description":"Omit it. Every rule is created inactive; any other value is refused with 400. Activation goes through preview-activation and activate.","enum":["inactive"],"examples":["inactive"],"type":"string"},"type":{"description":"fixed (the default): one fee, from percentBps and fixedAmountMinor, is withheld from every payee's share; a fixed rule needs at least one of the two. custom: each payee's fee comes from its own line. An absolute-mode rule must be custom.","enum":["fixed","custom"],"examples":["custom"],"type":"string"}},"type":"object"},"SplitRuleLineInput":{"additionalProperties":false,"properties":{"allocationFixedAmountMinor":{"description":"This payee's flat share in an absolute-mode rule, in minor units, added to the allocationPercentBps share when both are set. Must not be negative. Ignored in weight mode.","format":"int64","type":"integer"},"allocationPercentBps":{"description":"This payee's share of the amount in an absolute-mode rule, in basis points, 0 to 10000 (5000 = 50%). Ignored in weight mode.","examples":[5000],"format":"int64","type":"integer"},"allocationType":{"description":"Label for the line. Descriptive only: the split is computed from the amount fields, not from this value.","enum":["percent_bps","fixed_minor","mixed"],"examples":["percent_bps"],"type":"string"},"fixedAmountMinor":{"description":"Flat fee withheld from this payee's share, in minor units (1500 = 15.00). Read when the rule type is custom.","format":"int64","type":"integer"},"isEnabled":{"description":"Defaults to true. A disabled line stays on the rule but produces no share.","type":"boolean"},"maxAmountMinor":{"description":"Ceiling on this payee's share in an absolute-mode rule, in minor units.","format":"int64","type":"integer"},"minAmountMinor":{"description":"Floor on this payee's share in an absolute-mode rule, in minor units.","format":"int64","type":"integer"},"payeeRef":{"$ref":"#/components/schemas/SplitRulePayeeRef","description":"The payee this line pays. A reference that is empty, names both id and externalId, or resolves to no payee of yours is refused with 422."},"percentBps":{"description":"Fee withheld from this payee's share, in basis points (100 = 1%). Read when the rule type is custom.","examples":[500],"format":"int64","type":"integer"},"priority":{"description":"Serving order under cap_by_priority: lower numbers are paid first. Defaults to 100.","examples":[100],"format":"int64","type":"integer"}},"required":["payeeRef","allocationType"],"type":"object"},"SplitRuleLineResponse":{"additionalProperties":false,"properties":{"allocationFixedAmountMinor":{"description":"This payee's flat share in an absolute-mode rule, in minor units, added to the allocationPercentBps share when both are set. Ignored in weight mode.","examples":[3000],"format":"int64","type":"integer"},"allocationPercentBps":{"description":"This payee's share of the amount in an absolute-mode rule, in basis points (5000 = 50%). Ignored in weight mode.","examples":[5000],"format":"int64","type":"integer"},"allocationType":{"description":"Label for the line: percent_bps, fixed_minor or mixed. Descriptive only: the split is computed from the amount fields.","examples":["percent_bps"],"type":"string"},"createdAt":{"description":"RFC 3339, UTC.","examples":["2026-04-07T10:00:00Z"],"format":"date-time","type":"string"},"fixedAmountMinor":{"description":"Flat fee withheld from this payee's share, in minor units (1500 = 15.00). Read when the rule type is custom.","examples":[1500],"format":"int64","type":"integer"},"id":{"description":"Line id.","examples":[7],"format":"int64","type":"integer"},"isEnabled":{"description":"A disabled line stays on the rule but produces no share.","examples":[true],"type":"boolean"},"maxAmountMinor":{"description":"Ceiling on this payee's share in an absolute-mode rule, in minor units.","format":"int64","type":"integer"},"minAmountMinor":{"description":"Floor on this payee's share in an absolute-mode rule, in minor units.","format":"int64","type":"integer"},"payeeExternalId":{"description":"The payee's externalId (payeeContractId), when it has one.","examples":["contractor-42"],"type":"string"},"payeeId":{"description":"The payee this line addresses.","examples":[101],"format":"int64","type":"integer"},"percentBps":{"description":"Fee withheld from this payee's share, in basis points (100 = 1%). Read when the rule type is custom.","examples":[500],"format":"int64","type":"integer"},"priority":{"description":"Serving order under the cap_by_priority policy: lower numbers are paid first, ties go to the lower payee id.","examples":[100],"format":"int64","type":"integer"},"updatedAt":{"description":"RFC 3339, UTC.","examples":["2026-04-07T10:00:00Z"],"format":"date-time","type":"string"}},"required":["id","payeeId","allocationType","priority","isEnabled","createdAt","updatedAt"],"type":"object"},"SplitRuleListResponse":{"additionalProperties":false,"properties":{"limit":{"description":"The page size applied.","examples":[20],"format":"int64","type":"integer"},"offset":{"description":"The offset applied.","examples":[0],"format":"int64","type":"integer"},"splitRules":{"description":"Your rules, active and inactive, newest first by creation time. Deleted rules are not listed.","items":{"$ref":"#/components/schemas/SplitRuleResponse"},"type":"array"},"total":{"description":"Number of rules across all pages.","examples":[3],"format":"int64","type":"integer"}},"required":["splitRules","total","limit","offset"],"type":"object"},"SplitRulePayeeRef":{"additionalProperties":false,"properties":{"externalId":{"description":"Your own reference for the payee (its payeeContractId). Send exactly one of id and externalId.","examples":["contractor-42"],"type":"string"},"id":{"description":"The payee's Fynex id, from GET /payees. Send exactly one of id and externalId.","examples":[101],"format":"int64","type":"integer"}},"type":"object"},"SplitRulePreviewActivationResponse":{"additionalProperties":false,"properties":{"activationReceipt":{"description":"Send this as previewReceipt to POST /split-rules/{id}/activate. Bound to your seller account, this rule, its version and a fingerprint of its content: any change to the rule invalidates it.","type":"string"},"activationReceiptExpiresAt":{"description":"When the receipt stops being accepted, RFC 3339 UTC.","examples":["2026-04-07T10:10:00Z"],"format":"date-time","type":"string"},"splitRule":{"$ref":"#/components/schemas/SplitRuleResponse","description":"The saved rule exactly as the receipt binds it."}},"required":["splitRule","activationReceipt","activationReceiptExpiresAt"],"type":"object"},"SplitRuleResponse":{"additionalProperties":false,"properties":{"allocationMode":{"description":"Where the shares come from. weight: the payment's orderData.payeeDistribution, split in proportion. absolute: the rule's own lines (allocationPercentBps, allocationFixedAmountMinor), and the payment's distribution is ignored.","examples":["weight"],"type":"string"},"amountBase":{"description":"The amount the rule splits. net_settled, the settled amount, is the only value supported.","examples":["net_settled"],"type":"string"},"createdAt":{"description":"RFC 3339, UTC.","examples":["2026-04-07T10:00:00Z"],"format":"date-time","type":"string"},"effectiveFrom":{"description":"Start of the window in which the rule applies, RFC 3339 UTC. Absent means no start bound.","examples":["2026-05-01T00:00:00Z"],"format":"date-time","type":"string"},"effectiveTo":{"description":"End of the window, RFC 3339 UTC. Absent means open-ended. Deactivating an active rule sets it to the moment the rule stopped.","format":"date-time","type":"string"},"fixedAmountMinor":{"description":"Rule-level flat fee on each payee's share, in minor units (1500 = 15.00). Read only when type is fixed.","format":"int64","type":"integer"},"id":{"description":"Split rule id, used in the /split-rules/{id} paths.","examples":[42],"format":"int64","type":"integer"},"lines":{"description":"The rule's lines, one per payee. Empty, never null, for a rule without lines.","items":{"$ref":"#/components/schemas/SplitRuleLineResponse"},"type":"array"},"merchantId":{"description":"Your seller account id: the owner of the rule.","examples":["16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117"],"type":"string"},"overAllocationPolicy":{"description":"What an absolute-mode rule does when its lines claim more than the amount: fail (the split fails), cap_by_priority, scale_down_percent (only the percent allocations shrink) or scale_down_all.","examples":["fail"],"type":"string"},"percentBps":{"description":"Rule-level fee on each payee's share, in basis points (100 = 1%). Read only when type is fixed.","examples":[100],"format":"int64","type":"integer"},"remainderPolicy":{"description":"Where an absolute-mode rule's remainder (what no line allocates) goes: to_main_wallet, or to_remainder_wallet for the wallet in remainderWalletId. A weight-mode rule leaves no remainder.","examples":["to_main_wallet"],"type":"string"},"remainderWalletId":{"description":"The wallet that receives the remainder when remainderPolicy is to_remainder_wallet.","format":"int64","type":"integer"},"status":{"description":"active or inactive. A seller has at most one active rule: activation makes this rule the active one and stands every other active rule down.","examples":["active"],"type":"string"},"type":{"description":"fixed: one fee, from the rule's own percentBps and fixedAmountMinor, is withheld from every payee's share. custom: each payee's fee comes from its own enabled line, and a share for a payee with no enabled line fails the split.","examples":["custom"],"type":"string"},"updatedAt":{"description":"RFC 3339, UTC.","examples":["2026-04-07T10:00:00Z"],"format":"date-time","type":"string"},"version":{"description":"Increments on every change to the rule. A preview receipt is bound to the version it previewed.","examples":[4],"format":"int64","type":"integer"}},"required":["id","merchantId","type","status","amountBase","allocationMode","remainderPolicy","overAllocationPolicy","version","createdAt","updatedAt","lines"],"type":"object"},"SplitWebhookEvent":{"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 SplitExecuted.","enum":["SplitExecuted"],"examples":["SplitExecuted"],"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/SplitWebhookPayload"},"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"},"SplitWebhookPayload":{"additionalProperties":false,"properties":{"currencyCode":{"description":"ISO 4217 currency of the amounts.","examples":["GBP"],"type":"string"},"grossAmountMinor":{"description":"The amount that was split, integer minor units.","examples":[10000],"format":"int64","type":"integer"},"paymentId":{"description":"The split payment's externalOrderRef.","examples":["ORDER-100106"],"type":"string"},"postedAt":{"description":"When the split's transfers were posted.","examples":["2026-10-20T12:00:00Z"],"format":"date-time","type":"string"},"remainderMinor":{"description":"What the shares did not cover, integer minor units, routed per the rule's remainderPolicy.","examples":[1200],"format":"int64","type":"integer"},"ruleVersion":{"description":"The rule version that ran.","examples":[4],"format":"int64","type":"integer"},"shares":{"description":"One entry per payee that received a share.","items":{"$ref":"#/components/schemas/SplitWebhookShare"},"type":["array","null"]},"splitExecutionId":{"description":"The split execution's id, as GET /split-executions/{id} reads it.","examples":[77],"format":"int64","type":"integer"},"splitRuleId":{"description":"The split rule that ran.","examples":[42],"format":"int64","type":"integer"},"totalFeeMinor":{"description":"Sum of the fees taken from the shares, integer minor units. They go to the seller's own fee wallet.","examples":[300],"format":"int64","type":"integer"}},"required":["splitExecutionId","paymentId","splitRuleId","ruleVersion","grossAmountMinor","currencyCode","shares","totalFeeMinor","remainderMinor","postedAt"],"type":"object"},"SplitWebhookShare":{"additionalProperties":false,"properties":{"feeAmountMinor":{"description":"The fee taken from that share, integer minor units.","examples":[300],"format":"int64","type":"integer"},"netAmountMinor":{"description":"What the payee received: shareAmountMinor minus feeAmountMinor, integer minor units.","examples":[8500],"format":"int64","type":"integer"},"payeeId":{"description":"The payee that received the share.","examples":[101],"format":"int64","type":"integer"},"shareAmountMinor":{"description":"The payee's gross share, integer minor units.","examples":[8800],"format":"int64","type":"integer"},"walletId":{"description":"The payee wallet the net share was credited to.","examples":[22],"format":"int64","type":"integer"}},"required":["payeeId","walletId","shareAmountMinor","feeAmountMinor","netAmountMinor"],"type":"object"},"SubscriptionChargedCoupon":{"additionalProperties":false,"properties":{"code":{"description":"The coupon's code.","examples":["SPRING25"],"type":"string"},"duration":{"description":"once, repeating or forever.","enum":["once","repeating","forever"],"examples":["repeating"],"type":"string"},"periodsRemaining":{"description":"How many more terms the coupon discounts after this one; null when it lasts forever.","examples":[2],"format":"int64","type":["integer","null"]}},"required":["code","duration","periodsRemaining"],"type":"object"},"SubscriptionChargedWebhookEvent":{"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 charge fired.","enum":["SubscriptionCharged","SubscriptionInstallmentCharged"],"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/PaymentLinkSubscriptionChargedPayload"},"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"},"SubscriptionCouponEndedPayload":{"additionalProperties":false,"properties":{"campaignId":{"description":"The promotion campaign of the code, or null for a standalone promo code.","examples":["6f739c44-b75f-4f30-bf6f-1dc67cae08ef"],"type":["string","null"]},"code":{"description":"The coupon's code.","examples":["SPRING25"],"type":"string"},"couponId":{"description":"The subscription coupon.","examples":["0b7f4c1e-8d2a-4c55-9a51-3f0f6f2a9c10"],"type":"string"},"duration":{"description":"once, repeating or forever.","enum":["once","repeating","forever"],"examples":["repeating"],"type":"string"},"endedAt":{"description":"When it ended, RFC 3339 UTC.","type":"string"},"endedReasonDetail":{"description":"The reason recorded on the coupon, e.g. subscription_cancelled or the text given when it was stopped.","examples":["exhausted"],"type":"string"},"periodsUsed":{"description":"How many terms the coupon discounted.","examples":[3],"format":"int64","type":"integer"},"reason":{"description":"Why it ended: exhausted (its terms are used up), stopped (by you, by Fynex or because the subscription was cancelled) or replaced (another coupon took its place).","enum":["exhausted","stopped","replaced"],"examples":["exhausted"],"type":"string"},"source":{"description":"Which product the subscription belongs to.","enum":["payment_links","billing"],"examples":["payment_links"],"type":"string"},"subscriptionId":{"description":"The subscription whose coupon ended.","examples":[15],"format":"int64","type":"integer"}},"required":["source","subscriptionId","couponId","code","campaignId","duration","periodsUsed","reason","endedReasonDetail","endedAt"],"type":"object"},"SubscriptionCouponEndedWebhookEvent":{"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 SubscriptionCouponEnded.","enum":["SubscriptionCouponEnded"],"examples":["SubscriptionCouponEnded"],"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/SubscriptionCouponEndedPayload"},"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"},"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","SubscriptionTrialEnded","SubscriptionCommitmentCompleted","SubscriptionCancelled","SubscriptionPastDue"],"examples":["SubscriptionCreated"],"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"},"TestCheckoutCompleteResponse":{"additionalProperties":false,"properties":{"lifecycleStatus":{"description":"The payment's lifecycleStatus: captured, or authorized for a session created with manual capture.","examples":["captured"],"type":"string"},"paymentId":{"description":"The payment's externalOrderRef, the id every payment route and the settle call take.","examples":["ORDER-100106"],"type":"string"},"status":{"description":"completed: the session was paid now. already_completed: it had been paid before; nothing changed.","enum":["completed","already_completed"],"examples":["completed"],"type":"string"}},"required":["status","paymentId","lifecycleStatus"],"type":"object"},"TestSettleResponse":{"additionalProperties":false,"properties":{"paymentId":{"description":"The payment's externalOrderRef.","examples":["ORDER-100106"],"type":"string"},"status":{"description":"settled: the settlement ran now. already_settled: it had run before; nothing changed.","enum":["settled","already_settled"],"examples":["settled"],"type":"string"}},"required":["status","paymentId"],"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"}}},"paths":{"/payments-api/v1/account":{"get":{"description":"Returns the seller account the presented credential acts for: its id, name, `operationalMode` and the switches that change what the API accepts.\n\n**Demo or Live.** `operationalMode` decides whether money is real — not the host and not the key prefix. Call this first when you need to know: test cards only work on a `Demo` account.\n\n**Rate limits** are on the response headers of this call, as on every authenticated call: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` and `RateLimit-Policy`.","operationId":"get-account","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}},"description":"OK","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Get the authenticated account","tags":["Authentication"],"x-fynex-permissions":[],"x-mcp":{"name":"get_account","readOnlyHint":true,"guidance":"Call this first: it says whether the key acts for a Demo account (test cards, test settlement) or a Live one, and which features are on."}}},"/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). A session no payment terminal of the account can route — for example a currency the account has no terminal for — is refused with `422` and `code` `currency_not_supported`, and nothing is created. Replaying an `Idempotency-Key` that already created a session still returns that session.","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","Checkout"],"x-fynex-permissions":["payments:write"],"x-mcp":{"name":"create_checkout_session","readOnlyHint":false,"guidance":"The only way to take a payment here: card details never pass through these tools. Hand checkoutUrl to the buyer; on a Demo account, complete_test_checkout pays it with a sandbox test card instead. Then settle the completed payment with settle_test_payment."}}},"/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"],"x-fynex-permissions":["payments:write"]}},"/payments-api/v1/events":{"get":{"description":"Returns the events recorded for your account — the same events, in the same envelope, that webhook deliveries carry — whether or not you have a webhook endpoint configured. Use it when you cannot receive webhooks, to catch up after downtime, or to answer \"what happened in the last 24 hours\" (`occurredFrom`).\n\n**Oldest first, resumable.** Pass the returned `nextCursor` back as `cursor` while `hasMore` is true. `nextCursor` is present on the last page too: store it, call again later, and you receive only events recorded since. An event appears here a few seconds after it is recorded.\n\n**Payload.** `payload` is the event body exactly as a webhook delivery carries it; its shape per `eventType` is in the webhook catalogue. `eventId` is the same id a delivery carries, so a client that also receives webhooks can deduplicate on it.","operationId":"list-events","parameters":[{"description":"Keyset cursor: the nextCursor of a previous page. Omit to start from the oldest event. This list walks FORWARD (oldest first), so the cursor means ids above this value.","explode":false,"in":"query","name":"cursor","schema":{"description":"Keyset cursor: the nextCursor of a previous page. Omit to start from the oldest event. This list walks FORWARD (oldest first), so the cursor means ids above this value.","examples":["918204"],"type":"string"}},{"description":"Page size (1-100, default 50).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100, default 50).","examples":["50"],"type":"string"}},{"description":"Only these event types, comma-separated and matched as given, so a misspelt type yields an empty page.","explode":false,"in":"query","name":"eventType","schema":{"description":"Only these event types, comma-separated and matched as given, so a misspelt type yields an empty page.","examples":["PaymentCompleted,PaymentRefunded"],"type":"string"}},{"description":"Only events that occurred at or after this instant, RFC 3339.","explode":false,"in":"query","name":"occurredFrom","schema":{"description":"Only events that occurred at or after this instant, RFC 3339.","examples":["2026-10-15T00:00:00Z"],"type":"string"}},{"description":"Only events that occurred before this instant, RFC 3339; with occurredFrom it forms a half-open range.","explode":false,"in":"query","name":"occurredTo","schema":{"description":"Only events that occurred before this instant, RFC 3339; with occurredFrom it forms a half-open range.","examples":["2026-10-16T00:00:00Z"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventsResponse"}}},"description":"OK","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List what happened on your account","tags":["Webhooks"],"x-fynex-permissions":["events:read"],"x-mcp":{"name":"list_events","readOnlyHint":true,"guidance":"What happened on the account, oldest first: payments, refunds, payouts and more. Keep nextCursor to resume later."}}},"/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 payment's `lifecycleStatus` (`captured`, `authorized` for manual capture, `failed`, `cancelled`, or `pending` / `under_review` while still in progress), its `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","Server-to-server"],"x-fynex-permissions":["payments:write"]}},"/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"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The card was refused before any authorization, and re-sending it fails identically: `code` is `card_expired` (failureCode 1007) or `card_brand_not_supported` (failureCode 1008). Ask the customer for a different card and send a NEW Idempotency-Key; replaying this key returns the same 422."}},"security":[{"SellerAuth":[]}],"summary":"Initialize a payment (server-to-server)","tags":["Payments","Server-to-server"],"x-fynex-permissions":["payments:write"]}},"/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","Payees"],"x-fynex-permissions":["payees:read"],"x-mcp":{"name":"list_payees","readOnlyHint":true}},"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. Send an `Idempotency-Key` header to make retries safe: a retry with the same key and body returns the payee the first request created.","operationId":"create-payee","parameters":[{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers with the payee the first request created instead of creating a second one; the same key with a different body answers 409 idempotency_key_reused.","in":"header","name":"Idempotency-Key","schema":{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers with the payee the first request created instead of creating a second one; the same key with a different body answers 409 idempotency_key_reused.","examples":["5f0c2b9e-8e1d-4f7a-9a43-0f6d1c2b7e10"],"type":"string"}}],"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","Payees"],"x-fynex-permissions":["payees:write"]}},"/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. Send an `Idempotency-Key` header to make retries safe: a retry with the same key and body returns what the first request created instead of a second payee and bank account.","operationId":"setup-payee","parameters":[{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers with the payee, payout method and wallet the first request created instead of creating a second payee and bank account; the same key with a different body answers 409 idempotency_key_reused.","in":"header","name":"Idempotency-Key","schema":{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers with the payee, payout method and wallet the first request created instead of creating a second payee and bank account; the same key with a different body answers 409 idempotency_key_reused.","examples":["5f0c2b9e-8e1d-4f7a-9a43-0f6d1c2b7e10"],"type":"string"}}],"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","Payouts","Payees"],"x-fynex-permissions":["payees:write","payout_methods:write"],"x-mcp":{"name":"setup_payee","readOnlyHint":false,"guidance":"Creates a payee, its first bank account and its cashout wallet in one call. Send the same idempotencyKey when retrying, or a retry creates a second payee and bank account."}}},"/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","Payees"],"x-fynex-permissions":["payees:write"]},"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","Payees"],"x-fynex-permissions":["payees:read"],"x-mcp":{"name":"get_payee","readOnlyHint":true}},"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","Payees"],"x-fynex-permissions":["payees:write"]}},"/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","Payouts"],"x-fynex-permissions":["payout_methods:read"]},"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. The method lands with `approvalStatus: pending` until Fynex approves it, unless your account auto-approves payee destinations; on a Demo account it is always `approved` at creation. A payee that does not exist, or belongs to another seller, returns 404. Returns `201 Created` with the payout method. Send an `Idempotency-Key` header to make retries safe: a retry with the same key and body returns the method the first request created.","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"}},{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers with the payout method the first request created instead of a second bank account; the same key with a different body or payee answers 409 idempotency_key_reused.","in":"header","name":"Idempotency-Key","schema":{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers with the payout method the first request created instead of a second bank account; the same key with a different body or payee answers 409 idempotency_key_reused.","examples":["5f0c2b9e-8e1d-4f7a-9a43-0f6d1c2b7e10"],"type":"string"}}],"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","Payouts"],"x-fynex-permissions":["payout_methods:write"]}},"/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","Payouts"],"x-fynex-permissions":["payout_methods:write"]},"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","Payouts"],"x-fynex-permissions":["payout_methods:write"]}},"/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","Payouts","Payees"],"x-fynex-permissions":["payouts:write"],"x-mcp":{"name":"pay_payee","readOnlyHint":false,"guidance":"Funds the payee's cashout wallet from your main wallet, not from what a split credited the payee: for that, use create_payout from the payee's split wallet. Amounts are decimal strings in the payout currency (\"12.50\"). Send the same idempotencyKey when retrying, or a retry pays out twice. Poll get_payout for the outcome."}}},"/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","Payment Methods"],"x-fynex-permissions":["payments:read"]}},"/payments-api/v1/payments":{"get":{"description":"Lists the account's payments, newest first, filterable by `status` (the `lifecycleStatus` values), a `createdFrom`/`createdTo` range, `currency`, an inclusive `amountMin`/`amountMax` range in that currency, and `payeeId` (the payments whose split credited that payee). Walk the list with `limit`, passing the returned `nextCursor` back as `cursor` while `hasMore` is true.\n\nEach entry carries the same fields, under the same names, as `GET /payments/{payment_id}`, without `allowedActions`, `settlementStatus` and `promotion` — read one payment for those. Every attempt is listed: a reused `externalOrderRef` appears once per attempt, and `GET /payments/{payment_id}` returns the newest.","operationId":"list-payments","parameters":[{"description":"Keyset cursor: the nextCursor of the previous page. Omit for the first page. This list walks BACKWARD (newest first).","explode":false,"in":"query","name":"cursor","schema":{"description":"Keyset cursor: the nextCursor of the previous page. Omit for the first page. This list walks BACKWARD (newest first).","examples":["20481"],"type":"string"}},{"description":"Page size (1-100, default 20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100, default 20).","examples":["20"],"type":"string"}},{"description":"Only payments whose lifecycleStatus is one of these, comma-separated: pending, under_review, authorized, captured, refunded, failed, cancelled.","explode":false,"in":"query","name":"status","schema":{"description":"Only payments whose lifecycleStatus is one of these, comma-separated: pending, under_review, authorized, captured, refunded, failed, cancelled.","examples":["captured,refunded"],"type":"string"}},{"description":"Only payments created at or after this instant, RFC 3339.","explode":false,"in":"query","name":"createdFrom","schema":{"description":"Only payments created at or after this instant, RFC 3339.","examples":["2026-10-15T00:00:00Z"],"type":"string"}},{"description":"Only payments created before this instant, RFC 3339; with createdFrom it forms a half-open range.","explode":false,"in":"query","name":"createdTo","schema":{"description":"Only payments created before this instant, RFC 3339; with createdFrom it forms a half-open range.","examples":["2026-10-16T00:00:00Z"],"type":"string"}},{"description":"Only payments in this currency, ISO 4217.","explode":false,"in":"query","name":"currency","schema":{"description":"Only payments in this currency, ISO 4217.","examples":["GBP"],"type":"string"}},{"description":"Only payments of at least this amount, in MAJOR units like amount (49.99 is £49.99). Requires currency.","explode":false,"in":"query","name":"amountMin","schema":{"description":"Only payments of at least this amount, in MAJOR units like amount (49.99 is £49.99). Requires currency.","examples":["10.00"],"type":"string"}},{"description":"Only payments of at most this amount, in MAJOR units like amount. Requires currency.","explode":false,"in":"query","name":"amountMax","schema":{"description":"Only payments of at most this amount, in MAJOR units like amount. Requires currency.","examples":["49.99"],"type":"string"}},{"description":"Only payments whose split credited this payee: a split share or the split remainder posted to one of the payee's wallets. A payment matches once its split has run, after settlement.","explode":false,"in":"query","name":"payeeId","schema":{"description":"Only payments whose split credited this payee: a split share or the split remainder posted to one of the payee's wallets. A payment matches once its split has run, after settlement.","examples":["42"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPaymentsResponse"}}},"description":"OK","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"List payments","tags":["Payments"],"x-fynex-permissions":["payments:read"],"x-mcp":{"name":"list_payments","readOnlyHint":true,"guidance":"Newest first; pass nextCursor back as cursor for the next page."}}},"/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"],"x-fynex-permissions":["split_rules:read"],"x-mcp":{"name":"list_split_decisions","readOnlyHint":true,"guidance":"One decision per settled payment that ran a split: which lines paid whom."}}},"/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 `lifecycleStatus` is `captured`, and `settlementStatus` then moves from `pending` through `in_flight` to `settled`. After `POST /payments/{payment_id}/refund` the `lifecycleStatus` stays `captured` while the refund is in flight and after a partial refund, and becomes `refunded` once the whole captured amount is refunded; each refund's own outcome is on `GET /payments/{payment_id}/refunds`. 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"],"x-fynex-permissions":["payments:read"],"x-mcp":{"name":"get_payment","readOnlyHint":true}}},"/payments-api/v1/payments/{payment_id}/cancel-promotion":{"post":{"description":"Cancels an order that a coupon and Fynex promotional credit paid in full, so no card or bank payment ran. There is no cash to refund, so `POST /refund` answers 409 for such an order; cancel it here instead. The buyer's promotional credit is restored and the coupon given back where its terms allow; the payment's `lifecycleStatus` moves to `refunded`, as after a full refund, and a `PaymentPromotionCancelled` webhook is sent. The payment link the order paid is not re-opened. An order that took any cash is refused with 409 until that cash has been refunded. Repeating the call is harmless.","operationId":"cancel-promotion","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. Cancelling an order twice is harmless: a repeat answers the same cancelled payment.","in":"header","name":"Idempotency-Key","schema":{"description":"Idempotency key (UUID). Required. Cancelling an order twice is harmless: a repeat answers the same cancelled payment.","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/CancelPromotionRequest"}}},"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":"Cancel an order paid entirely by promotions","tags":["Payments","Captures \u0026 refunds"],"x-fynex-permissions":["refunds:write"]}},"/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's `lifecycleStatus` moves to `captured`; a partial capture is `captured` too. 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"],"x-fynex-permissions":["payments:write"],"x-mcp":{"name":"capture_payment","readOnlyHint":false,"guidance":"Only for a payment authorized with manual capture."}}},"/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"],"x-fynex-permissions":["refunds:write"],"x-mcp":{"name":"refund_payment","readOnlyHint":false,"destructiveHint":true,"guidance":"Returns money to the buyer; a partial refund may be up to the captured amount."}}},"/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","Captures \u0026 refunds"],"x-fynex-permissions":["refunds:read"]}},"/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","Payouts"],"x-fynex-permissions":["payout_methods:read"]},"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 on a Demo account, or 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","Payouts"],"x-fynex-permissions":["payout_methods:write"]}},"/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","Payouts"],"x-fynex-permissions":["payout_methods:write"]},"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 the account is a Demo account or 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","Payouts"],"x-fynex-permissions":["payout_methods:write"]}},"/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","Payouts"],"x-fynex-permissions":["payouts:read"],"x-mcp":{"name":"list_payouts","readOnlyHint":true}},"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 subscribe a registered webhook endpoint to the opt-in `PayoutCompleted` and `PayoutFailed` events. Payout events are not sent to a per-request `webhookUrl` yet; the field is only validated, as described below.\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","Payouts"],"x-fynex-permissions":["payouts:write"],"x-mcp":{"name":"create_payout","readOnlyHint":false,"guidance":"Pays out from the wallet you name. To pay a payee what a split credited it, pay from that payee's split wallet: list_wallets shows it as type other with the payee's payeeId. pay_payee instead funds the payee's cashout wallet from your main wallet. Amounts are decimal strings in the payout currency (\"12.50\"). Send the same idempotencyKey when retrying, or a retry pays out twice. Poll get_payout for the outcome."}}},"/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","Payouts"],"x-fynex-permissions":["payouts:read"],"x-mcp":{"name":"get_payout","readOnlyHint":true,"guidance":"Poll until status is completed, failed or cancelled. manual_reconciliation means Fynex is resolving an uncertain bank answer by hand: stop polling and read it again later."}}},"/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","Payouts"],"x-fynex-permissions":["payouts:write"],"x-mcp":{"name":"cancel_payout","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"guidance":"Only while the payout still awaits approval."}}},"/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","Captures \u0026 refunds"],"x-fynex-permissions":["refunds:read"]}},"/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"],"x-fynex-permissions":["refunds:write"]}},"/payments-api/v1/split-rules":{"get":{"description":"Every rule you own, active and inactive, newest first by creation time. Deleted rules are not listed.","operationId":"list-split-rules","parameters":[{"description":"Page size, 1 to 100; default 20. A value outside that range, or not an integer, is not refused: the default applies.","in":"query","name":"limit","schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Rules to skip; default 0. A negative or non-integer value is not refused: 0 applies.","in":"query","name":"offset","schema":{"minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleListResponse"}}},"description":"A page of rules."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`)."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"List split rules","tags":["Splits"],"x-fynex-permissions":["split_rules:read"],"x-mcp":{"name":"list_split_rules","readOnlyHint":true}},"post":{"description":"Stores a rule and its lines, **inactive**. Nothing runs against payments until the rule is previewed with `POST /split-rules/{id}/preview-activation` and activated with the receipt that returns. Omit `status`: every rule is created inactive and any other value is refused. Send an `Idempotency-Key` header to make retries safe: a retry with the same key and body returns the rule the first request created.","operationId":"create-split-rule","parameters":[{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers with the rule the first request created instead of a second rule; the same key with a different body answers 409 `idempotency_key_reused`.","in":"header","name":"Idempotency-Key","schema":{"maxLength":255,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleCreateRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleResponse"}}},"description":"The rule, inactive, at version 1."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The body is not JSON, `effectiveFrom` / `effectiveTo` is not RFC 3339, `status` is not `inactive`, or the rule is invalid: an unsupported value, a fixed rule with neither fee, a payee on two lines, an allocation out of range, an absolute rule that is not custom or has a line with no allocation, or `to_remainder_wallet` without `remainderWalletId`. The message says which. An `Idempotency-Key` longer than 255 characters is `code` `invalid_idempotency_key`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`). Also `forbidden` (`code` `insufficient_scope`), when the credential is a delegated dashboard session whose user may not change split rules; a seller API key always may."},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`code` `idempotency_key_reused`: the `Idempotency-Key` was already sent with a different request."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`lines[i]`: the line's `payeeRef` is empty, names both `id` and `externalId`, or resolves to no payee of yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Create a split rule","tags":["Splits"],"x-fynex-permissions":["split_rules:write"],"x-mcp":{"name":"create_split_rule","readOnlyHint":false,"guidance":"The rule is created inactive. Next call preview_split_rule_activation, then pass its receipt to activate_split_rule. Send the same idempotencyKey when retrying, or a retry creates a second rule."}}},"/payments-api/v1/split-rules/active":{"get":{"description":"The rule that would split a payment settling now. An active rule whose `effectiveFrom` is still in the future, or whose `effectiveTo` has passed, is not in effect, so it is not returned here: read it with `GET /split-rules/{id}` or the list.","operationId":"get-active-split-rule","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleResponse"}}},"description":"The rule in effect."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`)."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`no active split rule`: no rule is in effect for you now, so nothing would split."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Get the active split rule","tags":["Splits"],"x-fynex-permissions":["split_rules:read"],"x-mcp":{"name":"get_active_split_rule","readOnlyHint":true,"guidance":"A payment splits only while a rule is active; with none, it settles unsplit."}}},"/payments-api/v1/split-rules/calculate":{"post":{"description":"Runs your active rule, or the inline `rule` you send, against a hypothetical payment and returns each payee's share, fee and net amount. Nothing is stored and no activation state changes. It is the same calculation a settled payment runs, so it rounds the same way. All amounts, `distribution[].amount` included, are integers in minor units. A split that would fail still answers `200`, with `wouldFailReason` saying why.","operationId":"calculate-split","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleCalculateRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleCalculateResponse"}}},"description":"The allocation the rule would produce."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The body is not JSON, `amountMinor` is not greater than 0, or the rule is in weight mode and `distribution` is empty."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`)."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"No inline `rule` was sent and no rule is in effect for you now."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`distribution[i]` or `lines[i]` of the inline rule: the `payeeRef` is empty, names both `id` and `externalId`, or resolves to no payee of yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Calculate a split without running it","tags":["Splits"],"x-fynex-permissions":["split_rules:read"],"x-mcp":{"name":"calculate_split","readOnlyHint":true,"guidance":"A dry run: how an amount would split under the active rule. Creates nothing."}}},"/payments-api/v1/split-rules/{id}":{"delete":{"description":"Deletes an inactive rule. Splits that already ran under it are not reversed, and their decision records still explain them.","operationId":"delete-split-rule","parameters":[{"description":"Split rule id, from create or list. A value with anything but digits does not match the route and answers the router's plain-text 404.","in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"Deleted. No body."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`invalid split rule id`: the id is 0, or too large for a 64-bit integer."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`). Also `forbidden` (`code` `insufficient_scope`), when the credential is a delegated dashboard session whose user may not change split rules; a seller API key always may."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`split rule not found`: no rule with this id belongs to you. An id with anything but digits matches no route: the router answers `404` with `code` `not_found`."},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`cannot delete an active split rule; deactivate first`. Deactivate it, or activate its replacement, first."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Delete a split rule","tags":["Splits"],"x-fynex-permissions":["split_rules:write"]},"get":{"operationId":"get-split-rule","parameters":[{"description":"Split rule id, from create or list. A value with anything but digits does not match the route and answers the router's plain-text 404.","in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleResponse"}}},"description":"The rule."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`invalid split rule id`: the id is 0, or too large for a 64-bit integer."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`)."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`split rule not found`: no rule with this id belongs to you. An id with anything but digits matches no route: the router answers `404` with `code` `not_found`."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Get a split rule","tags":["Splits"],"x-fynex-permissions":["split_rules:read"]}},"/payments-api/v1/split-rules/{id}/activate":{"post":{"description":"Makes the previewed rule your active rule. Activation is an atomic swap: every other active rule is stood down in the same transaction, including one whose `effectiveFrom` has not arrived, and `deactivated` lists them. The swap is immediate even when this rule's `effectiveFrom` is in the future: until then no rule applies and payments that settle are not split.","operationId":"activate-split-rule","parameters":[{"description":"Split rule id, from create or list. A value with anything but digits does not match the route and answers the router's plain-text 404.","in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleActivateRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleActivateResponse"}}},"description":"The rule, now active, and every rule the swap stood down."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`invalid split rule id`: the id is 0, or too large for a 64-bit integer. Also: the body is not JSON, or `confirmed` is not true (`activation confirmation is required`)."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`). Also `forbidden` (`code` `insufficient_scope`), when the credential is a delegated dashboard session whose user may not change split rules; a seller API key always may."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`split rule not found`: no rule with this id belongs to you. An id with anything but digits matches no route: the router answers `404` with `code` `not_found`."},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`invalid or expired split rule activation receipt`: the receipt is missing, malformed, expired, or was issued for another rule or seller; the same answer for each, so it reveals nothing about rules that are not yours. `split rule changed after preview; run preview again`: the rule changed after the preview. `split rule is already active`."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Activate a split rule","tags":["Splits"],"x-fynex-permissions":["split_rules:write"],"x-mcp":{"name":"activate_split_rule","readOnlyHint":false,"destructiveHint":true,"guidance":"Replaces the account's active rule: the previous one stops applying to new payments."}}},"/payments-api/v1/split-rules/{id}/clone":{"post":{"description":"Copies a rule and its lines into a new **inactive** rule at version 1: the way to change a rule that is active, since an active rule cannot be edited. The copy carries no effective window. Preview and activate the copy to replace the original; activation stands the original down.","operationId":"clone-split-rule","parameters":[{"description":"Split rule id, from create or list. A value with anything but digits does not match the route and answers the router's plain-text 404.","in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleResponse"}}},"description":"The new inactive copy."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`invalid split rule id`: the id is 0, or too large for a 64-bit integer."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`). Also `forbidden` (`code` `insufficient_scope`), when the credential is a delegated dashboard session whose user may not change split rules; a seller API key always may."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`split rule not found`: no rule with this id belongs to you. An id with anything but digits matches no route: the router answers `404` with `code` `not_found`."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Clone a split rule","tags":["Splits"],"x-fynex-permissions":["split_rules:write"]}},"/payments-api/v1/split-rules/{id}/deactivate":{"post":{"description":"Stops an active rule now: its `effectiveTo` is set to this moment and its version increments. From then on no rule applies until another is activated, and payments that settle meanwhile are not split. Deactivating a rule that is already inactive changes nothing and returns it as it is.","operationId":"deactivate-split-rule","parameters":[{"description":"Split rule id, from create or list. A value with anything but digits does not match the route and answers the router's plain-text 404.","in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRuleResponse"}}},"description":"The rule, inactive."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`invalid split rule id`: the id is 0, or too large for a 64-bit integer."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`). Also `forbidden` (`code` `insufficient_scope`), when the credential is a delegated dashboard session whose user may not change split rules; a seller API key always may."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`split rule not found`: no rule with this id belongs to you. An id with anything but digits matches no route: the router answers `404` with `code` `not_found`."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Deactivate a split rule","tags":["Splits"],"x-fynex-permissions":["split_rules:write"]}},"/payments-api/v1/split-rules/{id}/preview-activation":{"post":{"description":"Returns the saved rule and an `activationReceipt`, valid for 10 minutes, that `POST /split-rules/{id}/activate` requires. The receipt is bound to your seller account, this rule, its version and a fingerprint of its content: any change to the rule before activation invalidates it, and a fresh preview is needed. No body. To see what the rule would allocate for a given amount, use `POST /split-rules/calculate`.","operationId":"preview-split-rule-activation","parameters":[{"description":"Split rule id, from create or list. A value with anything but digits does not match the route and answers the router's plain-text 404.","in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitRulePreviewActivationResponse"}}},"description":"The rule and its activation receipt."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`invalid split rule id`: the id is 0, or too large for a 64-bit integer."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is missing (`code` `missing_api_key`) or invalid (`code` `invalid_api_key`)."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The key is valid but the seller account is not active (`code` `account_not_active`). Also `forbidden` (`code` `insufficient_scope`), when the credential is a delegated dashboard session whose user may not change split rules; a seller API key always may."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`split rule not found`: no rule with this id belongs to you. An id with anything but digits matches no route: the router answers `404` with `code` `not_found`."},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"`split rule is already active`."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The seller's request budget is spent. Retry after the number of seconds in the `Retry-After` header."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal fault, answered as `{\"error\":\"internal server error\"}`; retry. A fault in the authentication layer itself carries `code` `auth_unavailable`."}},"security":[{"SellerAuth":[]}],"summary":"Preview a split rule for activation","tags":["Splits"],"x-fynex-permissions":["split_rules:write"],"x-mcp":{"name":"preview_split_rule_activation","readOnlyHint":false,"idempotentHint":true,"guidance":"Returns the short-lived receipt activate_split_rule requires; preview again if the rule changes."}}},"/payments-api/v1/test/checkout/{sessionId}/complete":{"post":{"description":"**Demo accounts only.** Pays one of your hosted checkout sessions the way its buyer would: Fynex submits the session's own checkout page with the sandbox's frictionless Visa test card and the test customer data from the Test cards guide, runs 3DS, and completes the payment. No card details are sent to or returned by this call. The payment is the session's own, so `GET /payments/{paymentId}`, webhooks and events see an ordinary card payment.\n\nThe answer carries the payment's `paymentId`. Settle it with `POST /test/payments/{paymentId}/settle` to run its split and credit the wallets; a Demo payment of up to 500 also settles on its own within about a minute. A session created with manual capture ends `authorized`: capture it first.\n\nRepeating the call is safe: a session that was already paid answers `200` with `status: already_completed` and changes nothing.\n\nRefused with `403` (`code` `demo_only`) on a Live account, `404` (`checkout_session_not_found`) for a session of another account, `410` (`checkout_expired`) once the session has expired, and with the checkout page's own refusal otherwise.","operationId":"complete-test-checkout","parameters":[{"description":"The sessionId POST /checkout returned.","in":"path","name":"sessionId","required":true,"schema":{"description":"The sessionId POST /checkout returned.","examples":["6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestCheckoutCompleteResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Pay a Demo checkout session with a test card","tags":["Payments","Checkout"],"x-fynex-permissions":["payments:write"],"x-mcp":{"name":"complete_test_checkout","readOnlyHint":false,"idempotentHint":true,"guidance":"Demo accounts only. Pays a checkout session as its buyer would, with a sandbox test card Fynex picks, so no card details pass through you. Then settle the payment with settle_test_payment."}}},"/payments-api/v1/test/payments/{paymentId}/settle":{"post":{"description":"**Demo accounts only.** Settles a completed Demo card payment the way the acquirer's settlement report settles a Live one: the payment is reconciled, its split runs and the wallets are credited — so `GET /payments/{paymentId}/split-decisions`, wallet balances and payouts behave as they will in production. The sandbox acquirer never sends a settlement report, so without this call a Demo payment stops at completed.\n\nThe call takes a few seconds. The split runs right after it returns. Repeating it is safe: a payment that was already settled — by an earlier call, or by the automatic settlement a Demo payment of up to 500 gets within about a minute — answers `200` with `status: already_settled` and changes nothing.\n\nRefused with `403` (`code` `demo_only`) on a Live account, and with `409` (`payment_not_completed`) until the payment has completed — for manual capture, until it is captured.","operationId":"settle-test-payment","parameters":[{"description":"The payment's externalOrderRef, as on every other payment route.","in":"path","name":"paymentId","required":true,"schema":{"description":"The payment's externalOrderRef, as on every other payment route.","examples":["ORDER-100106"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestSettleResponse"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Error"}},"security":[{"SellerAuth":[]}],"summary":"Settle a Demo payment now","tags":["Payments"],"x-fynex-permissions":["payments:write"],"x-mcp":{"name":"settle_test_payment","readOnlyHint":false,"idempotentHint":true,"guidance":"Demo accounts only. The sandbox acquirer sends no settlement report, so this settles the payment, runs its split and credits the wallets."}}},"/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"],"x-fynex-permissions":["topups:read"]},"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"],"x-fynex-permissions":["topups:write"]}},"/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"],"x-fynex-permissions":["topups:read"]}},"/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"],"x-fynex-permissions":["topups:read"]}},"/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"],"x-fynex-permissions":["topups:write"]}},"/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"],"x-fynex-permissions":["topups:read"]}},"/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"],"x-fynex-permissions":["topups:write"]}},"/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","Wallets"],"x-fynex-permissions":["wallets:read"],"x-mcp":{"name":"list_wallets","readOnlyHint":true,"guidance":"walletId values are Fynex ids, not IBANs."}},"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. On a Live account this endpoint is disabled by default and must be enabled by Fynex support before use — a disabled account receives 403. It is always enabled on a Demo account. 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","Wallets"],"x-fynex-permissions":["wallets:write"]}},"/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","Wallets"],"x-fynex-permissions":["wallets:read"]}},"/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","Wallets"],"x-fynex-permissions":["wallets:write"]}},"/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","Wallets"],"x-fynex-permissions":["wallets:read"],"x-mcp":{"name":"list_wallet_transactions","readOnlyHint":true}}},"/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","Webhooks"],"x-fynex-permissions":["webhooks:read"]},"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. Send an `Idempotency-Key` header to make retries safe: a retry with the same key and body answers 201 with the webhook the first request created, signing secret included.","operationId":"create-webhook","parameters":[{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers 201 with the webhook the first request created, including its current signing secret; the same key with a different body answers 409 idempotency_key_reused.","in":"header","name":"Idempotency-Key","schema":{"description":"Optional, at most 255 characters. Retrying with the same key and the same body answers 201 with the webhook the first request created, including its current signing secret; the same key with a different body answers 409 idempotency_key_reused.","examples":["5f0c2b9e-8e1d-4f7a-9a43-0f6d1c2b7e10"],"type":"string"}}],"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","Webhooks"],"x-fynex-permissions":["webhooks:write"]}},"/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"],"x-fynex-permissions":["webhooks:read"]},"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"],"x-fynex-permissions":["webhooks:write"]}},"/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"],"x-fynex-permissions":["webhooks:write"]}},"/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","Webhooks"],"x-fynex-permissions":["webhooks:write"]},"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","Webhooks"],"x-fynex-permissions":["webhooks:write"]}}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"PaymentLinkUpdated","tags":["Webhooks"]}},"PaymentPromotionCancelled":{"post":{"description":"An order a coupon and promotional credit paid in full was cancelled — by you through `POST /payments/{payment_id}/cancel-promotion`, or by Fynex support. No cash moved, so no PaymentRefunded is sent; the buyer's credit and coupon were given back and the payment is now refunded.\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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentPromotionCancelled","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/PaymentPromotionCancelledWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"PaymentPromotionCancelled","tags":["Webhooks"]}},"PaymentPromotionCompleted":{"post":{"description":"An order was paid in full by a coupon and promotional credit, so no card or bank payment ran and no PaymentCompleted is sent for it. This is that order's completion signal; `sellerEntitlementMinor` is what Fynex owes you for 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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PaymentPromotionCompleted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/PaymentPromotionCompletedWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"PaymentPromotionCompleted","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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"PaymentRefunded","tags":["Webhooks"]}},"PayoutCompleted":{"post":{"description":"A payout reached `completed`: the banking provider confirmed it and the held funds left the wallet. Opt-in — delivered only to an endpoint whose `eventTypes` names 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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PayoutCompleted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/PayoutWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"PayoutCompleted","tags":["Webhooks"]}},"PayoutFailed":{"post":{"description":"A payout reached `failed`: the provider or a check before dispatch refused it, and the held funds went back to the wallet. `failureCode` says why and `failureMessage` is the text to show. Opt-in — delivered only to an endpoint whose `eventTypes` names it. A payout that becomes `cancelled` (you cancelled it, or it was rejected in review) emits nothing.\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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-PayoutFailed","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/PayoutWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"PayoutFailed","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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"PromoCodeRedeemed","tags":["Webhooks"]}},"SplitExecuted":{"post":{"description":"A settled payment's split posted: each payee's net share is in its wallet, the fees in your fee wallet and the remainder where the rule routes it. `paymentId` is the payment; GET /payments/{paymentId}/split-decisions explains every number. A payment with no active rule, or nothing to divide, emits nothing; a split that fails emits nothing either and shows on GET /split-executions. Opt-in — delivered only to an endpoint whose `eventTypes` names 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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SplitExecuted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/SplitWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"SplitExecuted","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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"SubscriptionCancelled","tags":["Webhooks"]}},"SubscriptionCharged":{"post":{"description":"A term was charged and the period advanced. `discountMinor` and `coupon` say what the subscription's coupon took off; a term the coupon paid in full is reported with `amountMinor` 0 and no payment behind 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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/SubscriptionChargedWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"SubscriptionCommitmentCompleted","tags":["Webhooks"]}},"SubscriptionCouponEnded":{"post":{"description":"A subscription's coupon stopped discounting its terms: `exhausted` (its terms are used up), `stopped` (by you, by Fynex, or because the subscription was cancelled) or `replaced` by another coupon. Later terms are charged in full unless a new coupon is attached.\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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-SubscriptionCouponEnded","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/SubscriptionCouponEndedWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"SubscriptionCouponEnded","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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"SubscriptionCreated","tags":["Webhooks"]}},"SubscriptionInstallmentCharged":{"post":{"description":"One monthly installment of an annual commitment was charged, with the same coupon fields as SubscriptionCharged.\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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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/SubscriptionChargedWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"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 \"\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e\"\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Concatenate the timestamp header, a literal `.`, and the RAW body, then verify before parsing; reject a timestamp more than 5 minutes from your clock.\n\nDelivery is at-least-once: only a `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed — 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 `\u003cX-Fynex-Timestamp\u003e.\u003craw request body\u003e` (timestamp, a literal dot, then the raw 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":"Delivered. Only a 200 ends the retry schedule for this delivery; any other status, including other 2xx codes, is retried up to 3 attempts in total and then marked failed."}},"security":[],"summary":"TaxLocationResolved","tags":["Webhooks"]}}},"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  \"merchantId\": \"16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117\",\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 first reached `captured`, `failed` or `cancelled`; it fires once per payment. `lifecycleStatus` 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. When the payment used a coupon or wallet credit, the payload carries an optional `promotion` object. |\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| `PaymentPromotionCompleted` | An order was paid in full by a coupon and promotional credit, so no card or bank payment ran. Such an order sends **no** `PaymentCompleted`; this is its completion signal. See [Promotions](#promotions). |\n| `PaymentPromotionCancelled` | An order a coupon and promotional credit paid in full was cancelled (by you or by Fynex support). No cash moved, so **no** `PaymentRefunded` is sent; the payment is now `refunded`. See [Promotions](#promotions). |\n\n### Payouts\n\nThis family is **opt-in**: an endpoint receives it only when its `eventTypes`\nnames the events you want, so existing endpoints never start receiving payout\nevents unasked. Naming them makes the list an allow-list, so include every\nother event the endpoint should keep receiving — or register a second\nendpoint for payouts alone.\n\n| Event | Fires when |\n| --- | --- |\n| `PayoutCompleted` | A payout reached `completed`: the banking provider confirmed it and the held funds left the wallet. |\n| `PayoutFailed` | A payout reached `failed`: the provider or a check before dispatch refused it, and the held funds went back to the wallet. `failureCode` says why; `failureMessage` is the text to show. |\n\nThe payload carries the fields `GET /payouts/{id}` returns, with the same\nvalues, so the event and a read of the payout agree:\n\n```json\n{\n  \"payoutId\": 9001,\n  \"status\": \"failed\",\n  \"walletId\": 15,\n  \"payoutMethodId\": 501,\n  \"amountMinor\": 1250,\n  \"amount\": \"12.50\",\n  \"currencyCode\": \"GBP\",\n  \"description\": \"Invoice 2026-091 September commission\",\n  \"failureCode\": \"payout_method_inactive\",\n  \"failureMessage\": \"This payout method is not currently available. Please contact support.\"\n}\n```\n\n`providerReference` is present once the provider has one. A payout that\nbecomes `cancelled` — you cancelled it, or it was rejected in review — sends\nno event.\n\n### Splits\n\nThis family is **opt-in**, like payouts: name it in an endpoint's `eventTypes`\nto receive it.\n\n| Event | Fires when |\n| --- | --- |\n| `SplitExecuted` | A settled payment's split posted: each payee's net share is in its wallet, the fees in your fee wallet, the remainder where the rule routes it. A payment with no active rule, or nothing to divide, sends nothing. A split that fails sends nothing either; it shows on `GET /split-executions`. |\n\nThe amounts carry the names and values of the payment's split decision record,\n`GET /payments/{paymentId}/split-decisions`, which explains every number:\n\n```json\n{\n  \"splitExecutionId\": 77,\n  \"paymentId\": \"ORDER-100106\",\n  \"splitRuleId\": 42,\n  \"ruleVersion\": 4,\n  \"grossAmountMinor\": 10000,\n  \"currencyCode\": \"GBP\",\n  \"shares\": [\n    { \"payeeId\": 101, \"walletId\": 22, \"shareAmountMinor\": 8800, \"feeAmountMinor\": 300, \"netAmountMinor\": 8500 }\n  ],\n  \"totalFeeMinor\": 300,\n  \"remainderMinor\": 1200,\n  \"postedAt\": \"2026-10-20T12:00:00Z\"\n}\n```\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| `SubscriptionCouponEnded` | The subscription's coupon stopped discounting its terms: `exhausted`, `stopped` or `replaced` (see below). |\n\n#### Subscription coupons\n\nA coupon attached to a subscription discounts its terms for its duration\n(`once`, `repeating` for N terms, or `forever`). `SubscriptionCharged` and\n`SubscriptionInstallmentCharged` then also carry:\n\n| Key | Meaning |\n| --- | --- |\n| `grossAmountMinor` | The term's price before the coupon. |\n| `discountMinor` | What the coupon took off this term; `0` without one. |\n| `amountMinor` | What the term was charged before tax: `grossAmountMinor` less `discountMinor`. `0` for a term the coupon paid in full — no payment is made for it. |\n| `coupon` | `{code, duration, periodsRemaining}` of the coupon that discounted the term (`periodsRemaining` is `null` for a coupon that lasts forever), or `null`. |\n\n`SubscriptionCouponEnded` carries `{source, subscriptionId, couponId, code,\ncampaignId, duration, periodsUsed, reason, endedReasonDetail, endedAt}`.\n`reason` is `exhausted` (its terms are used up), `stopped` (by you, by Fynex,\nor because the subscription was cancelled — `endedReasonDetail` is then\n`subscription_cancelled`) or `replaced` (another coupon took its place). A\ncoupon given back unused when its subscription is cancelled sends no event.\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### Promotions\n\nCoupons and promotional credit (the coupon wallet) are reported on three\nevents, all on the default subscription. A payment that used a promotion and\nstill took cash also carries the optional `promotion` object on its\n`PaymentCompleted` payload — the same breakdown `GET\n/payments/{payment_id}` returns (amounts before and after, who funded what,\n`subsidyStatus`); it is absent for a payment without a promotion.\n\n**`PromoCodeRedeemed`** fires when a code's discount is applied to a funded\norder. Next to the original keys (`promoCodeId`, `redemptionId`,\n`sellerCustomerId`, `paymentLinkId`, `subscriptionId`, `paymentId`,\n`discountMinor`, all unchanged) it carries:\n\n| Key | Meaning |\n| --- | --- |\n| `code` | The code's text. |\n| `campaignId` | The promotion campaign the code belongs to; `null` for a standalone promo code. |\n| `fundingParty` | Who pays the discount: `seller` (it comes off your proceeds) or `platform` (Fynex pays it to you). |\n| `currency` | ISO 4217 currency of `discountMinor` and `creditsMinor`; `null` when the redemption was not priced in one (a subscription promo). |\n| `creditsMinor` | Promotional credit the buyer spent on the same order; `0` when none. |\n\n```json\n{\n  \"promoCodeId\": 12,\n  \"redemptionId\": 3391,\n  \"sellerCustomerId\": null,\n  \"paymentLinkId\": 4021,\n  \"subscriptionId\": null,\n  \"paymentId\": 88213,\n  \"discountMinor\": 1000,\n  \"code\": \"SPRING25\",\n  \"campaignId\": \"6f739c44-b75f-4f30-bf6f-1dc67cae08ef\",\n  \"fundingParty\": \"seller\",\n  \"currency\": \"EUR\",\n  \"creditsMinor\": 0\n}\n```\n\n**`PaymentPromotionCompleted`** fires once per order when a coupon and\npromotional credit covered the **whole** total, so the buyer paid nothing and\nno provider was involved. `PaymentCompleted` is not sent for such an order:\nthat event describes a charge, and its `amountMinor` is always positive. Treat\n`PaymentPromotionCompleted` as the order's completion and fulfil on it; it is\ndeduplicated per payment, so a retry repeats the same `eventId`.\n\n```json\n{\n  \"genericPaymentId\": 88214,\n  \"reservationId\": \"0b7f4c1e-8d2a-4c55-9a51-3f0f6f2a9c10\",\n  \"campaignId\": \"6f739c44-b75f-4f30-bf6f-1dc67cae08ef\",\n  \"currency\": \"EUR\",\n  \"subtotalMinor\": 4999,\n  \"couponDiscountMinor\": 1000,\n  \"couponFundingParty\": \"seller\",\n  \"taxMinor\": 0,\n  \"totalMinor\": 3999,\n  \"creditsMinor\": 3999,\n  \"promotionFundedMinor\": 4999,\n  \"sellerEntitlementMinor\": 3999,\n  \"fundedAt\": \"2026-10-01T09:30:12Z\"\n}\n```\n\n`totalMinor` is what was due after the coupon (tax included) and\n`creditsMinor` always equals it: the buyer paid nothing. `promotionFundedMinor`\nis the value promotion delivered (coupon discount plus credit).\n`sellerEntitlementMinor` is what you are owed for the order before fees — the\nplatform-funded part, which Fynex pays into your balance; a seller-funded\ncoupon is your own discount and is not owed back. `campaignId` and\n`couponFundingParty` are `null` when only credit paid.\n\n**`PaymentPromotionCancelled`** fires once per order when such an order is\ncancelled — by you, through `POST /payments/{payment_id}/cancel-promotion`\n(a refund of it answers `409`: there is no cash to return), or by Fynex\nsupport. The buyer's credit is restored and the coupon given back where its\nterms allow; the payment ends `refunded`, and the payment link it paid is not\nre-opened.\n\n```json\n{\n  \"paymentId\": 88214,\n  \"externalOrderRef\": \"ORDER-100106\",\n  \"couponDiscountMinor\": 1000,\n  \"creditsMinor\": 3999,\n  \"currency\": \"EUR\",\n  \"campaignId\": \"6f739c44-b75f-4f30-bf6f-1dc67cae08ef\",\n  \"code\": \"SPRING25\",\n  \"cancelledAt\": \"2026-10-02T14:05:00Z\",\n  \"reason\": \"customer changed their mind\"\n}\n```\n\n`reason` is the reason you gave when you cancelled the order yourself; when\nFynex support cancelled it, it is always `\"cancelled by Fynex\"` (the support\nnote stays internal).\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## Reading events without an endpoint\n\nEvery event is also recorded on your account, whether or not a webhook URL is registered, and `GET /events` lists them in the same envelope a delivery carries. Use it when you cannot receive webhooks (a coding agent, a script behind a firewall), or to catch up on deliveries that exhausted their retries.\n\n```bash\ncurl -sS \"$FYNEX_API/events?occurredFrom=2026-10-15T00:00:00Z\u0026eventType=PaymentCompleted,PaymentRefunded\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\nThe list is oldest first. Pass `nextCursor` back as `cursor` while `hasMore` is true; `nextCursor` is returned on the last page too, so a stored cursor later yields only newer events. An event appears a few seconds after it is recorded. `eventId` is the same id a delivery carries — deduplicate on it if you read both.\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!` | `Contractor` or `Tax` for payees you create; `Itself` (the seller's own payee) and `fynex_platform` are assigned by Fynex. `Seller` is a deprecated legacy value — do not send it |\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  \"merchantId\": \"16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117\",\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## Hosted payee onboarding contract (ONBOARD-01)\n\nThis is the contract for the planned hosted onboarding session. **It is not a\nregistered public route yet.** Until the runtime session store, redirect\nhandler, and webhook consumer implement these guarantees, integrations must\nuse the existing payee verification routes (`POST /payees/{payee_id}/verification`)\nor vendor verification link route. No identity or money-moving endpoint is\nadvertised by this contract-only slice.\n\n### Proposed operation\n\n```http\nPOST /payments-api/v1/payees/{payee_id}/onboarding-sessions\nAuthorization: Bearer \u003cseller_sk_key\u003e\nIdempotency-Key: \u003cstable-client-key\u003e\nContent-Type: application/json\n\n{ \"returnUrl\": \"https://merchant.example/payees/onboarding/complete\" }\n```\n\nThe authenticated seller owns the payee: a payee belonging to another seller\nis indistinguishable from an unknown payee and returns `404`. The operation\nmust not accept `sellerAccountId`, `legalEntityId`, or an identity-provider\napplicant id from the caller. It creates no payout method and moves no money.\n\nThe successful response is:\n\n```json\n{\n  \"sessionId\": \"pos_01J...\",\n  \"payeeId\": 101,\n  \"onboardingUrl\": \"https://onboarding.fynex.ai/payee/pos_01J...\",\n  \"expiresAt\": \"2026-09-15T12:00:00Z\",\n  \"status\": \"pending\"\n}\n```\n\n`returnUrl` is optional only when the seller has a configured default. When\nprovided it must be HTTPS, have no userinfo, resolve to a public address, and\nmatch the seller's allowed-return-hosts configuration. The service validates\nit before creating a session; it never reflects an unvalidated URL into a\nredirect.\n\n### Session, replay, and completion semantics\n\n- A session is owned by exactly one seller and payee, expires at `expiresAt`,\n  and is unusable after expiry (`410 onboardingSessionExpired`). Expiry is\n  server-enforced, not merely a UI timer.\n- `Idempotency-Key` is required and scoped to the seller and payee. The same\n  key with an equivalent body returns the original session (`200`); reusing it\n  with a different `returnUrl` returns `409 idempotencyConflict`. A retried\n  request never creates a second provider applicant or session.\n- Opening the URL is safe to repeat. The provider's hosted result is accepted\n  only after a verified provider webhook updates the payee's verification\n  state. A browser redirect is advisory and cannot enable payouts by itself.\n- On completion, the browser is redirected to the validated `returnUrl` with\n  only opaque `sessionId` and `status` parameters. No identity document,\n  provider token, or secret is placed in the URL. If no return URL is usable,\n  the session ends on a Fynex-hosted status page.\n- The seller receives the existing payee verification webhook/event state\n  transition. Webhook delivery is signed, retried until a `2xx`, and must be\n  consumed idempotently; duplicate events do not create a second transition.\n\nThe route remains intentionally absent from OpenAPI until these runtime\nproperties have a durable implementation and authenticated integration tests.\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: PayeeInput!)` | Mutation | `PAYEES_CREATE` |\n| `updatePayee(id: Int!, input: PayeeInput!)` | Mutation | `PAYEES_UPDATE` |\n| `deletePayee(id: Int!)` | Mutation | `PAYEES_DELETE` — **staff surface only**, not callable on `/dashboard/graphql` |\n\n### `PayeeInput` 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 | `Contractor` or `Tax` (`Itself` is reserved for the seller's own payee; `Seller` is deprecated) |\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\n### `PayeeInput` 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` | `Contractor` or `Tax` (`Seller` is deprecated) |\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\n\u003e **Routing is not set through this mutation.** `splitRuleLine` was removed from `PayeeInput`; it had been rejected with an error since 2026-08-26, so this table promised a field that could not be used. Configure an inactive split rule, preview the saved version, then activate it — and manage individual lines with `upsertSplitRuleLineByPayee` / `deleteSplitRuleLineByPayee`.\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: PayeeInput!) { createPayee(input: $input) { id displayName role status } }\",\n    \"variables\": {\n      \"input\": {\n        \"legalEntityId\": 1,\n        \"merchantId\": \"42\",\n        \"role\": \"Contractor\",\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: PayeeInput!) {\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: 'Contractor',\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: PayeeInput!) {\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\": \"Contractor\",\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: PayeeInput!) {\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 `PayeeInput` 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 | `PayeeInput.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 on a Live account it is\n\u003e **gated per seller** by `walletCreationEnabled` (off by default). If it is not\n\u003e enabled for your account, `POST /payees/setup` returns `403 wallet creation is\n\u003e not enabled for this account; contact support`. Contact support to enable it.\n\u003e A Demo account needs no switch: wallet creation is always on there.\n\u003e `GET /account` reports the effective value as `features.walletCreation`.\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. On a Live account auto-funding is off by default and must be enabled\n\u003e by Fynex; on a Demo account it is always on. It does not apply to the\n\u003e 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 (both are always on for a\n\u003e Demo account) 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    \"description\": \"Invoice 2026-091 September commission\",\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```bash\ncurl -sS \"$FYNEX_API/payees/42/payout-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" | jq\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```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    \"description\": \"Invoice 2026-091 September commission\",\n    \"idempotencyKey\": \"'$(uuidgen)'\"\n  }'\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     \"description\": \"Invoice 2026-091 September commission\"\n   }\n   ```\n\n   Save the `id` — you will need it to poll status.\n\n3. **Poll for the final status**\n\n   Poll `GET /payouts/\\{id\\}` until `status` reaches a terminal value, or subscribe a webhook endpoint to the opt-in `PayoutCompleted` and `PayoutFailed` events (see [Webhooks](#tag/webhooks)).\n\n```bash\ncurl -sS \"$FYNEX_API/payouts/9001\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" | jq .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| `description` | `string` | No (will become required) | What the beneficiary sees on their bank statement, sent to the bank as remittance information. Send it on every payout now: it will become required. When supplied, leading/trailing whitespace is trimmed and internal runs collapse to one space; the result must be 1–140 characters of `a-z A-Z 0-9 / - ? : ( ) . , ' +` and space. Anything else — including an empty or blank string — is rejected with `400`, never transliterated. It is split into up to four 35-character lines at word boundaries where possible. |\n| `idempotencyKey` | `string` (UUID) | No | If omitted, a UUID is auto-generated. Replays with the same key return the original payout; a replay with a different `description` returns `409`. |\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| `description` | No (will become required) | 1–140 characters of `a-z A-Z 0-9 / - ? : ( ) . , ' +` and space; sent as the SEPA remittance information. |\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` | `pending` with `approvalStatus: \"awaiting_approval\"` when the payout needs review (the default on a Live account — see **Approval** below); otherwise usually `processing` |\n| `approvalStatus` | `string` | Review state: `approved`, `awaiting_approval`, `compliance_review` or `rejected` — see **Approval** below |\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| `description` | `string` | The normalized description sent to the bank. Omitted for payouts created before descriptions were required (omitempty) |\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| `approvalStatus` | `string` | Review state — see **Approval** below |\n| `rejectionReason` | `string` | Set when the payout was rejected in review; always the same support message, never the reviewer's note (omitempty) |\n| `approvedAt` | `string` | RFC3339 UTC, when the payout was approved (omitempty) |\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| `description` | `string` | The normalized description sent to the bank. Omitted for payouts created before descriptions were required (omitempty) |\n| `failureCode` | `string` | Set when the payout fails (omitempty) |\n| `failureMessage` | `string` | Seller-safe explanation of `failureCode`, suitable to show as-is. Present on `failed` payouts and on `cancelled` ones — \"payout cancelled before approval\" when you cancelled it, \"Our system paused your payout. Please contact support.\" when it was rejected in review — both with no `failureCode`; absent otherwise (omitempty) |\n| `requestedAt` | `string` | RFC3339 UTC |\n| `processedAt` | `string` | RFC3339 UTC (omitempty) |\n| `completedAt` | `string` | RFC3339 UTC (omitempty) |\n\n\u003e [!NOTE]\n\u003e Branch on `failureCode`, display `failureMessage`. The message is the fixed catalogue text for the\n\u003e code — never raw bank or operator notes — so it is stable enough to show to a seller but not\n\u003e meant to be parsed.\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### Approval\n\nPayouts above your account's auto-approve limit — or held by another control on your account — wait for review by Fynex before they are processed. Such a payout is returned with `status: \"pending\"` and `approvalStatus: \"awaiting_approval\"`; `approvalStatus` is a separate field, not a `status` value, and it is present on every new payout. Unless Fynex has set an auto-approve limit for your account, the limit is `0`, so **every payout on a Live account starts awaiting approval**.\n\n**On a Demo account every payout is approved automatically**, whatever its amount and wherever it was created, so a test payout runs to completion with nobody at Fynex involved. A payout to a marketplace vendor can still be held by the vendor limit.\n\nThe same holds for **payout methods**: on a Demo account a bank account you add is `approved` at creation, so you can pay out to it at once. On a Live account a new or re-routed payout method starts `pending` unless Fynex has enabled auto-approval for your account. When a Demo account goes Live, every payout method nobody at Fynex reviewed returns to `pending` and must be approved before it pays out.\n\n| `approvalStatus` | Meaning |\n|------------------|---------|\n| `approved` | Released for processing — either approved automatically or after review (`approvedAt` is set on the detail response). |\n| `awaiting_approval` | Waiting for review. You can still cancel it with `POST /payouts/{id}/cancel`; it then becomes `cancelled` with `failureMessage` \"payout cancelled before approval\". |\n| `compliance_review` | Held for an automated compliance check. It cannot be cancelled (`409`) and resolves on its own. |\n| `rejected` | Rejected in review: the payout becomes `cancelled` with `failureMessage` \"Our system paused your payout. Please contact support.\" |\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's **withdrawable** balance does not cover `amountMinor`, the request returns `409 Conflict`:\n\n```json\n{ \"error\": \"insufficient balance\" }\n```\n\nThe check is against `withdrawableBalanceMinor`, not `availableBalanceMinor`. Settled funds stay inside a **T+N maturity hold** (the seller's `payoutHoldDays`, 3 business days by default) before a payout can take them, so this `409` often means the money *is* in the wallet but has not matured yet — common for a marketplace paying out shortly after a split. A Demo account has no hold: its settled funds are withdrawable at once. Check the wallet first (see [Wallets](#tag/wallets)):\n\n- `maturingBalanceMinor` covers the shortfall → wait until those funds mature, then retry with the **same** `idempotencyKey`. A top-up is not held and would also unblock it, but it is unnecessary.\n- Otherwise the wallet really is short → top it up via incoming payments or a treasury transfer, then retry with the same key.\n\nFor an auto-funded payee payout (`POST /payees/{id}/payouts`), the check covers the cashout wallet plus your main wallet's withdrawable balance, so check the main wallet's `maturingBalanceMinor` too. A separate `409 merchant balance cannot cover the payout fee` means the payout fits but its fee does not.\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| `400` | `description is required` | `description` supplied as an empty or whitespace-only string |\n| `400` | `description must be at most 140 characters` | `description` longer than 140 characters after whitespace normalization |\n| `400` | `description contains unsupported characters; allowed: letters, digits, space and / - ? : ( ) . , ' +` | `description` contains any other character (accents, `\u0026`, `_`, quotes, emoji, …) |\n| `409` | `idempotency key was already used with a different description` | Same `idempotencyKey` replayed with a different `description` |\n| `401` | `authorization token is required` / `invalid authorization token` (`code` `missing_api_key` / `invalid_api_key`) | 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` | Withdrawable balance below the requested amount — funds missing, or still inside the maturity hold |\n| `409` | `merchant balance cannot cover the payout fee` | The main wallet's withdrawable balance does not cover the payout fee |\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` carrying `Retry-After` and the JSON body `{\"error\":\"rate limit exceeded; retry after the Retry-After header value\",\"code\":\"rate_limited\"}` 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 Examples use this environment's API, `https://api.fynex.ai`. Whether a payment is a test or real is decided by your **account**, not the host: a **Demo** account never moves real money. See [Going live](#tag/going-live) when you are ready for real payments.\n\n- **API base URL:** `https://api.fynex.ai/payments-api/v1`\n- **Full API reference:** this site\n- **Dashboard:** `https://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://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 an opaque string with no prefix — it is exactly the value you\npass 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\nStore the token and base URL in your environment:\n\n```bash\nexport FYNEX_API=\"https://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://pay.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 — a Demo account is a sandbox account on any Fynex host. For a guaranteed\nsuccess on **either** option above, use:\n\n| Card number | Notes |\n|-------------|-------|\n| `4000 0000 0000 2701` | Visa — authenticates frictionlessly, reaches `lifecycleStatus` `captured` |\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 On **Option B** (server-to-server) with `skip3DS: true`, 3DS never runs and any well-formed\n\u003e card number reaches the same generic success/decline path. On **Option A** (hosted checkout)\n\u003e and on Option B with `skip3DS` omitted or `false`, 3DS always runs and the card's PAN decides\n\u003e the outcome — see [Test cards \u0026 sandbox](#tag/test-cards) for the full decline and 3DS-challenge\n\u003e card set.\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 `lifecycleStatus` (e.g. `captured`, `failed`, `cancelled` — see [Payment Lifecycle](#tag/payment-lifecycle)) along with `status`, `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 the payment operations** — checkout, initialize-payment, finalize-payment, capture, refund and refund cancellation. Use a fresh UUID per logical request; retrying with the *same* key safely returns the existing operation instead of creating a duplicate. Payouts take `idempotencyKey` in the body. Creating a payee, a payee setup, a payout method, a webhook or a split rule accepts the header optionally; wallet routes do not read it.\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 both `initialize-payment` and `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 captured 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## Connect an AI agent (MCP)\n\nAn MCP endpoint for AI agents is in preview and not yet available on production.\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` with `GET /account` 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 Examples use this environment's API, `https://api.fynex.ai`. Whether a payment is a test or real is decided by your **account**, not the host: a **Demo** account never moves real money. See [Going live](#tag/going-live) when you are ready for real payments.\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`.\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://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://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```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   A `201 Created` response returns:\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-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 | No | 2-letter ISO code (e.g., `GB`) |\n   | `autoSettlement` | bool | No | `true` to auto-capture; `false` for manual capture later |\n   | `returnUrls.success` | string | Yes | Customer redirected here on success. The key is required; an empty string falls back to the seller's checkout settings, then the hosted checkout page |\n   | `returnUrls.failure` | string | Yes | Customer redirected here on failure. The key is required; an empty string falls back to the seller's 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://pay.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:** `4000 0000 0000 2701`\n   - **Expiry:** any future month/year\n   - **CVV:** any 3 digits\n\n\u003e [!NOTE]\n\u003e This is an authoritative frictionless-success card (a Mastercard equivalent also exists) — it authenticates without a challenge and\n\u003e reaches `lifecycleStatus` `captured`. For declines, 3DS challenges, and other specific outcomes, see\n\u003e [Test cards \u0026 sandbox](#tag/test-cards).\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 payment's `lifecycleStatus`, 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 (`https://dashboard.fynex.ai`) with your username and password.\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, sign up self-service (see\n**Programmatically** below).\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 each environment's dashboard issues its own — a token only works against the API host of the dashboard that issued it.\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\nThere are two credential shapes on this API, and which one you get depends on\nhow you got it:\n\n- **Dashboard-issued (Integration page, above).** An opaque long-lived string\n  with no prefix — treat the entire value as opaque, don't parse it.\n- **Secret key** (Integration → API keys, see [/keys](/keys)). Prefixed\n  `sk_test_…` while the account is in demo mode and `sk_live_…` once it has\n  gone live.\n\nBoth authenticate identically as a Bearer token on the Payments API. Don't\nassume every token on this API carries an `sk_` prefix — the dashboard-issued\nshape doesn't.\n\nThe **Billing API** (`/billing-api/v1`) is stricter: it accepts only\n`sk_test_…` / `sk_live_…` secret keys and answers `401` to the prefix-less\ndashboard token. If you need both APIs, use an `sk_` key for both — one key\nauthenticates both surfaces, the legacy token authenticates only this one.\n\n## Verifying a token\n\nThe quickest \"is this token alive, and which account is it?\" check is `GET /account`. It requires only a valid seller token and returns the account the token acts for — including `operationalMode`, which tells you whether you are in `Demo` (test cards, no real money) or `Live`.\n\n```bash\ncurl -sS \"$FYNEX_API/account\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n```json\n{\n  \"sellerAccountUuid\": \"3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n  \"name\": \"Acme Marketplace\",\n  \"operationalMode\": \"Demo\",\n  \"status\": \"Active\",\n  \"currency\": \"EUR\",\n  \"selfServeSandbox\": false,\n  \"features\": { \"walletCreation\": true, \"payeePayoutAutoFunding\": true, \"refunds\": true }\n}\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## Rotating an API key without an outage\n\n`sk_` API keys, unlike the dashboard token, rotate with an overlap: regenerating a key can leave the\nold one working for up to 7 days beside its replacement, so you deploy the new key first and let the\nold one lapse. Regenerating answers with the replacement's plaintext, once; the replacement keeps the\nold key's name, permissions and lifetime.\n\n```bash\n# $FYNEX_TOKEN is the dashboard token from the Integration page, not an sk_ key.\ncurl -sS -X POST https://api.fynex.ai/api/v1/developer/api-keys/\u003cid\u003e/regenerate \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"overlapHours\": 24}'\n```\n\n- `overlapHours` — `0` to `168`. `0`, or no body at all, stops the old key at once, as before.\n- `expiresAt` — optional RFC 3339 time at which the replacement stops working. Without it the\n  replacement lives as long as the old key was issued to, counted from now: a 30-day key regenerated\n  today expires in 30 days, and a key that never expired is replaced by one that never expires.\n\nKey management needs the dashboard token or a dashboard session: an `sk_` key cannot create,\nregenerate or revoke keys.\n\n## Naming and expiring keys\n\nGive each integration its own key — production, CI, one per agent — so one can be revoked without\ntouching the others. `POST /api/v1/developer/api-keys` takes:\n\n| Field | Required | Meaning |\n|-------|----------|---------|\n| `type` | yes | `secret` or `publishable` |\n| `name` | no | What the key is for, up to 64 characters. Shown in the key list |\n| `expiresAt` | no | RFC 3339 time at which the key stops working, at least a minute ahead. Omit it for a key that never expires |\n\nA key used after its `expiresAt` is refused with `401` `api_key_expired`. The key list reports it\nwith `status: \"expired\"`; create or regenerate a key to replace it.\n\n## Restricted keys\n\nA secret key can be **restricted** to what one integration needs: a reporting agent that reads\npayments and events, a metering job that only submits usage. Pass `permissions` when you create it:\n\n```json\n{ \"type\": \"secret\", \"name\": \"metering\", \"permissions\": [\"billing_usage:write\"] }\n```\n\nEach permission is a resource at `read` or `write`; `write` includes `read`. Without `permissions`\nthe key has full access, as every key had before.\n\n| Resource | Covers |\n|----------|--------|\n| `payments` | Payments, checkout sessions, capture, payment methods, settling a test payment |\n| `refunds` | Refunds and their cancellation, cancelling a promotion-paid order |\n| `payees` | Payees |\n| `payout_methods` | Payout methods, the seller's own and its payees' |\n| `payouts` | Payouts |\n| `wallets` | Wallets, their funding and transactions |\n| `topups` | Top-up invoices |\n| `split_rules` | Split rules and a payment's split decisions |\n| `webhooks` | Webhook endpoints and the allowlist |\n| `events` | The events feed |\n| `vendors` | Marketplace vendors |\n| `billing_contracts` | Billing customers, contracts, credits, prices and the test clock |\n| `billing_subscriptions` | Subscriptions |\n| `billing_invoices` | Billing invoices |\n| `billing_usage` | Usage events, metrics, dead letters and usage webhook endpoints |\n\nEvery operation in the OpenAPI documents states the permissions it needs in\n`x-fynex-permissions`. A restricted key calling an operation it lacks a permission for is refused\nwith `403` `insufficient_scope`, and the message names what is missing:\n\n```json\n{ \"error\": \"this API key is restricted: this route needs payouts:write\", \"code\": \"insufficient_scope\" }\n```\n\nA restricted key reaches only the documented Payments and Billing operations, under\n`/payments-api/v1` and `/billing-api/v1`, and the MCP endpoint. `GET /account` works with any key\nand reports the calling key's `access` and `permissions` under `apiKey`, so an integration can check\nwhat it may do. Regenerating a restricted key keeps its permissions; only a secret key can be\nrestricted.\n\n## OAuth for AI clients (MCP)\n\nNot yet available on production.\n\n## Token properties\n\n| Property | Dashboard token | `sk_` API key |\n|----------|-----------------|---------------|\n| Scope | Single seller account | Single seller account |\n| Expiry | None | Optional, per key (`expiresAt`) |\n| Revoke | None — rotate to invalidate | Any key, at any time |\n| Rotation | Replaces the token at once | At once, or with up to 7 days of overlap |\n| Several per account | No — one per seller account | Yes, each with its own name |\n| Permissions | Full access | Full access, or restricted to listed permissions |\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 | Same |\n\n## Common errors\n\n| Status | Body | Cause |\n|--------|------|-------|\n| `401` | `{\"error\": \"authorization token is required\", \"code\": \"missing_api_key\"}` | Header absent or malformed at the middleware level |\n| `401` | `{\"error\": \"invalid authorization token\", \"code\": \"invalid_api_key\"}` | Token value is not recognised |\n| `401` | `{\"error\": \"invalid authorization token: this API key has expired …\", \"code\": \"api_key_expired\"}` | An `sk_` key used after its `expiresAt` |\n| `401` | `{\"error\": \"invalid authorization token: invalid access token: …\", \"code\": \"invalid_token\"}` | An OAuth access token that expired, whose connection was revoked, or presented outside the MCP endpoint |\n| `401` | `{\"error\": \"seller auth is required\"}` | Missing seller context inside a handler |\n| `403` | `{\"error\": \"seller account is not active\", \"code\": \"account_not_active\"}` | 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\": \"this API key is restricted: …\", \"code\": \"insufficient_scope\"}` | A restricted `sk_` key without the permission this route needs — see [Restricted keys](#restricted-keys) |\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 Every error body is JSON. Branch on `code`, not on the `error` text — see [Errors](#tag/errors).\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 | `code` | `error` |\n|-----------|--------|--------|---------|\n| Header absent or empty | `401` | `missing_api_key` | `authorization token is required` |\n| Token not recognized | `401` | `invalid_api_key` | `invalid authorization token` |\n| Seller account inactive | `403` | `account_not_active` | `seller account is not active` |\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 on the payment operations — `/checkout`, `/initialize-payment`, `/finalize-payment`, `/capture`, `/refund`, refund cancellation. Optional fallback on `/payouts` (see note below). Optional on `POST /payees`, `POST /payees/setup`, `POST /payees/{id}/payout-methods`, `POST /webhooks` and `POST /split-rules`, where any string of at most 255 characters is accepted. Not read by wallet or vendor routes |\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## Correlation header\n\nThere are no `X-Fynex-Request-Id` or `X-Fynex-Trace-Id` headers. The correlation header is **`X-Request-Id`** (`req_\u003cuuid\u003e`), set on every `/payments-api/v1` response including errors — quote it, together with the `paymentId` (your `externalOrderRef`), when you contact support.\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 | Payment operations (checkout, initialize, finalize, capture, refund, refund cancel) |\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| `Simulator: force_failure=\u003ccode\u003e` | No | `POST /initialize-payment`, Demo-mode accounts only — see [Test cards](#tag/test-cards) |\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\nTwo token shapes authenticate this API: an opaque dashboard-issued token with\nno prefix, or\na secret key prefixed `sk_test_…` or `sk_live_…` — see\n[Authentication](#tag/authentication) for both. Replace `YOUR_SELLER_TOKEN`\nwith your own token's full value either way.\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[![Process map: 1st request, 202 Accepted, 2nd request, 200 OK](/payments-api/v2/docs/diagrams/336a02045484e7fc.svg)](/payments-api/v2/docs/diagrams/336a02045484e7fc.svg)\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| `POST /payees` | `Idempotency-Key` (optional) | Don't create a second payee |\n| `POST /payees/setup` | `Idempotency-Key` (optional) | Don't create a second payee and bank account |\n| `POST /payees/{id}/payout-methods` | `Idempotency-Key` (optional) | Don't add a second bank account |\n| `POST /webhooks` | `Idempotency-Key` (optional) | Get the signing secret back after a lost response |\n| `POST /split-rules` | `Idempotency-Key` (optional) | Don't create a second rule |\n\nReplay semantics depend on the endpoint — see each endpoint's docs. On the five create routes above that mark it optional — payees, payee setup, payout methods, webhooks, split rules — the key may be any string of at most 255 characters, and its namespace is your account. A retry with the same key and the same body answers `201` with what the first request created, as it is now (a webhook includes its current signing secret). The same key with a different body, or on another route, answers `409` with `code` `idempotency_key_reused`. A request that failed recorded nothing, so its key can be sent again. Keys do not expire: mint a new one for each new object. 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```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\nEvery error — every `4xx` and `5xx` — is JSON with an `error` and a `code`:\n\n```json\n{ \"error\": \"amount must be greater than 0\", \"code\": \"bad_request\" }\n```\n\n`code` is the contract; `error` is prose for a human and may be reworded in any\nrelease. Where the API can say more than the status, `code` is specific — see\n*Authentication errors* and *Common 422 responses* below, `idempotency_key_reused`,\n`rate_limited`. Otherwise it is derived from the status:\n\n| Status | `code` |\n|--------|--------|\n| `400` | `bad_request` |\n| `401` | `unauthorized` |\n| `403` | `forbidden` |\n| `404` | `not_found` |\n| `405` | `method_not_allowed` |\n| `409` | `conflict` |\n| `410` | `gone` |\n| `413` | `payload_too_large` |\n| `415` | `unsupported_media_type` |\n| `422` | `unprocessable_entity` |\n| `429` | `rate_limited` |\n| `500` | `internal_error` |\n| `502` | `bad_gateway` |\n| `503` | `service_unavailable` |\n| `504` | `gateway_timeout` |\n\nAny other `4xx` is `client_error` and any other `5xx` is `server_error`. A\nresponse may gain a more specific `code` in a later release, so branch on the\ncodes you know and handle any other `code` by its HTTP status.\n\n### Authentication errors\n\nAuthentication failures use the same JSON envelope, and always carry a `code`:\n\n| Status | `code` | `error` | Meaning |\n|--------|--------|---------|---------|\n| `401` | `missing_api_key` | `authorization token is required` | The `Authorization` header is absent, or has no token after `Bearer ` |\n| `401` | `invalid_api_key` | `invalid authorization token` | The token is not recognised — revoked, mistyped, or issued by the other environment's host |\n| `401` | `key_environment_mismatch` | `invalid authorization token: this key is for the account's other mode …` | A real key for the other mode — typically an `sk_test_` key after the account went Live. Use the `sk_live_` key on a Live account, `sk_test_` on a Demo one |\n| `401` | `sandbox_expired` | `invalid authorization token: this sandbox has expired …` | A self-serve sandbox key after its sandbox expired. Create a new sandbox |\n| `401` | `api_key_expired` | `invalid authorization token: this API key has expired …` | An `sk_` key used after its own `expiresAt`. Create or regenerate a key |\n| `401` | `invalid_token` | `invalid authorization token: invalid access token: …` | An OAuth access token of the MCP endpoint that expired (refresh it), whose connection was revoked, or that was sent to the REST API |\n| `403` | `account_not_active` | `seller account is not active` | The token is valid but the seller account is not active |\n| `403` | `insufficient_scope` | `forbidden`, or `this API key is restricted: …` | A delegated dashboard session whose user lacks the permission the route needs, or a restricted API key without the permission; the message names what is missing |\n| `500` | `auth_unavailable` | varies | The authentication layer itself failed; retry with backoff |\n\n```json\n{ \"error\": \"invalid authorization token\", \"code\": \"invalid_api_key\" }\n```\n\nEvery `401` also carries `WWW-Authenticate: Bearer`.\n\nA request no route serves answers `404` with `code` `not_found`, and a known path called with the wrong method answers `405` with `code` `method_not_allowed`. Operations live under `/payments-api/v1`; a request to an operation under `/payments-api/v2` — the documentation prefix — says so in its `error`.\n\n## Machine-walkable support path\n\nThe currently supported error-to-support path is described by this stable\nobject. It is a documentation contract, not an assertion that every runtime\nerror response already contains a `docsUrl` field (that response change is\nreserved for the PAY-16 contract).\n\n```json\n{\n  \"supportPath\": {\n    \"errorGuide\": \"/payments-api/v2/docs/errors.md\",\n    \"agentIndex\": \"/payments-api/v2/llms.txt\",\n    \"escalation\": {\n      \"kind\": \"email\",\n      \"address\": \"support@fynex.ai\",\n      \"required\": [\"requestId\", \"httpStatus\", \"responseBody\", \"occurredAt\"]\n    }\n  }\n}\n```\n\nAn automated client should fetch `errorGuide`, then use `agentIndex` to find\nthe relevant skill or guide. Until a structured escalation endpoint is\npublished, send the listed fields to `support@fynex.ai`; `requestId` is the\nvalue of the `X-Request-Id` response header. Do not put bearer tokens,\ncardholder data, or secrets in `responseBody`. The email route is the current\nsupported escalation mechanism; it is not a promise that an API endpoint\nexists.\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| `422` | The card was refused and no funds moved (`initialize-payment`) | No — ask for a different card, then send a new `Idempotency-Key` |\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| `payment action is not allowed for the current payment status` | A capture or refund the payment's current state does not allow: it is not `authorized` and manual-capture (capture), not refundable yet or any more (refund), a refund is still `pending`, a refund already succeeded (capture), or the amount exceeds what is left to refund. `GET /payments/{payment_id}` shows which — `lifecycleStatus`, `settlementStatus` — and the conditions are listed under *What you can do next* in [Payment Lifecycle](#tag/payment-lifecycle). |\n| `settlement not yet available for refund` | The payment is captured, but the settlement a card refund must target is not ready: the acquirer refuses refunds until its settlement batch has run (a refund on the day of capture gets this), or the batch has not reached Fynex yet. Nothing was refunded and the payment stays refundable. Retry after the next batch, usually the next day, with a **fresh** `Idempotency-Key`; the refused attempt is listed by `GET /payments/{payment_id}/refunds` with `failureCode` `2004`. |\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## Common 422 responses\n\n`POST /initialize-payment` answers `422` when the card itself is refused before\nauthorization; no funds moved, and re-sending the same card fails identically —\nso, unlike a `502`, this is not a retry signal. Branch on `code`:\n\n| `code` | failureCode | Meaning | What to do |\n|--------|-------------|---------|------------|\n| `card_expired` | `1007` | The card's expiry date has already passed. | 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. |\n| `card_brand_not_supported` | `1008` | The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). | Ask the customer for a card of an accepted brand. If you believe the brand should be accepted, contact Fynex support. |\n\n```json\n{ \"error\": \"This card type is not accepted for this payment. Please use a different card.\", \"code\": \"card_brand_not_supported\" }\n```\n\nThe payment is still created, in `failed` with the matching `failureCode`, so\n`GET /payments-api/v1/payments/{externalOrderRef}` shows it. Replaying the same\n`Idempotency-Key` with the same body returns the same `422` and `code`; to try\nanother card, send a new `Idempotency-Key`. A replay reflects the payment's\ncurrent failure, so it answers from the stored payment rather than repeating\nthe first response verbatim. Treat an unrecognised `code` as a refusal you\ncannot fix automatically — new codes may be added. A generic HTTP client or SDK\nthat treats `4xx` as non-retryable is doing the right thing here.\n\n### A checkout session or payment link no terminal can take\n\n`POST /checkout` and `POST /api/v1/payment-links` answer `422` with `code`\n`currency_not_supported` when none of the account's payment terminals can take\nthe payment — most often a currency the account has no terminal for (a `GBP`\nsession on an account with only `EUR` terminals), or, for a declared\n`countryCode`, a country none of them serves. Nothing is created: no session,\nno payment, no link.\n\n```json\n{ \"error\": \"currency GBP is not supported for country GB on this account\", \"code\": \"currency_not_supported\" }\n```\n\nThe check runs at creation so the customer never reaches a checkout that would\nfail with `1003` (`routing`) after they press pay. A session is accepted when\nany method the hosted checkout can offer for it would route: the card form, or\na local payment method the account has a terminal for in that currency and\ncountry. A payment link lives longer than a session, so it is refused only when\nthe account has no terminal that matches the link's currency, mode (live or\ntest) and payment type and country; a matching terminal that is temporarily\nsuspended, or not effective yet, does not block the link.\n\nThe same request succeeds once the account has a terminal for that currency\n(and country), for example after the currency is activated on the account —\nask Fynex support to enable it. Until then, re-sending it gives the same\n`422`. Replaying an `Idempotency-Key` that already created a session still\nreturns that session. If Fynex cannot check the terminals at that moment (a\ntransient internal error), the session or link is created rather than refused.\n\n---\n\n## Rate limiting (429 responses)\n\nWhen the per-seller limiter is enabled and available, authenticated business\noperation endpoints under `/payments-api/v1/*` are rate-limited using a\ntoken-bucket keyed on the authenticated `seller_account_id`. The standard\nbudget is **100 requests per hour per seller**. An account or environment with\na provisioned exception can have a different budget, so read\n`X-RateLimit-Limit` and `X-RateLimit-Remaining` from the response: they are\nauthoritative for the environment you are calling. The budget is per-seller\n(one noisy client cannot starve others).\n\nA **Demo** account has its own, larger budget: **200 requests per minute per\nseller**, counted separately from the Live budget. Test traffic — including a\ncoding agent that lists, polls and retries in a loop — does not run into the\nLive limit, and switching an account to Live does not carry Demo usage over.\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 (normally `100`) |\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=100;qu=\"requests\";w=3600` — 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: 36\nX-RateLimit-Limit: 100\nX-RateLimit-Remaining: 0\nX-RateLimit-Reset: 3600\nRateLimit-Policy: \"seller\";q=100;qu=\"requests\";w=3600\nRateLimit: \"seller\";r=0;t=3600\n\n{\"error\":\"rate limit exceeded; retry after the Retry-After header value\",\"code\":\"rate_limited\"}\n```\n\nEvery `429` carries `Retry-After`, and a limiter's `429` carries `code`\n`rate_limited`. If the limiter itself is unavailable on a route that fails closed,\nthe answer is `503` with `Retry-After: 1` and `code` `temporarily_unavailable`.\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 — an account-specific budget can differ from the standard one. Add ±20% jitter to any loop.\n\n**Missing headers:** authorized responses — both `2xx` and `429` — normally\ncarry the `X-RateLimit-*` headers. `401`/`403` responses never do, because the\nbudget is keyed on the authenticated seller and the limit is applied after\nauthentication. A deployment may disable the limiter, and a temporary limiter\noutage can allow an authorized request without rate-limit headers. If the\nheaders are absent, hold your most conservative polling interval rather than\nreading 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  \"lifecycleStatus\": \"failed\",\n  \"legacyStatus\": \"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\nAn AVS refusal has its own code: `failureCode: 2005` (address verification\nfailed, `failureCategory: requires_change`) — the buyer must correct the billing\naddress, not retry. A CVV mismatch is still reported as an ordinary `2001`, and\n`avsResult` / `cvvResult` are not published on the payment, so for CVV the\nadvice above applies only when you already know the decline reason from your\nown checkout 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`3003` is the customer walking away rather than the issuer asking: the customer\nwas sent to 3-D Secure and did not complete it before the authentication session\nexpired, so no authorization was ever attempted and nothing was charged. It also\ncarries `retry: fix_first` and `failureCategory: requires_change`. Replaying the\nrequest with the same `Idempotency-Key` returns the same failed payment — the\nsession it depended on is gone — so ask the customer to pay again and complete\nthe verification step their bank shows them.\n\n---\n\n## The `/checkout` Idempotency-Key pitfall\n\n`POST /checkout` validates the `Idempotency-Key` header right after 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). A bad token still answers `401` first. 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| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |\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| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — 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 is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; 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| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |\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\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. A Demo account on `https://api.fynex.ai` is a sandbox\naccount, and that is not 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\n`code` `invalid_api_key` (`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\n\u003e `GET /payments-api/v1/payments/{payment_id}` (the path value is your `externalOrderRef`) or\n\u003e 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 (`lifecycleStatus` `captured`, or `authorized` for a manual-capture payment). 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 reach `lifecycleStatus` `captured`:\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## Forcing a specific `failureCode`\n\nThe card numbers above select a 3DS *authentication* outcome — they don't let you deterministically\nproduce a specific numeric `failureCode` (e.g. testing your handling of `2003` `ProviderTimeout`\nversus `3002` `AuthenticationRequired` versus `2001` `ProviderDeclined`). For that, send the\n**`Simulator`** request header on a **Demo-mode** account:\n\n```\nSimulator: force_failure=2003\n```\n\nThe payment is stamped `failed` with exactly that `failureCode` — same `status`, `failureDescription`,\n`retry` and `failureCategory` as a real failure, and the same `PaymentCompleted` webhook fires — but\n**no call reaches the card processor at all**. This makes failure-handling tests fully deterministic:\nno dependency on the upstream sandbox's own behavior, and no risk of the failure mode drifting if the\nprocessor's sandbox changes.\n\n- Any code in the [failure code catalogue](#tag/errors) can be forced, **except** `0` (no failure)\n  and `9999` (unclassified) — those describe the absence of a classified failure, not one to simulate.\n  An unrecognised or malformed value returns `400`.\n- **Demo-mode only.** On a **Live** account the `Simulator` header is never parsed or acted on,\n  whatever it contains — it cannot affect a real payment.\n- Applies to `POST /payments-api/v1/initialize-payment`.\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  -H \"Simulator: force_failure=2003\" \\\n  -d '{\n    \"externalOrderRef\": \"TEST-SIMULATE-TIMEOUT-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\n\u003e [!IMPORTANT]\n\u003e **The `initialize-payment` response itself does NOT carry the simulated `failureCode`.** A\n\u003e simulated failure follows the exact same path a real authorization-stage failure does, which\n\u003e returns `502` with a generic body — same as any other authorization-time failure on this call\n\u003e (validation errors return `400`, an idempotency conflict `409`, and so on; those aren't what a\n\u003e `Simulator` request produces, since it always reaches the authorization stage):\n\n```json\n{ \"error\": \"the request could not be processed - please try again or contact support\" }\n```\n\nThe requested `failureCode`, `status: \"failed\"`, and matching `failureDescription` are visible on\nthe payment row: poll `GET /payments-api/v1/payments/{paymentId}` (using your `externalOrderRef`),\nor read them off the `PaymentCompleted` webhook, which fires exactly as it would for a real\nfailure. This is the same behavior every other **authorization-stage** failure already has —\nsee the [Errors guide](#tag/errors) — the simulator does not special-case it.\n\nThe exception is the two card refusals made before authorization:\n`force_failure=1007` and `force_failure=1008` return `422` with a machine-readable\n`code` (`card_expired`, `card_brand_not_supported`), exactly as a real expired\ncard or unsupported brand does — see *Common 422 responses* in the\n[Errors guide](#tag/errors).\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's `lifecycleStatus` becomes `captured`.\nPoll `GET /payments-api/v1/payments/{payment_id}` 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  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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  \"lifecycleStatus\": \"failed\",\n  \"legacyStatus\": \"failed\",\n  \"failureCode\": 2001,\n  \"failureDescription\": \"Card declined by issuer\"\n}\n```\n\n| Field | Purpose |\n|-------|---------|\n| `lifecycleStatus` | `\"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 become refundable — `lifecycleStatus` `captured` and `settlementStatus` not `in_flight` (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 payments whose `lifecycleStatus` is `captured` can be refunded — a pre-capture refund attempt returns `409 \"payment action is not allowed for the current payment status\"`. Omit `amount` to refund the remaining refundable amount, or pass a smaller value for a partial refund.\n\n#### Partial capture\n\nCreate a manual-capture payment (`autoSettlement: false`), 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## Paying a Demo checkout without a browser\n\nA hosted checkout session is paid by its buyer, on its `checkoutUrl`. When there is no buyer — a\ntest suite, a CI job, an AI agent — a Demo account can pay its own session with one call. Fynex\nsubmits the session's checkout page with the frictionless Visa above (`4000 0000 0000 2701`) and\nthe [test customer data](#test-customer-data), runs 3DS and completes the payment:\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/test/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b/complete\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n```json\n{ \"status\": \"completed\", \"paymentId\": \"ORDER-100106\", \"lifecycleStatus\": \"captured\" }\n```\n\nThe path takes the `sessionId` `POST /checkout` returned. No card details are sent to or returned\nby the call, and the payment is the session's own: `GET /payments/{paymentId}`, webhooks and\nevents see an ordinary card payment. A session created with manual capture ends `authorized`;\ncapture it before settling. Repeating the call is safe — a session that was already paid answers\n`already_completed`. It is refused on a Live account (`403`, `code` `demo_only`), for another\naccount's session (`404`, `checkout_session_not_found`), once the session has expired (`410`,\n`checkout_expired`), and with the checkout page's own refusal otherwise.\n\nA Demo account takes payments in **one currency, its own** — `GET /account` shows it. A sandbox\nfrom `POST /sandbox/accounts` gets it from the `country` you send: GBP for `GB` (the default),\nUSD for `US`, EUR for a euro-area country such as `DE`. A session in any other currency is refused\nwith `422` `currency_not_supported`.\n\n---\n\n## Settling a Demo payment\n\nThe sandbox acquirer never sends a settlement report, so Fynex settles Demo payments itself: a completed Demo payment in GBP, USD or EUR of **at most 500** (so never more than £500) settles on its own within about a minute — its split runs and the wallets are credited. A bigger Demo payment is never settled automatically: settle it yourself once it has completed (for manual capture, once it is captured). The call also settles a smaller one at once instead of waiting:\n\n```bash\ncurl -sS -X POST \"$FYNEX_API/test/payments/ORDER-100106/settle\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n```json\n{ \"status\": \"settled\", \"paymentId\": \"ORDER-100106\" }\n```\n\nThe call takes a few seconds; the split runs right after it returns, so `GET /payments/{paymentId}/split-decisions`, wallet balances and payouts then behave as they will in production. Repeating the call is safe — a payment that was already settled, by an earlier call or by the automatic settlement, answers `already_settled`. It is refused on a Live account (`403`, `code` `demo_only`) and before the payment has completed (`409`, `payment_not_completed`).\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** | 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 | Real, when enabled | The hosted checkout page validates through `/api/v1/checkout/apple-pay-validate-merchant` (session-bound; active only where Apple Pay is enabled for the deployment and a merchant certificate is configured). Your own Apple Pay button must run merchant validation from your own server in every environment |\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":"This page tracks API-visible changes to Fynex public APIs: Payments and\nBilling. Entries identify the affected surface when it is not clear from the\nendpoint name. Internal work with no effect on what you can call or what you\nget back (infrastructure, internal tooling, refactors) is not listed here.\nSome entries below note when a change only affects certain accounts or\nenvironments — check those notes if a change doesn't seem to apply to you.\n\n---\n\n## API lifecycle and rate-limit contract\n\nThe Payments API's standard seller budget is **100 requests per hour**; a Demo\naccount has a separate budget of **200 requests per minute** (since 2026-10). When\nthe limiter is active and available, authorized responses report the live\nallowance with `X-RateLimit-*` and the structured `RateLimit` /\n`RateLimit-Policy` response headers; the policy field names the active window\nin seconds. The response values are authoritative for your account and\nenvironment. During a temporary limiter outage, requests are allowed without\nrate-limit headers rather than treating their absence as extra capacity.\n\nThe versioning and deprecation policy is part of the public contract: a major\nversion remains supported for at least 12 months after its successor becomes\ngenerally available, and a planned removal has at least 90 calendar days of\nnotice. The [API overview](https://api.fynex.ai/payments-api/v1/docs) defines\nthe OpenAPI and HTTP signals used for a deprecation; this changelog records\nits migration guidance and removal date.\n\n---\n\n## 2026-10\n\n### Breaking\n\n- **An `sk_` API key can no longer list, create, regenerate or revoke API keys.**\n  `GET`/`POST /api/v1/developer/api-keys`, `POST /api-keys/{id}/regenerate` and\n  `DELETE /api-keys/{id}` now answer `403` to an `sk_test_` or `sk_live_` key, so a leaked key\n  cannot mint a replacement that outlives its own revocation. Manage keys in the dashboard under\n  Integration → API keys.\n\n- **Replaying `POST /initialize-payment` after a refused card returns the same `422`, not `200`.**\n  Replaying the `Idempotency-Key` of a payment refused for an expired card (`1007`) or an\n  unsupported card brand (`1008`) used to return `200` with the failed payment in the body; it now\n  returns the same `422` and `code` the first attempt got. If you read the failed payment from a\n  replay, use `GET /payments/{ref}` instead.\n\n### Added\n\n- **Restricted API keys.** `POST /api/v1/developer/api-keys` takes `permissions`, a list such as\n  `[\"payments:read\", \"billing_usage:write\"]`; the key then reaches only the operations those\n  permissions cover and is refused the rest with `403` `insufficient_scope`, naming what is\n  missing. Each operation states what it needs in `x-fynex-permissions`, and `GET /account`\n  reports the calling key's `access` and `permissions` under `apiKey`. Keys created without\n  `permissions` keep full access.\n\n- **API keys can be named, can expire, and rotate without an outage.** `POST\n  /api/v1/developer/api-keys` takes an optional `name` and `expiresAt`; a key used after its\n  `expiresAt` is refused with `401` `api_key_expired`, and the key list reports it as\n  `expired`. `POST /api-keys/{id}/regenerate` takes an optional `overlapHours` (up to 168) that\n  keeps the old key working beside its replacement; without it the old key stops at once, as\n  before. The replacement keeps the old key's name, permissions and lifetime — a 30-day key\n  regenerated today expires 30 days from now — unless `expiresAt` sets another. Existing keys have\n  no name and never expire.\n\n- **An MCP endpoint for AI agents is in preview** and not yet available on production.\n\n- **Demo accounts can raise recurring invoices with no request to Fynex.** Recurring invoicing\n  is enabled per account and currency, and a demo account had to ask for it, so a test-clock\n  advance on a fresh sandbox renewed the subscription and issued nothing. A demo account is now\n  enabled from the start; an explicit setting still decides, and live accounts are unchanged.\n- **MCP tools `create_usage_metric` and `set_usage_price`.** Registering a metric and pricing it on\n  a contract were REST-only, so an agent could create a subscription but not meter it.\n\n- **`Idempotency-Key` on every create route that lacked one.** `POST /payees`,\n  `POST /payees/setup`, `POST /payees/{id}/payout-methods`, `POST /webhooks` and\n  `POST /split-rules` accept it, optionally. A retry with the same key and body answers with what\n  the first request created instead of a second object; for a webhook that includes its signing\n  secret. The same key with a different body answers `409` `idempotency_key_reused`. Requests\n  without the header behave as before.\n\n- **Split webhook: `SplitExecuted`.** A settled payment's split now records an event when it\n  posts, so `GET /events` shows the payment, its split and its payouts in one feed. The payload\n  carries the execution, the payment's `paymentId`, the rule, each payee's share, fee and net and\n  the wallet credited, the total fee and the remainder, in the names and values of\n  `GET /payments/{paymentId}/split-decisions`. A payment with no active rule, or nothing to divide,\n  sends nothing. Opt-in, like the payout family: an endpoint receives it only when its\n  `eventTypes` names it.\n\n- **Payout webhooks: `PayoutCompleted` and `PayoutFailed`.** A payout that reaches `completed` or\n  `failed` now sends a signed event whose payload carries the fields `GET /payouts/{id}` returns,\n  including `failureCode` and `failureMessage` on a failure. The family is opt-in: an endpoint\n  receives it only when its `eventTypes` names the events, so existing endpoints see no change. A\n  per-request `webhookUrl` on `POST /payouts` is still validated only, not delivered to.\n\n- **`POST /test/checkout/{sessionId}/complete` pays a Demo checkout without a browser.** It\n  submits the session's own checkout page with the sandbox's frictionless Visa test card and the\n  test customer data, runs 3DS and completes the payment, so an integration or agent that holds\n  only a key can take a test payment end to end without card details passing through it. MCP\n  tool `complete_test_checkout`. Demo accounts only (`403` `demo_only` on Live); repeating it\n  answers `already_completed`. `POST /test/payments/{paymentId}/settle` now also answers\n  `already_settled`, not `409`, for a Demo payment the automatic settlement got to first.\n\n- **`POST /test/payments/{paymentId}/settle` settles a Demo payment.** The sandbox acquirer sends\n  no settlement report, so a completed Demo payment never settled, split or credited a wallet\n  unless someone pressed the dashboard's button. This call does the same with an API key: the\n  payment settles, its split runs and the wallets are credited. Demo accounts only (`403`\n  `demo_only` on Live); repeating it answers `already_settled`.\n\n- **`GET /account` returns the account a credential acts for.** Its id, name, currency,\n  `operationalMode` (`Demo` or `Live`) and the switches that change what the API accepts\n  (`features.walletCreation`, `features.payeePayoutAutoFunding`, `features.refunds`). Call it to\n  confirm Demo or Live before sending a test card.\n\n- **Demo accounts: settled funds are withdrawable at once.** The T+N payout maturity hold no\n  longer applies to a Demo account's settlement credits or to the legs its splits post, so a\n  sandbox can take a payment, settle it, split it and pay a payee in one session. Live accounts\n  keep the hold exactly as before.\n- **MCP tool `create_payout`.** `POST /payouts` is now a tool too: it pays out from the wallet you\n  name, which is how a payee is paid what a split credited its split wallet. `pay_payee` keeps\n  funding the payee's cashout wallet from your main wallet.\n\n- **`GET /events` lists what happened on your account.** Every event the webhook pipe records —\n  payments, refunds, payment links, subscriptions, invoices, verification — in the same envelope a\n  delivery carries, whether or not you have a webhook URL. Oldest first, filterable by `eventType`\n  and an `occurredFrom`/`occurredTo` range; pass `nextCursor` back as `cursor`, and keep it to\n  resume later.\n\n- **`GET /payments` lists the account's payments.** Newest first, filterable by `status` (the\n  `lifecycleStatus` values), a `createdFrom`/`createdTo` range, `currency`, an inclusive\n  `amountMin`/`amountMax` range (major units, like `amount`; requires `currency`) and `payeeId` (the\n  payments whose split credited that payee, once the split has run), with keyset paging\n  (`cursor` / `nextCursor`). Each entry carries the fields of `GET /payments/{payment_id}`\n  except `allowedActions`, `settlementStatus` and `promotion`.\n\n### Changed\n\n- **A Demo payment of up to £500 settles on its own, on production too.** A completed Demo\n  payment in GBP, USD or EUR of at most 500 now settles within about a minute — its split runs and\n  the wallets are credited — on `https://api.fynex.ai` as on staging. A bigger Demo payment is not\n  settled automatically: call `POST /test/payments/{paymentId}/settle`. Live payments are\n  unchanged.\n\n- **Every error carries a `code`.** Each `4xx` and `5xx` body is now `{\"error\": \"...\", \"code\": \"...\"}`:\n  the specific code where the API names one (as before), otherwise one derived from the status —\n  `bad_request`, `not_found`, `conflict`, `unprocessable_entity`, `internal_error` and so on; see\n  *Error format* in [Errors](#tag/errors). The `error` text is unchanged. A panic answers JSON\n  `internal_error` instead of plain text, a refused checkout finalize says `payment_failed` for a\n  decline and `bad_gateway` for a retryable fault, and a route refused to a self-serve sandbox\n  says `not_available_on_sandbox`. Promotion and reCAPTCHA refusals repeat their `reason` as\n  `code`.\n\n- **Rate-limit refusals carry a `code`, and every `429` carries `Retry-After`.** A limiter's `429`\n  body is now `{\"error\": \"...\", \"code\": \"rate_limited\"}`, and its fail-closed `503` carries `code`\n  `temporarily_unavailable`; the `error` text is unchanged. `GET /billing-api/v1/invoices/export`\n  refused because too many exports are running now sends `Retry-After: 5` with its `429`.\n\n- **A checkout session or payment link no terminal can take is refused at creation with `422`.**\n  `POST /checkout` and `POST /api/v1/payment-links` used to accept a currency the account has no\n  terminal for (for example `GBP` on an account with only `EUR` terminals); the payment then failed\n  with `failureCode` `1003` (`routing`) only after the customer opened checkout and pressed pay.\n  Creation now answers `422` with `{\"error\": \"...\", \"code\": \"currency_not_supported\"}` and creates\n  nothing. A session is accepted when any method the hosted checkout can offer for it would route,\n  including a local payment method; replaying an `Idempotency-Key` that already created a session\n  still returns it. A payment link is refused only when no terminal matches its currency, mode\n  (live or test), payment type and country. See *Common 422 responses* in [Errors](#tag/errors).\n\n- **Authentication failures are JSON with a `code`, like every other error.**\n  A missing, unknown or inactive credential used to be answered with a `text/plain` body\n  (`authorization token is required`, `invalid authorization token`, `seller account is not\n  active`); it is now `application/json` — `{\"error\": \"...\", \"code\": \"...\"}` — with the same\n  `error` wording and `code` `missing_api_key`, `invalid_api_key` or `account_not_active`. A `403`\n  `forbidden` from a permission gate carries `insufficient_scope`, and every `401` carries\n  `WWW-Authenticate: Bearer`. If your client parsed these bodies as plain text, read `code`\n  instead; a substring match on the old wording still matches `error`.\n\n- **A refused key says why, and an unknown route answers in JSON.** A real key used in the\n  account's other mode (an `sk_test_` key after going Live) now answers `401` with `code`\n  `key_environment_mismatch`, and a self-serve sandbox key after expiry `sandbox_expired`; both\n  were `invalid_api_key`, and the `error` text still starts with `invalid authorization token`.\n  A path no route serves under `/payments-api/v1` used to answer a plain-text `404 page not\n  found`; every unknown route now answers `404` `{\"error\":\"route not found\",\"code\":\"not_found\"}`,\n  a wrong method `405` with `method_not_allowed`, and an operation requested under the\n  `/payments-api/v2` documentation prefix is told that operations live under `/payments-api/v1`.\n\n- **`POST /initialize-payment` answers `422` with a `code` for a refused card.**\n  An expired card (`failureCode` `1007`) used to return `502` with a generic message, which read as\n  \"retry\"; it now returns `422` with `{\"error\": \"...\", \"code\": \"card_expired\"}`. A card brand the\n  seller's acquiring agreement does not accept (`1008`) already returned `422`, but without `code`;\n  it now carries `\"code\": \"card_brand_not_supported\"`. The error body gains an optional `code`\n  field; bodies without one are unchanged. If your client retried every `5xx` automatically, an\n  expired card now stops at the first attempt — ask the customer for a different card and send a\n  new `Idempotency-Key`. Provider declines are unchanged.\n\n- **A bank's 3-D Secure refusal now carries the same code whichever way Fynex observes it.**\n  When the customer completed 3-D Secure but their bank refused the authentication, a payment\n  Fynex observed directly through the processor used to fail with `2002` (provider error,\n  `retry: safe`), while the same refusal observed at `/finalize-payment` failed with `2001` and\n  the description \"Card authentication failed (3-D Secure)\". When the bank's verdict is a refusal\n  (`threeDResult` `N` or `R`), both now fail with `2001`, that description,\n  `failureStage: authorization` and `retry: never` — the refusal is the bank's decision. A verdict\n  of 3-D Secure *unavailable* (`U`) or an unanswered challenge on a failed session is no longer\n  described as \"Card authentication failed\" on either path. A session that expires with the\n  challenge unanswered is `3003` on both paths.\n\n- **An abandoned 3-D Secure step now fails with `failureCode` `3003`.**\n  When the customer was sent to 3-D Secure and the authentication session expired before they\n  completed it, the payment used to fail with `2002` (provider error), `retry: safe` and\n  `failureCategory: transient` — advice to replay the same `Idempotency-Key`, which returns the same\n  expired payment. It now fails with `3003` (authentication not completed), `retry: fix_first` and\n  `failureCategory: requires_change`. `status` is still `failed` and `failureStage` is still\n  `checkout`; nothing was authorized. If you branch on `2002`, add `3003` and ask the customer to\n  pay again. Payments that failed before this change keep `2002`.\n\n- **An acquirer's card-brand refusal at authorization now fails with `failureCode` `1008`.**\n  When the acquirer refuses a card because the seller's account is not configured for its brand,\n  and does so at authorization rather than before it, the payment used to fail with `2001`\n  (provider declined) or `2002` (provider error); it now fails with `1008` (card brand not\n  supported) and `failureStage: authorization`, the code a refusal before authorization already\n  carried. No funds move. On `POST /initialize-payment` such a refusal now answers `422` with\n  `\"code\": \"card_brand_not_supported\"` where it used to answer `502`. Retrying the same card\n  fails identically: ask the customer for a card of an accepted brand.\n\n- **Payouts on a Demo account are approved automatically.**\n  A Demo account's payout used to start `awaiting_approval` like a Live one and wait for Fynex;\n  it is now `approved` at creation, at any amount and from any surface, so a test payout runs to\n  completion unattended. A compliance hold (such as the marketplace vendor limit) still applies.\n  Live accounts are unchanged.\n\n- **Payout methods on a Demo account are approved at creation.** `POST /payout-methods` and\n  `POST /payees/{payee_id}/payout-methods` (and a bank-routing change through `PATCH`) on a Demo\n  account now answer `approvalStatus: approved` instead of `pending`, so a bank account you add can\n  be paid out to without waiting for Fynex. When the account goes Live, every payout method nobody\n  at Fynex reviewed returns to `pending` and must be approved again. Live accounts are unchanged.\n\n- **Payee payouts on a Demo account fund themselves.** `POST /payees/{payee_id}/payouts` on a Demo\n  account now tops up a short payee wallet from your main wallet (and creates the payee's cashout\n  wallet if needed) without Fynex enabling auto-funding first. `GET /account` reports it as\n  `features.payeePayoutAutoFunding: true`. Live accounts are unchanged.\n\n- **A Demo account creates wallets without asking Fynex.** `POST /wallets`, `POST /payees/setup`,\n  the payee payout that provisions a cashout wallet and `POST /vendors` no longer answer `403`\n  `wallet creation is not enabled` on a Demo account, which moves no real money. `GET /account`\n  reports `features.walletCreation: true` for every Demo account. On a Live account the switch is\n  unchanged: off by default, Fynex turns it on; wallets made in Demo stay when the account goes\n  Live.\n\n---\n\n## 2026-09\n\n### Breaking\n\n- **Announced: payment `status` switches to the lifecycle enum no earlier than 2026-12-28.**\n  From that date `status` on `GET /payments/{payment_id}`, `POST /initialize-payment`,\n  `POST /finalize-payment`, `POST /payments/{payment_id}/capture` and the `PaymentCompleted`\n  webhook carries the seven lifecycle values below instead of the internal processing status.\n  Nothing changes before then. To be unaffected, read `lifecycleStatus` (the new values) or\n  `legacyStatus` (the current values) now — both are already present, see *Added*. The exact switch\n  date is confirmed here at least two weeks ahead.\n\n- **Announced: `captureMode` decides manual vs automatic capture, no earlier than 2026-12-28.**\n  Today `autoSettlement` decides — or, when it is left out, your account's default — and\n  `captureMode` is only recorded. From the switch date `captureMode: \"manual\"` or `\"auto\"` on\n  `POST /initialize-payment` decides, `autoSettlement` becomes a deprecated alias, and a request\n  whose two fields disagree (e.g. `autoSettlement: true` with `captureMode: \"manual\"`) is refused\n  with `400`. A request that sends both consistently, or only `autoSettlement`, is unaffected. The\n  exact date is confirmed here at least two weeks ahead.\n\n- **`bcPaymentId` removed from the REST payout response**, effective 2026-09-02. Read\n  `providerReference` instead — the supported field for this purpose, available since 2026-08-28.\n  If your integration reads `bcPaymentId` from a REST payout response, that key is now absent from\n  the JSON entirely (not `null`); switch to `providerReference`. This affects **REST only** — the\n  GraphQL payout schema still exposes `bcPaymentId` unchanged for now.\n- **Expired cards are now rejected before the provider is called**, with a dedicated\n  `failureCode` (`card_expired`) and `failureCategory: requires_change` — previously this returned\n  the generic provider-error code with `failureCategory: transient` and `retry: safe`, which could\n  read as \"try again.\" A new attempt with the same expired card will fail identically; the buyer\n  needs a different card.\n\n### Added\n\n- **`PaymentCompleted` fires once per outcome, not once per payment.** A payment reported `failed`\n  (or `cancelled` by an expired checkout) that its provider later proves was captured now sends a\n  second `PaymentCompleted` with `lifecycleStatus: captured`; so does a capture the provider\n  reverses. It used to be dropped as a duplicate, leaving your record on the first outcome while\n  `GET /payments/{payment_id}` showed the new one. A repeat of an outcome already sent is still not\n  re-sent. Each event has its own `eventId`.\n- **`lifecycleStatus`, `legacyStatus` and `settlementStatus` on payment responses.** Every response\n  that carries a payment `status`, and the `PaymentCompleted` webhook, now also carries\n  `lifecycleStatus` — one of seven closed values: `pending`, `under_review`, `authorized`,\n  `captured`, `refunded`, `failed`, `cancelled` — and `legacyStatus`, the current `status` value.\n  `legacyStatus` is deprecated and stays for one notice period after the switch announced above.\n  How far captured money has travelled — what `funds_in_flight`, `settled` and\n  `deposit_confirmed` used to say — is the new `settlementStatus` on `GET /payments/{payment_id}`.\n  GraphQL `GenericPayment` gains `lifecycleStatus` too, as the `PaymentLifecycleStatus` enum; its\n  `status` stays the internal value and does not switch.\n\n  | `legacyStatus` (current `status`) | `lifecycleStatus` |\n  |---|---|\n  | `draft`, `new`, `routed`, `provider_pending` | `pending` |\n  | `compliance_review`, `provider_risk_review` | `under_review` |\n  | `authorized` | `authorized` |\n  | `provider_completed`, `funds_in_flight`, `settled`, `deposit_confirmed`, `refund_pending`, `refund_failed`, `refund_cancelled` | `captured` |\n  | `refunded` | `refunded` |\n  | `failed` | `failed` |\n  | `cancelled` | `cancelled` |\n- **`allowedActions` and `captureMode` on `GET /payments/{payment_id}`.** `allowedActions` lists\n  what you can request on the payment next — `capture`, `partial_capture`, `refund`,\n  `partial_refund` — by the same rules the capture and refund endpoints enforce: a listed action is\n  accepted, an unlisted one is refused, and the array is empty when nothing is possible.\n  `captureMode` (`auto` or `manual`) says how the payment is actually captured.\n- **Split-decision records.** `GET /payments-api/v1/payments/{paymentId}/split-decisions` returns a\n  decision record for every split evaluation on a payment — what each line contributed, which\n  lines didn't fire and why, and which policy adjusted an amount. As with every other payment read\n  on this API, `paymentId` in the path is your `externalOrderRef` from `/initialize-payment`, not a\n  Fynex-issued id.\n- **Split-rule operations in the OpenAPI document.** The ten `/payments-api/v1/split-rules`\n  operations — create, list, get, get active, clone, delete, deactivate, `preview-activation`,\n  `activate` and `calculate` — are now described in `/payments-api/v1/openapi.json`, under the\n  `Splits` tag, with their request and response schemas and the statuses they answer. A client\n  generated from the spec can run create → `preview-activation` → `activate` with no hand-written\n  calls. The operations themselves answer as before, apart from the `remainderMinor` change below.\n  The retired operations — `PATCH /split-rules/active`, `PUT /split-rules/active/lines`, `PUT` and\n  `DELETE /split-rules/active/lines/by-payee/{payeeRef}`, and `POST /split-rules/{id}/schedule` —\n  are **deliberately absent** from the document rather than published with `deprecated: true`: they\n  answer only `409`, and a deprecated entry would still put them in every generated client.\n- **`retry` and `failureCategory` published alongside `failureCode`.** A failed payment, refund, or\n  the `PaymentCompleted` webhook now states whether re-sending the same request can succeed\n  (`retry`: `safe`, `fix_first`, or `never`) and the shape of the failure (`failureCategory`:\n  `hard`, `transient`, `requires_change`, `integration_error`, `cancelled`, or `unknown`).\n  `integration_error` is worth watching for specifically — it means the request itself was wrong,\n  not the payment method.\n- **Webhook event subscriptions.** `eventTypes` can now be set when registering or updating a\n  webhook to receive only the event types you choose; omitting it keeps receiving the full default\n  set as before.\n- **Marketplace vendor onboarding.** `POST /payments-api/v1/vendors` (plus list/get and a\n  verification-link endpoint) lets a marketplace onboard the sellers it pays out to directly,\n  including automated screening. A screened vendor can receive cumulative payouts up to a\n  threshold before full verification is required; further payouts either flag for follow-up or are\n  held for review, depending on your account's configuration, once that threshold is reached. See\n  the vendors guide for the full flow. These endpoints are enabled per account — a `404` on\n  `POST /payments-api/v1/vendors` means vendor onboarding isn't turned on for you yet.\n- **`MarketplaceVendor*` webhook family.** Platforms using vendor onboarding above can now receive\n  `MarketplaceVendorActivated`, `...VerificationRequired`, `...Blocked`, `...PayoutAllowanceChanged`\n  and related events — opt-in, via `eventTypes`.\n- **`Simulator` request header for deterministic failure testing.** On a Demo-mode seller account,\n  sending `Simulator: force_failure=\u003cfailureCode\u003e` on a payment initiation forces that exact\n  `failureCode` onto the resulting payment record — the `initialize-payment` call itself still\n  returns `502`, as every authorization-stage failure does, so read the forced code back from\n  `GET /payments/{paymentId}` or the `PaymentCompleted` webhook. Silently ignored outside Demo mode\n  (a Live payment is never affected); inside Demo mode, an unrecognized or unsupported code returns\n  `400`. See the [test cards guide](#tag/test-cards) for the full header contract.\n\n### Fixed\n\n- **`failureMessage` on REST payout responses is now populated.** Every payout response —\n  `GET /payouts/{id}`, `GET /payouts`, the cancel response, and a `POST /payouts` (or payee\n  payout) `201` whose `status` is already `failed`, including an idempotent replay — carries the seller-safe catalogue text for\n  `failureCode` (for example \"The bank rejected the payout.\"). A create that is rejected by the\n  provider still answers `422` with an error body, as before. The field was declared on the\n  schema since launch but never set; the guides that said \"do not rely on it\" have been\n  corrected. Branch on `failureCode`, display `failureMessage`.\n- **AVS refusals (billing address mismatch) now return a dedicated failure code**\n  (`failureCode: 2005`, address verification failed). Previously misclassified as a generic provider error at the\n  point it actually occurs (the `/payments` call, not handle creation), which surfaced to buyers as\n  an unhelpful \"try again\" — leading some to retry with the same wrong address and stack a second\n  authorization hold. Now correctly tells the integrator to prompt for a corrected billing address.\n- **Insufficient-funds and provider risk-management declines are now correctly classified as hard\n  declines** (`retry: never`) instead of a generic, retryable provider error (`retry: safe`).\n  Both still report the ordinary `provider_declined` failure code — this API does not yet\n  distinguish the specific reason for an issuer/acquirer decline — but integrators are no longer\n  told, incorrectly, that resending the same request is safe. Not backfilled: payments that failed\n  before this fix keep their original failure code.\n\n### Changed\n\n- **The payment's recorded `captureMode` now matches how it is captured.** It used to default to\n  `auto` even for a manual-capture payment; it now reads `manual` whenever the payment waits for\n  `POST /payments/{payment_id}/capture`, whether it was started with `POST /initialize-payment` or\n  a hosted `POST /checkout` session. Only what is recorded changed — capture behaviour did not.\n\n- **`remainderMinor` is always present in the `POST /split-rules/calculate` response**, `0`\n  included; it used to be omitted when zero. It is what no line allocates, and it goes to your main\n  wallet, or to the rule's `remainderWalletId`, as `remainderPolicy` says.\n- **`orderData.payeeDistribution` is not deprecated.** The Splits guide called it a legacy input and\n  described a migration away from it; both statements are withdrawn. A split rule's own lines\n  (`absolute` mode) and `payeeDistribution` (the per-payment input to a `weight`-mode rule) are\n  both supported, neither is legacy, and there is no migration path to follow — integrations that\n  send `payeeDistribution` need no change.\n\n---\n\n## 2026-08\n\n### Breaking\n\n- **Cardholder name is now validated on submission**: ASCII letters, spaces, apostrophes, dots and\n  hyphens only, up to 80 characters. A name outside this set — including one with an accented\n  letter such as é, ñ, or ü — is rejected at request validation with a field-level error, where\n  previously it was accepted and passed through. If you collect cardholder names from users whose\n  names use such characters, transliterate to this character set before sending.\n\n### Added\n\n- **Billing API launched** — a new, separate public REST surface at `/billing-api/v1`\n  (documented alongside this reference), authenticated with the same seller key as the Payments\n  API. Covers invoice creation and management (list, detail, PDF, send), subscriptions (create,\n  cancel, pause/resume, plan changes), contracts, usage ingestion and snapshots, and credit\n  balances. Top-up invoices are *not* duplicated here — they remain on `/payments-api/v1` as\n  before.\n- **Payouts now snapshot the destination account format at creation time**, recorded on the payout\n  going forward, rather than only reflecting the payee's current configuration. Editing the payout\n  method after a payout is created does not change what that payout already recorded.\n- **GBP payouts can dispatch via Faster Payments** (`uk_local`), in addition to existing rails.\n- **Fynex-hosted checkout return page.** A merchant with no return page of their own can supply\n  Fynex's hosted URL as their `returnUrl` instead of building and hosting one — a `returnUrl` is\n  still required on every checkout.\n- **Multibanco payment instructions** exposed for server-to-server alternative-payment-method\n  flows.\n- **MB WAY in-app notification flow.** In addition to the existing redirect flow (which stays\n  available as a fallback, since the in-app push has a four-minute window and can go unanswered),\n  buyers can now approve an MB WAY payment via an app notification without leaving the merchant's\n  checkout.\n- **Merchant fee annex and 3DS verdict fields** now exposed on the GraphQL payment type (backoffice\n  and dashboard surfaces). Not currently exposed on the public REST payment record.\n- **Payout approval is now configured separately for self-payouts vs. payee payouts.** This is set\n  on your account by Fynex, not by API call. A payout awaiting review reports\n  `approvalStatus: awaiting_approval` and dispatches nothing until it is resolved — poll or\n  subscribe as you would for any other non-terminal payout state.\n\n### Fixed\n\n- **Seller-initiated top-up invoice creation is now enabled in production.**\n  `POST /payments-api/v1/topup-invoices` is available as documented.\n\n### Changed\n\n- **Error message bodies are now standardized across the payments API.** Generic Go error text that\n  could previously appear in a `5xx` response body was replaced with the platform's standard\n  client-safe error messages. Status codes and the conditions that produce an error are unchanged —\n  only the `message` text differs. If you matched on error message strings rather than\n  `failureCode`, switch to the code.\n","name":"Changelog"},{"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\nThe OpenAPI document's `servers` block declares the origin of the deployment\nserving it. Each entry is the bare origin; the `/payments-api/v1` prefix is\nalready part of every path in the document.\n\nWhich host you call does not decide whether money is real: that follows your\n**account's** operational mode. A Demo account is a sandbox account on any host.\n\n## Versioning\n\nEvery operation is served under **`/payments-api/v1`** — there is no `/v2`\noperation surface. `/payments-api/v2` carries only the documentation for that\nsame contract (`/payments-api/v2/docs`, `/openapi.json`, `/llms.txt`), because it\nwas the path the current spec was first published under during the docs\nmigration. A request to `/payments-api/v2/initialize-payment` answers `404`.\n\n**Call `/payments-api/v1`.** Reading the reference at `/v2/docs` does not put\nyou on a newer contract; there is only one.\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### Support and deprecation policy\n\nPayments and Billing use the same shared public API lifecycle and deprecation\npolicy. The terms below apply consistently to both public API references.\n\nA public major version remains supported for **at least 12 months** after its\nsuccessor becomes generally available. We give integrators **at least 90\ncalendar days** of notice before removing a public operation, field or enum\nvalue from a supported major version. Each notice names the replacement (when\nthere is one), the migration action and the planned removal date in the public\n[Changelog](#tag/changelog).\n\nThe OpenAPI `deprecated: true` marker is the machine-readable signal. For an\nHTTP operation with a scheduled retirement, the changelog entry records the\nplanned `Deprecation` and `Sunset` response headers: `Deprecation` will say the\noperation is no longer the preferred integration path, and `Sunset` will give\nits planned last-availability date. No HTTP operation has a scheduled\nretirement today, so neither header is currently emitted. A critical security,\nlegal or abuse response may need an earlier withdrawal; if that happens, we\npublish the reason and the affected contract in the Changelog as soon as it is\nsafe to do so.\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. Enabled per deployment (`404` where it is off); ask\n  Fynex before building on it.\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\", \"code\": \"\u003ccode\u003e\" }`:\n`code` is specific where the API can say more than the status, and otherwise\nderived from it (`bad_request`, `not_found`, `conflict`, …). 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\nRequest amounts are **major units** everywhere on this API (e.g. `19.99`), with one\nexception: a split share in `orderData.payeeDistribution` takes `amountMinor`, integer minor\nunits (its major-units `amount` is deprecated).\nPayouts take them as a decimal **string** (`\"amount\": \"19.99\"`); the integer\n`amountMinor` on payouts is a deprecated fallback that `amount` overrides when\nboth are sent. Currencies are ISO 4217 codes (`GBP`, `EUR`, `USD`).\n\n\n## Error format\n\nEvery error returns JSON with an `error` and a machine-readable `code`. Branch\non `code`, never on the `error` text, which may be reworded:\n\n```json\n{ \"error\": \"This card has expired. Please use a different card.\", \"code\": \"card_expired\" }\n```\n\n`code` is specific where the API can say more than the status (`card_expired`\nabove); otherwise it is derived from the status — `bad_request`,\n`unauthorized`, `forbidden`, `not_found`, `conflict`, `unprocessable_entity`,\n`rate_limited`, `internal_error`, `bad_gateway` and so on.\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). A refund refused by the seller balance check carries `code` `insufficient_balance` (the main wallet cannot cover the refund plus its fee), `balance_settling` (retry later: the payment's balance is still settling) or `refund_requires_review` (Fynex must review the payment first); no refund was created |\n| 422  | Card refused with no funds moved (`code`: `card_expired`, `card_brand_not_supported`) — ask for a different card. On `POST /checkout` and payment-link creation, `code` `currency_not_supported`: no terminal of the account can take the currency (or declared country), and nothing was created |\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\nRequest amounts are **major units** everywhere on this API (e.g. `19.99`).\nPayouts take them as a decimal **string** (`\"amount\": \"19.99\"`); the integer\n`amountMinor` on payouts is a deprecated fallback that `amount` overrides when\nboth are sent. Responses add `…Minor` integer twins where the wire needs them.\nCurrencies 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","name":"Overview"},{"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(merchantId: 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`, `apm`\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 `pending` to `captured`, `refunded`, `failed` or `cancelled`.\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- `lifecycleStatus` — the status to branch on; `status` and the deprecated `legacyStatus` carry the previous values until the switch. See [Payment Lifecycle](#tag/payment-lifecycle)\n- `amount` — in major units via REST responses; stored in minor units internally\n- `captureMode` — `auto` or `manual`; accepted, but capture is decided by `autoSettlement` (see [Captures \u0026 refunds](#tag/captures-refunds))\n- `requiresAction` / `actionUrl` — set when a 3DS redirect is needed (see [3DS Authentication](#tag/3ds))\n- `failureCode` / `failureDescription` — populated when `lifecycleStatus` 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 `pending` `GenericPayment` (previous status value `draft`). 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- `postalCode` (or `zip`) is at most 10 characters after trimming; a longer value is rejected with `400` naming the field that was sent: `billingDetails.postalCode must be at most 10 characters`, or `billingDetails.zip must be at most 10 characters` when only `zip` was sent. Not checked on `paymentType: apm` requests, which never send a postcode\n\n\n---\n\n### SavedCard\n\nA card stored on Fynex, linked to a `SellerCustomer`, that the Billing API's off-session collection charges against. Created only by the hosted checkout flow when the buyer ticks *save card* and gives an e-mail, on a successful payment with a billing address.\n\n**API surface:** Internal — no public endpoint lists or charges it. There is no \"charge saved card by reference\" call on this API: the `subscription` block on `/initialize-payment` only flags the processor-side stored-credential relationship and does **not** create a `SavedCard`. Off-session charging happens through Billing invoices — see [Recurring payments](#tag/recurring).\n\n---\n\n### SellerCustomer\n\nA repeat customer under a specific seller, shared with the Billing API: its `id` is the `sellerCustomerId` that Billing contracts, subscriptions and invoices refer to. Matched by e-mail; carries the seller's own `merchantCustomerId` as a reference and owns the customer's `SavedCard`s.\n\n**API surface:** Created by the hosted checkout flow on the same event as a `SavedCard` — the buyer ticks *save card*, gives an e-mail, and the payment succeeds — or by `POST /billing-api/v1/customers` (resolve-or-create by e-mail). Referenced on `/initialize-payment` via the `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), `POST /payouts/{id}/cancel`, and `POST /payees/{payee_id}/payouts` to pay a payee directly. GraphQL queries `payout(id)` / `payouts(...)`.\n\n**Key fields:**\n- `amount` — decimal string in major units (`\"49.99\"`), preferred; `amountMinor` (`4999`) is a deprecated fallback that `amount` overrides. Responses carry both.\n- `currencyCode`\n- `walletId` — source wallet\n- `payoutMethodId` — destination bank account\n- `description` — the text the beneficiary sees on their bank statement (1–140 SEPA-safe characters); optional today, will become required\n- `idempotencyKey` — in the **request body**; the `Idempotency-Key` header is an optional fallback used only when the body field is empty\n- `status` — `pending`, `dispatching`, `processing`, `completed`, `failed`, `cancelled`, plus `manual_reconciliation` when an operator has to resolve the row; `approvalStatus` (`awaiting_approval` / `approved` / `rejected` / `compliance_review`) is separate and set when the seller's approval threshold applies — `compliance_review` means the payout is waiting on a screening verdict, not on a human approver\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`/`POST /payments-api/v1/payees/{payee_id}/payout-methods`, `PATCH`/`DELETE …/payout-methods/{method_id}`, and the seller's own methods at `/payments-api/v1/payout-methods`. GraphQL queries `payoutMethod(id)` / `payoutMethods(payeeId, ...)` and mutations `createPayoutMethod`, `updatePayoutMethod`, `deletePayoutMethod` remain for dashboard sessions.\n\n**Key fields:**\n- `payeeId` — the owning payee\n- `currency` — supported currency for this method\n- `status` — `active` or `inactive`; 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`/`POST /payments-api/v1/payees`, `GET`/`PATCH`/`DELETE /payees/{payee_id}`, and `POST /payees/setup` to create a payee, payout method and wallet in one call. GraphQL queries `payee(id)` / `payees` and mutations `createPayee`, `updatePayee`, `deletePayee` remain for dashboard sessions.\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:** REST — `GET`/`POST /payments-api/v1/wallets`, `GET /wallets/{wallet_id}`, `GET /wallets/{wallet_id}/transactions`, `POST /wallets/{wallet_id}/fund`. GraphQL queries `wallet(id)` / `wallets(...)` and mutations `createWallet`, `updateWallet` remain for dashboard sessions.\n\n**Key fields:**\n- `currencyCode` — one wallet per currency\n- `balance` — current available balance\n- `sellerAccountId`\n\n**Wallet balance, credit balance and customer balance.** A *wallet balance*\nis money a seller owns and is paid out from (`wallets`). A *credit balance*\nis stored value a buyer holds and spends instead of cash; it lives in the\ncredit ledger, anchored either on a billing contract (the merchant's\ncustomer) or on a platform consumer (the coupon wallet). A *customer balance*\nis that credit balance for one consumer. The two stored-value systems never\nhold the same money: credit becomes wallet money only when an order it funded\nsettles to the seller.\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. What a split does with the money — rounding, fees, over-allocation, the remainder, holds, refunds and chargebacks — is described in one place: [Split Rules](#tag/splits), section *How Fynex splits money*.\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:** REST — `GET`/`POST /payments-api/v1/split-rules`, `GET /split-rules/active`, `POST /split-rules/calculate` (see **Splits**). GraphQL queries `splitRule(id)` / `splitRules(...)` and mutations `createSplitRule`, `updateSplitRule`, `deleteSplitRule` remain for dashboard sessions.\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's `settlementStatus` reaches `deposit_confirmed` and a matching split rule exists.\n\n**API surface:** REST — `GET /payments-api/v1/payments/{payment_id}/split` and `GET /payments/{paymentId}/split-decisions`. GraphQL query `splitExecutions(dateFrom, dateTo, sellerAccountId)` remains for dashboard sessions.\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 one place payment status is defined. Other guides link here rather than restate it.\n\n---\n\n## Status fields\n\nEvery response that carries a payment — `GET /payments/{payment_id}`, `POST /initialize-payment`, `POST /finalize-payment`, `POST /payments/{payment_id}/capture` — and the `PaymentCompleted` webhook carry the same status fields:\n\n| Field | Values | Use it for |\n|-------|--------|------------|\n| `lifecycleStatus` | the seven lifecycle values below | **Branch on this.** A closed enum: a new value is a documented, versioned change. |\n| `status` | the previous values today; the lifecycle values after the switch announced in the [changelog](#tag/changelog) (no earlier than 2026-12-28) | Existing integrations. Read `lifecycleStatus` now and the switch does not affect you. |\n| `legacyStatus` | the previous values (`provider_completed`, `settled`, …) — deprecated | Only while migrating off the previous values. It is removed after a notice period. |\n| `settlementStatus` | `pending`, `in_flight`, `settled`, `deposit_confirmed` — on `GET /payments/{payment_id}` only, once money is captured | How far captured money has travelled. |\n\nIn GraphQL, `GenericPayment.lifecycleStatus` is the same closed enum (`PaymentLifecycleStatus`); `GenericPayment.status` is the internal value and does not switch.\n\n---\n\n## Lifecycle status\n\n| `lifecycleStatus` | Meaning | Final? |\n|-------------------|---------|--------|\n| `pending` | Created, being routed, or waiting on the provider or the customer (3-D Secure, a redirect). Nothing is reserved on the card yet. | No |\n| `under_review` | Held by a compliance or provider risk review before authorization. It resolves to `pending` (review cleared, processing resumes), `captured`, `failed` or `cancelled`. | No |\n| `authorized` | Funds are reserved on the card and waiting for your capture (manual capture only). | No |\n| `captured` | Money was taken. It stays `captured` through settlement, after a partial refund, and while a refund is in flight or has failed. | No — it can still be refunded |\n| `refunded` | The whole captured amount was refunded. | **Yes** |\n| `failed` | Declined or errored before any money was taken. `failureCode`, `retry` and `failureCategory` say why and whether to retry. | **Yes**, with one exception (below) |\n| `cancelled` | Called off before any money was taken, including an expired checkout. | **Yes**, with one exception (below) |\n\n**The one exception.** A payment is recorded `failed`, or `cancelled` by an expired checkout, when Fynex could not see that the charge went through. If the provider's own authoritative event later proves the money was taken, the payment is corrected to `captured`. No ordinary event can revive a `failed` or `cancelled` payment; only that proof can.\n\n### Previous values\n\n`legacyStatus` (and `status`, until the switch) carries the previous, finer-grained values. Each maps to exactly one lifecycle status:\n\n| `legacyStatus` | `lifecycleStatus` | `settlementStatus` |\n|----------------|-------------------|--------------------|\n| `draft`, `new`, `routed`, `provider_pending` | `pending` | — |\n| `compliance_review`, `provider_risk_review` | `under_review` | — |\n| `authorized` | `authorized` | — |\n| `provider_completed` | `captured` | `pending` |\n| `funds_in_flight` | `captured` | `in_flight` |\n| `settled` | `captured` | `settled` |\n| `deposit_confirmed` | `captured` | `deposit_confirmed` |\n| `refund_pending`, `refund_failed`, `refund_cancelled` | `captured` | the stage reached before the refund |\n| `refunded` | `refunded` | the stage reached before the refund |\n| `failed` | `failed` | — |\n| `cancelled` | `cancelled` | — |\n\n---\n\n## Transitions\n\n[![Process map: Create, pending, under_review, captured, refunded, authorized, failed / cancelled](/payments-api/v2/docs/diagrams/defbd72ab6c444d9.svg)](/payments-api/v2/docs/diagrams/defbd72ab6c444d9.svg)\n\n*The main transitions. Every transition is listed in the table below.*\n\n| From | To | When |\n|------|----|------|\n| `pending` | `authorized` | The provider authorized a manual-capture payment. |\n| `pending` | `captured` | The provider authorized and captured an automatic-capture payment. |\n| `pending` | `under_review` | A compliance or risk review holds the payment before authorization. |\n| `pending` | `failed` / `cancelled` | Declined, errored, 3-D Secure abandoned, or the checkout expired. |\n| `under_review` | `pending` | A compliance review cleared the payment; processing resumes. |\n| `under_review` | `captured` | A provider risk review released the payment. |\n| `under_review` | `failed` / `cancelled` | The review declined it, or it was not resolved in time. |\n| `authorized` | `captured` | You captured it (`POST /payments/{payment_id}/capture`). |\n| `authorized` | `under_review` | The provider placed it in risk review. |\n| `authorized` | `failed` / `cancelled` | The capture failed, or the provider voided the authorization. |\n| `captured` | `refunded` | Refunds reached the whole captured amount. |\n| `captured` | `failed` / `cancelled` | Rare: the provider reversed a capture it had reported. |\n| `failed` / `cancelled` | `captured` | Only a provider-proven correction (see above). |\n\nA partial refund, a refund in flight and a failed refund leave the payment `captured`; each refund has its own status (see [Refunds](#refunds)).\n\n**Initial, intermediate, final.** Every payment starts `pending`. `under_review` and `authorized` are intermediate: something still has to happen. `captured` is where a successful payment rests; only refunds move it on. `refunded`, `failed` and `cancelled` are final.\n\n**Retrying a failed payment.** Branch on `retry`: `safe` means the same request with the same `Idempotency-Key` can succeed; `fix_first` and `never` mean it cannot. On hosted checkout the buyer can try again on the same session after a decline — the payment goes back to `pending` for the new attempt.\n\n---\n\n## What you can do next\n\n`GET /payments/{payment_id}` answers this for you: `allowedActions` lists exactly the actions below that the payment accepts right now (`capture`, `partial_capture`, `refund`, `partial_refund`; empty when none), and `captureMode` says whether it captures automatically or waits for your capture. The table spells out the rules behind it.\n\n| Action | Allowed when | Result |\n|--------|--------------|--------|\n| Capture — `POST /payments/{payment_id}/capture` | `lifecycleStatus` is `authorized` and the payment was created with manual capture | `captured` |\n| Refund — `POST /payments/{payment_id}/refund` | `lifecycleStatus` is `captured`, `settlementStatus` is not `in_flight`, no refund is in flight, and refunds are enabled for your account | A refund object with `status: pending`; the payment stays `captured` until the whole amount is refunded |\n| Cancel a refund — `POST /refunds/{refund_id}/cancel` | The refund is still `pending` (supported for card refunds) | Refund `cancelled`; the payment stays `captured` |\n| Cancel an authorization | Not available yet. An uncaptured authorization is not released by Fynex; the card issuer releases the hold on its own schedule. | — |\n\nA capture or refund requested outside these conditions is refused — with `409` when the payment's status does not allow it.\n\n### Manual and automatic capture\n\nA payment captures automatically when created with `autoSettlement: true` on `POST /initialize-payment`, or when the request leaves `autoSettlement` out and your account's default is automatic capture. Otherwise — including `autoSettlement` left out on an account without that default — it is a manual-capture payment: it rests at `authorized` until you capture it.\n\n### Partial capture\n\nPass an `amount` lower than the authorized amount to `POST /payments/{payment_id}/capture`. The payment becomes `captured`, the same as a full capture; the capture response's `amount` is what was captured. A second capture of the same authorization is not supported.\n\n### Cancellation and expiry\n\n`cancelled` is set by the system, not by an API call: the provider voided the payment, or a hosted checkout expired before payment (`failureCode` `9002`, \"checkout session expired\"). There is no Fynex-side expiry of an uncaptured authorization.\n\nA customer who abandons 3-D Secure ends in whichever expiry comes first: the authentication session's, which fails the payment with `failureCode` `3003` (authentication not completed), or the hosted checkout's, which cancels it with `9002`. Neither authorized anything, and neither is revived by replaying the request — the customer has to pay again.\n\n---\n\n## Refunds\n\nEach refund is its own object with its own `status` — `pending`, `succeeded`, `failed` or `cancelled` — on `GET /payments/{payment_id}/refunds` and `GET /refunds/{refund_id}`. The payment's `lifecycleStatus`:\n\n- stays `captured` while a refund is `pending`, after it `failed` or was `cancelled`, and after a partial refund;\n- becomes `refunded` once succeeded refunds add up to the whole captured amount.\n\nOnly one refund can be `pending` at a time. See [Captures \u0026 Refunds](#tag/captures-refunds) for the requests.\n\n---\n\n## Webhooks\n\n| Event | Fires when | Status it carries |\n|-------|-----------|-------------------|\n| `PaymentCompleted` | The payment reaches `captured`, `failed` or `cancelled` — once per outcome. A payment reported `failed` and later proven `captured` (see the exception above) sends a second event with `lifecycleStatus: captured`; a repeat of an outcome already sent is not re-sent. | The same `status`, `lifecycleStatus` and `legacyStatus` as `GET /payments/{payment_id}` |\n| `PaymentRefunded` | A refund reaches `succeeded`. | The refund's amount; `partial` says whether this refund was less than the payment |\n\nA `PaymentCompleted` event is not by itself a success: branch on `lifecycleStatus`. There is no event yet for `authorized`, `under_review`, a failed refund or settlement progress — poll `GET /payments/{payment_id}` for those.\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. Above the seller's approval threshold it also carries `approvalStatus: awaiting_approval` until approved. | No |\n| `dispatching` | Claimed by the dispatcher; submission to the banking provider is in progress. | No |\n| `processing` | Payout submitted to the banking provider. | No |\n| `manual_reconciliation` | The provider's answer could not be matched to the row; an operator resolves it. It does not move on its own — raise it with Fynex. | 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` | `dispatching` | Internal dispatcher claims the row. |\n| `dispatching` | `processing` | Submission to the banking provider accepted. |\n| `processing` | `completed` | The banking provider's webhook confirms success. |\n| `processing` | `failed` | The banking provider rejects or a timeout occurs. |\n| `dispatching` | `manual_reconciliation` | The provider's answer could not be matched to the row; parked for an operator. |\n| `manual_reconciliation` | `dispatching` | An operator resumes the dispatch. |\n| `pending` | `cancelled` | Seller or system cancels before dispatch. |\n\n### State diagram\n\n[![Process map: POST /payouts, pending, dispatching, processing, completed, failed, manual_reconciliation, cancelled](/payments-api/v2/docs/diagrams/2aeb36b4b2720424.svg)](/payments-api/v2/docs/diagrams/2aeb36b4b2720424.svg)\n\n### Payout amount field\n\nSend the payout amount as `amount`, a decimal string in **major units** (`\"49.99\"` for £49.99), like every other endpoint. The integer `amountMinor` (`4999`) is a deprecated fallback that `amount` overrides when both are present; responses return both.\n\n---\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| `1004` | Compliance screening declined the transaction | compliance |\n| `1005` | Compliance screening paused the transaction for review (and timed out or was rejected on review) | compliance |\n| `1006` | Initialization interrupted before the provider was reached (retry safely) | checkout |\n| `1007` | Card expired (rejected before the provider is called) | validation |\n| `1008` | Card brand not accepted by the seller's acquiring agreement — refused by Fynex before the provider is called, or by the acquirer when the payment is set up or authorized; no funds move in any case | validation, or authorization when the acquirer refuses |\n| `2001` | Provider declined | authorization |\n| `2002` | Provider error | authorization |\n| `2003` | Provider timeout | authorization |\n| `2004` | Refund requested before settlement was ready (transient) | — (refund responses carry no stage) |\n| `2005` | Address verification (AVS) failed | authorization |\n| `3001` | Capture failed | capture |\n| `3002` | Authentication required — issuer soft decline asking for 3-D Secure | authorization |\n| `3003` | Authentication not completed — the customer did not finish 3-D Secure before the session expired; nothing was authorized | checkout |\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\nOn `POST /initialize-payment`, `1007` and `1008` are also answered with HTTP `422` and a machine-readable `code` (`card_expired`, `card_brand_not_supported`) — replaying the same `Idempotency-Key` returns the same `422`. See *Common 422 responses* in [Errors](#tag/errors).\n\nThe `failureDescription` field carries a human-readable message from the provider. The `failureStage` field (`validation`, `risk`, `compliance`, `routing`, `checkout`, `authorization`, `capture`, `settlement`, `deposit_confirm`) tells you where in the pipeline the failure occurred — read it from the response rather than from the stage column above, which is indicative. The `retry` and `failureCategory` fields beside `failureCode` say whether re-sending can help; the full catalogue is in [Errors](#tag/errors).\n\n---\n\n---\n\n## Polling for status\n\nWebhooks are the fast path; polling is the backstop:\n\n- Poll `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`). Stop on `refunded`, `failed` or `cancelled`, or on `captured` if you do not need settlement progress.\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`, which uses its own checkout vocabulary.\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```bash\ncurl -sS \"$FYNEX_API/payments-api/v1/payment-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\n## Response\n\n**`200 OK`** — returns `dtos.PaymentMethodsResponse`:\n\n```json\n{\n  \"merchantId\": \"16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117\",\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| `merchantId` | `string` (UUID) | The authenticated seller account's UUID. |\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` | `no payment configuration is set up for this account; contact support` — the account has not been provisioned for payments yet |\n| `500` | A dependency is not configured, or loading the payment configuration or terminals failed. The body is a fixed string; the cause is logged server-side, never returned. Retry with backoff |\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 - `POST /payments-api/v1/wallets` — create a wallet in a currency\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 - `POST /payments-api/v1/wallets/{wallet_id}/fund` — fund it\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`, plus `withdrawableBalanceMinor` / `maturingBalanceMinor` for the payout maturity hold (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| `withdrawableBalanceMinor` | `Int!` | Part of available a payout can take now — see **Withdrawable vs. maturing** |\n| `maturingBalanceMinor` | `Int!` | Settled funds still inside the payout maturity hold |\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 reserved against an in-flight payout or currency conversion; released back to available if it fails, is cancelled or is rejected. Not the payout maturity hold — see below |\n| `pendingBalanceMinor` | `Int!` | Legacy field; stays `0`. Read `maturingBalanceMinor` on the wallet instead |\n| `lastEntrySeq` | `Int!` | Sequence number of the entry that produced this snapshot |\n| `updatedAt` | `Time!` | When the snapshot was last written |\n\n### Withdrawable vs. maturing\n\nSettled funds count in `availableBalanceMinor` straight away, but each settlement or split leg is held for **T+N business days** before it can be paid out — N is the seller's `payoutHoldDays` (3 by default; see [Splits](#tag/splits)). Two fields on the wallet (REST `GET /wallets` and the GraphQL `Wallet` type) show that hold:\n\n| Field | Meaning |\n|-------|---------|\n| `withdrawableBalanceMinor` | The part of `availableBalanceMinor` a payout can take **now**. Check this, not `availableBalanceMinor`, before creating a payout. |\n| `maturingBalanceMinor` | Settled funds still inside the maturity hold: counted in available, not yet withdrawable. |\n\nUsually `withdrawableBalanceMinor + maturingBalanceMinor == availableBalanceMinor`, but withdrawable is floored at `0`, so after a debit that takes available below the maturing sum the two no longer add up — do not rely on the equality.\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":"A payment is split by the seller's **active split rule**: a persistent, versioned rule\nthat owns the fee policy and decides where each payee's share comes from, applied\nautomatically when the payment settles. The rule's `allocationMode` names the source:\n\n- **`absolute`** — the rule's own lines. Every payment splits the same way, and the\n  payment's `orderData.payeeDistribution` is ignored.\n- **`weight`** (the default) — the payment's `orderData.payeeDistribution`: the\n  per-payment input that says how *this* payment divides between payees. The rule adds\n  its fee policy on top.\n\n\u003e [!IMPORTANT]\n\u003e **Both are supported, and neither is legacy or deprecated.** This is the design, not a\n\u003e migration in progress. `payeeDistribution` is not a standalone split mechanism: it is\n\u003e the per-payment input to a `weight`-mode rule. An `absolute`-mode rule ignores it, and\n\u003e with no active rule nothing splits at all. There is therefore no migration path from\n\u003e one to the other to publish, and none is coming.\n\nUse an `absolute`-mode rule alone when the distribution is the same for every payment.\nUse a `weight`-mode rule plus `payeeDistribution` when one fee policy applies across many\npayments but each payment divides differently. Either way, the rule version that ran is\ncaptured in the split decision and remains the explanation of record even after the rule\nis replaced or retired.\n\n\u003e [!NOTE]\n\u003e **Two surfaces, one lifecycle.** With a seller API key, manage split rules over REST\n\u003e under `/payments-api/v1/split-rules`: the operations are listed under this tag in the\n\u003e API reference, with their request and response schemas, and are described in the\n\u003e OpenAPI document a client is generated from. With a dashboard session, manage them over\n\u003e GraphQL on `POST /dashboard/graphql` (`dashboard_session` cookie required — see\n\u003e [GraphQL Authentication](#tag/graphql-auth)), which is what the Fynex dashboard uses. Both surfaces\n\u003e enforce the same create → preview → activate contract described below, and the REST\n\u003e shape is set out under **REST operations (API key)**.\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\n```\nsplit rule status is lifecycle-controlled; create an inactive rule, preview it, then activate it\n```\n\nThe 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[![Process map: createSplitRule, previewSplit(ruleId:), activateSplitRule](/payments-api/v2/docs/diagrams/e78c377b480960f1.svg)](/payments-api/v2/docs/diagrams/e78c377b480960f1.svg)\n\n*Activation is an atomic swap: it activates this rule and deactivates the previous one.*\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. Every other active rule becomes inactive in the same transaction, including one that was activated with a later `effectiveFrom` and has not started yet. That is what `deactivated` reports. A unique index in the database backs the same invariant.\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- **Every payee a rule pays must be payable.** Creating or activating a rule whose enabled line names a disabled or deleted payee is refused (`422` on activation). The live split would otherwise refuse that payee after the payment.\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. The swap is still immediate: the rule that was active stops now, not at the new rule's `effectiveFrom`. Until that date no rule applies, and payments that settle in the gap stay unsplit on the main wallet. `effectiveTo` must be later than `effectiveFrom`. Activating a rule whose `effectiveTo` has already passed (for example, one you deactivated earlier) clears that end: the rule applies from the moment it is activated.\n\n---\n\n## REST operations (API key)\n\nEverything above — the lifecycle, the three enforced rules, the receipt/fingerprint contract — applies\nidentically here. The only difference from GraphQL is the wire shape and the identity a receipt is bound\nto: a REST receipt is bound to the seller account behind your API key (not a dashboard user/session), so\nit authorizes activation for any request authenticated with that same key.\n\nAll endpoints below require the standard seller `Authorization: Bearer \u003capi-key\u003e` header and live under\n`/payments-api/v1`. Each one is in the OpenAPI document (`/payments-api/v1/openapi.json`) with its request\nand response schemas and the statuses it answers, and is listed under this tag in the API reference — a\ngenerated client can run create → preview → activate with no hand-written calls.\n\n| Operation | Endpoint | Notes |\n|---|---|---|\n| Create | `POST /split-rules` | Body per `SplitRuleCreateRequest`. Always created inactive. |\n| List | `GET /split-rules` | Paginated (`limit`, `offset`), newest first. |\n| Get one | `GET /split-rules/{id}` | |\n| Get active | `GET /split-rules/active` | The rule in effect now; 404 when there is none. |\n| Clone | `POST /split-rules/{id}/clone` | Returns a new inactive draft with the same lines. |\n| Delete | `DELETE /split-rules/{id}` | Refused while the rule is active. |\n| Deactivate | `POST /split-rules/{id}/deactivate` | Stops the rule now; nothing splits until another rule is activated. |\n| **Preview (issues receipt)** | `POST /split-rules/{id}/preview-activation` | Rule must be inactive. Returns `activationReceipt` + `activationReceiptExpiresAt` (10 minutes). |\n| **Activate** | `POST /split-rules/{id}/activate` | Body: `{ \"previewReceipt\": \"\u003cfrom preview\u003e\", \"confirmed\": true }`. |\n| Calculate (dry run) | `POST /split-rules/calculate` | Simulates a hypothetical payment against the active rule (or an inline `rule`); does not touch activation state. Every amount is integer minor units, `distribution[].amount` included. |\n\nErrors are the usual `{\"error\": \"\u003cmessage\u003e\", \"code\": \"\u003ccode\u003e\"}` JSON: a `401` for a missing or invalid\nkey (`missing_api_key`, `invalid_api_key`), a `403` for an inactive seller account\n(`account_not_active`) or — on the six operations that change rules — a delegated dashboard session\nwhose user may not change split rules (`insufficient_scope`; a seller API key always may), and a `404`\n`not_found` for an `{id}` that is not digits, which matches no route.\n\n`PATCH /split-rules/active`, `PUT /split-rules/active/lines`, `PUT` and `DELETE\n/split-rules/active/lines/by-payee/{payeeRef}`, and `POST /split-rules/{id}/schedule` are retired on REST\nthe same way as on GraphQL (immutable-active / disabled-scheduling): each answers `409`. Clone, edit the\ninactive copy, preview, activate. They are deliberately absent from the OpenAPI document, so that no\ngenerated client offers them.\n\n#### Worked example — create, preview, activate\n\n```bash\nBASE=\"https://api.fynex.ai/payments-api/v1\"\nAUTH=\"Authorization: Bearer $SELLER_API_KEY\"\n\n# 1. Create — inactive by default.\nRULE=$(curl -s -X POST \"$BASE/split-rules\" -H \"$AUTH\" -H \"Content-Type: application/json\" -d '{\n  \"type\": \"custom\",\n  \"lines\": [{ \"payeeRef\": { \"id\": 101 }, \"allocationType\": \"percent_bps\", \"percentBps\": 5000 }]\n}')\nID=$(echo \"$RULE\" | jq -r '.id')\n\n# 2. Preview — issues the activation receipt. No amount/distribution needed; this previews the\n#    saved rule content itself, unlike /split-rules/calculate which simulates a hypothetical payment.\nPREVIEW=$(curl -s -X POST \"$BASE/split-rules/$ID/preview-activation\" -H \"$AUTH\")\nRECEIPT=$(echo \"$PREVIEW\" | jq -r '.activationReceipt')\n\n# 3. Activate with the receipt (valid 10 minutes).\ncurl -s -X POST \"$BASE/split-rules/$ID/activate\" -H \"$AUTH\" -H \"Content-Type: application/json\" -d \"{\n  \\\"previewReceipt\\\": \\\"$RECEIPT\\\",\n  \\\"confirmed\\\": true\n}\"\n```\n\nA refused receipt answers `409`. A stale preview — the rule's content or version changed since, for\ninstance through the GraphQL `updateSplitRule` mutation if dashboard staff also adjust your rules —\nanswers `split rule changed after preview; run preview again`: preview again. A receipt that is missing,\nmalformed, expired, or was issued for another rule or seller answers the same\n`invalid or expired split rule activation receipt` in every case, so a caller can't use the failure\nshape to probe rule ids it doesn't own.\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 Fynex splits money\n\nThis section is the one description of what a split does with money. Every other page that mentions splits points here. All the figures below are integer minor units (`10000` is €100.00) and were produced by the same calculation the preview, `POST /split-rules/calculate` and the live split run.\n\n### What a split divides, and when\n\nA split runs **once**, when the payment's money is credited to the seller's main wallet at settlement. It divides the settled credit (`amountBase: net_settled`, the only base supported today; `gross_payment` is refused). It then moves each payee's net from the main wallet to that payee's wallet as a `WalletTransfer`, and records a `SplitExecution` with the `ruleVersion` it ran and a decision record (`GET /payments/{paymentId}/split-decisions`). The rule in effect at the credit's settlement time is the one that applies.\n\n### Weight mode: the payment says how it divides\n\nEach payee gets `amount × weight / sum of weights`, where the weights are the payment's `orderData.payeeDistribution` shares. The shares always add up to the whole amount.\n\n\u003e **Example.** A `weight` rule of `type: fixed` with a 5 % fee (`percentBps: 500`), and a payment of 10000 distributed 7500 / 2500:\n\u003e\n\u003e | Payee | Share | Fee (5 %) | Net |\n\u003e |---|---|---|---|\n\u003e | A | 7500 | 375 | 7125 |\n\u003e | B | 2500 | 125 | 2375 |\n\u003e\n\u003e The seller keeps the fees, 500, on its own operational wallet. Nothing else is left over.\n\nThree equal weights on 100 give 34 / 33 / 33: the unit that does not divide evenly goes to the largest dropped fraction, ties to the lowest payee ID (see [Rounding](#rounding)).\n\n### Absolute mode: the rule says how it divides\n\nEach enabled line claims `allocationPercentBps` of the amount, `allocationFixedAmountMinor`, or both. Whatever the lines do not claim is the remainder (see [Where the rest goes](#where-the-rest-goes)).\n\n\u003e **Percentage.** Lines of 60 % and 30 % on 10001 pay 6001 and 3000. The 90 % the two lines take together is 9001 (half-up), and the extra unit goes to the 60 % line, which dropped the larger fraction. The remainder is 1000.\n\u003e\n\u003e **Fixed.** Lines of 2000 and 500 on 10000 pay exactly 2000 and 500. The remainder is 7500.\n\u003e\n\u003e **Mixed.** A line of 10 % plus 200 on 10000 pays 1000 + 200 = 1200. The remainder is 8800.\n\n### Fees\n\nThe fee is withheld from each payee's share and goes to the seller's own operational wallet:\n\n- **`type: fixed`**: the rule's `percentBps` and `fixedAmountMinor` apply to every payee's share alike.\n- **`type: custom`**: each line carries its own `percentBps` and `fixedAmountMinor`. Every payee that gets a share must have an enabled line.\n\nThe fee is `share × percentBps / 10000`, rounded half-up, plus the fixed part. A fee larger than the share fails the split.\n\n\u003e **Example.** A `custom` line of 60 % with a fee of 2 % + 30 on 10000: share 6000, fee 120 + 30 = 150, net 5850.\n\n### Priority and over-allocation\n\nWhen the lines of an absolute rule claim more than the amount, `overAllocationPolicy` decides. `priority` (lower goes first, ties to the lowest payee ID) matters only to `cap_by_priority`.\n\n\u003e Two fixed lines of 7000 (priority 1) and 5000 (priority 2) on 10000, i.e. 12000 claimed:\n\u003e\n\u003e | Policy | Result |\n\u003e |---|---|\n\u003e | `fail` (the default) | The split fails and moves nothing; the money stays on the main wallet. |\n\u003e | `cap_by_priority` | 7000 and 3000: priority 1 is paid in full, priority 2 gets what is left. |\n\u003e | `scale_down_all` | 5833 and 4167: both scaled to fit, the leftover unit by largest remainder. |\n\u003e\n\u003e `scale_down_percent` keeps fixed claims whole and scales only the percentage claims. A fixed 2000 plus two 50 % lines on 10000 pays 2000, 4000 and 4000. If the fixed claims alone exceed the amount, the split fails.\n\nA rule that fails at preview never gets an activation receipt. A rule can still preview cleanly on a round amount and fail on a real one, which is why `fail` is worth choosing deliberately.\n\n### Minimum and maximum\n\nIn absolute mode, a line's `minAmountMinor` raises its share and `maxAmountMinor` caps it. Both apply after the over-allocation policy.\n\n\u003e **Example.** A 1 % line with `minAmountMinor: 500` and a 50 % line with `maxAmountMinor: 3000`, on 10000, pay 500 (1 % would be 100) and 3000 (50 % would be 5000). The remainder is 6500.\n\nIf raising shares to their minimums would take more than the amount, the split fails.\n\n### Rounding\n\nAll split arithmetic is in integer minor units, and the preview, `/split-rules/calculate` and the live execution run the same code, so they always round the same way.\n\n- **Absolute-mode percent lines.** Together they receive `gross × (sum of their allocationPercentBps) / 10000`, rounded half-up. Each line first gets its share with the fraction dropped. Any minor units left over go one each to the lines with the largest dropped fractions. Ties go to the lowest payee ID. A 50 % / 50 % rule on 101 pays 51 to the lower payee ID and 50 to the other, never 51 + 51. A single percent line simply rounds half-up.\n- **Fixed amounts** (`allocationFixedAmountMinor`) are added to a line exactly. They are never rounded.\n- **Weight mode.** Each payee gets `gross × weight / sum of weights` with the fraction dropped. The minor units left over go the same way: largest dropped fraction first, ties to the lowest payee ID. The shares always sum to exactly the gross.\n- **Fees.** Each line's fee is `share × percentBps / 10000`, rounded half-up, plus its fixed fee.\n\nA live split can pay less than the preview showed in one case only; see [When a wallet cannot cover it](#when-a-wallet-cannot-cover-it).\n\nWhatever the absolute-mode lines leave unallocated is the remainder, and `remainderPolicy` decides where it goes. See [Where the rest goes](#where-the-rest-goes).\n\n### Where the rest goes\n\nA split never leaves money unaccounted for. Whatever the payees' nets do not take stays with the seller, and `previewSplit` names it instead of leaving you to subtract:\n\n- `remainderMinor` — the part of the amount that no allocation takes;\n- `remainderDestination` — where that remainder goes: `main_wallet` or `remainder_wallet`;\n- `sellerRetainedMinor` — the seller's own share: `remainderMinor` plus `totalFeeMinor`.\n\nThe payees' `netAmountMinor` values plus `sellerRetainedMinor` always add up to `totalAmountMinor`. The fees always go to the seller's own operational wallet. The remainder goes as follows:\n\n| Case | `remainderMinor` | `remainderDestination` |\n|------|------------------|------------------------|\n| Weight mode | `0`: the weights share out the whole amount, so the seller keeps only the fees | `main_wallet` |\n| Weight mode, every weight `0` | The whole amount: the split is skipped | `main_wallet` |\n| Absolute mode, `remainderPolicy: to_main_wallet` (the default) | What the lines leave unallocated | `main_wallet`: it stays where the payment was credited |\n| Absolute mode, `remainderPolicy: to_remainder_wallet` | What the lines leave unallocated | `remainder_wallet`: moved to the rule's `remainderWalletId` |\n| The rule would fail (`wouldFailReason` is set) | The whole amount: a failed split moves nothing | `main_wallet` |\n| No active rule | The whole amount: the split is skipped | Not reported. `previewSplit` returns an error unless you pass an inline `rule` |\n\nWeight mode ignores `remainderPolicy`, so it reports `main_wallet` whatever the policy says. A zero remainder moves nothing, whichever destination is reported.\n\nA 30 % / 50 % absolute rule on 10000, with a 1 % fee on the 30 % line, allocates 3000 (fee 30, net 2970) and 5000 (net 5000). It reports `remainderMinor: 2000`, `totalFeeMinor: 30` and `sellerRetainedMinor: 2030`. 2970 + 5000 + 2030 = 10000.\n\n### When a wallet cannot cover it\n\n- **At split time.** A payment credited net of Fynex's fees is split on its gross. When the seller's main wallet cannot cover those fees, the payees (and a remainder routed to the remainder wallet) give back up to that amount, pro rata to their nets. Finance gets a `split_fees_uncovered` review. This is the one case where a live split pays less than the preview showed.\n- **After a refund or chargeback.** A payee who has already been paid out may not hold their share of it. See [Refunds and chargebacks](#refunds-and-chargebacks): the shortfall is shown and the seller's main wallet bears it. Fynex never drives a payee wallet negative.\n- **Paying out.** A payout is capped at what the wallet can withdraw: its available balance less any unmatured hold.\n\n### Holds\n\nEvery leg a split posts lands in the destination wallet at once, but under a maturity hold. The part the main wallet keeps is held the same way. It becomes withdrawable **T+N business days** after settlement, where N is the seller's `payoutHoldDays` (3 by default, between 1 and 30), counted on the currency's settlement calendar. Until then it counts in the wallet's balance but not in what a payout can take.\n\nOn a **Demo** account there is no hold: sandbox money has no chargeback window to wait out, so\nthe payment's credit and every leg of its split are withdrawable as soon as they post, and a\npayee can be paid in the same session as the payment.\n\n### Rule versions\n\nA rule never changes under a payment:\n\n- Every activation records a new `version`, and each `SplitExecution` stores the `ruleVersion` it ran.\n- The decision record keeps the rule and its lines exactly as they were, so a split stays explainable after the rule is replaced.\n- An active rule cannot be edited, only replaced: clone it, edit the copy, preview it, activate it.\n- One rule is active per seller at a time (see [Three rules the API enforces](#three-rules-the-api-enforces)).\n\n### Refunds and chargebacks\n\nA split runs once. Money that later goes back to the buyer is **not** taken back from the payees automatically. What Fynex does instead:\n\n**Who bears it.** Every party gives back the same fraction of what the split paid it as the fraction of the payment that went back. That covers each payee, the rule's commission, a remainder routed to its own wallet, and the seller's main wallet for what it kept. A refund of 30 % of the payment takes back 30 % of each leg. The part of the payment the split never divided (for example fees withheld before a `net_settled` or annex-net split) is the seller's to bear. Rounding is half-up on the cumulative refunded amount, spread over the legs by largest remainder, so a series of partial refunds adds up to exactly the whole split once the payment is fully refunded.\n\n**What happens.** For every refund and every lost chargeback on a live payment whose split succeeded, Fynex computes that plan once. It records the plan with each wallet's available balance and any shortfall, and sends it to Fynex finance for review. **No wallet is debited automatically**: an operator makes the transfers. The payee wallets, the commission wallet and the remainder wallet each return their share to the seller's main wallet, and the main wallet carries the refund. This runs when `payments.split.refund_reversal_review` is on, and picks up refunds and chargebacks from the last 14 days.\n\n| Scenario | What Fynex does | Who bears it | API / webhook |\n|---|---|---|---|\n| Refund **before** the split (the credit has not arrived yet) | When the split runs, the refund is planned as `refund_before_split`. Some providers net the refund out of the credit the split divides, so the operator checks the credit before acting on the plan. | As above, only if the credit included the refunded money. | `PaymentRefunded` webhook when the refund succeeds. |\n| Refund **after** the split, before any payout | Planned as `refund_after_split`. The legs hold the money, so there is no shortfall. | Each leg pro rata; the seller bears the undivided part. | `PaymentRefunded` webhook. The split execution and its decision record (`GET /payments/{paymentId}/split-decisions`) are unchanged. |\n| Refund **after** a payee was paid out | Same plan. A leg whose available balance is below its give-back shows a shortfall. | Until the payee is asked for it outside Fynex, the seller's main wallet bears the shortfall. Fynex never drives a payee wallet negative. | `PaymentRefunded` webhook. |\n| **Partial** refund | Each partial refund gets its own plan, computed on the cumulative refunded amount. | Pro rata to the refunded fraction. | `PaymentRefunded` webhook per refund. |\n| **Insufficient** payee balance | Shown as `shortfallMinor` per leg in the plan. | Seller's main wallet, as above. | — |\n| Chargeback **opened** / under review | Nothing moves. The dispute is recorded; the provider may hold the funds on its side. | — | See [Disputes \u0026 chargebacks](#tag/disputes). |\n| Chargeback **won** | Nothing moves (nothing moved when it opened). | — | — |\n| Chargeback **lost** | Planned as `chargeback_lost` for the disputed amount, exactly like a refund of that amount. No chargeback fee is charged to the split today. | Each leg pro rata; seller bears the undivided part. | — |\n| **Automatic vs manual** reversal | Manual: Fynex decides and records who owes what, and finance moves the money. Automatic reversal of split legs is not available yet. | — | — |\n\nA payment whose split moved nothing off the seller's main wallet (no payee legs, no commission) is recorded as `not_applicable`: the refund is the seller's alone. So is a refund or chargeback that arrives after earlier ones already took back everything the split divided. Neither asks finance for anything.\n\n\u003e **Example.** A payment of 10000 settles as 9700 and splits 70 / 30: 6790 and 2910. A refund of 3000 (30 % of the payment) reverses 30 % of what the split divided, which is 2910: A gives back 2037 and B 873. The other 90 is the seller's, the fees the split never divided.\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 settled credit the lines run against. `gross_payment` is refused today. |\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`). It must be one of your own active wallets other than the main wallet. This is checked when the rule is saved and again when it is activated (`422` if the wallet no longer qualifies) |\n| `percentBps` | `Int` | Fee in basis points (1 bps = 0.01 %) withheld from every payee's share when `type` is `fixed` |\n| `fixedAmountMinor` | `Int` | Fixed fee in minor units withheld from every payee's share when `type` is `fixed` |\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` | This line's **fee** in basis points when the rule's `type` is `custom` (e.g. `200` = 2 %) — not its share |\n| `fixedAmountMinor` | `Int` | This line's fixed **fee** in minor units when the rule's `type` is `custom` |\n| `allocationPercentBps` | `Int` | The line's share in basis points, in `absolute` mode |\n| `allocationFixedAmountMinor` | `Int` | The line's fixed share in minor units, in `absolute` mode (added to `allocationPercentBps` when both are set) |\n| `priority` | `Int!` | Lower is paid first under `overAllocationPolicy: cap_by_priority`; ignored otherwise |\n| `isEnabled` | `Boolean!` | Whether this line is active |\n| `minAmountMinor` | `Int` | Raises the line's share to at least this, in `absolute` mode, after over-allocation |\n| `maxAmountMinor` | `Int` | Caps the line's share at this, in `absolute` mode, after over-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| `remainderMinor` | `Int!` | The part of `totalAmountMinor` no allocation takes: `totalAmountMinor` minus the `grossShareMinor` values. `0` in weight mode unless every weight is `0`; the whole amount when `wouldFailReason` is set. See [Where the rest goes](#where-the-rest-goes) |\n| `remainderDestination` | `SplitRuleRemainderDestination!` | Where `remainderMinor` goes: `main_wallet` or `remainder_wallet`. Only an absolute-mode rule with `remainderPolicy: to_remainder_wallet` reports `remainder_wallet` |\n| `sellerRetainedMinor` | `Int!` | The seller's own share: `remainderMinor` plus `totalFeeMinor`. The `netAmountMinor` values plus this always equal `totalAmountMinor` |\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: [SplitRuleLineInput!]\n}\n\ninput SplitRuleLineInput {\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. There is none in which they have none either, unless the clone carries an `effectiveFrom` in the future (see the scheduling note above).\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\nPass `payeeDistribution` inside `orderData` when you create the payment — on `POST /payments-api/v1/checkout` or `POST /payments-api/v1/initialize-payment` — to steer that one payment's split. Each element names a `payeeId` and that payee's share in `amountMinor`: integer **minor units** of the payment currency, so `3000` is €30.00. That is the same unit split rules, previews and executions use. The shares must add up to the payment `amount`.\n\n```json\n{\n  \"amount\": 100.00,\n  \"currencyCode\": \"EUR\",\n  \"orderData\": {\n    \"totalAmount\": 100.00,\n    \"payeeDistribution\": [\n      { \"payeeId\": 101, \"amountMinor\": 3000 },\n      { \"payeeId\": 102, \"amountMinor\": 7000 }\n    ]\n  }\n}\n```\n\nOnly the shares are in minor units. The payment `amount` and `orderData.totalAmount` stay in **major** units (`100.00` is €100.00).\n\n- **Use `amountMinor`.** The older `amount` field carries the same share in **major** units (`30.00` for €30.00). It is deprecated but still accepted, and it behaves exactly as it always has.\n- **Use one unit per distribution.** Each element sets exactly one of `amountMinor` or `amount`, and every element in the distribution sets the same one. An element that sets both or neither is refused with `400`, and so is a distribution that mixes the two.\n- **Send positive shares.** An `amountMinor` of zero or less is refused with `400`, the same as a zero or negative `amount`.\n\n\u003e [!WARNING]\n\u003e **Read the field name before you send a number.** A field ending in `Minor` takes minor units: €30.00 is `3000`. The deprecated `amount` takes major units, like the payment `amount` and `orderData.totalAmount`: €30.00 is `30.00`. Checkout and initialize-payment both check the shares against the payment amount. A distribution in the wrong unit is a hundred times too large or too small, so it cannot add up. It is refused with `400` and `orderData.payeeDistribution total must equal payment amount`, or `… must equal orderData.totalAmount` when you sent a total. That error is about the sum, so check the units before you change the numbers.\n\n### Precedence when both are present\n\n`payeeDistribution` is **not** a standalone mechanism — it only ever takes effect alongside an active split rule, and which one actually decides the payee shares depends on that rule's allocation mode:\n\n- **No active split rule on the seller account.** The split is skipped entirely — `payeeDistribution` is not read, and nothing is distributed. A persistent rule must exist and be active for a split to run at all.\n- **Exactly one active rule, `absolute` mode.** The rule's own lines determine every payee's share. `payeeDistribution` is ignored completely, even if present and non-empty.\n- **Exactly one active rule, `weight` mode (the default).** `payeeDistribution` supplies the shares; the rule contributes only its fee policy on top of them — `weight` mode never produces a remainder, so a rule's remainder policy has no effect here. An empty or missing `payeeDistribution` means the split is skipped, same as having no rule at all.\n- **More than one active rule, with a non-empty `payeeDistribution`.** The split execution fails outright. With an empty or missing `payeeDistribution`, the empty-distribution skip above takes precedence instead and the split is silently a no-op success — this is a real, deliberate ordering in the code, not a documentation simplification.\n\nActivation stands down every other active rule in the same transaction, and a unique index in the database backs it, so activating through either surface cannot put a seller in the last case.\n\n### Choosing a rule mode — there is no migration path\n\n`payeeDistribution` is not deprecated and has no removal date: it is how a payment tells a\n`weight`-mode rule how to divide that payment, and it is supported on the same terms as the\nrule itself. There is nothing to migrate away from. What you choose is the rule's mode:\n\n- **The distribution is the same for every payment** — an `absolute`-mode rule alone. Its\n  versioned lines are the whole allocation, in integer **minor units** (`3000` = €30.00).\n  Stop sending `payeeDistribution`: the rule ignores it.\n- **The distribution varies per payment under one fee policy** — a `weight`-mode rule, plus\n  `payeeDistribution` on every payment you want split, in the units the warning above\n  describes. A payment without one is not split.\n\nMoving from one mode to the other is an ordinary rule change: create the new rule inactive,\npreview it, activate it — activation replaces the previous rule atomically. The\n[Hosted Checkout](#tag/hosted-checkout) guide shows `payeeDistribution` on a checkout request.\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":"# Flow `externalId` contract (FLOW-09 / #3234)\n\n**Status:** adopted contract; runtime implementation is pending the Flow aggregate.\n\n`externalId` is the seller-provided join key for a Flow and its related\nartifacts. It is not a globally unique identifier and it is not an internal\nFlow ID.\n\n## Identity and uniqueness\n\nThe uniqueness key is the tuple:\n\n```text\n(seller account, flow type, externalId)\n```\n\n- `seller account` is the authenticated seller scope. A value in one seller's\n  namespace does not collide with the same value in another seller's namespace.\n- `flow type` is the canonical Flow type, compared after the API's normal enum\n  parsing. A value may be reused for a different Flow type in the same seller.\n- `externalId` is compared as the submitted identifier after the API's normal\n  validation/normalisation. The runtime implementation must document that\n  normalisation before accepting writes; it must not silently trim or case-fold\n  values as an undocumented compatibility rule.\n\nA create request whose tuple already exists is a conflict, not a second Flow.\nThe API returns **HTTP `409 Conflict`** and includes the existing Flow's\ncanonical `flowId` in the error details. The response must not create or mutate\nany Flow, event, invoice, or other linked artifact.\n\nThis collision response is also the retry/idempotency rule for `externalId`:\nretrying a create with the same tuple converges on the existing `flowId`; it\nmust never create a duplicate. A separate request idempotency key, if added to\na Flow endpoint, cannot widen this uniqueness scope or override the conflict.\n\n## Immutability and lifecycle\n\n`externalId` is write-once. It is required on Flow creation, is returned on\nreads, and cannot be changed by update, transition, close, or reopen operations.\nThe seller cannot reuse the tuple after a Flow closes: closure does not release\nor recycle the uniqueness reservation. A replacement Flow must use a new\n`externalId` (or a different Flow type).\n\nThe identifier may be copied into events and invoice metadata as a join\nreference, but those copies do not establish identity and cannot be used to\nchange the source Flow.\n\n## Runtime boundary\n\nRuntime implementation is tracked separately in [FLOW-09b / #3309](https://github.com/fynex-ai/fynex-api/issues/3309). That follow-up is blocked on [FLOW-00 / #3225](https://github.com/fynex-ai/fynex-api/issues/3225), because the Flow aggregate and public endpoint do not exist yet.\n\nThis document is the contract for the future Flow aggregate only. There is currently no Flow aggregate, persistence constraint, handler, or public\nmoney-moving endpoint in this repository. No implementation may claim that\n`409`, immutability, or Flow-linked artifact propagation is live until the\naggregate, seller-scoped unique constraint, transaction boundary, and handlers\nare implemented and covered by runtime tests.\n\nThe implementation follow-up must at minimum prove:\n\n1. concurrent creates arbitrate on the seller/type/externalId unique constraint;\n2. the losing create returns `409` with the winner's `flowId` and creates no\n   linked rows;\n3. update and lifecycle writes reject any externalId change before persistence;\n4. a closed Flow still reserves its tuple; and\n5. authenticated seller scope is part of every lookup and write.\n","name":"Flow externalId"},{"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[![Process map: Create session, Session created, Redirect customer, Hosted payment page, Process payment, Return to your site, Verify and fulfil](/payments-api/v2/docs/diagrams/13aedf8562725cfc.svg)](/payments-api/v2/docs/diagrams/13aedf8562725cfc.svg)\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 `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`) from your server, or subscribe to SSE events in the browser. 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```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   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 `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`) 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; `false` leaves it for manual `POST /capture`. Omitted means the seller account's configured default. |\n| `orderData.payeeDistribution` | array | Split funds across payees. Each element is `{ \"payeeId\": \u003cint64\u003e, \"amountMinor\": \u003cint64\u003e }`, the share in integer minor units (`8500` for £85.00). The deprecated `{ \"payeeId\": \u003cint64\u003e, \"amount\": \u003cfloat\u003e }` in major units is still accepted; every element must use the same one. The shares must add up to the payment `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| `allowPromotions` | bool | When `true`, the customer may apply a coupon or Fynex wallet credit on the hosted page, and the amount charged is then lower than `amount` by the promotion. Defaults to `false`: the customer pays exactly `amount`. Promotions are offered only once Fynex has switched coupon checkout on. |\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\u003e [!NOTE]\n\u003e `payeeDistribution` only takes effect alongside an active [Split Rule](#tag/splits) on your\n\u003e seller account — with no active rule, it's silently ignored and nothing is distributed. See\n\u003e [Split Rules § Precedence when both are present](#tag/splits) for exactly how the two interact.\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, \"amountMinor\": 8500 },\n      { \"payeeId\": 102, \"amountMinor\": 1500 }\n    ]\n  }\n}\n```\n\n`amount` and `orderData.totalAmount` are in major units. Each share's `amountMinor` is in integer minor units, so `8500` is £85.00. The shares must add up to `amount` in minor units, which is `10000` for the £100.00 above (or to `orderData.totalAmount` if you provide it). A distribution sent in the wrong unit cannot add up, so it is refused with `400`. The deprecated major-units `amount` on a share is still accepted, but every share in one distribution must use the same unit — see [Split Rules](#tag/splits). What happens to the money afterwards (fees, rounding, holds, refunds) is described in [Split Rules](#tag/splits), section *How Fynex splits money*.\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 **`401 seller auth is required`**\n\u003e\n\u003e `/checkout` answers this when the request carries no authenticated seller — a missing, malformed or revoked Bearer token (`/initialize-payment` phrases the same condition as `merchantId is missing in auth context`). It is not an `Idempotency-Key` problem: a missing or non-UUID key answers `400` with `Idempotency-Key header is required` / `Idempotency-Key must be a valid UUID`, and that check runs only after the token has passed. Fix the token for a 401, the header for those 400s.\n\n- **`Idempotency-Key` reuse with a different body returns `409 Conflict`** — `allowPromotions` included. 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\nWith no buyer at hand — a test suite, a CI job, an AI agent — a Demo account can pay its own\nsession with `POST /test/checkout/{sessionId}/complete`: Fynex submits this page with the\nfrictionless Visa above, so no card details pass through your code. See\n[Test cards \u0026 sandbox](#tag/test-cards).\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[![Process map: Fill card form, Initialize payment, Processor authorizes, 202 response, Redirect browser, Customer completes 3DS, Finalize payment, Capture / finalize, Verify and fulfil](/payments-api/v2/docs/diagrams/7e4a1ba20b5f53a3.svg)](/payments-api/v2/docs/diagrams/7e4a1ba20b5f53a3.svg)\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). As a backstop, poll `GET /payments-api/v1/payments/{payment_id}` with the same bearer token (the path value is your `externalOrderRef`). 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```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   The response is `202 Accepted` for a new payment:\n\n   ```json\n   {\n     \"paymentId\": \"ORDER-1042\",\n     \"status\": \"provider_pending\",\n     \"lifecycleStatus\": \"pending\",\n     \"legacyStatus\": \"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     \"lifecycleStatus\": \"pending\",\n     \"legacyStatus\": \"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```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   The response is `200 OK`:\n\n   ```json\n   {\n     \"paymentId\": \"ORDER-1042\",\n     \"status\": \"provider_completed\",\n     \"lifecycleStatus\": \"captured\",\n     \"legacyStatus\": \"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 `lifecycleStatus` is `captured` — a manual-capture payment rests at `authorized` until you capture it (see [Payment Lifecycle](#tag/payment-lifecycle)). If `failureCode` is present, the payment was declined — surface the `failureDescription` to the customer as appropriate.\n\n   Poll `GET /payments-api/v1/payments/{payment_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 | `true` captures automatically. `false` makes it a manual-capture payment that rests at `authorized` until `POST /payments/{id}/capture`. Left out, your account's default applies: automatic capture if the account defaults to it, otherwise manual. |\n| `captureMode` | string | `auto` (default) or `manual`. Accepted, but it does not decide capture today — `autoSettlement` does. |\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`, at most 10 characters; longer is rejected with `400` `billingDetails.postalCode must be at most 10 characters`, or `billingDetails.zip must be at most 10 characters` when only `zip` was sent; not checked for `paymentType: apm`), `countryCode` (or `country`). Optional at the API level, but **the upstream card processor requires `country` and `zip`**. Omitting them returns 502 with `{\"error\":\"\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, \"amountMinor\": \u003cint64\u003e }`, the share in integer minor units (`8500` for £85.00). The deprecated `{ \"payeeId\": \u003cint64\u003e, \"amount\": \u003cfloat\u003e }` in major units is still accepted; every element must use the same one. The shares must add up to the payment `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, \"amountMinor\": 8500 },\n      { \"payeeId\": 102, \"amountMinor\": 1500 }\n    ]\n  }\n}\n```\n\n`amount` and `orderData.totalAmount` are in major units. Each share's `amountMinor` is in integer minor units, so `8500` is £85.00. The sum of `payeeDistribution[*].amountMinor` must equal `amount` in minor units, which is `10000` for the £100.00 above. A distribution sent in the wrong unit cannot add up, so it is refused with `400`. The deprecated major-units `amount` on a share is still accepted, but every share in one distribution must use the same unit — see [Split Rules](#tag/splits).\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```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**Response shape with `skip3DS: true`:** the upstream payment handle is created in `PAYABLE` state and the payment is auto-finalized by Fynex's upstream status poller (5-second tick), so its `lifecycleStatus` typically reaches `captured` (`authorized` for a manual-capture payment) within ~5 seconds of `/initialize-payment` returning. The `/initialize-payment` response will contain:\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"provider_pending\",\n  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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 `GET /payments-api/v1/payments/{payment_id}` (or wait ~5s for one poller cycle) and `lifecycleStatus` will be `captured` (`authorized` for a manual-capture payment).\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\nThe `subscription` block above opts the *first* payment into the card processor's stored-credential flow; this API has no call that charges a stored card by reference afterwards. Recurring **billing** — subscriptions, invoices and off-session collection against a card the customer saved on hosted checkout — is the separate Billing API at `/billing-api/v1`. See [Recurring payments](#tag/recurring) for how the two fit together. Do not rely on the legacy `/api/v1/checkout/customer` endpoint documented elsewhere — that is the dashboard's internal path, not part of this public API surface.\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 `lifecycleStatus` (alongside `status`, the deprecated `legacyStatus` and, once captured, `settlementStatus`), the originally authorized `amount`, `currencyCode` / `countryCode`, `paymentType` / `paymentMethod`, `externalOrderRef`, the latest `failureCode` / `failureDescription` / `failureStage` if the payment failed, and `createdAt` / `updatedAt` / `failedAt` timestamps. (The raw upstream `providerStatus` is not part of this REST response — it is exposed only on the GraphQL `genericPayment` type.) Stop polling once `lifecycleStatus` is `refunded`, `failed` or `cancelled` (or `captured` if you don't need settlement progress) — see [Payment Lifecycle](#tag/payment-lifecycle). 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 `lifecycleStatus` 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` — left out, your account's default applies, which may be automatic capture. `captureMode: \"manual\"` alone does not make a payment manual-capture. The capture endpoint refuses a payment that captured automatically.\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  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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[![Process map: Check availability, Show Apple Pay button, Validate merchant, Apple Pay sheet opens, Customer authorizes, Submit token, 3DS if requiresAction, Complete payment](/payments-api/v2/docs/diagrams/3ac3d88af9d333a8.svg)](/payments-api/v2/docs/diagrams/3ac3d88af9d333a8.svg)\n\nThe submit call is `POST /api/v1/checkout/apple-pay-submit`; it responds `{ success, status, requiresAction?, actionUrl? }`. In production, merchant validation calls Apple from your own server — there is no Fynex endpoint for it. If `requiresAction` is set (3DS), redirect the browser to `actionUrl`; once the customer authenticates, the hosted page finalizes through the shared checkout contract. Then call `session.completePayment(ApplePaySession.STATUS_SUCCESS)`.\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). Fynex's `/api/v1/checkout/apple-pay-validate-merchant` exists only for the hosted checkout page — it is bound to a live checkout session and is not usable from your own Apple Pay button |\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 for your own button (the hosted checkout page has a session-bound one, `/api/v1/checkout/apple-pay-validate-merchant`, which requires a live checkout session). 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 internal status after initialization — a previous-style value, which on this legacy route can also be `initializing` or `finalizing`. This response has no `lifecycleStatus`; read it from `GET /payments/{payment_id}` or the webhook |\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; // a previous status value, e.g. provider_pending — branch on lifecycleStatus from the 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 Direct API is not yet running on production (`api.fynex.ai`); 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 (`lifecycleStatus` `captured` 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  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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, `lifecycleStatus` moves from `pending` to `captured`.\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  \"lifecycleStatus\": \"captured\",\n  \"legacyStatus\": \"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\n\nA Google Pay payment has the same lifecycle as a card payment: `lifecycleStatus` goes from `pending` to `captured`, or to `failed` / `cancelled`. See [Payment Lifecycle](#tag/payment-lifecycle).\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 posts `PaymentCompleted` — and `PaymentRefunded` on refunds — 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`. Stop once `lifecycleStatus` is `refunded`, `failed` or `cancelled` (or `captured` if you don't need settlement progress). 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 echoes what you sent on `/initialize-payment` — `google_pay` for a wallet payment, `card` for a typed card — even though the underlying card is charged through the same upstream processor. Use it to distinguish the two at the data level.\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's `lifecycleStatus` can remain `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 — a Demo account reaches them on any Fynex host. Call the base URL your token was issued for: each environment keeps its own credential store, 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 a payment that stays `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  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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| `lifecycleStatus` | string | `pending` until the scheme reports back — see [Payment Lifecycle](#tag/payment-lifecycle). (`status` and `legacyStatus` carry the previous value, `provider_pending`.) |\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  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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[![Process map: Go to actionUrl, Authorize, Fynex return handler, Find destination, Buyer lands](/payments-api/v2/docs/diagrams/e6eae910ace1ddca.svg)](/payments-api/v2/docs/diagrams/e6eae910ace1ddca.svg)\n\nOn server-to-server payments your UI sends the buyer to `actionUrl`; on hosted checkout the\nFynex page does it. The buyer lands on the hosted-checkout success / failure URL, the caller's\n`returnUrl` with `?status=\u0026paymentId=`, or the Fynex-hosted status page (no caller destination).\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  \"merchantId\": \"16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117\",\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  \"lifecycleStatus\": \"pending\",\n  \"legacyStatus\": \"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's `lifecycleStatus` is `pending`. The customer must complete the issuer's challenge at `actionUrl` before the payment can be authorized. If `requiresAction` is `false` (frictionless flow), the payment's `lifecycleStatus` may already be `authorized` or `captured` 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[![Process map: Initialize payment, Action required, Return actionUrl, Redirect browser, Customer completes, Back at returnUrl, Finalize payment, Payment captured](/payments-api/v2/docs/diagrams/de941d5d52d5a095.svg)](/payments-api/v2/docs/diagrams/de941d5d52d5a095.svg)\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.lifecycleStatus will be 'captured' on success ('authorized' for a manual-capture payment)\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---\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 `lifecycleStatus` of `failed` or `cancelled` plus `failureCode` and `failureDescription`:\n\n```json\n{\n  \"paymentId\": \"ORDER-100106\",\n  \"status\": \"failed\",\n  \"lifecycleStatus\": \"failed\",\n  \"legacyStatus\": \"failed\",\n  \"failureCode\": 2001,\n  \"failureDescription\": \"Card declined by issuer\"\n}\n```\n\nCommon failure scenarios:\n\n| Scenario | Resulting `lifecycleStatus` | 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. `lifecycleStatus` 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- For capture, the payment must be manual-capture: created with `autoSettlement: false`, or with `autoSettlement` left out on an account whose default is not automatic 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- the payment is manual-capture (`autoSettlement: false` on the original `/initialize-payment` request, or `autoSettlement` left out on an account whose default is not automatic capture), **and**\n- its `lifecycleStatus` is `authorized`.\n\nOtherwise capture returns `409 Conflict` with `\"payment action is not allowed for the current payment status\"`.\n\n### Full capture\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**Response (200 OK):**\n\n```json\n{\n  \"paymentId\": \"ORDER-1042\",\n  \"status\": \"provider_completed\",\n  \"lifecycleStatus\": \"captured\",\n  \"legacyStatus\": \"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```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\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` | Payment status post-capture — the previous values until the switch (see [Payment Lifecycle](#tag/payment-lifecycle)) |\n| `lifecycleStatus` | `string` | `captured` after a successful capture |\n| `legacyStatus` | `string` | Deprecated: the previous status value |\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- the payment's `lifecycleStatus` is `captured`, **and**\n- its `settlementStatus` is not `in_flight`, **and**\n- no refund on the payment is still `pending`, **and**\n- refunds are enabled for your account.\n\nAttempting a new refund outside these conditions is refused (`409 Conflict` when the payment's status does not allow it).\n\n\u003e [!NOTE]\n\u003e A card refund also waits for the acquirer's settlement batch. Refunding on the day the payment was captured answers `409` with `settlement not yet available for refund`: nothing is refunded, the payment stays refundable, and the same request succeeds after the next batch, usually the next day. Send it again with a fresh `Idempotency-Key`. See [Errors](#tag/errors).\n\n\u003e [!NOTE]\n\u003e An order that a coupon and promotional credit paid **in full** took no cash, so there is nothing to refund: `POST /refund` answers `409` (\"this order was paid entirely by promotions; cancel the promotional order instead\"). Cancel it with `POST /payments-api/v1/payments/{payment_id}/cancel-promotion` and a JSON body `{\"reason\": \"...\"}` (1 to 512 characters, same `Idempotency-Key` header). The buyer's credit and coupon are given back, the payment becomes `refunded`, and you receive a `PaymentPromotionCancelled` webhook instead of `PaymentRefunded`. An order that took any cash must have that cash refunded first. A payment that a promotion part-paid can be captured only in full (a partial `amount` answers `409`).\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```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### 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\nPayment status is defined in [Payment Lifecycle](#tag/payment-lifecycle). A capture moves `lifecycleStatus` from `authorized` to `captured`; a partial capture, a partial refund and a refund in flight leave it `captured`, and it becomes `refunded` only once the whole captured amount is refunded.\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 over `\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e` (`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, read the payment over REST with your bearer token —\n`GET /payments-api/v1/payments/{payment_id}`, where the path value is your\n`externalOrderRef` — and watch `lifecycleStatus` become `refunded` once the whole\namount is refunded. A partial or failed refund leaves it `captured`, so follow the\nrefund itself on `GET /refunds/{refund_id}`.\nFrom a dashboard session the GraphQL query reads the same payment. Its\n`lifecycleStatus` is the same value as on REST; its `status` is the internal value:\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    lifecycleStatus\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 [!IMPORTANT]\n\u003e **Two surfaces, two jobs.** This Payments API can mark the *first* card payment as the start of a stored-credential relationship (the `subscription` block on `POST /initialize-payment`), but it has no call that charges a stored card by reference and no subscription object. Subscriptions, recurring invoices and off-session charging of a saved card are the **Billing API** at `/billing-api/v1` — same seller secret key, its own reference at `/billing-api/v1/docs`. Do not rely on any `/api/v1/checkout/*` endpoints; they are the dashboard's internal path, not part of either public API.\n\n---\n\n## Which one you need\n\n| You want to | Use |\n|-------------|-----|\n| Take one card payment now and tell the processor it may be followed by merchant-initiated charges (stored-credential / COF flagging) | This API: `POST /initialize-payment` with `subscription.enabled` and `subscription.allowSubsequentMerchantInitiated` — see [Server-to-Server](#tag/server-to-server). |\n| Bill a customer on a schedule: trials, renewals, plan changes, pauses, cancellations | Billing API: `POST /billing-api/v1/contracts/{contractId}/subscriptions` to create, then `POST /billing-api/v1/subscriptions/{subscriptionId}/cancel` · `/pause` · `/resume` · `/change-plan` · `/end-trial`. |\n| Issue and collect an invoice — hosted payment link, bank transfer, or stored credit | Billing API: `POST /billing-api/v1/invoices` and `POST /invoices/{invoiceId}/send`. |\n| Charge a returning customer's saved card without them present | Billing API off-session collection: a sent or overdue invoice is charged against the customer's saved card by a background loop, where the deployment has `billing.invoice.auto_charge` switched on (a platform flag operated by Fynex, not a per-account setting). There is no public \"charge this card\" call on either API. |\n| Know that a renewal was paid, failed or went past due | Billing API webhooks: `InvoicePaid`, `InvoiceOverdue`, `BillingSubscriptionPastDue` and the rest of the lifecycle catalogue, on the same signed pipe as `PaymentCompleted`. |\n\n---\n\n## How the two connect\n\n**The customer is shared.** A card payment on this API that carries a customer e-mail (hosted checkout collects it; `merchantCustomerId` is stored as your reference for the same record) resolves to the seller customer that the Billing API's `POST /billing-api/v1/customers` returns for that e-mail. That `id` is the `sellerCustomerId` every contract, subscription and invoice refers to.\n\n**The saved card comes from hosted checkout, not from the `subscription` block.** A card is stored on Fynex only when the buyer ticks *save card* on the hosted checkout page, gives an e-mail, and the payment succeeds with a billing address. The `subscription` block by itself creates a stored-credential anchor at the card processor — enough for the processor to accept later merchant-initiated charges — but does **not** store a card that Billing's collection loop can use. If you rely on off-session collection, your customers must have saved a card on hosted checkout; a server-to-server first payment alone is not enough.\n\n**The rail is the same.** An invoice collected off-session is finalized by the same payment engine as a hosted-checkout payment, so it fires the same `PaymentCompleted` webhook, and the invoice reads `paidVia: payment_link` either way. The payment is stamped with what it settled — its payment link's `metadata` carries `billingInvoiceId`, `invoiceNumber`, `contractId`, `agreementNumber` and `sellerCustomerId` — so you join the two from the payment side without reconstructing anything from an order reference. See the Billing **Workflows** guide.\n\n---\n\n## Stored-credential flagging on this API\n\n| Field | What it does |\n|-------|--------------|\n| `subscription.enabled` | Marks the payment as the initial transaction of a stored-credential relationship at the processor. Card-only. |\n| `subscription.allowSubsequentMerchantInitiated` | With `enabled`, tells the processor later charges may be merchant-initiated (cardholder absent). Send `false` for one-tap re-purchase where the cardholder is always present. |\n| `merchantCustomerId` | Your stable customer reference; stored on the payment and on the seller customer record. |\n| `skip3DS` | Only for payments that qualify for an SCA exemption (merchant-initiated, recurring with stored credentials). See [3DS](#tag/3ds). |\n\n\u003e [!NOTE]\n\u003e **PCI scope.** Raw card data stays on Fynex's infrastructure, but instructing Fynex to store credentials and relying on merchant-initiated 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## What is not available\n\n- No public endpoint on either API to charge a previously stored card *by reference* on demand. Off-session charging happens only through Billing's collection loop against a sent or overdue invoice.\n- No subscription object on this Payments API. Subscription state lives on the Billing API only.\n- Off-session collection retries a declined charge **up to 3 attempts in total, at least 20 minutes apart**, and only when that budget is spent does the subscription go `past_due`. Do not build your own retry on top of it — you would double-charge. A `past_due` subscription recovers on its own when an invoice on the contract is paid. See the Billing **Subscriptions** and **Workflows** guides.\n\n---\n\n## See also\n\n- **Billing API reference** — `/billing-api/v1/docs`: Concepts, Subscriptions, Invoices, Workflows, Webhooks.\n- **[Server-to-Server](#tag/server-to-server)** — The `subscription` block on `POST /initialize-payment`.\n- **[Hosted Checkout](#tag/hosted-checkout)** — Where the buyer saves the card that Billing collects against.\n- **[Webhooks](#tag/webhooks)** — One receiver for `PaymentCompleted` and every Billing event.\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** — `POST /payments-api/v1/payees/{payee_id}/payouts` with the `payoutMethodId`, or `POST /payments-api/v1/payouts` with `walletId` + `payoutMethodId` for your own account. 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```bash\ncurl -sS \"https://api.fynex.ai/payments-api/v1/payees/7/payout-methods\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\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```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```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```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```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 (payout webhooks are opt-in, so poll for payouts unless your endpoint subscribes to `PayoutCompleted` and `PayoutFailed`). 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. For a payment, stop once `lifecycleStatus` is `refunded`, `failed` or `cancelled` (or `captured` if you don't need settlement progress) — see [Payment Lifecycle](#tag/payment-lifecycle); for a payout, `completed`, `failed` or `cancelled`. A payout in `manual_reconciliation` is parked for an operator and will not move on its own — stop polling it and raise it with Fynex.\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 `lifecycleStatus` (alongside `status`, the deprecated `legacyStatus` and, once captured, `settlementStatus` — see [Payment Lifecycle](#tag/payment-lifecycle)), 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```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 '.lifecycleStatus')\n  echo \"$(date -u +%H:%M:%S) lifecycleStatus=$STATUS\"\n  case \"$STATUS\" in\n    captured|refunded|failed|cancelled) break;;\n  esac\n  sleep 5\ndone\n```\n\n`lifecycleStatus` follows the [Payment Lifecycle](#tag/payment-lifecycle) state machine. Treat `pending` and `under_review` as \"keep waiting\"; `authorized` means a manual-capture payment is waiting for your capture.\n\n### Every payment at once (REST)\n\nWhen you do not hold the ids — after a restart, or to answer \"what came in today\" — list them:\n\n```bash\ncurl -sS \"$FYNEX_API/payments?createdFrom=2026-10-16T00:00:00Z\u0026status=captured,refunded\" \\\n  -H \"Authorization: Bearer $FYNEX_TOKEN\"\n```\n\nThe list is newest first, filterable by `status` (the `lifecycleStatus` values), a `createdFrom`/`createdTo` range, `currency`, an inclusive `amountMin`/`amountMax` range in that currency's major units (`amountMin=10.00\u0026currency=GBP`), and `payeeId` — the payments whose split credited that payee, which a payment joins once its split has run after settlement. Each entry carries the same fields as `GET /payments/{payment_id}`, without `allowedActions`, `settlementStatus` and `promotion`. Pass `nextCursor` back as `cursor` while `hasMore` is true.\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```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### 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://pay.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://pay.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 `lifecycleStatus` `pending` for hours signals an upstream issue that needs human attention, not more polling.\n- **Don't forget terminal-state caching.** Once a payment's `lifecycleStatus` is `refunded`, `failed` or `cancelled`, it is final (bar the one exception in [Payment Lifecycle](#tag/payment-lifecycle)) — 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 until it reaches a terminal state — for a payment, `lifecycleStatus` (see [Payment Lifecycle](#tag/payment-lifecycle)); for a payout, `status` `completed`, `failed` or `cancelled`.\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, refund or payout 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 (`PayoutCompleted`, `PayoutFailed`) are opt-in: an endpoint receives them only when its `eventTypes` names them.\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 at-least-once: only a `200` counts as delivered, anything else is retried up to **3 attempts in total** and then marked failed — so a receiver that is down for more than a few minutes must reconcile by polling. Without idempotency handling you will process duplicate events; a dead-letter queue on your side is essential for production.\n\n**Implementation complexity**: the highest of the three. A correct webhook handler must: verify the HMAC-SHA256 signature on every request, deduplicate on the envelope's `eventId` (there is no event-id header), 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 + webhooks, with polling as the backstop**. Initialize and finalize payments from your own server; subscribe to `PaymentCompleted`, and poll `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`) or `GET /payouts/{id}` until a terminal state when a delivery is missed. SSE is irrelevant here (no customer browser session).\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**: receives `PaymentCompleted`, or polls `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`), 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[![Process map: 0–10 s, 10–70 s, 70 s–30 m, \u003e 30 m](/payments-api/v2/docs/diagrams/4e7befa6c472d83f.svg)](/payments-api/v2/docs/diagrams/4e7befa6c472d83f.svg)\n\n*Stop as soon as the payout reaches a terminal status.*\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** (`PayoutCompleted`, `PayoutFailed`) are opt-in: name them in the endpoint's `eventTypes`, or poll for payout status.\n- **Delivery semantics**: at-least-once; only HTTP `200` counts as delivered, anything else is retried up to 3 attempts in total and then marked failed.\n- **Signature verification**: each request carries `X-Fynex-Signature: sha256=\u003chex\u003e` — an HMAC-SHA256, keyed with your endpoint secret, over the string `\u003cX-Fynex-Timestamp\u003e.\u003craw body\u003e` (timestamp header, a literal dot, the raw body) — plus the `X-Fynex-Timestamp` itself. Reject a timestamp more than 5 minutes from your clock. See [Webhooks](#tag/webhooks) for verifiers.\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.\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[![Process map: Create session, Session created, Pass sessionId, Load widget.js, Checkout iframe, Customer fills form, postMessage to page, Verify final state](/payments-api/v2/docs/diagrams/45e1b376e7ce6a0b.svg)](/payments-api/v2/docs/diagrams/45e1b376e7ce6a0b.svg)\n\nYour backend verifies the final state with `GET /payments-api/v1/payments/{payment_id}`.\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 res = await fetch(\n     `${process.env.FYNEX_API}/payments-api/v1/payments/${encodeURIComponent(externalOrderRef)}`,\n     { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` } }\n   );\n   const payment = await res.json();\n   if (payment.lifecycleStatus === 'captured') {\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 — `GET /payments-api/v1/payments/{payment_id}` (your `externalOrderRef`) with your bearer token, or the GraphQL `genericPayment` query from a dashboard session — 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[![Onboarding phases: Start sign-up, Wizard steps ×N, Verify email, Complete sign-up, createOrganization, Create legal entity, Create org profile, Additional entities, Initiate KYB, KYB Web SDK, Fynex staff review, operationalMode → Live](/payments-api/v2/docs/diagrams/8b6f7beb00776807.svg)](/payments-api/v2/docs/diagrams/8b6f7beb00776807.svg)\n\n*Three sequential phases; Phase 2 is optional.*\n\n`POST /onboarding/complete` creates the organization, legal entity, organization profile and a\nDemo seller account, and provisions the sandbox.\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[![Process map: legal_entity_type, personal_details, company_details, business_details, individual_details](/payments-api/v2/docs/diagrams/a3e730dfde596c30.svg)](/payments-api/v2/docs/diagrams/a3e730dfde596c30.svg)\n\nThe last step of each track (`business_details`, `individual_details`) is where\n`complete` is called from; KYB is not part of sign-up.\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`, `employeeCount`,\n`paymentFlowsDescription`.\n`country` is screened against the supported jurisdictions when it is submitted;\nan unsupported country answers `422 countryNotSupported`.\n\nRequired per step (a missing or out-of-range value answers `400` naming the field):\n\n| Step | Required |\n|---|---|\n| `personal_details` | `firstName`, `lastName` |\n| `company_details` | `businessName`, `country` |\n| `business_details` | `businessModel` (`marketplace`, `mor_marketplace`, `creator_platform`, `saas`, `service_aggregator`, `investment_platform`, `crowdfunding`, `professional_services`, `home_services`, `other`), `estimatedMonthlyVolume` (`no_revenue_yet`, `under_10k`, `10k_50k`, `50k_250k`, `250k_1m`, `over_1m` — USD per month), `averageTransactionSize` (`not_known_yet`, `under_50`, `50_200`, `200_1k`, `over_1k` — USD), `employeeCount` (`just_me`, `1_10`, `11_50`, `51_200`, `201_1000`, `over_1000`). The first value of each is for a business that has not started trading |\n| `individual_details` | `country` |\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\": \"company_details\", \"data\": {\"businessName\": \"Acme Ltd\", \"country\": \"GB\", \"businessWebsite\": \"https://acme.example\", \"registeredAddress\": \"1 High St, London\", \"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_details\", \"data\": {\"businessModel\": \"saas\", \"estimatedMonthlyVolume\": \"10k_50k\", \"averageTransactionSize\": \"50_200\", \"employeeCount\": \"1_10\"}}'\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\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[![Process map: createOrganization, createLegalEntity, Create org profile](/payments-api/v2/docs/diagrams/49c2f29538b066e5.svg)](/payments-api/v2/docs/diagrams/49c2f29538b066e5.svg)\n\n`createOrganizationProfile` takes both `organizationUuid` and `legalEntityId`.\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: SellerAccountInput!) { 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```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\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```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\nAfter your account goes Live, its `sk_test_` keys stop working: they answer `401` with `code`\n`key_environment_mismatch`. Use the account's `sk_live_` key from the production dashboard.\n\nToken rotation is available via the GraphQL mutation\n`regenerateSellerAccountToken(merchantId: ID!)` 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| | Production |\n|---|------------|\n| API base | `https://api.fynex.ai/payments-api/v1` |\n| Customer dashboard | `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# 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. Payout webhooks (`PayoutCompleted`, `PayoutFailed`) are opt-in: an endpoint\nreceives them only when its `eventTypes` names them, so poll for payout status otherwise.\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\n  `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`).\n\nSee the [Polling \u0026 SSE](#tag/polling-sse) guide for code samples and\n[Payment Lifecycle](#tag/payment-lifecycle) for the status transitions.\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- [ ] A payment whose `lifecycleStatus` stays `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` (your `externalOrderRef`, which is what `GET /payments/{payment_id}`\ntakes) and the `X-Request-Id` response header alongside your internal order ID on every\npayment event. This is what Fynex support will ask for\nwhen you raise an issue.\n\n\u003e [!NOTE]\n\u003e Every `/payments-api/v1` response carries `X-Request-Id` (`req_\u003cuuid\u003e`), 4xx included.\n\u003e Quote it together with the `paymentId` when you raise an issue.\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 an `actionUrl`, 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** — call `GET /account` with your key and verify\n   `operationalMode` is `Live` before routing real traffic.\n\n5. **Re-check your payout methods** — a Demo account approves every payout method at\n   creation. When the account goes Live, every payout method nobody at Fynex reviewed\n   returns to `approvalStatus: pending` and cannot be paid out to until it is approved\n   again; the sandbox test destination is retired. List them with\n   `GET /payees/{payee_id}/payout-methods` and `GET /payout-methods`.\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 sending a token to a host other than the one that issued it — staging and production tokens are not interchangeable. 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: How do I tell a missing key from a wrong one?\n\nBranch on `code` in the JSON `401` body: `missing_api_key` means the `Authorization` header was absent or had no token after `Bearer `; `invalid_api_key` means the token was not recognised — including a token sent to the other environment's host. Both bodies are JSON, like every other error.\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 `lifecycleStatus` 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 `GET /payments-api/v1/payments/{payment_id}` until `lifecycleStatus` is `captured` (`authorized` for a manual-capture payment), `failed` or `cancelled`. 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\nHosted checkout sessions and payment links are checked when they are created: one no terminal of the account can take is refused there with `422` and `code` `currency_not_supported` (see [Errors](#tag/errors)). The check accepts a session when any offered method can route it, so a `1003` on a hosted checkout can still mean the customer picked a method that cannot (a card on an account that routes this currency only through a local payment method, or a card whose issuing country has no terminal on an account that routes by card country), or that the account's terminals changed after the session was created.\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's `lifecycleStatus` may already be `authorized` or `captured` 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\nThe opposite timing has its own code. If the customer never answers the challenge and the authentication session **expires** first, the payment fails with `failureCode: 3003` (authentication not completed, `failureStage: checkout`) — whether or not `/finalize-payment` is called afterwards. Nothing was authorized; replaying the request cannot revive the session, so ask the customer to pay again. And if the customer **did** answer the challenge but their bank refused the authentication, the payment fails with `2001` and the description \"Card authentication failed (3-D Secure)\".\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 or refund returns `409 \"payment action is not allowed for the current payment status\"`\n\nEvery capture or refund the payment's state does not allow returns this one message. Read the payment with `GET /payments-api/v1/payments/{payment_id}` and check:\n\n- **Capture** needs a manual-capture payment — created with `autoSettlement: false`, or with `autoSettlement` left out on an account whose default is not automatic capture (`captureMode` alone does not decide this) — whose `lifecycleStatus` is `authorized`, with no refund pending or already succeeded.\n- **Refund** needs `lifecycleStatus` `captured`, `settlementStatus` other than `in_flight`, no refund still `pending`, refunds enabled for your account, and an amount no larger than what is left to refund.\n\nThe full conditions are under *What you can do next* in [Payment Lifecycle](#tag/payment-lifecycle).\n\n### Q: What is `provider_completed`? I expected `captured`\n\n`provider_completed` is a previous status value, which `status` still carries until the switch announced in the [changelog](#tag/changelog). Its lifecycle status is `captured`: read `lifecycleStatus` instead. A partial capture and a partial refund also leave `lifecycleStatus` at `captured`. See [Payment Lifecycle](#tag/payment-lifecycle).\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 refund is `pending` and the payment stays `captured`. You cannot issue another refund until the first one is no longer `pending`. A successful partial refund leaves the payment `captured` and refundable; once refunds add up to the whole captured amount it becomes `refunded`; a failed or cancelled refund 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\nMajor, like every other endpoint — but as a decimal **string**: `\"amount\": \"19.99\"`. The integer `amountMinor` (`1999`) still works as a deprecated fallback, and `amount` wins when both are sent. Responses return both fields.\n\n### Q: When is `failureMessage` on `GET /payouts/{id}` populated?\n\nWhenever `failureCode` is. It carries the seller-safe catalogue text for that code (for example \"The bank rejected the payout.\"), so you can display it as-is; branch on `failureCode`, not on the message text. Both fields are absent while the payout has not failed. Older integrations that treated the field as always empty can start reading it — nothing else about the response changed.\n\n---\n\n## 6. Verification \u0026 polling\n\n### Q: How do I know when a payment succeeds?\n\nThree options, in order of preference:\n\n- **Webhooks:** Fynex POSTs a signed `PaymentCompleted` event to every endpoint registered on your account (`X-Fynex-Signature`, `X-Fynex-Timestamp`). Only a `200` counts as delivered; anything else is retried up to 3 attempts in total and then marked failed. Deduplicate on `eventId` and treat the event as the signal, the API read as the source of truth. See [Webhooks](#tag/webhooks).\n- **Polling:** `GET /payments-api/v1/payments/{payment_id}` (the path value is your `externalOrderRef`) with the same bearer token until `lifecycleStatus` is `captured`, `failed` or `cancelled`. No dashboard session is needed.\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\nSee [Polling \u0026 SSE](#tag/polling-sse) for cadence and backoff.\n\n### Q: I'm polling for `status === \"succeeded\"` and never getting a match\n\n`succeeded` is a refund status, not a payment status. A successful payment has `lifecycleStatus` `captured` — branch on that. See [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 `lifecycleStatus` is `refunded`, `failed` or `cancelled` (or `captured` if you don't need settlement progress), 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` | `authorization token is required` (`code` `missing_api_key`) | Missing or malformed `Authorization` header | [Authentication](#tag/authentication) |\n| `401` | `invalid authorization token` (`code` `invalid_api_key`) | Token not recognised, or issued by the other environment's host | [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` | `payment action is not allowed for the current payment status` | The payment's state does not allow this capture or refund — see the capture \u0026 refund question above | [Payment Lifecycle](#tag/payment-lifecycle) |\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| `failureCode: 3003` | payment row `failureStage: checkout` | The customer never completed the 3DS challenge and the authentication session expired — ask them to pay again | [Errors](#tag/errors) |\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 for operations that have no REST equivalent: reconciliation data, the full wallet ledger (`WalletEntry` / `WalletTransfer` rows), token rotation, and dashboard-only queries. Payee CRUD, payout methods, wallets, split rules and payment reads all have bearer-token REST routes under `/payments-api/v1` — prefer those from a server.\n\n---\n\n## When to use the GraphQL endpoint\n\nUse `/dashboard/graphql` (cookie-session auth) when you need to:\n\n- Query reconciliation statements or the wallet ledger at entry level.\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\nPayees, payout methods, wallets, split rules and split executions **are** on the REST surface (`/payments-api/v1/payees`, `/payout-methods`, `/wallets`, `/split-rules`, `/payments/{payment_id}/split`); use GraphQL for them only from a dashboard session.\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. Whether a session survives an API restart depends on the deployment's session store: shared-store deployments keep it, a single-process run loses it. Re-login on `401` either way |\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"}],"x-tagGroups":[{"name":"Getting started","tags":["Getting started","Quickstart","Authentication","Headers","Idempotency","Errors","Test cards","Changelog","Overview"]},{"name":"Concepts","tags":["Concepts","Payment lifecycle","Payment methods","Wallets","Virtual accounts","Splits","Flow externalId"]},{"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"]}]}