# Fynex APIs > Two public REST surfaces on this host, one product and one credential. > Payments moves money — checkout, payouts, wallets, payment links. Billing > raises and collects the documents that say how much — contracts, > subscriptions, metered usage, invoices, credits. ## Instructions for coding agents Read these before writing code against either surface. Each is a mistake this platform has actually seen. - **The two APIs do not agree about money units.** Billing is integer minor units throughout — a field ending `Minor` carries `4999` for €49.99. Payments checkout takes MAJOR units (`"totalAmount": 49.99`) while its payouts take `amountMinor`. Read the field name, never the other API's habit: guessing here is wrong by a factor of a hundred, and neither surface will reject the wrong one. - **One credential covers both.** A seller secret key issued in the Fynex dashboard (Integration → API keys — see [/keys](/keys)) authenticates Payments and Billing alike, as `Authorization: Bearer sk_test_…` or `sk_live_…`. There is no second token to obtain. The prefix says which mode the account is in; after go-live the test key answers `401`. - **One webhook pipe.** Both surfaces deliver through the same signed pipe, with one endpoint registration and one signature scheme (`X-Fynex-Signature: sha256=` plus `X-Fynex-Timestamp`). A body is an envelope — `eventId`, `eventType`, `sellerAccountUuid`, `occurredAt` — with the event's fields under `payload`. Deduplicate on `eventId`. - **`Subscription*` and `BillingSubscription*` are different objects.** The unprefixed events describe payment-link recurring subscriptions; the prefixed ones describe billing subscriptions on a contract. Subscribing to one tells you nothing about the other. - **Billing v1 is read-oriented.** Contracts, invoices, subscriptions and credit grants are created in the dashboard, not through the API. Metering is the exception and is fully writable. Do not invent a `POST /invoices` — ask the user. - **`sellerAccountId` is the Fynex account; `sellerCustomerId` is who pays it; a `payee` is who it pays out to.** One marketplace operator is all three at once. ## Billing API — `/billing-api/v1` - [Agent front door](/billing-api/v1/llms.txt) — start here; the facts specific to billing, and links to every guide as markdown - [OpenAPI 3.1 document](/billing-api/v1/openapi.json) — operations and the webhook catalog; generate a client from this - [Complete reference (markdown)](/billing-api/v1/docs.md) - [Rendered documentation](/billing-api/v1/docs) ## Payments API — endpoints under `/payments-api/v1`, documentation under `/payments-api/v2` - [Agent front door](/payments-api/v2/llms.txt) — start here; the facts specific to payments, and links to every guide as markdown - [OpenAPI document](/payments-api/v2/openapi.json) — generate a client from this; every operation path in it already carries its `/payments-api/v1` base - [Complete reference (markdown)](/payments-api/v2/docs.md) - [Rendered documentation](/payments-api/v2/docs) ## Everything at once - [llms-full.txt](/llms-full.txt) — this file with both complete references inlined, so you do not need a request per guide - [API reference index](/api-reference) — plain HTML, no JavaScript, both surfaces linked - [sitemap.xml](/sitemap.xml) — every documentation URL on this host - [/keys](/keys) — where the credential comes from, in one page - [Agent skills](/.well-known/skills/index.json) — task-shaped skills for marketplaces, splits, payouts, metering, reconciliation and migrating off Stripe Connect. Install with `npx skills add https://api.fynex.ai` Both surfaces serve each guide as markdown at its own URL (`…/docs/.md`), and answer `Accept: text/markdown` at the extensionless URL with the same content. ## Environments Production is this host. The sandbox is `https://staging-api.fynex.ai`, with the same paths. What decides whether money is real is the ACCOUNT's mode, not the host: a demo account behaves as a sandbox on either. --- # Fynex Payments API Version 1.0.0 Base URL: https://api.fynex.ai # Fynex Payments API Integration 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. ## What is Fynex? Fynex 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. These 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 & SSE, errors, reconciliation, and the production checklist. **Reference** lists every endpoint, parameter, and response — generated from the live OpenAPI spec. ## Where to start - **[Quickstart](https://api.fynex.ai/payments-api/v2/docs#tag/quickstart)** — Take your first test payment in 5 minutes. - **[Hosted checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — The fastest way to accept a payment with the lowest PCI scope. - **[Server-to-server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Full control of the flow when you collect card data on your own server. - **[Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts)** — Move money out from a wallet to a registered payee. - **[Going live](https://api.fynex.ai/payments-api/v2/docs#tag/going-live)** — Production checklist: tokens, monitoring, and the launch playbook. ## Related: the Billing API Invoices, subscriptions, contracts, metered usage and credit balances live on their own surface, **`/billing-api/v1`**, documented at `/billing-api/v1/docs` with the machine contract at `/billing-api/v1/openapi.json`. It authenticates with the same seller secret key. One convention difference to know before writing code: Billing carries money exclusively as integer **minor units** in fields ending `Minor`, while this API's checkout takes major units — see **Amounts and currency**. # Welcome to the Fynex Payments API Fynex is a unified payments platform for accepting cards, Apple Pay, Google Pay, and bank transfers, plus moving money out via payouts. This reference describes every endpoint a seller integration needs. All routes live under `/payments-api/v1` and authenticate with a seller bearer token. You can mint a token from the Fynex Dashboard. ## Base URLs - **Production:** `https://api.fynex.ai/payments-api/v1` - **Staging (sandbox):** `https://staging-api.fynex.ai/payments-api/v1` Both are declared in the OpenAPI document's `servers` block — production first, the sandbox second, each labelled — so a generated client or an agent reading only the spec can pick the sandbox instead of defaulting to production. Each entry is the bare origin; the `/payments-api/v1` prefix is already part of every path in the document. Which host you call does not decide whether money is real: that follows your **account's** operational mode. Demo accounts exist on both. ## Versioning `/payments-api/v1` and `/payments-api/v2` serve the **same document and the same routes**. They are not two versions you can choose between: `v2` was the path the current spec was first published under during the docs migration, and `v1` is aliased to it so existing bookmarks and generated clients keep resolving. Requests to either prefix reach identical handlers. **Use `/payments-api/v1`.** It is the path every example here, every SDK and every guide uses. If you are pinned to `/v2` nothing is broken — but you are not on a newer contract, and you will not be left behind by staying on `/v1`. When a genuinely incompatible version ships it will be a new prefix, announced before it lands, and this section will say what changed and by when the old one stops. Until then there is one contract. ### How a change is signalled Additive changes — a new endpoint, a new optional field, a new enum value — ship without notice, so write clients that ignore fields they do not recognise and do not fail on an unknown enum value. Anything being withdrawn is marked `deprecated: true` in `openapi.json` before it goes, on the field, schema or operation itself. A renamed schema keeps its former name published as a deprecated alias pointing at the replacement, so a client generated against the older document still resolves. Read the spec, not just the guides: the deprecation markers are in the schema, which is where a generated client and an agent will see them. ## Integration paths Pick the flow that fits your product: - **Hosted checkout** — call `POST /checkout`, redirect the buyer to the returned URL, receive the result via webhook. Lowest PCI scope. - **Server-to-server** — call `POST /initialize-payment`, complete any 3DS challenge client-side, then call `POST /finalize-payment`. You handle card data. - **Capture / refund** — once a payment is authorized or captured, use `POST /payments/{payment_id}/capture` and `/refund` for the full amount or partials. - **Payouts** — `POST /payouts` to send money from a wallet to a registered payee's payout method. - **Top-up invoices** — `POST /payments-api/v1/topup-invoices` issues an invoice a buyer settles by bank transfer, funding the seller's wallet once the deposit is matched. ### The open top-up invoice cap A seller may hold at most **20 open** top-up invoices at a time — open being `pending`, `outdated` or `marked_paid`. Issuing the twenty-first answers `422` with `too many open top-up invoices`; settle or cancel one and the same request succeeds. The bound exists because every issued invoice burns one number from the shared `(agreement number, YYMM)` invoice-number series, which is also drawn on by billing and settlement invoices. It is a supply bound, not a rate limit: retrying the same request without clearing an open invoice will keep answering `422`, and there is nothing to back off for. ## Errors Errors return JSON in the form `{ "error": "" }`. Status codes follow the usual conventions: | Code | Meaning | |------|---------| | 400 | Validation error | | 401 | Missing or invalid bearer token | | 404 | Resource not found for this seller | | 409 | Conflict (e.g., invalid status transition, duplicate refund) | | 422 | The request is well formed but not applicable in the seller's current state (e.g., the open top-up invoice cap above) | | 500 | Internal error | | 502 | Upstream provider failure | ## Amounts and currency Most endpoints accept the amount in **major units** (e.g., `19.99`). Payouts accept **minor units** (`amountMinor`, e.g., `1999` for £19.99) — see each endpoint for specifics. Currencies are ISO 4217 codes (`GBP`, `EUR`, `USD`). ## Error format All handler errors return JSON with a single `error` field: ```json { "error": "" } ``` Status codes: | Code | Meaning | |------|---------| | 400 | Validation error | | 401 | Missing or invalid bearer token | | 404 | Resource not found for this seller | | 409 | Conflict (e.g., invalid status transition, duplicate refund) | | 500 | Internal error | | 502 | Upstream provider failure | ## Quoting a failure to support Every response carries `X-Request-Id` — a `req_`-prefixed identifier generated at the boundary, on failures as well as successes, and on a 404 for a URL that does not exist. Log it alongside your own request and quote it when reporting a problem: it is what turns "a call failed this morning" into one line in ours. It is generated here and never taken from your request, so it is a correlation id and not a field you can set. If you need to carry your own, send it in a header of your own choosing — and note that on writes, `Idempotency-Key` is the one caller-supplied value this API does act on. ## Amounts and currency Most endpoints accept the amount in **major units** (e.g., `19.99`). Payouts accept **minor units** (`amountMinor`, e.g., `1999` for £19.99) — see each endpoint for specifics. Currencies are ISO 4217 codes (`GBP`, `EUR`, `USD`). **Read the field name before the value — this is where the 100× mistake lives.** The platform's house convention (the Billing API follows it throughout, and new Payments fields adopt it) is: | Value | Type | Field name | Example | | --- | --- | --- | --- | | Money amount | Integer, **minor units** | ends `Minor` | `4999` is £49.99 | | Rate / percentage | Integer, **basis points** | ends `Bps` | `275` is 2.75% | | Quantity | Decimal **string** | — | `"1250.5"` | | Tax rate | Decimal **string**, percent | — | `"20.0"` | Checkout's major-unit fields predate that convention and remain accepted. The trap is real in both directions: `49.99` sent into a `…Minor` field underpays a hundredfold, and `4999` sent into checkout's `totalAmount` overcharges by the same factor — **neither errors**. When a field name ends `Minor`, it is an integer count of the currency's smallest unit, everywhere, on both APIs. ## Payments ## Pagination List endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and return a `total` count alongside the page. Iterate by incrementing `offset` by `limit` until the response returns fewer than `limit` items or `offset >= total`. ## Idempotency All mutating endpoints accept the `Idempotency-Key` header (a UUID). Submitting the same key twice within the retention window returns the existing operation — safe to retry on network errors without risking duplicate charges. Recommended practice: - Generate a fresh UUID per logical operation (e.g., per checkout attempt). - Persist the key alongside the operation in your own store so retries reuse it. - Treat any 2xx response as authoritative; retry only on 5xx or timeouts. - On `/initialize-payment`, a replay returns `200`; active redirect APMs rehydrate the existing provider redirect instead of creating a second charge. ### `POST /payments-api/v1/checkout` Create a hosted checkout session 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). ### `POST /payments-api/v1/device-intelligence/token` Create a Sumsub Device Intelligence token 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. ### `POST /payments-api/v1/finalize-payment` Finalize a payment Confirms a payment after `POST /initialize-payment` and any 3DS or redirect challenge has completed on the customer's side. Returns the final payment state (`succeeded` / `failed` / `cancelled` / intermediate processor status) and the captured amount. You may pass an `amount` lower than the originally authorized amount to capture less than the hold; otherwise the full amount is captured. Pass the same `Idempotency-Key` as in `/initialize-payment` for safe retries. ### `POST /payments-api/v1/initialize-payment` Initialize a payment (server-to-server) 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. ### Dynamic webhook URL This 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: - Be HTTPS (`http://` rejected with `webhook_url_not_https`) - Be ≤ 1024 chars (`webhook_url_too_long`) - Resolve via DNS (`webhook_url_dns_failed`) - Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`) - Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth) The 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=`, `X-Fynex-Timestamp: `). See the `Webhooks` tag for the signature verification flow. ### `GET /payments-api/v1/payees` List payees 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. ### `POST /payments-api/v1/payees` Create a payee 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. ### `POST /payments-api/v1/payees/setup` Create a payee with a payout method (one call) 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. ### `DELETE /payments-api/v1/payees/{payee_id}` Delete a payee Deletes a payee the authenticated seller owns. A payee that does not exist, or belongs to another seller, returns 404. ### `GET /payments-api/v1/payees/{payee_id}` Get a payee 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. ### `PATCH /payments-api/v1/payees/{payee_id}` Update a payee 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. ### `GET /payments-api/v1/payees/{payee_id}/payout-methods` List a payee's payout methods 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. ### `POST /payments-api/v1/payees/{payee_id}/payout-methods` Create a payee's payout method Registers a new payout destination (bank account) for a specific payee under the authenticated seller. Only `bank_account` methods are supported. `currency` is required. EUR/GBP/USD IBAN methods are payable; GBP `uk_local` methods require `bankCountry: GB`, an 8-digit `accountNumber`, and a 6-digit `sortCode` and route through Faster Payments. US-local and SWIFT methods may be registered but are rejected at `POST /payouts` before funds are held. A payee that does not exist, or belongs to another seller, returns 404. Returns `201 Created` with the payout method. ### `DELETE /payments-api/v1/payees/{payee_id}/payout-methods/{method_id}` Delete a payee's payout method 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. ### `PATCH /payments-api/v1/payees/{payee_id}/payout-methods/{method_id}` Update a payee's payout method 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. ### `POST /payments-api/v1/payees/{payee_id}/payouts` Pay a payee (wallet auto-resolved) 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}`. ### `GET /payments-api/v1/payment-methods` List allowed payment methods 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. ### `GET /payments-api/v1/payments/{payment_id}` Get a payment's current status 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. ### Use after `/capture` or `/refund` This is the canonical way to check whether a capture or refund has reached its terminal state. After `POST /payments/{payment_id}/capture` the `status` will move through `funds_in_flight` to `settled`. After `POST /payments/{payment_id}/refund` the `status` will move from `refund_pending` to `refunded` (success) or `refund_failed` (rejected by the upstream processor). For refunds, poll every 30s for the first 5 minutes, then every 60s. ### `POST /payments-api/v1/payments/{payment_id}/capture` Capture an authorized payment Captures funds for a payment that has been authorized but not yet captured. Pass an `amount` in the body to capture **less** than the authorized amount; omit it to capture the full amount. APM-backed payments currently support full capture only: omit `amount` or pass the full authorized amount; a smaller amount is rejected until partial-capture accounting is available. The payment moves to `captured` (or `partially_captured` for partial captures). Captures must occur within the provider's authorization window (typically 7 days for cards). Use the same external `payment_id` (your order reference) you sent on `/initialize-payment`. ### Dynamic webhook URL This 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: - Be HTTPS (`http://` rejected with `webhook_url_not_https`) - Be ≤ 1024 chars (`webhook_url_too_long`) - Resolve via DNS (`webhook_url_dns_failed`) - Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`) - Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth) The 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=`, `X-Fynex-Timestamp: `). See the `Webhooks` tag for the signature verification flow. ### `POST /payments-api/v1/payments/{payment_id}/refund` Refund a captured payment 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. ### Checking refund status The 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. ### Concurrent partial refunds Multiple 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. ### Dynamic webhook URL This 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: - Be HTTPS (`http://` rejected with `webhook_url_not_https`) - Be ≤ 1024 chars (`webhook_url_too_long`) - Resolve via DNS (`webhook_url_dns_failed`) - Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`) - Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth) The 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=`, `X-Fynex-Timestamp: `). See the `Webhooks` tag for the signature verification flow. ### `GET /payments-api/v1/payments/{payment_id}/refunds` List refunds for a payment 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. ### Lifecycle Refund `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. ### `GET /payments-api/v1/payout-methods` List your own payout methods 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. ### `POST /payments-api/v1/payout-methods` Add a payout method to cash out your own balance Registers a new bank account to cash your own balance out to (a self-withdrawal destination). You do not supply a payee — the method is attached to your own account. Only `bank_account` methods are supported. `currency` is required. EUR/GBP/USD IBAN methods are payable; GBP `uk_local` methods require `bankCountry: GB`, an 8-digit `accountNumber`, and a 6-digit `sortCode` and route through Faster Payments. US-local and SWIFT methods may be registered but are rejected at payout time before funds are held. The method lands with `approvalStatus: pending` and cannot be paid out from until it is approved (it is approved automatically when your account has auto-approval enabled). Returns `201 Created` with the payout method. ### `DELETE /payments-api/v1/payout-methods/{method_id}` Delete your own payout method Deletes one of your own payout methods. The method must belong to your account — otherwise 404. ### `PATCH /payments-api/v1/payout-methods/{method_id}` Update your own payout method Updates the supplied fields of one of your own payout methods. Only fields present in the body are changed; omitted fields are left untouched. `currency` cannot be changed. Changing any bank-routing field sends the method back to `pending` for re-approval (unless your account has auto-approval). Set `status` to `inactive` to retire a method or `active` to restore it. The method must belong to your account — otherwise 404. ### `GET /payments-api/v1/payouts` List payouts 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. ### `POST /payments-api/v1/payouts` Create a payout Initiates a payout from one of the seller's wallets to a registered payee payout method. Specify the amount as a decimal `amount` in the payout currency (e.g. "12.50"); the deprecated `amountMinor` (minor units, e.g. 1999 for £19.99) is still accepted as a fallback, and when both are sent `amount` wins. Exactly one is required. The wallet must hold sufficient balance in the requested currency. `walletId` is the internal numeric id from `GET /payments-api/v1/wallets` — **not** an IBAN. Pick the wallet id for the currency you want to pay out from. `payoutMethodId` is **required** — pass an id from `GET /payees/{id}/payout-methods`; it must belong to the wallet's payee and be active. The endpoint is idempotent on `idempotencyKey` — replays return the original payout. The response is `201 Created` with the payout in `pending` state; settlement happens asynchronously through the banking provider. EUR IBAN methods use SEPA, GBP `uk_local` methods use Faster Payments, and GBP/USD IBAN methods use cross-border transfers. Poll `GET /payouts/{id}` for status updates or rely on configured webhooks. ### Dynamic webhook URL This 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: - Be HTTPS (`http://` rejected with `webhook_url_not_https`) - Be ≤ 1024 chars (`webhook_url_too_long`) - Resolve via DNS (`webhook_url_dns_failed`) - Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`) - Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth) The 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=`, `X-Fynex-Timestamp: `). See the `Webhooks` tag for the signature verification flow. ### `GET /payments-api/v1/payouts/{id}` Get a payout 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. ### `POST /payments-api/v1/payouts/{id}/cancel` Cancel a payout awaiting approval 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. ### `GET /payments-api/v1/refunds/{refund_id}` Get a refund by ID 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`). ### Lifecycle Refund `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. ### `POST /payments-api/v1/refunds/{refund_id}/cancel` Cancel a pending refund 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. ### `GET /payments-api/v1/topup-invoices` List top-up invoices Lists the authenticated seller's top-up invoices (newest first) with optional status/currency filters. ### `POST /payments-api/v1/topup-invoices` Create a top-up invoice Issues a top-up invoice for the authenticated seller in the given currency and amount (minor units). ### `GET /payments-api/v1/topup-invoices/currencies` List invoiceable currencies Currencies the authenticated seller can issue a top-up invoice in (active safeguarding account + a seller main wallet). ### `GET /payments-api/v1/topup-invoices/{uuid}` Get a top-up invoice Returns one of the authenticated seller's top-up invoices by UUID. ### `POST /payments-api/v1/topup-invoices/{uuid}/cancel` Cancel a top-up invoice Voids the seller's own pending/marked_paid invoice. ### `GET /payments-api/v1/topup-invoices/{uuid}/document` Get the printable top-up invoice document 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. ### `POST /payments-api/v1/topup-invoices/{uuid}/mark-paid` Mark a top-up invoice as paid Records the seller's declaration that they have paid the invoice (pending → marked_paid). Does not credit funds. ### `GET /payments-api/v1/wallets` List wallets 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. ### `POST /payments-api/v1/wallets` Create a wallet Provisions a cashout_balance wallet for a payee in the requested currency, so the seller can pay that payee in a currency beyond their account currency. `payeeId` is required and must be an active payee owned by the authenticated seller (404 otherwise); the payout debits this wallet. This endpoint is disabled by default per seller and must be enabled by Fynex support before use — a disabled account receives 403. Only currencies Fynex can pay out from are accepted (today: EUR); other currencies return 422. Provisioning is idempotent per (payee, currency): requesting one that already exists returns the existing wallet without creating a duplicate. ### `GET /payments-api/v1/wallets/{wallet_id}` Get a wallet 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. ### `POST /payments-api/v1/wallets/{wallet_id}/fund` Fund a cashout wallet from the main balance 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. ### `GET /payments-api/v1/wallets/{wallet_id}/transactions` List wallet transactions 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. ### `GET /payments-api/v1/webhooks` List seller webhook URLs Lists webhook URLs configured for the authenticated seller account. ### `POST /payments-api/v1/webhooks` Create seller webhook URL 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. ### `DELETE /payments-api/v1/webhooks/{id}` Disable seller webhook URL Disables a webhook URL for the authenticated seller account while preserving history (soft delete — the row stays with status=disabled). ### `PATCH /payments-api/v1/webhooks/{id}` Update seller webhook URL Updates a webhook URL or status for the authenticated seller account. Both body fields are optional — absent fields are left unchanged. ## Payment Methods ## Webhooks ## Pagination List endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and return a `total` count alongside the page. Iterate by incrementing `offset` by `limit` until the response returns fewer than `limit` items or `offset >= total`. --- Fynex 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. > [!IMPORTANT] > 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. ## Register a webhook URL `POST /payments-api/v1/webhooks` registers a URL and returns the per-config signing secret. ### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/webhooks" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhookUrl":"https://merchant.example.com/webhooks/fynex"}' ``` **Response (201 Created):** ```json { "id": 1, "sellerAccountId": 42, "webhookUrl": "https://merchant.example.com/webhooks/fynex", "status": "active", "secretKey": "3b8f1d2c4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c", "createdAt": "2026-05-19T10:11:12Z", "updatedAt": "2026-05-19T10:11:12Z" } ``` `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. If 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. ## Choosing what you receive An endpoint registered without an `eventTypes` field receives **every event type except those marked opt-in below**. That is the default, and it is what every endpoint created before subscriptions existed still does — you do not have to change anything. To narrow an endpoint, send the list you want: ```json { "webhookUrl": "https://merchant.example.com/webhooks/fynex", "eventTypes": ["PaymentCompleted", "PaymentRefunded"] } ``` A non-empty list is an allow-list: exactly those types and nothing else. An unrecognised type is rejected with `400`, so a typo cannot leave your endpoint silently receiving nothing. `PATCH /webhooks/{id}` replaces the list wholesale; send `"eventTypes": []` to go back to the default. Two things worth knowing before you narrow an endpoint: - **Only `200` counts as delivered.** Everything else is retried up to three times and then marked failed, so returning a non-2xx for an event you did not want costs you a retry storm rather than filtering anything. Narrow the subscription instead. - **Ignore unknown `eventType` values.** New types are added over time. An endpoint on the default subscription will start receiving new non-opt-in types without any action from you, so treat an unfamiliar `eventType` as a no-op rather than an error. ## What you can receive Every event below is also published in the OpenAPI document under its top-level `webhooks` key, so a generated client carries the delivered body type instead of a map. Register once and you receive all of them, unless you narrow the endpoint with `eventTypes` as described above. ### Payments | Event | Fires when | | --- | --- | | `PaymentCompleted` | A payment reached a terminal state. `status` says which one — this is not by itself a success, and `failureCode` / `retry` / `failureDescription` carry the reason, and whether re-sending can help, when it is not. | | `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. | ### Payment links | Event | Fires when | | --- | --- | | `PaymentLinkCreated` | A link was created and is payable. | | `PaymentLinkUpdated` | Its amount, title, expiry or recipient changed. | | `PaymentLinkPaid` | Someone paid it. A multi-use link stays active with a higher `usageCount`; a single-use link moves to `paid`. | | `PaymentLinkExpired` | It passed its expiry unpaid. | | `PaymentLinkCancelled` | It was cancelled before payment. | | `PaymentLinkEmailFailed` | The invitation email could not be sent. The link itself is fine — send the URL another way rather than recreating it. | ### Recurring subscriptions These describe **payment-link** subscriptions. Billing subscriptions emit a separate `BillingSubscription*` family on this same pipe — see the Billing API's webhooks guide. Two products, one word; subscribing to these tells you nothing about the other. `SubscriptionTrialWillEnd` is the one exception: **both** products emit it, because the billing family has no trial-ending event of its own. Its payload is therefore one of two shapes — the payment-link one below, or a billing subscription's smaller `{source, subscriptionId, contractId, trialEnd, amountMinor, currency}`. Branch on **`source`**: every payload on this event carries it, `payment_links` on the payment-link shape and `billing` on the billing one. It is the OpenAPI discriminator for the `oneOf` the document publishes, so a generated client selects the right type from it without inspecting anything else. (`contractId` is still present on the billing shape only, and remains a valid tell for a receiver written before `source` existed.) | Event | Fires when | | --- | --- | | `SubscriptionCreated` | A recurring subscription was created on a link. | | `SubscriptionCharged` | A term was charged and the period advanced. | | `SubscriptionInstallmentCharged` | One monthly installment of an annual commitment was charged. | | `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. | | `SubscriptionTrialEnded` | The trial ended and the subscription became active. | | `SubscriptionCommitmentCompleted` | The final installment of an annual commitment was charged. | | `SubscriptionCancelled` | It was cancelled, on request or because a trial ended with cancel-on-trial-end behaviour. | | `SubscriptionPastDue` | A charge failed on its last permitted attempt. | ### Payee verification | Event | Fires when | | --- | --- | | `KYBVerificationStarted` | A verification was opened for a payee. | | `KYBVerificationPending` | Documents are with the provider, under automated review. | | `KYBVerificationManualReview` | The provider escalated to a human reviewer. | | `KYBVerificationApproved` | The payee passed — this is what unlocks payouts to them. | | `KYBVerificationRejected` | The payee failed. `reviewRejectType` separates a retryable request for documents from a final refusal. | | `KYBVerificationLevelChanged` | The verification level changed, usually a raised limit tier. | ### Marketplace vendors Delivered to the **platform** that onboarded the vendor (see the [Vendors guide](/payments-api/v2/docs/vendors.md)). This family is **opt-in**: an endpoint receives it only when its `eventTypes` names the events you want, so adding it to your integration is a deliberate step and existing endpoints never start receiving vendor events unasked. The payload is the vendor's capability view -- `payoutsEnabled`, `verification`, `payoutAllowance` -- never the vendor's personal details. | Event | Fires when | | --- | --- | | `MarketplaceVendorActivated` | Screening passed and `payoutsEnabled` became true: the vendor's payee can be a split or payout target, under the cumulative limit. | | `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`. | | `MarketplaceVendorVerificationInProgress` | The vendor submitted; the provider is reviewing. | | `MarketplaceVendorVerificationPendingReview` | The case is with a human reviewer. | | `MarketplaceVendorVerificationComplete` | Verification passed; the cumulative limit no longer applies. | | `MarketplaceVendorBlocked` | Refused at screening or verification, or blocked manually. No funds can reach the vendor. | | `MarketplaceVendorPayoutAllowanceChanged` | `payoutAllowance.limitMinor`, `enforcement` or `unlimited` changed. | ### Commerce | Event | Fires when | | --- | --- | | `PromoCodeRedeemed` | A promotion code was redeemed. The context ids say what against; the ones that do not apply are null. | | `TaxLocationResolved` | A checkout's tax jurisdiction was resolved and the evidence stored, so you can archive the trail beside your own records. | ## The delivery body Whatever fired, the body is an envelope with the event's payload nested inside it: ```json { "eventId": 918204, "eventType": "PaymentCompleted", "sellerAccountUuid": "6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55", "occurredAt": "2026-08-20T14:02:11Z", "payload": { } } ``` `eventId` is the delivery's stable identity — deduplicate on it, not on the payload. `occurredAt` is when the state changed, not when delivery was attempted: a retry repeats the original value. Per-event payload fields are in the OpenAPI document. > [!IMPORTANT] > Money in these payloads is in **integer minor units** (`"amountMinor": 4999` > is €49.99), including on `PaymentCompleted` — even though the checkout > request that created the payment takes major units. Read the field name. ## Verifying the signature Every outbound webhook request carries two headers: | Header | Format | Example | |---|---|---| | `X-Fynex-Signature` | `sha256=` | `sha256=27c6f9c3...` | | `X-Fynex-Timestamp` | unix-seconds (decimal string) | `1747654272` | ### Algorithm 1. Read the raw request body **before** any JSON parsing — the signature is computed over the exact bytes Fynex sent. 2. Build the signed string: ` + "." + ` (no whitespace between the parts). 3. Compute `HMAC-SHA256(secret, signed-string)` using your stored `secretKey` as the key. 4. Hex-encode the digest (lowercase) and compare it to the value after `sha256=` in `X-Fynex-Signature` using a constant-time comparison. 5. 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. ### Why timestamp + body, not just body? Signing 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. ### Node.js ```js import crypto from 'node:crypto'; export function verifyFynexWebhook(req, secret) { const signatureHeader = req.headers['x-fynex-signature'] || ''; const timestamp = req.headers['x-fynex-timestamp'] || ''; const rawBody = req.rawBody; // express.raw() / fastify rawBody / etc. // 1. Freshness — reject anything older than 5 minutes. const now = Math.floor(Date.now() / 1000); if (Math.abs(now - Number(timestamp)) > 300) { return false; } // 2. Re-compute the HMAC. const expected = crypto .createHmac('sha256', secret) .update(timestamp + '.' + rawBody) .digest('hex'); const signature = signatureHeader.startsWith('sha256=') ? signatureHeader.slice('sha256='.length) : signatureHeader; // 3. Constant-time comparison. const a = Buffer.from(signature, 'hex'); const b = Buffer.from(expected, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ### Python ```python import hmac, hashlib, time def verify_fynex_webhook(headers, raw_body: bytes, secret: str) -> bool: sig = headers.get("X-Fynex-Signature", "") ts = headers.get("X-Fynex-Timestamp", "") # 1. Freshness — reject events older than 5 minutes. try: if abs(int(time.time()) - int(ts)) > 300: return False except ValueError: return False # 2. Re-compute the HMAC. signed_string = f"{ts}.".encode() + raw_body expected = hmac.new(secret.encode(), signed_string, hashlib.sha256).hexdigest() received = sig[len("sha256="):] if sig.startswith("sha256=") else sig # 3. Constant-time comparison. return hmac.compare_digest(received, expected) ``` ### PHP ```php $headers Case-sensitive header names as Fynex sends them. * @param string $rawBody file_get_contents('php://input') — never the parsed array. */ function verify_fynex_webhook(array $headers, string $rawBody, string $secret): bool { $sig = $headers['X-Fynex-Signature'] ?? ''; $ts = $headers['X-Fynex-Timestamp'] ?? ''; // 1. Freshness — reject events older than 5 minutes. if (!ctype_digit(ltrim($ts, '-')) || abs(time() - (int) $ts) > 300) { return false; } // 2. Re-compute the HMAC. $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret); $received = str_starts_with($sig, 'sha256=') ? substr($sig, strlen('sha256=')) : $sig; // 3. Constant-time comparison. return hash_equals($expected, $received); } ``` ### curl smoke-test You can replay a captured event against your local verifier: ```bash curl -sS -X POST "http://localhost:3000/webhooks/fynex" \ -H "Content-Type: application/json" \ -H "X-Fynex-Timestamp: 1747654272" \ -H "X-Fynex-Signature: sha256=27c6f9c3..." \ --data-binary '{"eventId":42,"eventType":"PaymentCompleted","sellerAccountUuid":"6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55","occurredAt":"2026-05-19T10:11:12Z","payload":{}}' ``` The exact signature for that body and timestamp depends on your secret — generate it with the Node or Python snippet above. ## Retry and delivery semantics - Each event is delivered to every active webhook URL on your seller account. - A delivery is considered successful only on a `200 OK` response from your endpoint within 10 seconds. - Failed deliveries retry up to **3 attempts total**. After the third failure the delivery is marked `failed` and not retried automatically. - Acknowledge fast (200 in <2s) and process asynchronously — long-running handlers risk hitting the 10s timeout. ## See also - **[Polling & SSE guide](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — How to know when a payment, payout, or refund changes state — without webhooks. ### `GET /payments-api/v1/webhooks/allowlist` List webhook allowlist entries 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. ### `POST /payments-api/v1/webhooks/allowlist` Add webhook allowlist entry 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. ### `DELETE /payments-api/v1/webhooks/allowlist/{id}` Remove webhook allowlist entry 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. ## Payees ## Pagination List endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and return a `total` count alongside the page. Iterate by incrementing `offset` by `limit` until the response returns fewer than `limit` items or `offset >= total`. --- A **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. Payees have two API surfaces with different auth models: | Surface | Auth | Operations | |---------|------|------------| | REST `/payments-api/v1/payees` | Bearer token | Full CRUD: list, get, create, update, delete | | GraphQL `/dashboard/graphql` | `dashboard_session` cookie | Full CRUD: list, get, create, update, delete | > [!NOTE] > 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. --- ## Payee fields (from `schema.graphql`) ### `type Payee` | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Numeric payee ID — use this as the `payeeId` when creating payout methods | | `legalEntityId` | `Int!` | Legal entity this payee belongs to | | `sellerAccountId` | `Int!` | Your seller account ID | | `displayName` | `String` | Human-readable name shown in the dashboard | | `role` | `PayeeRole!` | `Seller`, `Contractor`, or `Tax` | | `status` | `PayeeStatus!` | `Active` or `Disabled` | | `isActive` | `Boolean!` | Convenience flag derived from `status` | | `email` | `String` | Contact email | | `phone` | `String` | Contact phone | | `businessName` | `String` | Legal business name | | `businessType` | `String` | Type of business entity | | `businessAddress` | `String` | Registered business address | | `taxId` | `String` | Tax identification number | | `payeeContractId` | `String` | Your internal contract reference | | `wallets` | `[Wallet!]!` | Wallets assigned to this payee | | `createdAt` | `Time!` | Creation timestamp | | `updatedAt` | `Time!` | Last update timestamp | --- ## REST — Full CRUD **Auth: Bearer token** (`Authorization: Bearer `) | Operation | Method & path | |-----------|---------------| | List payees | `GET /payments-api/v1/payees?limit={n}&offset={n}` | | Get a payee | `GET /payments-api/v1/payees/{payee_id}` | | Create a payee | `POST /payments-api/v1/payees` | | Update a payee | `PATCH /payments-api/v1/payees/{payee_id}` | | Delete a payee | `DELETE /payments-api/v1/payees/{payee_id}` | Every operation is scoped to the seller account behind your Bearer token. `role` accepts the lowercase values `contractor` and `tax`. > [!TIP] > **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](https://api.fynex.ai/payments-api/v2/docs#tag/payouts) in the Payouts guide. ### Payee response shape All single-payee responses (`GET` / `POST` / `PATCH`) return the same object; the list endpoint wraps an array of these under `payees`: ```json { "id": 101, "legalEntityId": 77, "sellerAccountId": 42, "displayName": "Acme Supplies Ltd", "role": "Contractor", "status": "Active", "isActive": true, "payeeContractId": "pc_001", "email": "finance@acme.example.com", "phone": "+447700900123", "businessName": "Acme Supplies Limited", "businessType": "limited_company", "businessAddress": "1 Example Street, London, GB", "taxId": "GB123456789", "createdAt": "2026-06-05T10:00:00Z", "updatedAt": "2026-06-05T10:00:00Z" } ``` ### List payees ``` GET /payments-api/v1/payees?limit={n}&offset={n} ``` Returns 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. ```bash curl -sS "https://api.fynex.ai/payments-api/v1/payees?limit=20&offset=0" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` ```json { "payees": [ { "id": 101, "displayName": "Acme Supplies Ltd", "role": "Contractor", "status": "Active" } ], "total": 1, "limit": 20, "offset": 0 } ``` ### Get a payee ```bash curl -sS "https://api.fynex.ai/payments-api/v1/payees/101" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` A payee that does not exist, or belongs to another seller, returns `404`. ### Create a payee `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. ```bash curl -sS -X POST "https://api.fynex.ai/payments-api/v1/payees" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "displayName": "Acme Supplies Ltd", "role": "contractor", "payeeContractId": "pc_001", "email": "finance@acme.example.com", "businessName": "Acme Supplies Limited", "taxId": "GB123456789" }' ``` Returns `201 Created` with the payee object. A `payeeContractId` already in use within your seller account returns `409 Conflict`. ### Update a payee `PATCH` is a partial update — send only the fields you want to change; omitted fields are left untouched. ```bash curl -sS -X PATCH "https://api.fynex.ai/payments-api/v1/payees/101" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "displayName": "Acme Supplies (EU) Ltd", "email": "eu-finance@acme.example.com" }' ``` Returns `200 OK` with the updated payee object. A foreign/unknown `payee_id` returns `404`. > [!NOTE] > `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. #### Archive / restore via `status` `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): ```bash # Archive (equivalent to DELETE, see below) curl -sS -X PATCH "https://api.fynex.ai/payments-api/v1/payees/101" \ -H "Authorization: Bearer $FYNEX_TOKEN" -H "Content-Type: application/json" \ -d '{ "status": "disabled" }' # Restore a previously archived payee curl -sS -X PATCH "https://api.fynex.ai/payments-api/v1/payees/101" \ -H "Authorization: Bearer $FYNEX_TOKEN" -H "Content-Type: application/json" \ -d '{ "status": "active" }' ``` ### Delete (archive) a payee `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"}`. ```bash curl -sS -X DELETE "https://api.fynex.ai/payments-api/v1/payees/101" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` ```json { "archived": true } ``` > [!WARNING] > **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. Returns `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. --- ## GraphQL — Full CRUD **Auth: `dashboard_session` cookie** — see [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth) for the login flow. All five operations are available on `/dashboard/graphql`: | Operation | Type | Permission | |-----------|------|------------| | `payees(limit, offset)` | Query | `PAYEES_READ` | | `payee(id: Int!)` | Query | `PAYEES_READ` | | `createPayee(input: CreatePayeeInput!)` | Mutation | `PAYEES_CREATE` | | `updatePayee(id: Int!, input: UpdatePayeeInput!)` | Mutation | `PAYEES_UPDATE` | | `deletePayee(id: Int!)` | Mutation | `PAYEES_DELETE` — **staff surface only**, not callable on `/dashboard/graphql` | ### `CreatePayeeInput` fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `legalEntityId` | `Int!` | Yes | Legal entity to associate this payee with | | `merchantId` | `ID!` | Yes | Your seller account ID, as a GraphQL `ID` (`"42"` and `42` are both accepted) | | `role` | `PayeeRole!` | Yes | `Seller`, `Contractor`, or `Tax` | | `displayName` | `String!` | Yes | Human-readable payee name | | `payeeContractId` | `String` | No | Your internal reference | | `email` | `String` | No | Payee contact email | | `phone` | `String` | No | Payee contact phone | | `businessName` | `String` | No | Legal business name | | `businessType` | `String` | No | Business entity type | | `businessAddress` | `String` | No | Registered address | | `taxId` | `String` | No | Tax identification number | | `splitRuleLine` | `PayeeSplitRuleLineInput` | No | Attach a split rule allocation at creation time | ### `UpdatePayeeInput` fields All fields are optional. Only fields you supply are updated; omit any field to leave it unchanged. | Field | Type | Description | |-------|------|-------------| | `legalEntityId` | `Int!` | Legal entity (required if updating) | | `displayName` | `String` | New display name | | `role` | `PayeeRole` | `Seller`, `Contractor`, or `Tax` | | `merchantId` | `ID` | Seller account ID | | `email` | `String` | Contact email | | `phone` | `String` | Contact phone | | `businessName` | `String` | Legal business name | | `businessType` | `String` | Business entity type | | `businessAddress` | `String` | Registered address | | `taxId` | `String` | Tax identification number | | `payeeContractId` | `String` | Internal contract reference | | `splitRuleLine` | `PayeeSplitRuleLineInput` | Update split allocation | --- ## Create a payee — code samples #### curl ```bash # First obtain the session cookie (see GraphQL Authentication guide) curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Create the payee curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation CreatePayee($input: CreatePayeeInput!) { createPayee(input: $input) { id displayName role status } }", "variables": { "input": { "legalEntityId": 1, "merchantId": "42", "role": "Seller", "displayName": "Acme Supplies Ltd", "email": "payments@acme.example.com", "businessName": "Acme Supplies Limited", "taxId": "GB123456789" } } }' ``` #### JavaScript (Apollo/fetch) ```js const BASE = 'https://api.fynex.ai'; // Assumes login was already called and the cookie is present async function createPayee(input) { const res = await fetch(`${BASE}/dashboard/graphql`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ` mutation CreatePayee($input: CreatePayeeInput!) { createPayee(input: $input) { id displayName role status } } `, variables: { input }, }), }); const { data, errors } = await res.json(); if (errors?.length) throw new Error(errors[0].message); return data.createPayee; } const payee = await createPayee({ legalEntityId: 1, merchantId: '42', role: 'Seller', displayName: 'Acme Supplies Ltd', email: 'payments@acme.example.com', businessName: 'Acme Supplies Limited', taxId: 'GB123456789', }); console.log(payee); // { id: 7, displayName: 'Acme Supplies Ltd', role: 'Seller', status: 'Active' } ``` #### Python ```python import requests BASE = "https://api.fynex.ai" session = requests.Session() # Login first session.post( f"{BASE}/api/v1/login/dashboard", json={"email": "you@example.com", "password": "your_password"}, ).raise_for_status() mutation = """ mutation CreatePayee($input: CreatePayeeInput!) { createPayee(input: $input) { id displayName role status } } """ variables = { "input": { "legalEntityId": 1, "merchantId": "42", "role": "Seller", "displayName": "Acme Supplies Ltd", "email": "payments@acme.example.com", "businessName": "Acme Supplies Limited", "taxId": "GB123456789", } } resp = session.post( f"{BASE}/dashboard/graphql", json={"query": mutation, "variables": variables}, ) resp.raise_for_status() body = resp.json() if "errors" in body: raise RuntimeError(body["errors"][0]["message"]) print(body["data"]["createPayee"]) # {'id': 7, 'displayName': 'Acme Supplies Ltd', 'role': 'Seller', 'status': 'Active'} ``` --- ## Other operations ### List payees via GraphQL ```graphql query ListPayees($limit: Int, $offset: Int) { payees(limit: $limit, offset: $offset) { id displayName role status email businessName } } ``` ### Get a single payee ```graphql query GetPayee($id: Int!) { payee(id: $id) { id displayName role status email phone businessName businessAddress taxId payeeContractId wallets { id currencyCode } createdAt } } ``` ### Update a payee ```graphql mutation UpdatePayee($id: Int!, $input: UpdatePayeeInput!) { updatePayee(id: $id, input: $input) { id displayName email updatedAt } } ``` Pass only the fields you want to change in `$input`. `legalEntityId` is required in `UpdatePayeeInput` even when you are not changing it. ### Delete a payee ```graphql # Not available on /dashboard/graphql — staff surface only. Shown for reference; # sellers archive a payee with the REST DELETE below. mutation DeletePayee($id: Int!) { deletePayee(id: $id) } ``` Returns `true` on success. > [!CAUTION] > **`deletePayee` is not exposed on `/dashboard/graphql`.** A dashboard session calling it > receives `Cannot query field "deletePayee" on type "Mutation"`; the mutation exists on the > Fynex staff surface only. From your integration, use the REST `DELETE /payees/{payeeId}` > described above — it archives the payee and is reversible. > [!WARNING] > 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. --- ## Common pitfalls | Pitfall | Resolution | |---------|------------| | 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. | | Sending `sellerAccountId` / `legalEntityId` in a REST body | They are ignored — REST payees are always pinned to the seller account behind your Bearer token. | | Forgetting `legalEntityId` on a GraphQL update | `UpdatePayeeInput.legalEntityId` is `Int!` — required even when not changing it. The REST `PATCH` has no such requirement. | | Using an uppercase `role` on REST | REST accepts the lowercase values `contractor` and `tax`; anything else returns `400`. | | 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). | ## See also - **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Obtain a dashboard_session cookie before calling any GraphQL mutation. - **[Payout Methods](https://api.fynex.ai/payments-api/v2/docs#tag/payout-methods)** — Register bank accounts as payout destinations for a payee. - **[Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts)** — Send money from a seller wallet to a payee ## Payouts ## Pagination List endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and return a `total` count alongside the page. Iterate by incrementing `offset` by `limit` until the response returns fewer than `limit` items or `offset >= total`. --- A **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. > [!IMPORTANT] > 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. > [!NOTE] > **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. > [!NOTE] > **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. --- ## Quick path: onboard and pay a new payee Starting from scratch? The convenience endpoints collapse the full flow into a short, self-contained sequence using a dedicated **cashout_balance** wallet — funded directly from your balance, isolated from split earnings. The same supported bank routes and Bearer auth apply as for the primitive payout flow. > [!IMPORTANT] > This flow provisions a `cashout_balance` wallet, so it is **gated per seller** > by `walletCreationEnabled` (off by default). If it is not enabled for your > account, `POST /payees/setup` returns `403 wallet creation is not enabled for > this account; contact support`. Contact support to enable it. **1. Create the payee, its payout method, and a cashout wallet** — `POST /payees/setup` (one atomic call): ```bash curl -sS -X POST "$FYNEX_API/payees/setup" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "displayName": "Acme Supplies Ltd", "role": "contractor", "payoutMethod": { "currency": "EUR", "iban": "DE89370400440532013000" } }' ``` Response (`201 Created`) returns all three records — keep `payee.id`, `payoutMethod.id`, and `cashoutWallet.id`: ```json { "payee": { "id": 101, "role": "Contractor" }, "payoutMethod": { "id": 501, "status": "active" }, "cashoutWallet": { "id": 139, "type": "cashout_balance", "currency": "EUR", "status": "active" } } ``` **2. Fund the cashout wallet from your main balance** — `POST /wallets/{cashout_wallet_id}/fund` (internal, same-currency): ```bash curl -sS -X POST "$FYNEX_API/wallets/139/fund" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amount": "12.50", "idempotencyKey": "'$(uuidgen)'" }' ``` Moves funds from your seller `main` wallet (same currency) into the payee's cashout wallet. Your main wallet must hold sufficient EUR. > [!NOTE] > **This step is optional for sellers enabled for payee payout auto-funding.** > On `POST /payees/{payee_id}/payouts`, Fynex atomically moves only the > cashout wallet's shortfall from the seller's same-currency `main` wallet and > then holds the payout amount. If either operation fails, neither movement is > committed. Auto-funding is off by default and must be enabled by Fynex for the > seller account. It does not apply to the primitive `POST /payouts` endpoint. > If the payout is definitively rejected or cancelled, the auto-funded > shortfall is returned to the seller's main wallet in the same transaction as > the payout hold release. An ambiguous provider outcome stays held until it is > reconciled, so the platform never returns funds that the provider may still > settle. When both `payeePayoutAutoFundingEnabled` and > `walletCreationEnabled` are enabled for the seller and the payee has no > `cashout_balance` wallet in the payout method's currency, this endpoint first > provisions one automatically and then auto-funds it. This also applies when > the payee already has an ordinary `other` or `tax` wallet, because those > split-destination wallets are intentionally not debited by auto-funding. > The seller's own `Itself` payee is excluded: its payouts keep using the > existing seller `main` wallet and never trigger cashout provisioning. > Provisioning is idempotent and remains separate from the money-movement > transaction, so a payout failure may leave the empty wallet available for a > safe retry but cannot leave a partial ledger movement. **3. Pay the payee** — `POST /payees/{payee_id}/payouts` (wallet auto-resolved): ```bash curl -sS -X POST "$FYNEX_API/payees/101/payouts" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "payoutMethodId": 501, "amount": "12.50", "idempotencyKey": "'$(uuidgen)'" }' ``` You 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). > [!NOTE] > `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`. The 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). --- ## Prerequisites - A seller wallet with sufficient balance in the target currency. - A **registered payee** — create one with `POST /payments-api/v1/payees` (see [Payees](https://api.fynex.ai/payments-api/v2/docs#tag/payees)) if you don't have one yet. - 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](https://api.fynex.ai/payments-api/v2/docs#tag/payout-methods)), then use `GET /payees/{payee_id}/payout-methods` to look up the available methods and their ids. > [!NOTE] > 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**. --- ## Step-by-step 1. **Find the payee's payout methods** `payee_id` is the numeric payee ID from `GET /payees`. #### curl ```bash curl -sS "$FYNEX_API/payees/42/payout-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" | jq ``` #### JavaScript ```js const res = await fetch(`${process.env.FYNEX_API}/payees/42/payout-methods`, { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` }, }); const { payoutMethods } = await res.json(); ``` #### Python ```python import os, requests data = requests.get( f"{os.environ['FYNEX_API']}/payees/42/payout-methods", headers={"Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}"}, ).json() payout_methods = data["payoutMethods"] ``` 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). 2. **Create the payout** #### curl ```bash curl -sS -X POST "$FYNEX_API/payouts" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "walletId": 15, "payoutMethodId": 501, "amount": "1999.00", "currencyCode": "EUR", "idempotencyKey": "'$(uuidgen)'" }' ``` #### JavaScript ```js import { randomUUID } from 'node:crypto'; const res = await fetch(`${process.env.FYNEX_API}/payouts`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ walletId: 15, payoutMethodId: 501, amount: '1999.00', // €1,999.00 currencyCode: 'EUR', idempotencyKey: randomUUID(), }), }); const payout = await res.json(); ``` #### Python ```python import os, uuid, requests res = requests.post( f"{os.environ['FYNEX_API']}/payouts", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Content-Type": "application/json", }, json={ "walletId": 15, "payoutMethodId": 501, "amount": "1999.00", # €1,999.00 "currencyCode": "EUR", "idempotencyKey": str(uuid.uuid4()), }, ) payout = res.json() ``` **Response (201 Created):** ```json { "id": 9001, "status": "processing", "amountMinor": 199900, "currencyCode": "EUR", "providerReference": "ref_9f2c1a4b", "bankAccountType": "uk_local" } ``` Save the `id` — you will need it to poll status. 3. **Poll for the final status** There are no outbound webhook events for payouts. Poll `GET /payouts/\{id\}` until `status` reaches a terminal value. #### curl ```bash curl -sS "$FYNEX_API/payouts/9001" \ -H "Authorization: Bearer $FYNEX_TOKEN" | jq .status ``` #### JavaScript ```js const res = await fetch(`${process.env.FYNEX_API}/payouts/9001`, { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` }, }); const { status } = await res.json(); ``` #### Python ```python import os, requests data = requests.get( f"{os.environ['FYNEX_API']}/payouts/9001", headers={"Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}"}, ).json() print(data["status"]) ``` Poll every 30–60 seconds until status is `completed`, `failed`, or `cancelled`. --- ## Request body fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `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. | | `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.) | | `amount` | `string` | Yes\* | Decimal amount in the payout currency, e.g. `"19.99"`. Preferred over `amountMinor`. | | `amountMinor` | `int64` | Yes\* | **Deprecated.** Amount in minor units (e.g. `1999` for €19.99). Fallback when `amount` is omitted; ignored when `amount` is set. | | `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. | | `idempotencyKey` | `string` (UUID) | No | If omitted, a UUID is auto-generated. Replays with the same key return the original payout. | \* Exactly one of `amount` or `amountMinor` is required. > [!NOTE] > The `Idempotency-Key` HTTP header is read as a fallback when the body's `idempotencyKey` is empty. When both are set, the body wins. ## SEPA payouts — mandatory parameters EUR 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. **1. Payout method (destination)** — `POST /payees/{id}/payout-methods` (or via the dashboard): | Field | Required | Notes | |-------|----------|-------| | `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. | | `currency` | **Yes** | Must be `EUR`. | | `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. | | `bic` | No | Recommended. Forwarded to the banking provider as the creditor institution only when present. | | `accountName` | No | Recommended — used as the SEPA creditor name. Falls back to `bankName`, then `Payee {id}` when omitted. | The payout method `type` is `bank_account` — the only supported value. **2. Payout request** — `POST /payouts`: | Field | Required | Notes | |-------|----------|-------| | `walletId` | **Yes** | Seller EUR wallet to debit; must hold sufficient EUR. | | `payoutMethodId` | **Yes** | The EUR IBAN method from step 1. Must belong to the wallet's payee and be active — there is no implicit default. | | `amount` | **Yes**\* | Decimal in the payout currency (`"1999.00"` = €1,999.00). Preferred over `amountMinor`. | | `amountMinor` | **Yes**\* | Deprecated minor-units fallback (`199900` = €1,999.00). \*Exactly one of `amount`/`amountMinor`. | | `currencyCode` | **Yes** | Must be `EUR`. | | `idempotencyKey` | No | UUID; auto-generated if omitted. | > [!NOTE] > 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. The payout transitions through `pending` → `processing` → `completed` once the banking provider confirms the SEPA settlement. ## GBP Faster Payments — mandatory parameters GBP 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. **1. Payout method (destination)** — `POST /payees/{id}/payout-methods` (or via the dashboard): | Field | Required | Notes | |-------|----------|-------| | `currency` | **Yes** | Must be `GBP`. | | `bankAccountType` | **Yes** | Must be `uk_local`. | | `bankCountry` | **Yes** | Must be `GB`. | | `accountNumber` | **Yes** | Eight-digit UK account number. Spaces are accepted and removed. | | `sortCode` | **Yes** | Six-digit UK sort code. Spaces and hyphens are accepted and removed. | | `accountName` | No | Recommended; used as the creditor name. | **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. > [!NOTE] > 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. > [!CAUTION] > **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. --- ## Response fields ### POST /payouts (201 Created) The create response is intentionally minimal: | Field | Type | Description | |-------|------|-------------| | `id` | `int64` | Internal payout ID — use this for `GET /payouts/\{id\}` | | `status` | `string` | Typically `processing` on a fresh create | | `amountMinor` | `int64` | Minor units | | `currencyCode` | `string` | | | `providerReference` | `string` | Upstream provider's reference for this payout. Opaque — match, do not parse (omitempty) | | `bankAccountType` | `string` | Destination account identifier format: `iban`, `uk_local`, `us_local`, `swift`. Empty means unknown (omitempty) | ### GET /payouts/\{id\} (200 OK) The detail response adds timing fields: | Field | Type | Description | |-------|------|-------------| | `id` | `int64` | | | `status` | `string` | Current status | | `amountMinor` | `int64` | | | `currencyCode` | `string` | | | `providerReference` | `string` | Upstream provider's reference for this payout. Opaque — match, do not parse (omitempty) | | `bankAccountType` | `string` | Destination account identifier format: `iban`, `uk_local`, `us_local`, `swift`. Empty means unknown (omitempty) | | `failureCode` | `string` | Set when the payout fails (omitempty) | | `failureMessage` | `string` | Declared on the response but is never populated by the current implementation | | `requestedAt` | `string` | RFC3339 UTC | | `processedAt` | `string` | RFC3339 UTC (omitempty) | | `completedAt` | `string` | RFC3339 UTC (omitempty) | > [!NOTE] > `failureMessage` appears in the response schema but is never set by the server today. Use `failureCode` to detect failures; do not rely on `failureMessage` for message text. --- ## Status values | Status | Meaning | |--------|---------| | `pending` | Created in Fynex, queued for processing | | `processing` | Submitted to the payment network | | `completed` | Funds left the wallet and reached the destination | | `failed` | Network rejected the payment; balance returned to wallet | | `cancelled` | Payout was cancelled before processing | --- ## Listing payouts ```bash curl -sS "$FYNEX_API/payouts?limit=20&offset=0" \ -H "Authorization: Bearer $FYNEX_TOKEN" | jq ``` Returns payouts ordered by `requestedAt` descending. Query params `limit` (1–100, default 20) and `offset` (default 0). --- ## Insufficient balance If the wallet does not cover `amountMinor`, the request returns `409 Conflict`: ```json { "error": "insufficient balance" } ``` Top up the wallet via incoming payments or a treasury transfer, then retry with the **same** `idempotencyKey`. Replaying a **successful** create with the same `idempotencyKey` returns the original payout — it does not create a second one and does not return `409`. --- ## Common errors | Status | Body | Cause | |--------|------|-------| | `400` | `walletId is required` | Missing or zero `walletId` | | `400` | `payoutMethodId is required` | Missing or zero `payoutMethodId` (no implicit default) | | `400` | `amount must be positive` | Zero or negative amount | | `400` | `currencyCode is required` | Missing currency | | `401` | `unauthorized` (plain text) | Missing or invalid bearer token | | `403` | `wallet does not belong to seller account` | `walletId` belongs to another seller | | `404` | `wallet not found for this currency` | `walletId` exists but currency mismatches | | `404` | `payee not found` | `payee_id` doesn't exist or belongs to another seller | | `409` | `insufficient balance` | Wallet balance below requested amount | | `422` | `currency not supported for payouts` | `currencyCode` has no enabled payout rail | | `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 | | `422` | `payout method does not belong to this wallet's payee` | `payoutMethodId` belongs to a different payee than the wallet | | `422` | provider rejection message | The banking provider rejected the payment at create time; check `failureCode` via `GET /payouts/{id}` | ## See also - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Poll payout status or subscribe to status events. - **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Safe retry patterns for payout creation. - **[Split rules](https://api.fynex.ai/payments-api/v2/docs#tag/splits)** — Splitting incoming funds across multiple payees? Use Split rules. - **[Virtual accounts](https://api.fynex.ai/payments-api/v2/docs#tag/virtual-accounts)** — Need a dedicated bank account for incoming payments? See Virtual accounts. ## Vendors ## Pagination List endpoints accept `limit` (1–100, default 20) and `offset` (default 0) and return a `total` count alongside the page. Iterate by incrementing `offset` by `limit` until the response returns fewer than `limit` items or `offset >= total`. --- A **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. The 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`. > [!NOTE] > 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. --- ## Lifecycle Every vendor response carries a `verification` block. `verification.status` is the one field to branch on: | `verification.status` | Meaning | `payoutsEnabled` | |---|---|---| | `pending_fynex` | Fynex is screening (`reason: screening`) or reviewing. Nothing for you or the vendor to do. | `false` | | `not_required` | Screening passed; no verification step is due. | `true`, under the cumulative limit | | `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 | | `in_progress` | The vendor has started the step. | unchanged | | `complete` | Fully verified; the cumulative limit no longer applies. | `true` | | `blocked` | Refused at screening (`reason: screening_refused`), declined at verification (`reason: declined`) or manually blocked (`reason: blocked`). | `false`, permanently | Screening 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`. `paymentsEnabled` is always `false`: in the merchant-of-record model the vendor never takes a payment itself. --- ## Create a vendor ```http POST /payments-api/v1/vendors Authorization: Bearer Content-Type: application/json { "externalRef": "vendor_8817", "entityType": "company", "displayName": "Blue Door Ceramics Ltd", "contactEmail": "owner@bluedoor.example", "country": "GB", "companyNumber": "09876543", "merchantCategoryCode": "5999", "storefrontUrl": "https://market.example/shops/blue-door", "address": { "street": "1 High Street", "city": "Edinburgh", "postalCode": "EH1 1AA" }, "consent": { "sumsubPrivacyNoticeAccepted": true, "acceptedAt": "2026-09-03T12:00:00Z", "ip": "203.0.113.7", "noticeVersion": "2026-07" } } ``` For an individual (sole trader), send `"entityType": "individual"` with a `person` object instead of `companyNumber`: ```json "person": { "firstName": "Jane", "lastName": "Maker", "dob": "1990-04-12" } ``` `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. ```json { "vendorId": "7c1f8a2e-6b3d-4e5f-9a0b-1c2d3e4f5a6b", "externalRef": "vendor_8817", "entityType": "company", "displayName": "Blue Door Ceramics Ltd", "paymentsEnabled": false, "payoutsEnabled": false, "payeeId": 101, "verification": { "status": "pending_fynex", "reason": "screening", "actionUrl": null, "actionExpiresAt": null }, "payoutAllowance": { "currency": "GBP", "limitMinor": 10000, "usedMinor": 0, "remainingMinor": 10000, "basis": "cumulative_lifetime_all_currencies", "enforcement": "monitored", "unlimited": false, "deferred": false }, "nextTier": { "limitMinor": null, "unlimited": true, "requirements": [ { "code": "screening_passed", "status": "in_progress" }, { "code": "identity_verification", "status": "not_started" }, { "code": "agreement_signed", "status": "not_started" } ] }, "createdAt": "2026-09-05T12:00:00Z", "updatedAt": "2026-09-05T12:00:00Z" } ``` `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`. ### Idempotency `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. ### Responses All errors use the standard envelope `{"error": ""}`. Field-level validation failures name the field: `{"error": "person.dob: required; YYYY-MM-DD"}`. | Status | `error` | When | |--------|---------|------| | `201` | — | Vendor created | | `200` | — | Idempotent replay of an equivalent request | | `400` | `: ` | A field is missing or malformed | | `401` | | Missing or invalid Bearer token | | `403` | `platformRequired` | Your account is not an active platform, or the credential is a delegated dashboard session rather than the platform's own key | | `409` | `externalRefConflict` | `externalRef` already refers to a vendor created with a different request | | `422` | `invalidCountry` | `country` is not an ISO 3166-1 alpha-2 code | | `422` | `countryNotSupported` | A real country Fynex does not onboard vendors from | | `422` | `mccProhibited` | The merchant category is prohibited | | `422` | `vendorScreeningRefused` | This vendor cannot be onboarded through the API. Contact support if you believe this is wrong | | `422` | `consentRequired` | Consent enforcement is on and `consent.sumsubPrivacyNoticeAccepted` was not `true` | | `429` | `dailyVendorLimitReached` | Daily vendor creation limit for your platform; retry after 24 hours | | `503` | `screeningUnavailable` | Screening is temporarily unavailable. Nothing was created; retry with the same `externalRef` | The vendor mutation routes also have their own per-platform rate-limit bucket, separate from the general API limit; a `429` with an empty-body `Retry-After` header is that bucket. --- ## Read vendors ```http GET /payments-api/v1/vendors/{vendor_id} GET /payments-api/v1/vendors?limit=20&offset=0 ``` The 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`. --- ## Verification link When 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: ```http POST /payments-api/v1/vendors/{vendor_id}/verification-link Authorization: Bearer ``` ```json { "actionUrl": "https://…", "actionExpiresAt": "2026-09-06T12:00:00Z" } ``` The 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. | Status | `error` | When | |--------|---------|------| | `200` | — | Link minted | | `403` | `platformRequired` | Not the platform's own key | | `404` | `vendorNotFound` | Not your vendor | | `409` | `verificationNotRequired` | No step is due for this vendor | | `503` | `screeningUnavailable` | The provider could not mint a link; retry | As the vendor progresses, `verification.status` moves through `in_progress` and `pending_fynex` to `complete` (or `blocked`), and the matching `MarketplaceVendorVerification*` events fire. ## Paying a vendor Each vendor has exactly one **payee** (`payeeId`), owned by your platform account. Use it exactly like any other payee: 1. Attach bank details: `POST /payees/{payee_id}/payout-methods` — see [Payout methods](/payments-api/v2/docs/payout-methods.md). 2. Route the vendor's share: name the payee in a split rule — see [Splits](/payments-api/v2/docs/splits.md). 3. Pay out: `POST /payouts` from the payee's wallet — see [Payouts](/payments-api/v2/docs/payouts.md). The 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. ### The cumulative payout limit A 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. When 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. `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. --- ## What vendors are not - 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. - 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. ## Checkout ## Getting started 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. > [!NOTE] > All examples target the **staging** environment — no real cards, no real money. Switch the base URL to `https://api.fynex.ai/payments-api/v1` when you are ready for production. - **API base URL:** `https://staging-api.fynex.ai/payments-api/v1` - **Full API reference:** this site - **Dashboard:** `https://staging-dashboard.fynex.ai` ## 1. Get your API token Every request is authenticated with a **seller bearer token**, which you obtain yourself from the dashboard: 1. Log in to `https://staging-dashboard.fynex.ai` with your username and password. 2. Select your seller account. 3. Open the **Integration** page. 4. Click the reveal (eye) icon to show the token, then **Copy**. The token is a plain string — there is no `sk_test_`-style prefix. It is exactly the value you pass as `Authorization: Bearer `. Treat it like a password. > [!CAUTION] > 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. If you don't have dashboard access yet, ask your Fynex contact to set you up. Store the token and base URL in your environment: ```bash export FYNEX_API="https://staging-api.fynex.ai/payments-api/v1" export FYNEX_TOKEN="" ``` See [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) for token storage, rotation, and error details. ## 2. Verify the token works The quickest "is my token alive?" check returns the payment methods enabled on your account: ```bash curl -sS "$FYNEX_API/payment-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` - **`200 OK`** with a JSON body → your token works and your account is active. - **`401 Unauthorized`** → the token is missing, malformed, or invalid. - **`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.) ## 3. Take your first test payment There are two integration styles. Start with **hosted checkout** — it's the fastest and keeps your servers out of PCI scope. ### Option A — Hosted checkout (recommended first) Fynex hosts the card form; you create a session and redirect the customer to it. ```bash curl -sS -X POST "$FYNEX_API/checkout" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-DEMO-1", "amount": 19.99, "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": true, "returnUrls": { "success": "https://example.com/success", "failure": "https://example.com/failure" } }' ``` A `201 Created` response returns a `checkoutUrl`: ```json { "sessionId": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "checkoutUrl": "https://staging-api.fynex.ai/checkout/6f9b84e1-...", "expiresAt": "2026-04-29T11:30:00Z" } ``` Open `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](https://api.fynex.ai/payments-api/v2/docs#tag/quickstart). ### Option B — Server-to-server Submit card details directly to the API. Use this when you collect card data yourself. ```bash curl -sS -X POST "$FYNEX_API/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "TEST-HAPPY-1", "amount": 19.99, "paymentType": "card", "paymentMethod": "card", "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": true, "skip3DS": true, "cardData": { "cardNumber": "4111111111111111", "expMonth": 12, "expYear": 2028, "holderName": "Test User", "cvv": "123" }, "returnLinks": [ { "rel": "default", "href": "https://example.com/return", "method": "GET" } ] }' ``` With `"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](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) for the full lifecycle. ## 4. Sandbox test cards Test cards work on a **Demo** account. What decides it is the account's operational mode, never the host — Demo accounts exist on both `https://staging-api.fynex.ai` and `https://api.fynex.ai`. Use any of these Visa sandbox numbers: | Card number | Notes | |-------------|-------| | `4111 1111 1111 1111` | Universal Visa test card | | `4530 9100 0001 2345` | Visa | | `4037 1122 3300 0001` | Visa | For all of them: - **Expiry month:** any future month (e.g. `12`) - **Expiry year:** any future year — 4-digit (`2028`) recommended - **CVV:** any 3 digits (e.g. `123`) - **Cardholder name:** Latin letters (`A-Z`), spaces, apostrophes, dots, or hyphens only > [!NOTE] > 3DS is controlled by the `skip3DS` request flag, **not** by the card number. Don't expect a particular card number to force "approved" vs "declined" — that's controlled by the flow and the sandbox challenge page. You 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](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards) for the full reference. ## 5. Check the result You have two ways to confirm a payment's outcome: 1. **Poll** the payment status (same token): ```bash curl -sS "$FYNEX_API/payments/TEST-HAPPY-1" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` The response includes the Fynex `status` (e.g. `provider_completed`, `settled`, `failed`, `cancelled`) along with `amount`, `currencyCode`, `countryCode`, `paymentType`, `paymentMethod`, `externalOrderRef`, the `failureCode` / `failureDescription` / `failureStage` if the payment failed, and the `createdAt` / `updatedAt` / `failedAt` timestamps. 2. **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. > [!NOTE] > Distinguish `cancelled` (customer abandoned the form) from `failed` (hard decline) — show a neutral "payment not completed" message for `cancelled`, not an error. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) for the full verification pattern. ## Conventions to know - **`Idempotency-Key` header is mandatory** on every `POST` (checkout, initialize-payment, finalize-payment, refund). Use a fresh UUID per logical request; retrying with the *same* key safely returns the existing operation instead of creating a duplicate. - **`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. - **`countryCode` is required on `initialize-payment`** (and recommended on `checkout`); if your account/terminal is pinned to a country, it must match it. - **Amounts** are in major units (e.g. `19.99` = £19.99). ## Common next steps - **[Captures & refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — manual capture (`autoSettlement: false`) and refunding settled payments. - **[Server-to-server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — the full direct-integration and 3DS redirect lifecycle. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — verify payment status without webhooks. ## Going to production When 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. **Never send test card numbers on a Live account.** Note the wording: sandbox versus real card networks is a property of your account's **operational mode**, not of the host you call — Demo accounts exist on both environments, and test cards are correct on any of them. Confirm `operationalMode` before sending a test PAN; see [Test cards & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards). ## Need help? Reach out to your Fynex contact with your `externalOrderRef` and the approximate time of the request, and we can trace it end to end. ## Quickstart 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. > [!NOTE] > All examples target the **staging** environment. Switch the base URL to `https://api.fynex.ai` when you are ready for production. ## Prerequisites - A seller API token — mint your own from the dashboard **Integration** page, or via the API (see [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)). - 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.) - A terminal with `curl`, plus Node.js 20+ or Python 3.10+ if you prefer those snippets. ## Step-by-step 1. **Get a token and configure your environment** Get your token yourself from the dashboard: log in to `https://staging-dashboard.fynex.ai`, select your seller account, open the **Integration** page, reveal the token, and copy it (see [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) for details). Then export it: ```bash export FYNEX_API="https://staging-api.fynex.ai/payments-api/v1" export FYNEX_TOKEN="" ``` 2. **Create a hosted checkout session** 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. #### curl ```bash curl -sS -X POST "$FYNEX_API/checkout" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-DEMO-1", "amount": 19.99, "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": true, "returnUrls": { "success": "https://example.com/success", "failure": "https://example.com/failure" } }' ``` #### JavaScript ```js import { randomUUID } from 'node:crypto'; const res = await fetch(`${process.env.FYNEX_API}/checkout`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: JSON.stringify({ externalOrderRef: 'ORDER-DEMO-1', amount: 19.99, currencyCode: 'GBP', countryCode: 'GB', autoSettlement: true, returnUrls: { success: 'https://example.com/success', failure: 'https://example.com/failure', }, }), }); const session = await res.json(); console.log(session.checkoutUrl); // redirect the customer here ``` #### Python ```python import os, uuid, requests res = requests.post( f"{os.environ['FYNEX_API']}/checkout", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={ "externalOrderRef": "ORDER-DEMO-1", "amount": 19.99, "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": True, "returnUrls": { "success": "https://example.com/success", "failure": "https://example.com/failure", }, }, ) session = res.json() print(session["checkoutUrl"]) # redirect the customer here ``` A `201 Created` response returns: ```json { "sessionId": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "checkoutUrl": "https://staging-api.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "expiresAt": "2026-04-29T11:30:00Z" } ``` ### Request field reference | Field | Type | Required | Notes | |-------|------|----------|-------| | `externalOrderRef` | string | Yes | Your order ID — must be unique per seller | | `amount` | float | Yes | Major units (e.g., `19.99` for £19.99) | | `currencyCode` | string | Yes | 3-letter ISO code (e.g., `GBP`). Unsupported currencies may be rejected when the payment is processed. | | `countryCode` | string | Yes | 2-letter ISO code (e.g., `GB`) | | `autoSettlement` | bool | No | `true` to auto-capture; `false` for manual capture later | | `returnUrls.success` | string | No | Customer redirected here on success; falls back to seller checkout settings, then the hosted checkout page | | `returnUrls.failure` | string | No | Customer redirected here on failure; falls back to seller checkout settings, then the hosted checkout page | | `sellerMerchantName` | string | No | Displayed on the checkout page | | `logoUrl` | string | No | Merchant logo URL shown on the checkout page | | `locale` | string | No | Falls back to seller account locale, then `"en"` | | `description` | string | No | Order description shown to the customer | 3. **Redirect the customer to the checkout URL** Take `checkoutUrl` from the response and redirect the customer's browser to it: ``` HTTP/1.1 302 Found Location: https://staging-api.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b ``` Fynex hosts the card entry form. Your server stays out of PCI scope. 4. **Complete the payment with a test card** On the hosted checkout page, enter: - **Card number:** `4111 1111 1111 1111` - **Expiry:** any future month/year - **CVV:** any 3 digits > [!NOTE] > This is a common sandbox test card. Confirm with your Fynex contact for the current set of accepted test cards and any outcome-specific numbers (decline, 3DS, etc.). Submit the form. The page will redirect to `returnUrls.success` on completion, or `returnUrls.failure` if the payment fails. 5. **Verify the result** Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200), and you can also poll for the payment status as a backstop. The simplest polling path for server-to-server backends is `GET /payments-api/v1/payments/{externalOrderRef}` — same bearer token you already have, returning the current Fynex `status`, amount, currency, payment method, timestamps, and any seller-safe failure summary. For browser/dashboard contexts the GraphQL `genericPayment` query or the SSE stream are also available. See the [Polling & SSE guide](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) for the full verification pattern. ## What just happened - 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. - `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 & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)). - 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. ## See also - **[Authentication & Tokens](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)** — How tokens work, how to rotate them, and what to do if one leaks. - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Full hosted checkout reference — optional fields, return URL handling, and more. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — How to verify payment status without webhooks. - **[Payment methods](https://api.fynex.ai/payments-api/v2/docs#tag/payment-methods)** — Discover what payment methods are enabled on your account. ## Authentication 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. ## The auth header Include the token in every request as an HTTP Bearer token: ```http Authorization: Bearer ``` Anything else — missing header, malformed value, or an unrecognised token — returns `401 Unauthorized`. ## How to get a token You obtain your token yourself — there is no need to wait on the Fynex team. **From the dashboard (easiest):** 1. Log in to the dashboard with your username and password — staging: `https://staging-dashboard.fynex.ai`, production: `https://dashboard.fynex.ai`. 2. Select your seller account. 3. Open the **Integration** page. It shows the API token masked, with a reveal (eye) toggle and a copy button. 4. Click the reveal icon, then copy the token. This value is exactly what you pass as `Authorization: Bearer `. If you don't have a dashboard login yet, ask your Fynex contact to set you up. **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 session cookie) → the `createSellerAccount` GraphQL mutation, which returns the new seller account's `authorizationToken`. The first user of a new organization is automatically granted the `selleraccounts:create` / `selleraccounts:update` permissions this requires. See the [Account setup & onboarding](https://api.fynex.ai/payments-api/v2/docs#tag/onboarding) guide for the full walkthrough. > [!IMPORTANT] > A new seller account is created in `Demo` mode and **activated automatically**, so your token > works immediately for **sandbox testing** — you can take test payments straight away (see the > [Quickstart](https://api.fynex.ai/payments-api/v2/docs#tag/quickstart)). **Going live** (real-money processing) is separate: it requires > KYB approval and a Fynex-assigned live acquirer terminal. If a sandbox call ever returns > `403 seller account is not active`, the token is still valid — the account just isn't active > yet; contact Fynex with your seller account ID. > [!NOTE] > A separate token is issued per seller account, and staging and production are independent — get the staging token from `https://staging-dashboard.fynex.ai` and the production token from `https://dashboard.fynex.ai`. > [!CAUTION] > 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)). ## What a token looks like Tokens are opaque database strings. There are **no prefix conventions** such as `sk_test_` or `sk_live_` — the string you receive is the full token value. ## Verifying a token The quickest "is this token alive?" check is `GET /payment-methods`. It requires only a valid seller token and returns the payment methods enabled on your account. #### curl ```bash curl -sS "$FYNEX_API/payment-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` #### JavaScript ```js const res = await fetch(`${process.env.FYNEX_API}/payment-methods`, { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` }, }); const data = await res.json(); console.log(data); // { sellerAccountId, allowedPaymentMethods, allowedCurrencies, ... } ``` #### Python ```python import os, requests res = requests.get( f"{os.environ['FYNEX_API']}/payment-methods", headers={"Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}"}, ) print(res.json()) # { "sellerAccountId": ..., "allowedPaymentMethods": [...], ... } ``` A `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)). ## Storing tokens securely - **Never commit tokens to source control.** Use a secret manager (HashiCorp Vault, AWS Secrets Manager, GitHub Encrypted Secrets) or environment variables loaded at runtime. - **One token per environment.** Keep staging and production tokens separate. - **One token per seller account.** There is no cross-account access; operate multiple sellers with one token each. ```bash # .env — never commit this file FYNEX_TOKEN= FYNEX_API=https://api.fynex.ai/payments-api/v1 ``` ## Rotating a token The 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. > [!CAUTION] > 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. If you prefer to automate it, the same operation is exposed as the GraphQL mutation `regenerateSellerAccountToken` on the `/dashboard/graphql` endpoint (cookie-session authenticated). ### Rotation steps (GraphQL) 1. **Authenticate with the dashboard** to obtain a `dashboard_session` cookie: ```bash curl -c cookies.txt -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "..."}' ``` 2. **Call `regenerateSellerAccountToken`** with your seller account ID as `merchantId`: ```bash curl -b cookies.txt -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation Rotate($merchantId: ID!) { regenerateSellerAccountToken(merchantId: $merchantId) { authorizationToken } }", "variables": { "merchantId": "42" } }' ``` The response contains the new `authorizationToken` value. 3. **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). ### When to rotate - A developer with access leaves the team. - You suspect or confirm a token leak. - As a precautionary measure on a regular schedule (quarterly is a common default). ## Token properties | Property | Value | |----------|-------| | Scope | Single seller account | | Expiry | None — tokens do not expire | | Revoke operation | None — rotate to invalidate | | Multiple tokens per account | Not supported — one bearer token per seller account | | 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 | ## Common errors | Status | Body | Cause | |--------|------|-------| | `401` | `authorization token is required` (plaintext) | Header absent or malformed at the middleware level | | `401` | `invalid authorization token` (plaintext) | Token value is not recognised | | `401` | `{"error": "seller auth is required"}` | Missing seller context inside a handler | | `403` | `seller account is not active` (plaintext) | Token is valid but the seller account isn't active — new demo accounts activate automatically; if you see this, contact Fynex with your seller account ID | | `403` | `{"error": "resource does not belong to this seller"}` | Token is valid but the resource belongs to a different seller | > [!NOTE] > The `401` response body from the auth middleware is plain text, not JSON. Once inside a handler, all error responses are JSON `{"error": "..."}`. ## See also - **[Quickstart](https://api.fynex.ai/payments-api/v2/docs#tag/quickstart)** — Take your first test payment in 5 minutes. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — Full status code and error body reference. - **[Request headers](https://api.fynex.ai/payments-api/v2/docs#tag/headers)** — All request headers reference — required, conditional, and optional. ## Headers 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. --- ## Required headers ### `Authorization` ```http Authorization: Bearer ``` | Property | Value | |----------|-------| | Type | String | | Required | Yes — all endpoints | | Format | `Bearer ` followed by the seller bearer token (no quotes, no extra whitespace) | Every 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. **Failure modes:** | Condition | Status | Body (note: plaintext, not JSON) | |-----------|--------|----------------------------------| | Header absent or empty | `401` | `authorization token is required` | | Token not recognized | `401` | `invalid authorization token` | | Seller account inactive | `403` | `seller account is not active` | > [!CAUTION] > The `401` response from the auth middleware is **plain text**, not JSON. Once a request passes authentication and enters a handler, all subsequent errors are JSON `{"error": "..."}`. --- ### `Content-Type` ```http Content-Type: application/json ``` | Property | Value | |----------|-------| | Type | String | | Required | Yes — POST requests with a body | | Format | `application/json` | Required 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. --- ### `Idempotency-Key` ```http Idempotency-Key: 6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b ``` | Property | Value | |----------|-------| | Type | UUID string | | Required | Yes — all POST endpoints (except `/payouts`; see note below) | | Format | UUID v4, lower-case, hyphenated — e.g. `6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b` | The 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. **Validation:** | Condition | Status | Body | |-----------|--------|------| | Header absent or empty | `400` | `{"error": "Idempotency-Key header is required"}` | | Value is not a valid UUID | `400` | `{"error": "Idempotency-Key must be a valid UUID"}` | **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. **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. > [!NOTE] > `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. --- ## Optional headers ### `X-Device-Fingerprint` ```http X-Device-Fingerprint: ``` | Property | Value | |----------|-------| | Type | String | | Required | No | | Format | Opaque string, trimmed of whitespace | Device 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. For 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`. --- ### `X-Source-Channel` ```http X-Source-Channel: api ``` | Property | Value | |----------|-------| | Type | Enum string | | Required | No | | Allowed values | `api` (default), `admin_panel` | Identifies 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`. Use `admin_panel` only when the request originates from a Fynex internal backoffice action. Partner integrations should omit this header or use `api`. --- ### `Accept-Language` ```http Accept-Language: en-GB,en;q=0.9 ``` | Property | Value | |----------|-------| | Type | String (standard HTTP) | | Required | No | | Format | Standard `Accept-Language` value per RFC 7231 | Stored 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. --- ## Headers the server does not set The Fynex API does not currently set `X-Fynex-Request-Id` or `X-Fynex-Trace-Id` response headers. Do not rely on these for correlation — use the `paymentId` (your `externalOrderRef`) and the provider's `providerPaymentId` from the response body instead. --- ## Quick reference | Header | Required | Endpoints | |--------|----------|-----------| | `Authorization: Bearer ` | Yes | All | | `Content-Type: application/json` | Yes | POST with body | | `Idempotency-Key: ` | Yes | All POST | | `X-Device-Fingerprint: ` | No | All POST | | `X-Source-Channel: api\|admin_panel` | No | All POST | | `Accept-Language: ` | No | All POST | --- ## Example request with all headers ```http POST /payments-api/v1/initialize-payment HTTP/1.1 Host: api.fynex.ai Authorization: Bearer YOUR_SELLER_TOKEN Content-Type: application/json Idempotency-Key: 6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b X-Device-Fingerprint: fp_a1b2c3d4e5f6 X-Source-Channel: api Accept-Language: en-GB,en;q=0.9 { ... } ``` Tokens are opaque strings with no prefix (e.g. no `sk_live_` or `sk_test_`). Replace `YOUR_SELLER_TOKEN` with the full token value provided by Fynex. ## See also - **[Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)** — How to obtain, use, and rotate seller bearer tokens. - **[Idempotency](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — How the Idempotency-Key header prevents duplicate payments. ## Idempotency Networks fail. Servers restart. The right response to a flaky call is to **retry safely** — and that's what idempotency keys are for. ## How it works Every 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. ``` 1st request: POST /initialize-payment Idempotency-Key: abc... ─► 202 Accepted (payment created) 2nd request: POST /initialize-payment Idempotency-Key: abc... ─► 200 OK (replay of original) ``` ## Where keys are accepted | Endpoint | Header | Purpose | |----------|--------|---------| | `POST /checkout` | `Idempotency-Key` | Don't create duplicate hosted sessions | | `POST /initialize-payment` | `Idempotency-Key` | Don't double-charge | | `POST /finalize-payment` | `Idempotency-Key` | Idempotent capture | | `POST /payments/{id}/capture` | `Idempotency-Key` | Don't double-capture | | `POST /payments/{id}/refund` | `Idempotency-Key` | Don't double-refund | | `POST /payouts` | Body field `idempotencyKey` | Don't double-pay out | Replay semantics depend on the endpoint — see each endpoint's docs. The `Idempotency-Key` on `/finalize-payment` is required as a valid UUID but is not used for replay; the payment's state machine itself prevents double-finalize. `POST /payouts` is special: idempotency lives in the body field `idempotencyKey`, not the header (see [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts)). ## Generating keys Use a UUID v4 from any standard library: ```js import { randomUUID } from 'node:crypto'; const key = randomUUID(); ``` ```python import uuid key = str(uuid.uuid4()) ``` ```bash key=$(uuidgen) ``` ## When to mint a new key vs. reuse - **One operation → one key.** Generate the key when you start the operation; persist it alongside the order so retries reuse it. - **A retry of the same operation reuses the same key.** That's the whole point. - **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. ```js // Pseudocode for resilient charge logic async function charge(order) { if (!order.idempotencyKey) { order.idempotencyKey = randomUUID(); await db.orders.update(order.id, { idempotencyKey: order.idempotencyKey }); } for (let attempt = 1; attempt <= 3; attempt++) { try { return await fynex.initializePayment({ ...order, key: order.idempotencyKey }); } catch (err) { if (!isRetryable(err)) throw err; await sleep(2 ** attempt * 100); // 200ms, 400ms, 800ms } } } ``` ## Retry strategy Retry on: - Network errors (`ECONNRESET`, timeouts, DNS failures) - HTTP `502 Bad Gateway` (upstream provider hiccup) - HTTP `503 Service Unavailable` Don't retry on: - `400 Bad Request` — fix the payload - `401 Unauthorized` — fix the token - `403 Forbidden` — fix the permission/seller - `404 Not Found` — fix the resource ID - `409 Conflict` — read the body and decide; usually a state issue, not transient Use **exponential backoff with jitter**: 200ms → 400ms → 800ms with random jitter, capped at 3–5 attempts. ```js async function retry(fn, max = 4) { for (let i = 0; i < max; i++) { try { return await fn(); } catch (e) { if (!isRetryable(e) || i === max - 1) throw e; const base = Math.min(2 ** i * 200, 5000); const jitter = Math.random() * base * 0.3; await new Promise(r => setTimeout(r, base + jitter)); } } } ``` > [!CAUTION] > **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. ## What gets replayed Fynex 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. ## Reusing a key with a different body `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: ```json { "error": "Idempotency-Key reused with a different currencyCode: original=USD, request=EUR" } ``` ```json { "error": "Idempotency-Key reused with a different amount: original=4999 minor units, request=9999 minor units" } ``` This 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. Fields 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." When you see a `409` from this check: 1. Decide whether you actually want to replay the original payment or create a new one. 2. To replay → re-send the request with the original body unchanged. 3. To create a new payment → mint a **fresh `Idempotency-Key`** (fresh UUID) and send the new body. ## Errors and replays Error 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. ## Next steps - [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) — distinguish retryable from permanent failures - [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) — poll for the canonical payment state ## Errors ## Error format All handler errors return JSON with a single `error` field: ```json { "error": "amount must be greater than 0" } ``` > [!CAUTION] > **Auth middleware errors are plain text, not JSON.** The `SellerAccountAuthMiddleware` uses `http.Error()` which returns a `text/plain` body. If your client parses every response as JSON, handle the 401 case separately. Example plain-text bodies: `authorization token is required` (401, header absent/malformed), `invalid authorization token` (401, token not recognised), `seller account is not active` (403, account inactive). > > Once a request passes the middleware and reaches a handler, all subsequent error responses are JSON. --- ## Status codes | Code | Meaning | Retry safely? | |------|---------|---------------| | `200` / `201` / `202` | Success | n/a | | `400` | Validation error in your request | No — fix the input | | `401` | Missing or invalid bearer token | No — fix auth | | `403` | Token valid but resource belongs to another seller | No — use the right resource | | `404` | Resource not found for this seller | No — check the ID | | `409` | Conflict — current state forbids the action | No — read the body and decide | | `429` | Per-seller rate limit exceeded | Yes — honor `Retry-After`, then retry | | `500` | Internal error | Yes — backoff + retry; alert on persistence | | `502` | Upstream provider failure | Yes — backoff + retry | | `503` | Service temporarily unavailable | Yes — backoff + retry | --- ## Common 400 responses | Message | Likely cause | |---------|--------------| | `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}`. | | `Idempotency-Key header is required` | POST endpoint called without the header | | `Idempotency-Key must be a valid UUID` | Header value is not a UUID | | `currencyCode is required` | Missing required field | | `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. | | `amount must be greater than 0` | Zero or negative `amount` | | `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. | | `payment configuration is not set for seller account` | Seller has no payment methods configured | --- ## Common 409 responses These mean the resource's current state forbids the action. Read the message before deciding whether to retry: | Message | What to do | |---------|------------| | `Idempotency-Key reused with a different : 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 & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency). | | `invalid status transition from to capture` | Payment is not in `authorized` or `provider_completed` — check status first | | `manual settlement required` | Payment was created with `autoSettlement: true` — cannot capture manually | | `payment is already refunded` | Nothing left to refund | | `capture is not allowed while a refund is in progress` | Wait for the pending refund to finish before retrying any capture decision | | `capture is not allowed after a successful refund` | Do not capture again after any successful refund on the payment | | `refund is already in progress` | A refund is in `refund_pending`. Retry the original `POST /refund` with the same `Idempotency-Key` to replay that row; use a different key only after it reaches `succeeded`, `failed`, or `cancelled`. | | `refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments` | Payment is not in a refundable captured state and is not a retryable failed/cancelled refund | | `refund amount exceeds remaining refundable amount` | The requested refund exceeds the remaining captured balance after successful prior refunds | | `payout with this idempotency key already exists` | Replay of a successful create — fetch the existing payout instead | | `insufficient balance` | Top up the wallet, then retry with a fresh `idempotencyKey` | --- ## Rate limiting (429 responses) Every endpoint under `/payments-api/v1/*` is rate-limited per seller account using a token-bucket keyed on the authenticated `seller_account_id`. **The budget differs between staging and production**, so do not hard-code a rate: read `X-RateLimit-Limit` and `X-RateLimit-Remaining` from the response, which are authoritative for the environment you are calling. The budget is per-seller (one noisy client cannot starve others). Every authorized response — both 200 and 429 — normally carries: | Header | Meaning | |--------|---------| | `X-RateLimit-Limit` | The bucket size for this seller (e.g. `30`) | | `X-RateLimit-Remaining` | Tokens left in the bucket after this request | | `X-RateLimit-Reset` | Whole seconds until the bucket has refilled to `X-RateLimit-Limit` | | `RateLimit-Policy` | The budget itself, in IETF structured-field syntax: `"seller";q=30;qu="requests";w=60` — quota, unit, window in seconds | | `RateLimit` | Where you stand against it: `"seller";r=12;t=45` — remaining, and seconds until reset | | `Retry-After` *(429 only)* | Whole seconds to wait before the next request is guaranteed to succeed | > [!NOTE] > `RateLimit` and `RateLimit-Policy` are the current standards-track fields > (`draft-ietf-httpapi-ratelimit-headers`). They are **not** the > `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` triple you may > remember — that spelling is from an earlier revision of the same draft and > Fynex does not send it. If your client only knows the old shape, read the > `X-RateLimit-*` headers, which carry the same numbers. `X-RateLimit-Reset` is what lets you pace a loop *before* you are refused: `X-RateLimit-Remaining` alone tells you how many requests are left but not how long you have to spend them over, and `Retry-After` arrives only once you have already been throttled. When the bucket is empty: ```http HTTP/1.1 429 Too Many Requests Content-Type: application/json Retry-After: 1 X-RateLimit-Limit: 30 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 12 RateLimit-Policy: "seller";q=30;qu="requests";w=60 RateLimit: "seller";r=0;t=12 {"error":"rate limit exceeded; retry after the Retry-After header value"} ``` **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. **How to avoid it:** read `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response and back off proactively when the remaining count approaches zero. Drive your polling interval from those values rather than from a fixed number of seconds — the budget differs by environment, so an interval that is comfortable in one may exhaust the other. Add ±20% jitter to any loop. **Missing headers:** authorized responses — both `2xx` and `429` — normally carry the `X-RateLimit-*` headers. `401`/`403` responses never do, because the budget is keyed on the authenticated seller and the limit is applied after authentication. If the headers are absent on an authorized response, hold your most conservative polling interval rather than reading it as headroom, and contact support if it persists. --- ## 502 vs 500 - **`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. - **`500 Internal Server Error`** — Fynex itself encountered an unexpected error. Should be rare. If you see persistent 500s, contact support with the request details. ### Common 502 causes A 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: ```json { "error": "upstream card processor returned " } ``` To distinguish *why* the upstream rejected the request, fetch the payment afterwards (`GET /payments-api/v1/payments/{externalOrderRef}`) and inspect its `failureCode` + `failureStage` fields. | Body (abridged) | failureCode | Cause | Fix | |------|-------------|-------|-----| | `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](https://api.fynex.ai/payments-api/v2/docs#tag/troubleshooting). | | `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](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server). | | `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](https://api.fynex.ai/payments-api/v2/docs#tag/3ds) and [Troubleshooting](https://api.fynex.ai/payments-api/v2/docs#tag/troubleshooting). | | `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`. | | `upstream card processor returned ` | `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. | --- ## Failure codes inside payment responses Even when the HTTP call returns `200`, the payment itself may have failed at the processor. Inspect the response body: ```json { "paymentId": "ORDER-1042", "status": "failed", "failureCode": 2001, "retry": "never", "failureCategory": "hard", "failureDescription": "Provider declined the transaction" } ``` `failureCode` is the contract; `failureDescription` is prose for a human and may be reworded in any release, so branch on the code and show the description. Every code is enumerated below with its cause, whether retrying can succeed, and what to do — and the same table is on the field itself in `openapi.json`, so a generated client carries it. The retry verdict also rides on the response as `retry` (`safe`, `fix_first` or `never`), on every payment and refund response and on the `PaymentCompleted` webhook, so you can branch on it without joining the table yourself. The retry column is the part worth reading twice. Getting it wrong costs money in both directions: retrying a decline the issuer already made loses the sale and can get the card blocked, while *not* retrying a timeout with the **original** `Idempotency-Key` is how a customer gets charged twice. `failureCategory` rides beside it and answers the other question — not *what to do* but *what happened*: `hard`, `transient`, `requires_change`, `integration_error`, `cancelled`, or `unknown` for a code this catalogue does not classify. Branch your retry loop on `retry`; count, chart and alert on `failureCategory`. The split is what keeps a spike of `integration_error` (your bug) out of the same number as a spike of `hard` (your conversion rate). Both fields are absent while the payment has not failed. `failureStage` narrows it further — the stage says *where* the payment stopped, the code says *why*. Anything before `authorization` never reached a provider, so no money moved. ### Two card declines that leave a hold A declined payment is not always a released card. On an **AVS mismatch** — the billing address or postal code did not match the issuer's record — the issuer may have placed a temporary authorization hold that the cardholder sees on their statement for up to 3–5 business days even though the payment failed and Fynex captured nothing. The same is true on a **CVV mismatch**: the security code was wrong, the payment failed, and the cardholder may still see a pending authorization for up to 3–5 business days before their issuer drops it. Neither hold is something Fynex can release, and neither is a charge. Say so in your own customer-facing copy before the buyer calls their bank — and do not re-run the same card repeatedly to "clear" it, because each attempt can add another hold. Note that the API does not currently distinguish either decline from an ordinary `2001`: there is no dedicated `failureCode` for an AVS or CVV mismatch, and `avsResult` / `cvvResult` are not published on the payment. The advice above therefore applies when you already know the decline reason from your own checkout flow. ### Soft declines and 3-D Secure `3002` is the one decline that is not a refusal of the card: the issuer soft-declined the authorization and asked for strong customer authentication instead. The card processor reports its own soft-decline code at the authorization gate and Fynex maps it onto `3002`, so branch on `3002` rather than on any processor-specific number. It carries `retry: fix_first` and `failureCategory: requires_change`, not `hard` — re-run the payment through a 3-D Secure flow and it can succeed. Retrying without one fails identically, and counting it as a decline throws away a sale you can still make. See [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds). --- ## The `/checkout` Idempotency-Key pitfall `POST /checkout` validates the `Idempotency-Key` header **before** auth, in `initFinalizeHeaderMiddleware`. A missing or malformed key returns a JSON `400` — `{"error":"Idempotency-Key header is required"}` (header absent or empty) or `{"error":"Idempotency-Key must be a valid UUID"}` (value is not a UUID) — not a 401. If you see one of these 400s from `/checkout`, fix the `Idempotency-Key` header rather than your token. --- ## Recovering from partial failures If `/initialize-payment` returns `502` but you don't know whether the processor created or charged the payment: 1. Wait 30–60 seconds. 2. 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. 3. 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. The same pattern applies to `/finalize-payment`, `/payments/{id}/capture`, `/payments/{id}/refund`, and `/payouts`. ## See also - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment and payout state by polling or server-sent events. - **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make your retries safe with idempotency keys. --- ## Payment failure codes Returned as `failureCode` on every payment and refund response, and on the `payment.completed` webhook. A `200` does not mean the payment succeeded — read `status`, and when it is `failed` read this. Why the payment failed. `0` means it has not. **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. **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`. | Code | Meaning | Category | Retry | What to do | |---|---|---|---|---| | `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. | | `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. | | `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. | | `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. | | `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`. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `2004` | Refund is not yet available: the provider settlement has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; it is not a permanent refusal. | | `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. | | `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. | | `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`. | | `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. | | `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. | | `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. | | `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. | ## Payout failure codes Why the payout failed. Absent while it has not. In 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. The enumerated code list is not published yet; it is pending a rename that removes supplier-specific prefixes from four of the values. ## Test cards > [!NOTE] > Whether a card payment reaches real card networks is decided by your account's > **operational mode**, not by the host you call. A **Demo** account routes to the upstream > card processor's **sandbox** — no real card networks, no money movement. A **Live** account > does not: test PANs on a Live account can reach real cards, so confirm your > `operationalMode` before sending one. > > Whether a payment goes through a 3DS challenge is controlled by the **`skip3DS` request flag**, not by the card number (see below). This page is your self-service reference for sandbox testing. > [!TIP] > **No account yet?** `POST /sandbox/accounts` with `Content-Type: application/json` and a > `{}` body mints an anonymous demo seller and returns its `sk_test_` key — time-limited > (`expiresAt` in the response says when), no e-mail, no real money. Everything below works > against it. The door is open on the sandbox host (`staging-api.fynex.ai`), whose `/sandbox` > page has the details; a host with it switched off answers `503`. --- ## How the sandbox works **Sandbox or live is a property of your ACCOUNT, not of the URL you call.** Your seller account carries an `operationalMode`, and that mode — not the hostname — decides where a payment goes: - **Demo** routes to the upstream processor's **sandbox**. No real card networks are involved and no money moves, so test card numbers are exactly what you should be using. - **Live** routes to **real card networks**. Never send a test card number on a Live account — it will be declined or, worse, charged to a real card if the number happens to exist. - The Fynex-hosted checkout page, the server-to-server flow, and the checkout widget all route through the same upstream sandbox when the account is in Demo mode. - Alternative payment methods (APMs) use a separate local-payment sandbox flow. Bancontact has its own public test PANs, Multibanco / MB WAY use the provider-hosted mock page, Wero uses a UAT consent site, and Swish credentials are provisioned separately. See [Alternative payment methods (APM)](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm) for the step-by-step APM testing table. > [!CAUTION] > **Never send a test PAN on a Live account.** Confirm your mode first: query your seller > account and check `operationalMode` — the same check described in > [Going live](https://api.fynex.ai/payments-api/v2/docs#tag/going-live). If it reads `Live`, test cards are not safe. Use real cards, > or ask us to provision a Demo account for integration work. ### Which base URL do I use? Mode is independent of environment. Demo accounts exist on **both** `https://api.fynex.ai` and `https://staging-api.fynex.ai` — most integration accounts are issued on staging, some on production, and neither is a misconfiguration. So the host does not tell you your mode, and your mode does not tell you the host. **Use the base URL you were issued alongside your token.** The two environments keep entirely separate credential stores, so a token only authenticates against the host it was issued for. Pointing it at the other one returns HTTP **401** with the plain-text body `invalid authorization token`. That means *wrong host for this token* — not that your credentials are broken, and not that you should switch hosts to fix it. > [!NOTE] > Fynex delivers outbound `PaymentCompleted` webhooks to the URLs configured on your seller > account (and to any per-request `webhookUrl`). Your receiver must return **HTTP 200** for a > delivery to count as successful — any other status is retried (up to 3 attempts) and then > marked failed. You can also verify payment outcomes by polling the GraphQL `genericPayment` > query or subscribing to the SSE stream — see the [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) guide. --- ## Controlling 3DS On the server-to-server flow (`POST /payments-api/v1/initialize-payment`), **whether** a 3DS flow runs is selected by the **`skip3DS`** flag in the request body. When 3DS does run, the **card number** then determines the authentication *outcome* (`threeDResult` — see [Card numbers](#card-numbers)): | `skip3DS` | Behaviour | |-----------|-----------| | `true` | No 3DS. The response has `requiresAction: false` and the payment proceeds straight to authorization (`provider_completed`). Use this for the **no-redirect** test flow. | | `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](https://api.fynex.ai/payments-api/v2/docs#tag/3ds). | On the **hosted checkout** (`POST /checkout`) the hosted page manages 3DS internally — you only observe the final outcome when polling. --- ## Card numbers > [!IMPORTANT] > On this sandbox the **card number selects the 3DS authentication outcome** (`threeDResult`). > `skip3DS` only controls *whether* a 3DS flow runs at all — when 3DS runs (e.g. on the hosted > checkout, which always runs it), the **PAN** determines whether authentication succeeds (`Y`), > challenges (`C`), or is rejected/unable (`R`/`U`/`N`). A handle whose authentication is not > `Y`/`A` is failed by the processor's risk rule and **cannot be settled** — the settle call comes > back with a field-level error saying the payment handle is in a non-payable state. Use the right PAN. ### Cards that authenticate successfully (`threeDResult=Y`, frictionless — no challenge) Use these for a **happy-path success** on the hosted checkout — they authenticate without an OTP and settle to `provider_completed`: | Card number | Brand | |-------------|-------| | `4000 0000 0000 2701` | Visa — frictionless `Y` | | `5200 0000 0000 2235` | Mastercard — frictionless `Y` | ### Cards that trigger a 3DS challenge (`threeDResult=C`) These redirect to the processor's 3DS **emulator**, where you select the authentication status/reason to test (choose `Y` to complete successfully): | Card number | Brand | |-------------|-------| | `4000 0000 0000 2503` / `4000 0000 0000 2370` / `4000 0000 0000 2420` | Visa — challenge | | `5200 0000 0000 2490` / `5200 0000 0000 2151` / `5200 0000 0000 2664` | Mastercard — challenge | ### Cards that fail authentication (for decline/UI testing) | Card number | Outcome | |-------------|---------| | `4530 9100 0001 2345`, `4500 0300 0000 0004`, `4037 1122 3300 0001` | `threeDResult=U` (unable to authenticate) → risk-rule rejected | | `4000 0000 0000 2925` | `threeDResult=N` (not enrolled) | Unless noted otherwise, use: - **Expiry month:** any future month, 2 digits (e.g. `12`) - **Expiry year:** any future year. **4-digit (`2028`) is recommended.** A 2-digit year (`28`) is also accepted — Fynex widens it to `2028` before calling the processor. - **CVV / CVC:** any 3-digit number (e.g. `123`); American Express CID: any 4-digit number - **Cardholder name:** Latin letters (`A-Z`), spaces, apostrophes, dots, or hyphens only > [!NOTE] > The card numbers listed in this section are the set Fynex supports; the upstream sandbox can > add or retire PANs without notice, so email support@fynex.ai if one stops behaving as documented. > On a **Live** account a real issuer returns `Y` for a genuine card, so the `U`/`N` failure modes > above are sandbox-only artifacts of the test PANs. --- ## Common test scenarios #### Happy path — no 3DS ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "TEST-HAPPY-1", "amount": 19.99, "paymentType": "card", "paymentMethod": "card", "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": true, "skip3DS": true, "cardData": { "cardNumber": "4111111111111111", "expMonth": 12, "expYear": 2028, "holderName": "Test User", "cvv": "123" }, "returnLinks": [ { "rel": "default", "href": "https://example.com/return", "method": "GET" } ] }' ``` The response has `requiresAction: false` and the payment transitions to `provider_completed`. Poll `genericPayment` to confirm before fulfilling the order. > [!NOTE] > `countryCode` is required and, if your terminal pins a country, must match it. `returnLinks` > (note: not `returnUrls`) is required on the server-to-server flow — each link needs > `rel` ∈ `default | on_completed | on_failed | on_cancelled`, an `http`/`https` `href`, and > `method: "GET"`. The hosted checkout flow uses `returnUrls` at session creation instead. #### 3DS challenge Send the same request **without** `skip3DS` (or with `"skip3DS": false`). The response is: ```json { "paymentId": "TEST-3DS-1", "status": "provider_pending", "requiresAction": true, "actionUrl": "https://3ds.example.com/.../authentication/..." } ``` Redirect the customer's browser to `actionUrl`, let them complete the sandbox challenge, then call `POST /finalize-payment` with the same `paymentId`. See the [3DS guide](https://api.fynex.ai/payments-api/v2/docs#tag/3ds) for the full localStorage bridge pattern. #### Decline / failure UI testing A payment that the processor declines (or a 3DS challenge the customer fails) transitions to `failed`; a customer who abandons the challenge returns `cancelled`. When you poll for status, the response includes: ```json { "paymentId": "TEST-DECLINE-1", "status": "failed", "failureCode": 2001, "failureDescription": "Card declined by issuer" } ``` | Field | Purpose | |-------|---------| | `status` | `"failed"` for hard declines; `"cancelled"` if the customer cancelled | | `failureCode` | Numeric code — see the [Errors guide](https://api.fynex.ai/payments-api/v2/docs#tag/errors) for the full list | | `failureDescription` | Human-readable reason — do not display verbatim to customers | > [!CAUTION] > Distinguish `"cancelled"` from `"failed"`. A customer who clicks "Back" or abandons the > form returns a `cancelled` status — show a neutral "payment not completed" message rather > than an error. #### Refund testing 1. Pay and wait for the payment to reach a refundable status (`provider_completed`, `settled`, or `deposit_confirmed` — see [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)). 2. Issue the refund using the public endpoint with your `externalOrderRef` as the path param: ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/TEST-REFUND-1/refund" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "amount": 19.99 }' ``` > [!CAUTION] > Only captured/settled payments can be refunded — pre-capture refund attempts return `409 "refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments"`. Omit `amount` to refund the remaining refundable amount, or pass a smaller value for a partial refund. #### Partial capture Create a payment with `autoSettlement: false` (or `captureMode: "manual"`), then capture less than the authorized amount: ```bash # Authorize £50 curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "TEST-PARTIAL-1", "amount": 50.00, "paymentType": "card", "paymentMethod": "card", "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": false, "captureMode": "manual", "skip3DS": true, "cardData": { "cardNumber": "4111111111111111", "expMonth": 12, "expYear": 2028, "holderName": "Test User", "cvv": "123" }, "returnLinks": [ { "rel": "default", "href": "https://example.com/return", "method": "GET" } ] }' # Capture only £35 curl -sS -X POST "$FYNEX_API/payments-api/v1/finalize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "paymentId": "TEST-PARTIAL-1", "amount": 35.00 }' ``` The response `capturedAmount` will be `35.00`. The remaining £15 is released back to the customer's card. --- ## Test customer data The sandbox does not validate billing data against real services. Use any well-formed values: | Field | Suggested test value | Notes | |-------|---------------------|-------| | Email | `test@example.com` | Any `*@example.com` address works; no emails are sent from sandbox | | Billing name | `Test User` | Any non-empty string | | Billing address | `1 Test Street, London, EC1A 1BB, GB` | Staging does not run AVS checks against real addresses | | Phone | `+44 7700 900000` | Any well-formed `+44` number; not validated | | Postcode / ZIP | `EC1A 1BB` | Not validated by AVS in sandbox | --- ## What does NOT work in sandbox | Feature | Status | Notes | |---------|--------|-------| | Real customer email receipts | **Sent on a Demo account** | Suppressed on `staging-api.fynex.ai` only. There is no operational-mode gate on the email path, so a Demo account on production sends real mail to whatever address you supply — use addresses you control when testing | | KYB / identity checks | Separate sandbox | Contact your Fynex representative for a KYB sandbox link; it is independent of the payments sandbox | | Apple Pay merchant validation | Faked | `completeMerchantValidation({})` is accepted in sandbox but will fail against real Apple Pay; a real server-side merchant validation endpoint is required for production | | Google Pay in production mode | Not available | The dashboard uses `environment: 'TEST'`; switch to `environment: 'PRODUCTION'` only when going live | --- ## Sandbox limits | Limit | Value | Notes | |-------|-------|-------| | Rate limiting | Enforced | The same mechanism as production, with its own budget — read `X-RateLimit-Remaining`; tell Fynex before high-volume load testing | | 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 | | Minimum test amount | £0.01 | | | Session expiry | Set server-side | Check `expiresAt` in the checkout session response | --- ## See also - **[Quickstart](https://api.fynex.ai/payments-api/v2/docs#tag/quickstart)** — Take your first test payment in 5 minutes. - **[Hosted checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Full hosted checkout reference — optional fields, return URL handling, and more. - **[Server-to-server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — POST /initialize-payment and POST /finalize-payment, including 3DS handling. - **[3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds)** — The redirect lifecycle, localStorage bridge, and finalize call. - **[Alternative payment methods (APM)](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm)** — Sandbox steps for Bancontact, Multibanco, MB WAY, Wero, and Swish. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes, failure codes, and error response shapes. ## Concepts 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. Entities 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. --- ## Account & configuration These entities define who you are in Fynex and how payments are routed. ### SellerAccount A seller's top-level commercial profile in Fynex. One account owns the API bearer token, one or more wallets, terminals, and split rules. **API surface:** GraphQL query `sellerAccount(id)` / `sellerAccounts`. Token is rotated via `regenerateSellerAccountToken(id)`. Not directly accessible via REST — the bearer token implicitly identifies the account on every request. **Key fields:** - `authorizationToken` — the bearer token used on all `Authorization: Bearer` REST calls - `operationalMode` — `demo` or `live`; governs which provider environment receives payments - `id` (int) — required for GraphQL mutations that target a specific account --- ### Terminal A 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. **API surface:** GraphQL queries `terminal(id)` / `terminals`. Internal-only for configuration; partners do not reference terminals directly in REST requests. **Key fields:** - `paymentPartner` — the upstream PSP this terminal belongs to - `currencyCode` — currency this terminal accepts - `paymentMethods` — instrument types (card, google_pay, apple_pay, bank_account) --- ### PaymentPartner The 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. **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. --- ### PaymentConfiguration The seller-level allowed payment methods, currencies, and rails. Determines what `GET /payment-methods` returns. Configuration is managed by Fynex on your behalf during onboarding. **API surface:** GraphQL queries `paymentConfiguration(id)` / `paymentConfigurations`. The effective values are surfaced via `GET /payments-api/v1/payment-methods`. **Key fields:** - `allowedPaymentMethods` — instrument types enabled for this seller - `allowedCurrencies` — supported ISO-4217 currency codes - `allowedPaymentRails` — `card`, `bank_transfer` --- ## Money in These entities represent inbound payment flows. ### GenericPayment The canonical payment object across all providers. Created by `POST /initialize-payment` (or internally when a hosted checkout session is submitted). Carries the full payment lifecycle from `draft` through to `settled` or `failed`. **API surface:** REST — created via `POST /initialize-payment`, acted on by `POST /capture` and `POST /refund`. GraphQL queries `genericPayment(id)` / `genericPayments(...)`. **Key fields:** - `externalOrderRef` — your order reference; returned as `paymentId` in REST responses and used as the path parameter for capture/refund - `status` — see [Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle) for all values - `amount` — in major units via REST responses; stored in minor units internally - `captureMode` — `auto` or `manual` - `requiresAction` / `actionUrl` — set when a 3DS redirect is needed (see [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds)) - `failureCode` / `failureDescription` — populated when status is `failed` - `threeDs` — 3DS authentication result object (version, ECI, CAVV, liability shift) --- ### CheckoutSession The database row created when you call `POST /checkout` to start a hosted checkout. It is backed by a `draft` `GenericPayment`. The session carries the `sessionId` (UUID) used in all subsequent browser-side checkout routes (`/checkout/{session_id}/*`). **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. **Key fields:** - `sessionId` — UUID returned on checkout creation; embed in the `checkoutUrl` - `checkoutUrl` — the URL to redirect the customer to - `returnUrls.success` / `returnUrls.failure` — where the hosted page redirects after completion > [!NOTE] > `CheckoutSession` is internal-only on the GraphQL surface. Use the REST response from `POST /checkout` to obtain the `sessionId` and `checkoutUrl`. --- ### BillingDetails The customer-side billing identity attached to a payment — name, email, phone, and address. Passed as `billingDetails` in `POST /initialize-payment` and stored on `GenericPayment`. **API surface:** Nested field in REST request and response bodies; also surfaced as a sub-type in GraphQL `GenericPayment`. **Key fields:** - `firstName`, `lastName`, `email`, `phone` - `addressLine1`, `city`, `postalCode`, `countryCode` - Aliases accepted on input: `street` = `addressLine1`, `zip` = `postalCode`, `country` = `countryCode` --- ### SavedCard A tokenized card, optionally linked to a `SellerCustomer`, for use in merchant-initiated (MIT) or recurring payments. Created automatically by the hosted checkout flow when the customer opts in to saving their card. **API surface:** Internal — referenced in recurring payment flows. The token itself is held by the upstream card processor; Fynex stores only an opaque reference. Partners trigger saved-card payments by passing a `merchantCustomerId` on `/initialize-payment` with `subscription.enabled: true`. --- ### SellerCustomer A repeat customer under a specific seller, used for recurring billing. Linked to a `SavedCard` and identified by the seller's own `merchantCustomerId`. **API surface:** Internal — created automatically when a hosted checkout saves a card. Referenced on `/initialize-payment` via `merchantCustomerId` field. **Key fields:** - `sellerCustomerRef` — the `merchantCustomerId` you passed on `/initialize-payment` - `sellerAccountId` — the owning seller --- ## Money out These entities represent outbound payment flows to payees. ### Payout A wallet-to-payee transfer requested by the seller. Sent via the banking provider. Each payout debits a specific wallet and credits a specific `PayoutMethod`. **API surface:** REST — `POST /payments-api/v1/payouts` (create), `GET /payouts` (list), `GET /payouts/{id}` (get). GraphQL queries `payout(id)` / `payouts(...)`. **Key fields:** - `amountMinor` — amount in minor units (e.g. `4999` for £49.99); note this differs from payment endpoints which use major units - `currencyCode` - `walletId` — source wallet - `payoutMethodId` — destination bank account - `idempotencyKey` — passed in the **request body** (not the `Idempotency-Key` header; the header is still required) - `status` — `pending`, `processing`, `completed`, `failed`, `cancelled` --- ### PayoutMethod A registered bank-account destination for a payee — IBAN, sort code/account number, or other bank-transfer credentials. A payee can have multiple payout methods. **API surface:** REST `GET /payments-api/v1/payees/{payee_id}/payout-methods`. GraphQL queries `payoutMethod(id)` / `payoutMethods(payeeId, ...)` and mutations `createPayoutMethod`, `updatePayoutMethod`, `deletePayoutMethod`. **Key fields:** - `payeeId` — the owning payee - `currency` — supported currency for this method - `status` — `Active` or `Disabled`; only active methods appear in REST listing --- ### Payee A 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. **API surface:** REST `GET /payments-api/v1/payees` (list). GraphQL queries `payee(id)` / `payees` and mutations `createPayee`, `updatePayee`, `deletePayee`. **Key fields:** - `displayName` — human-readable label - `role` — `Itself` (the seller itself), `Contractor`, `Tax`, or `fynex_platform` - `status` — `Active` or `Disabled` --- ### VirtualAccount A 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. **API surface:** GraphQL queries `virtualAccount(id)` / `virtualAccounts(first, after, type)`. **Key fields:** - `iban` — the virtual IBAN assigned to this account - `currency` — account currency - `type` — virtual account type --- ## Balances These entities represent the seller's internal ledger. ### Wallet A per-seller, per-currency balance ledger. Settlement of card payments credits the relevant wallet. Payouts debit from it. **API surface:** GraphQL queries `wallet(id)` / `wallets(...)` and mutations `createWallet`, `updateWallet`. **Key fields:** - `currencyCode` — one wallet per currency - `balance` — current available balance - `sellerAccountId` --- ### WalletEntry An append-only ledger entry on a wallet — every credit or debit is recorded as an immutable entry. Used for reconciliation. **API surface:** GraphQL (nested within `Wallet`). **Key fields:** - `amount` — signed; positive = credit, negative = debit - `type` — entry type (settlement, payout, fee, etc.) - `referenceId` — links back to the originating `GenericPayment` or `Payout` --- ### WalletTransfer An inter-wallet movement — for example, when a split execution moves funds from the seller wallet to a payee wallet. **API surface:** GraphQL (nested within `Wallet`). **Key fields:** - `fromWalletId` / `toWalletId` - `amount` - `referenceType` — indicates the source (split execution, manual transfer, etc.) --- ## Splits These entities distribute payment proceeds across multiple payees. ### SplitRule A per-seller rule that distributes a settled payment's proceeds across one or more payees. Each rule has one or more `SplitRuleLine` entries. **API surface:** GraphQL queries `splitRule(id)` / `splitRules(...)` and mutations `createSplitRule`, `updateSplitRule`, `deleteSplitRule`. **Key fields:** - `name` — descriptive name for this rule - `sellerAccountId` - `lines` — list of `SplitRuleLine` --- ### SplitRuleLine One line within a `SplitRule` — specifies a payee and their share (by amount or percentage). **API surface:** Nested within `SplitRule` in GraphQL. **Key fields:** - `payeeId` — recipient - `amount` or `percentage` — the payee's share - `currencyCode` --- ### SplitExecution A historical record of a split rule being applied to a specific settled payment. Created automatically when a payment reaches `settled` and a matching split rule exists. **API surface:** GraphQL query `splitExecutions(dateFrom, dateTo, sellerAccountId)`. **Key fields:** - `splitRuleId` — the rule that was applied - `genericPaymentId` — the payment that triggered the execution - `executedAt` - `lines` — actual amounts distributed per payee ## See also - **[Quickstart](https://api.fynex.ai/payments-api/v2/docs#tag/quickstart)** — Take your first payment in 5 minutes. - **[Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle)** — All GenericPayment and Payout statuses and transitions. - **[Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts)** — Create and track wallet-to-payee payouts. - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Use the hosted checkout page to accept payments without handling card data. ## Payment lifecycle This page is the authoritative reference for status values and transitions. Whenever a response body contains a `status` field, the values come from one of the two enums described here. --- ## GenericPayment status ### Status values | Status | Meaning | Terminal? | |--------|---------|-----------| | `draft` | Payment row created but not yet submitted to any processor (e.g. a hosted-checkout session opened but the customer has not submitted card data). | No | | `new` | Submitted to the processing pipeline; routing not yet resolved. | No | | `routed` | A terminal (processor + MID) has been selected; submission to the provider is imminent. | No | | `provider_pending` | Request sent to the upstream provider; waiting for an async confirmation or 3DS challenge. **This is the status returned when `requiresAction: true`** — the payment waits here until `/finalize-payment` is called. | No | | `authorized` | Provider has authorized the funds but not yet captured them. Capture is required before settlement. Eligible for `POST /capture`. | No | | `provider_completed` | Provider confirmed capture. Also eligible for `POST /capture` (some providers report completion at this stage). | No | | `provider_risk_review` | The provider has placed the payment into a risk-review queue. Fynex waits for a review decision before proceeding. | No | | `funds_in_flight` | Captured funds are moving through the settlement pipeline. | No | | `settled` | Funds have been received into the operational account. Refunds become eligible at this point. | No | | `deposit_confirmed` | Settlement confirmed at the safeguarding/deposit layer. Refunds remain eligible. | No | | `refund_pending` | A refund has been requested and is being processed by the provider. | No | | `refunded` | Refund completed successfully. | **Yes** | | `refund_failed` | Refund attempt failed. The engine can re-queue to `refund_pending`. | No | | `refund_cancelled` | Refund was cancelled. The engine can re-queue to `refund_pending`. | No | | `failed` | Payment failed at any stage. See `failureCode` and `failureDescription` in the response for details. | **Yes** | | `cancelled` | Payment was cancelled (by merchant or system) before completion. | **Yes** | ### What triggers each status | Transition | Trigger | |------------|---------| | `draft` → `new` | `POST /initialize-payment` is processed; routing starts. | | `new` → `routed` | Internal routing engine selects a terminal. | | `routed` → `provider_pending` | Payment sent to provider; 3DS or async response required. | | `provider_pending` → `authorized` | `POST /finalize-payment` called after 3DS; provider confirms authorization. | | `provider_pending` → `failed`/`cancelled` | Customer cancels 3DS, OTP fails, issuer declines, or system timeout. | | `authorized` → `provider_completed` | `POST /payments/{id}/capture` called (manual-capture mode) or auto-capture by provider. | | `provider_completed` → `funds_in_flight` | Settlement pipeline picks up the captured payment. | | `funds_in_flight` → `settled` | Operational account receives the funds. | | `settled` → `deposit_confirmed` | Safeguarding layer confirms the deposit. | | `provider_completed`/`settled`/`deposit_confirmed` → `refund_pending` | `POST /payments/{id}/refund` called. | | `refund_pending` → `provider_completed` | Provider confirms a partial refund while refundable balance remains. | | `refund_pending` → `refunded` | Provider confirms the cumulative refunded amount equals the captured amount. | | `refund_pending` → `refund_failed`/`refund_cancelled` | Provider rejects or system cancels the refund. | | `refund_failed`/`refund_cancelled` → `refund_pending` | Refund can be retried. | | any non-terminal → `failed`/`cancelled` | Provider decline, system error, or merchant cancellation. | ### Capture pre-conditions `POST /payments/{id}/capture` enforces two pre-conditions: 1. `captureMode` must be `manual` (set on `/initialize-payment`). Auto-settlement payments return `409`. 2. Current status must be `authorized` or `provider_completed`. Any other status returns `409`. ### ASCII state diagram ``` ┌──────────────────────────────────────────────┐ │ POST /initialize-payment │ ▼ │ idempotent draft │ replay │ │ ▼ new │ ▼ routed │ ▼ provider_pending ───────────────────────────► failed │ ▲ POST /finalize- │ cancelled ◄──┤ (any stage) payment (3DS ok) │ │ ▼ authorized ──────────────────────────────────►│ │ POST /capture │ ▼ │ provider_completed │ │────────────── POST /refund ───────────▼ provider_risk_review ─────────────────────────────►│ │ │ ▼ │ funds_in_flight │ │ │ ▼ settled ─────────────────────────────────►deposit_confirmed │ │ POST /refund POST /refund │ │ ▼ ▼ refund_pending ◄──────────── refund_failed / refund_cancelled │\\ partial success│ \\ full cumulative refund ▼ ▼ provider_completed refunded (terminal) ``` --- ## Payout status Payouts represent wallet-to-payee transfers sent via the banking provider. ### Status values | Status | Meaning | Terminal? | |--------|---------|-----------| | `pending` | Payout created; funds held in the wallet; not yet submitted to the banking provider. | No | | `processing` | Payout submitted to the banking provider. | No | | `completed` | The banking provider confirmed the payment reached the destination. | **Yes** | | `failed` | The banking provider rejected the payment or an error occurred. See `failureCode` and `failureDescription`. | **Yes** | | `cancelled` | Payout was cancelled before it reached the banking provider. | **Yes** | ### Status transitions | From | To | Trigger | |------|----|---------| | `pending` | `processing` | Internal dispatcher submits to the banking provider. | | `processing` | `completed` | The banking provider's webhook confirms success. | | `processing` | `failed` | The banking provider rejects or a timeout occurs. | | `pending` | `cancelled` | Seller or system cancels before dispatch. | ### ASCII state diagram ``` POST /payouts │ ▼ pending │ ▼ processing │ ┌──┴──────────┐ ▼ ▼ completed failed (both terminal) pending ──► cancelled (terminal, if cancelled before dispatch) ``` ### Payout amount field Payout amounts use **minor units** in the API wire format (`amountMinor`). This differs from payment endpoints, which use major units. For example, £49.99 is `4999` in a payout request. --- ## Cross-reference: actions and status transitions | API action | Required pre-status | Resulting status | |-----------|---------------------|-----------------| | `POST /initialize-payment` | — (new payment) | `provider_pending` (if 3DS needed) or `authorized`/`provider_completed` (if frictionless) | | `POST /finalize-payment` | `provider_pending` | `authorized` or `provider_completed` (on success) / `failed`/`cancelled` (on provider decline) | | `POST /payments/{id}/capture` | `authorized` or `provider_completed` | `provider_completed` → continues toward `funds_in_flight` | | `POST /payments/{id}/refund` | `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled` | `refund_pending` | | `POST /payouts` | — (new payout) | `pending` | > [!CAUTION] > There are no `captured` or `partially_captured` statuses. Partial capture is supported by passing a lower `amount` to `POST /capture`, but the status after capture is always `provider_completed`. --- ## Failure codes When a payment reaches `failed`, the response includes a numeric `failureCode`: | Code | Name | Stage | |------|------|-------| | `0` | None | — | | `1001` | Validation failed | validation | | `1002` | Risk rejected | risk | | `1003` | Routing failed | routing | | `2001` | Provider declined | authorization | | `2002` | Provider error | authorization | | `2003` | Provider timeout | authorization | | `3001` | Capture failed | capture | | `4001` | Settlement failed | settlement | | `5001` | Deposit-confirm timeout | deposit_confirm | | `9001` | Cancelled by merchant | — | | `9002` | Cancelled by system | — | | `9999` | Unknown | — | The `failureDescription` field carries a human-readable message from the provider. The `failureStage` field (`validation`, `risk`, `routing`, `authorization`, `capture`, `settlement`, `deposit_confirm`) tells you where in the pipeline the failure occurred. --- ## Polling for status Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). Polling remains available as a backstop. To track status changes: - Poll `genericPayment(id)` via GraphQL (dashboard-authenticated). - Poll `GET /payments-api/v1/payouts/{id}` for payout status. - While a customer is on the hosted checkout page, the browser can use the SSE stream at `GET /checkout/{session_id}/events`. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) for details. ## See also - **[Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — How to capture authorized payments and issue refunds. - **[Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts)** — Create and track wallet-to-payee payouts. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Monitor payment status changes in real time. - **[3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds)** — Handle 3DS challenges in the server-to-server flow. ## Payment methods `GET /payments-api/v1/payment-methods` is the first call a server integration should make. It returns the exact set of instruments, currencies, and payment rails your seller account has been configured for — the intersection of your account settings and what the active terminals support. Use this response to drive your checkout UI rather than hard-coding assumptions. ## Request | Aspect | Value | |--------|-------| | Method | `GET` | | Path | `/payments-api/v1/payment-methods` | | Auth | `Authorization: Bearer ` | | Body | None | | Query params | None | No `Idempotency-Key` is required — this is a read-only endpoint. #### curl ```bash curl -sS "$FYNEX_API/payments-api/v1/payment-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` #### JavaScript ```js export async function getPaymentMethods() { const res = await fetch( `${process.env.FYNEX_API}/payments-api/v1/payment-methods`, { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, }, } ); if (!res.ok) throw new Error(await res.text()); return res.json(); } ``` #### Python ```python import os import requests def get_payment_methods() -> dict: res = requests.get( f"{os.environ['FYNEX_API']}/payments-api/v1/payment-methods", headers={"Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}"}, timeout=10, ) res.raise_for_status() return res.json() ``` ## Response **`200 OK`** — returns `dtos.PaymentMethodsResponse`: ```json { "sellerAccountId": 42, "allowedPaymentMethods": ["card", "google_pay", "apple_pay"], "allowedCurrencies": ["GBP", "EUR"], "allowedPaymentRails": ["card"] } ``` ### Response fields | Field | Type | Description | |-------|------|-------------| | `sellerAccountId` | `integer` | The numeric ID of the authenticated seller account. | | `allowedPaymentMethods` | `string[]` | Payment instruments enabled for this account. See [Payment instruments](#payment-instruments) below. | | `allowedCurrencies` | `string[]` | ISO 4217 currency codes supported by this account. See [Currencies](#currencies) below. | | `allowedPaymentRails` | `string[]` | Active payment rails. Omitted (`omitempty`) when no rails are configured. See [Payment rails](#payment-rails) below. | ## Enum values ### Payment instruments Values from `model.PaymentInstrumentType`: | Value | Description | |-------|-------------| | `card` | Standard card payment (Visa, Mastercard, Amex, etc.) | | `bank_account` | Bank account / direct debit | | `google_pay` | Google Pay (tokenised card via Google's wallet) | | `apple_pay` | Apple Pay (tokenised card via Apple's wallet) | | `bancontact` | Bancontact (Belgian local scheme, `apm` rail) | | `multibanco` | Multibanco (Portuguese reference/voucher payment, `apm` rail) | | `mbway` | MB WAY (Portuguese mobile-app payment, `apm` rail) | | `wero` | Wero (European account-to-account wallet, `apm` rail) | | `swish` | Swish (Swedish mobile payment, `apm` rail) | ### Currencies Values from `model.CurrencyCode`. All 3-letter ISO 4217: | Code | Currency | |------|----------| | `EUR` | Euro | | `USD` | US Dollar | | `GBP` | British Pound | | `DKK` | Danish Krone | | `NOK` | Norwegian Krone | | `SEK` | Swedish Krona | ### Payment rails Values from `model.PaymentRailType`: | Value | Description | Instruments | |-------|-------------|-------------| | `card` | Card network processing | `card`, `google_pay`, `apple_pay` | | `bank_transfer` | Bank transfer / account-to-account | `bank_account` | | `apm` | Alternative / local payment methods (redirect-based) | `bancontact`, `multibanco`, `mbway`, `wero`, `swish` | The instrument-to-rail mapping is enforced by the server: a `card` rail will only accept `card`, `google_pay`, or `apple_pay` instruments; `bank_transfer` only accepts `bank_account`; the `apm` rail carries the local-scheme instruments (`bancontact`, `multibanco`, `mbway`, `wero`, `swish`). > [!NOTE] > The `apm` instruments are **redirect-based**: accept them either server-to-server via > `POST /initialize-payment` with `paymentType: "apm"` (follow the returned redirect action), > or through Fynex-hosted checkout. See [Alternative payment methods > (APM)](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm) for both flows and the per-checkout > availability rules. ## Status codes | Status | When | |--------|------| | `200` | Success | | `401` | Missing or invalid bearer token | | `404` | Payment configuration not found for this seller account | | `500` | Service dependency unavailable or terminal load failure | ## Using the response to drive your checkout UI > [!NOTE] > Always fetch payment methods at session start and render **only** what the response > contains. Do not hard-code which instruments or currencies to show — your account > configuration can change without a code deployment. 1. **Fetch on server startup or per-request** Call `GET /payment-methods` when your server starts (and cache for a short period), or fetch it once per checkout session before rendering the payment form. 2. **Render only enabled instruments** ```js const { allowedPaymentMethods, allowedCurrencies } = await getPaymentMethods(); // Show card form only if enabled const showCard = allowedPaymentMethods.includes('card'); // Show Google Pay button only if enabled and browser supports it const showGooglePay = allowedPaymentMethods.includes('google_pay') && await isGooglePayReady(); // Show Apple Pay button only if enabled and browser supports it const showApplePay = allowedPaymentMethods.includes('apple_pay') && isApplePayAvailable(); // Populate currency selector from the live list renderCurrencySelect(allowedCurrencies); ``` 3. **Pass the correct instrument and rail when initializing a payment** When you call `POST /payments-api/v1/initialize-payment` or `POST /payments-api/v1/checkout`, the `paymentType` (rail) and `paymentMethod` (instrument) fields must be values the server already told you are allowed. Sending a disallowed combination returns `400 Bad Request`. > [!CAUTION] > `allowedPaymentRails` uses `omitempty` — it will be absent from the response if no > rails are configured on the account. Always check for the key's presence before > reading it; don't assume an empty array. ## See also - **[Hosted checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Accept payments by redirecting customers to a Fynex-hosted page. - **[Server-to-server payments](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Collect card details in your own UI and call the API directly. ## Wallets 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. > [!NOTE] > **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: > - `GET /payments-api/v1/wallets` — list the seller's wallets with balance snapshots > - `GET /payments-api/v1/wallets/{wallet_id}` — a single wallet > - `GET /payments-api/v1/wallets/{wallet_id}/transactions` — that wallet's transactions > > The REST surface uses your `Authorization: Bearer ` (no cookie), is seller-scoped, hides Fynex system wallets, and returns balances as `availableBalanceMinor` / `heldBalanceMinor` / `pendingBalanceMinor` / `totalBalanceMinor` (minor units). See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth) for the cookie flow used by the GraphQL queries below. --- ## Wallet types The `WalletType` enum describes the role of each wallet: | Type | Purpose | |------|---------| | `MAIN` | Primary seller balance — most payments settle here | | `SELLER_OPERATIONAL` | Operational float held by the seller | | `PAYOUT` | Funds earmarked for outgoing payouts | | `PLATFORM_COMMISSION` | Fynex platform fee collection wallet | | `TAX` | Tax reserve wallet | | `OTHER` | Custom-purpose wallet | | `EXTERNAL_SAFEGUARDING_ACCOUNT` | Funds held in a safeguarding account | | `FYNEX_OPERATIONAL` | Internal Fynex operational ledger | | `EXTERNAL_CREDIT` | Credit facility wallet | Wallet status can be `ACTIVE`, `INACTIVE`, or `FROZEN`. --- ## GraphQL operations ### List all wallets for a seller ```graphql query ListWallets($merchantId: ID, $limit: Int, $offset: Int) { wallets(merchantId: $merchantId, limit: $limit, offset: $offset) { id name type status currencyCode currencyScale isSystem createdAt snapshot { availableBalanceMinor heldBalanceMinor pendingBalanceMinor updatedAt } } } ``` ### Get a single wallet with ledger entries ```graphql query GetWallet($id: Int!, $entryLimit: Int, $entryOffset: Int) { wallet(id: $id) { id name type status currencyCode currencyScale snapshot { availableBalanceMinor heldBalanceMinor pendingBalanceMinor } entries(limit: $entryLimit, offset: $entryOffset) { id entrySeq direction kind amountMinor availableDeltaMinor heldDeltaMinor balanceBeforeMinor balanceAfterMinor occurredAt } } } ``` --- ## Type reference ### `Wallet` | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Numeric wallet ID | | `payeeId` | `Int!` | Payee this wallet belongs to | | `sellerAccountId` | `Int!` | Owning seller account | | `type` | `WalletType!` | Wallet role (see table above) | | `status` | `WalletStatus!` | `ACTIVE`, `INACTIVE`, or `FROZEN` | | `name` | `String!` | Human-readable label | | `currencyCode` | `CurrencyCode!` | ISO 4217 currency code | | `currencyScale` | `Int!` | Decimal scale (e.g. `2` for EUR meaning amounts are in cents) | | `isSystem` | `Boolean!` | `true` for Fynex-managed system wallets | | `createdAt` | `Time!` | Creation timestamp | | `snapshot` | `WalletBalanceSnapshot` | Latest balance snapshot | | `entries` | `[WalletEntry!]!` | Paginated ledger entries | | `payoutMethod` | `PayoutMethod` | Associated payout method if any | ### `WalletBalanceSnapshot` The snapshot is updated after each ledger write and represents the current balance at the time of the last entry. | Field | Type | Description | |-------|------|-------------| | `availableBalanceMinor` | `Int!` | Spendable balance in minor currency units | | `heldBalanceMinor` | `Int!` | Funds on hold (pending authorization) | | `pendingBalanceMinor` | `Int!` | Funds in transit | | `lastEntrySeq` | `Int!` | Sequence number of the entry that produced this snapshot | | `updatedAt` | `Time!` | When the snapshot was last written | All balance amounts are in **minor currency units** (e.g. cents for EUR/USD). Divide by `10^currencyScale` to get the decimal amount. ### `WalletEntry` Each entry is an append-only record of a balance movement. | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Entry ID | | `entrySeq` | `Int!` | Monotonically increasing per-wallet sequence | | `direction` | `WalletEntryDirection!` | `DEBIT` or `CREDIT` | | `kind` | `WalletEntryKind!` | Cause: `PAYMENT`, `PAYOUT`, `SPLIT_EXECUTION`, `MANUAL_ADJUST`, `INCOMING_PAYMENT_PROCESSED`, `OUTGOING_PAYMENT_PROCESSED` | | `amountMinor` | `Int!` | Movement amount in minor units | | `balanceBeforeMinor` | `Int!` | Available balance before this entry | | `balanceAfterMinor` | `Int!` | Available balance after this entry | | `heldDeltaMinor` | `Int!` | Change in held balance | | `occurredAt` | `Time!` | When the movement occurred | | `transferId` | `Int` | Linked `WalletTransfer` ID if this entry resulted from an inter-wallet movement | | `reconciledAt` | `Time` | Set when the entry is reconciled | ### `WalletTransfer` A transfer records an inter-wallet movement (e.g. when a split rule distributes funds from the main wallet to a payee wallet). | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Transfer ID | | `type` | `String!` | Transfer type | | `status` | `String!` | Current status | | `sourceWalletId` | `Int!` | Origin wallet | | `destinationWalletId` | `Int` | Target wallet (if intra-Fynex) | | `destinationPayoutMethodId` | `Int` | Target payout method (if external) | | `sourceAmountMinor` | `Int!` | Amount debited from source | | `destinationAmountMinor` | `Int!` | Amount credited to destination | | `sourceCurrencyCode` | `CurrencyCode!` | Source currency | | `destinationCurrencyCode` | `CurrencyCode!` | Destination currency | | `splitExecutionId` | `Int` | Linked split execution if applicable | | `requestedAt` | `Time` | When the transfer was requested | | `postedAt` | `Time` | When the transfer was posted | | `errorCode` | `String` | Present if the transfer failed | --- ## Code samples #### curl ```bash # Step 1 — login and save the cookie curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Step 2 — list wallets for a seller account curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "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 } } }", "variables": { "merchantId": "42", "limit": 20, "offset": 0 } }' ``` #### JavaScript ```js const BASE = 'https://api.fynex.ai'; async function login(email, password) { const res = await fetch(`${BASE}/api/v1/login/dashboard`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new Error(`Login failed: ${res.status}`); } async function gql(query, variables = {}) { const res = await fetch(`${BASE}/dashboard/graphql`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), }); const { data, errors } = await res.json(); if (errors?.length) throw new Error(errors[0].message); return data; } await login('you@example.com', 'your_password'); const { wallets } = await gql( `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 } } }`, { merchantId: '42', limit: 20, offset: 0 } ); for (const w of wallets) { const scale = Math.pow(10, 2); // adjust if currencyScale != 2 console.log( `${w.name} (${w.currencyCode}): ${w.snapshot.availableBalanceMinor / scale} available` ); } ``` #### Python ```python import requests BASE = "https://api.fynex.ai" session = requests.Session() # Step 1 — login session.post( f"{BASE}/api/v1/login/dashboard", json={"email": "you@example.com", "password": "your_password"}, ).raise_for_status() # Step 2 — list wallets query = """ query ListWallets($merchantId: ID, $limit: Int, $offset: Int) { wallets(merchantId: $merchantId, limit: $limit, offset: $offset) { id name type status currencyCode currencyScale snapshot { availableBalanceMinor heldBalanceMinor pendingBalanceMinor updatedAt } } } """ resp = session.post( f"{BASE}/dashboard/graphql", json={"query": query, "variables": {"sellerAccountId": 42, "limit": 20, "offset": 0}}, ) resp.raise_for_status() result = resp.json() if "errors" in result: raise RuntimeError(result["errors"][0]["message"]) for wallet in result["data"]["wallets"]: scale = 10 ** wallet["currencyScale"] available = wallet["snapshot"]["availableBalanceMinor"] / scale print(f'{wallet["name"]} ({wallet["currencyCode"]}): {available:.2f} available') ``` --- ## Use cases **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. **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. > [!CAUTION] > 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`. ## See also - **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — How to obtain the dashboard_session cookie required for all GraphQL calls. - **[Reconciliation](https://api.fynex.ai/payments-api/v2/docs#tag/reconciliation)** — Match Fynex settlement records against your own books. - **[Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts)** — Request a wallet-to-payee transfer via REST or GraphQL. ## Virtual accounts A **virtual account** is an issued IBAN that lets customers pay a seller by bank transfer without exposing the seller's primary bank account. > [!CAUTION] > **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. --- ## When to use virtual accounts - **Collect bank-transfer payments** without exposing the seller's primary IBAN. - **Isolate funds per customer** — issue one virtual account per customer and reconcile inflows by IBAN. - **Reconcile provider account activity** using the provider-specific account surface enabled for the seller. --- ## Provider boundaries - The GraphQL operations below list and inspect banking-provider-backed virtual accounts. - `POST /api/v1/accounts/virtual` is a tier-gated issuance preview. It currently returns only an internal Fynex UUID. - 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. - 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. Use 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. --- ## GraphQL operations > [!CAUTION] > Staff surface only — not callable with a `dashboard_session`. Shown for reference. | Operation | Signature | |-----------|-----------| | List virtual accounts | `virtualAccounts(first: Int, after: String, type: VirtualAccountType): VirtualAccountConnection!` | | Get a single account | `virtualAccount(id: ID!): VirtualAccount` | ### `VirtualAccountType` enum | Value | Description | |-------|-------------| | `CLIENT` | Account issued for a specific customer / payer | | `OPERATING` | Account used for the seller's own operating funds | --- ## Type reference ### `VirtualAccount` | Field | Type | Description | |-------|------|-------------| | `id` | `ID!` | Internal Fynex account ID | | `accountId` | `String!` | The issued IBAN or account number to share with the payer | | `virtualAccountId` | `String!` | Banking partner's own reference for this account | | `currency` | `Currency!` | Account currency | | `type` | `VirtualAccountType!` | `CLIENT` or `OPERATING` | | `status` | `String!` | Current provisioning status | | `balance` | `Float!` | Current balance (in major currency units) | | `createdAt` | `String!` | ISO timestamp of account creation | | `customerInfo` | `VirtualAccountCustomer` | Optional customer metadata (name, email, phone, address) | ### `VirtualAccountConnection` Cursor-based pagination is used for `virtualAccounts`: | Field | Type | Description | |-------|------|-------------| | `edges` | `[VirtualAccountEdge!]!` | Paginated account nodes | | `pageInfo` | `PageInfo!` | Cursor and page metadata | `PageInfo` fields: `hasNextPage`, `hasPreviousPage`, `startCursor`, `endCursor`, `totalCount`, `currentPage`, `totalPages`. Advance pages by passing the `endCursor` value as the `after` argument on the next call. --- ## Requesting a virtual account > [!CAUTION] > **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. ```http POST /api/v1/accounts/virtual Content-Type: application/json Authorization: Bearer Idempotency-Key: va_customer-jane_2026-08-08 { "name": "Jane Smith" } ``` The seller is derived from the bearer token; the request does not accept a merchant or seller ID. A successful request returns `201 Created`: ```json { "accountId": "0f834fa8-f5ab-4d80-96b1-fc2d9e4e1824" } ``` `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. --- ## Code samples — list virtual accounts #### curl ```bash # Step 1 — login curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Step 2 — list virtual accounts (first 20, CLIENT type) curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "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 } } } } }", "variables": { "first": 20, "type": "CLIENT" } }' ``` #### JavaScript ```js const BASE = 'https://api.fynex.ai'; async function login(email, password) { const res = await fetch(`${BASE}/api/v1/login/dashboard`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new Error(`Login failed: ${res.status}`); } async function gql(query, variables = {}) { const res = await fetch(`${BASE}/dashboard/graphql`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), }); const { data, errors } = await res.json(); if (errors?.length) throw new Error(errors[0].message); return data; } await login('you@example.com', 'your_password'); // Fetch all CLIENT-type virtual accounts using cursor pagination let after = undefined; let allAccounts = []; do { const { virtualAccounts } = await gql( `# Not available on /dashboard/graphql — staff surface only query ListVAs($first: Int, $after: String, $type: VirtualAccountType) { virtualAccounts(first: $first, after: $after, type: $type) { pageInfo { hasNextPage endCursor totalCount } edges { node { id accountId virtualAccountId currency type status balance createdAt customerInfo { name email } } } } }`, { first: 20, after, type: 'CLIENT' } ); allAccounts = allAccounts.concat(virtualAccounts.edges.map((e) => e.node)); after = virtualAccounts.pageInfo.hasNextPage ? virtualAccounts.pageInfo.endCursor : undefined; } while (after); console.log(`Fetched ${allAccounts.length} virtual accounts`); for (const va of allAccounts) { console.log(`${va.accountId} (${va.currency}) — status: ${va.status}, balance: ${va.balance}`); } ``` #### Python ```python import requests BASE = "https://api.fynex.ai" session = requests.Session() session.post( f"{BASE}/api/v1/login/dashboard", json={"email": "you@example.com", "password": "your_password"}, ).raise_for_status() query = """ # Not available on /dashboard/graphql — staff surface only query ListVAs($first: Int, $after: String, $type: VirtualAccountType) { virtualAccounts(first: $first, after: $after, type: $type) { pageInfo { hasNextPage endCursor totalCount } edges { node { id accountId virtualAccountId currency type status balance createdAt customerInfo { name email } } } } } """ all_accounts = [] after = None while True: variables = {"first": 20, "type": "CLIENT"} if after: variables["after"] = after resp = session.post( f"{BASE}/dashboard/graphql", json={"query": query, "variables": variables}, ) resp.raise_for_status() result = resp.json() if "errors" in result: raise RuntimeError(result["errors"][0]["message"]) page = result["data"]["virtualAccounts"] all_accounts.extend(edge["node"] for edge in page["edges"]) if page["pageInfo"]["hasNextPage"]: after = page["pageInfo"]["endCursor"] else: break print(f"Total virtual accounts: {len(all_accounts)}") for va in all_accounts: print(f"{va['accountId']} ({va['currency']}) — {va['status']}, balance: {va['balance']}") ``` ## See also - **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Obtain the dashboard_session cookie required for all GraphQL calls. - **[Wallets](https://api.fynex.ai/payments-api/v2/docs#tag/wallets)** — Inspect wallet-ledger activity for products that post funds to the seller wallet. The issuance preview does not currently create those entries. - **[Concepts](https://api.fynex.ai/payments-api/v2/docs#tag/concepts)** — The object model behind virtual accounts: sellers, payees, wallets and how funds move between them. ## Splits Fynex supports two complementary ways to distribute payment funds across multiple payees: 1. **Persistent split rules** — a standing rule attached to a seller account that automatically distributes funds at settlement time. Managed via GraphQL mutations. 2. **Per-payment inline splits** — a one-shot distribution specified at payment creation time via `orderData.payeeDistribution`. Documented in the Hosted Checkout and Server-to-Server guides. > [!NOTE] > **GraphQL only — cookie session required.** Split rule operations and execution queries live on `POST /dashboard/graphql`. A valid `dashboard_session` cookie is required. See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth). > [!IMPORTANT] > **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: > > ``` > split rule status is lifecycle-controlled; create an inactive rule, preview it, then activate it > ``` > > The splits section of the Fynex dashboard implements exactly this sequence and is the reference implementation. --- ## The lifecycle Three steps, in order. Skipping the preview is not possible: activation requires a receipt that only a successful preview issues. ``` createSplitRule ──▶ previewSplit(ruleId:) ──▶ activateSplitRule(previewReceipt:, confirmed: true) (inactive) (returns receipt, (atomic swap: activates this rule, valid 10 minutes) deactivates the previous one) ``` 1. **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. 2. **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. 3. **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: ``` split rule changed after preview; run preview again ``` 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`. `activateSplitRule` returns `SplitRuleActivationResult`, whose `deactivated` array names every rule the activation stood down. ### Three rules the API enforces - **One active rule per seller.** Activation is an atomic swap, not an addition. Whatever was active becomes inactive in the same transaction — that is what `deactivated` reports. - **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. - **Active rules cannot be deleted.** `deleteSplitRule` refuses while the rule is active. Deactivate it first, or activate its replacement — which deactivates it for you. > [!NOTE] > `scheduleSplitRule` is disabled and returns an error. To schedule, set `effectiveFrom` / `effectiveTo` on the inactive rule, preview the saved version, then activate it. --- ## GraphQL operations ### Queries | Operation | Signature | Permission | |-----------|-----------|------------| | List rules | `splitRules(limit: Int, offset: Int, merchantId: ID): [SplitRule!]!` | `SPLITRULES_READ` | | Get rule | `splitRule(id: Int!): SplitRule` | `SPLITRULES_READ` | | Get the active rule | `activeSplitRule(merchantId: ID!): SplitRule` | `SPLITRULES_READ` | | Preview a split | `previewSplit(input: SplitRulePreviewInput!): SplitRulePreviewResult!` | `SPLITRULES_READ` | | List executions | `splitExecutions(dateFrom: Time, dateTo: Time, merchantId: ID): [SplitExecution!]!` | `SPLITRULES_READ` | ### Mutations | Operation | Signature | Permission | |-----------|-----------|------------| | Create (inactive) | `createSplitRule(input: CreateSplitRuleInput!): SplitRule!` | `SPLITRULES_CREATE` | | Update (inactive only) | `updateSplitRule(id: Int!, merchantId: ID, input: UpdateSplitRuleInput!): SplitRule!` | `SPLITRULES_UPDATE` | | Activate | `activateSplitRule(id: Int, input: ActivateSplitRuleInput): SplitRuleActivationResult!` | `SPLITRULES_UPDATE` | | Deactivate | `deactivateSplitRule(id: Int!, merchantId: ID): SplitRule!` | `SPLITRULES_UPDATE` | | Clone (to edit an active rule) | `cloneSplitRule(id: Int!, merchantId: ID): SplitRule!` | `SPLITRULES_CREATE` | | Upsert one line by payee | `upsertSplitRuleLineByPayee(input: UpsertSplitRuleLineByPayeeInput!): SplitRule!` | `SPLITRULES_UPDATE` | | Delete one line by payee | `deleteSplitRuleLineByPayee(input: DeleteSplitRuleLineByPayeeInput!): SplitRule!` | `SPLITRULES_UPDATE` | | Delete (inactive only) | `deleteSplitRule(id: Int!, merchantId: ID): Boolean!` | `SPLITRULES_DELETE` | --- ## How a rule runs 1. **Fynex applies the active rule** server-side when a payment settles. `amountBase` controls whether lines run against the `net_settled` or `gross_payment` amount. 2. **Funds are transferred** from the seller's main wallet to each payee's wallet via `WalletTransfer` records. The split produces a `SplitExecution` record you can query later. 3. **Inspect executions** via `splitExecutions(dateFrom, dateTo, merchantId)` to audit how funds were distributed. Each execution snapshots the `ruleVersion` it ran, so an execution stays readable after the rule is replaced. --- ## Type reference ### `SplitRule` | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Rule ID | | `merchantId` | `ID!` | Owning seller account | | `type` | `SplitRuleType!` | `fixed` or `custom` | | `status` | `SplitRuleStatus!` | `active` or `inactive`. Read-only — set by the activation lifecycle, never by input | | `amountBase` | `SplitRuleAmountBase!` | `net_settled` (the default) or `gross_payment` — the amount the lines run against. Commission is computed on net settled money unless the rule explicitly says gross | | `allocationMode` | `SplitRuleAllocationMode!` | `weight` or `absolute` — whether line allocations are relative shares or fixed claims | | `remainderPolicy` | `SplitRuleRemainderPolicy!` | Where leftover funds go: `to_main_wallet` or `to_remainder_wallet` | | `overAllocationPolicy` | `SplitRuleOverAllocationPolicy!` | How to handle over-allocation: `cap_by_priority`, `scale_down_percent`, `scale_down_all`, or `fail` | | `remainderWalletId` | `Int` | Target wallet for remainder (when `remainderPolicy` is `to_remainder_wallet`) | | `percentBps` | `Int` | Top-level percentage in basis points (1 bps = 0.01%) | | `fixedAmountMinor` | `Int` | Top-level fixed amount in minor currency units | | `effectiveFrom` | `Time` | Rule starts applying from this time | | `effectiveTo` | `Time` | Rule stops applying after this time | | `version` | `Int!` | Incremented on each saved edit; `SplitExecution.ruleVersion` records which version ran | | `lines` | `[SplitRuleLine!]!` | Per-payee allocation lines | ### `SplitRuleLine` | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Line ID | | `splitRuleId` | `Int!` | Parent rule | | `payeeId` | `Int!` | Recipient payee | | `allocationType` | `SplitRuleAllocationType!` | `percent_bps`, `fixed_minor`, or `mixed` | | `percentBps` | `Int` | Share in basis points (e.g. `1000` = 10%) | | `fixedAmountMinor` | `Int` | Fixed amount in minor units | | `allocationPercentBps` | `Int` | Percentage component when `allocationType` is `mixed` | | `allocationFixedAmountMinor` | `Int` | Fixed component when `allocationType` is `mixed` | | `priority` | `Int!` | Execution order when funds are insufficient | | `isEnabled` | `Boolean!` | Whether this line is active | | `minAmountMinor` | `Int` | Minimum transfer amount (clamps the allocation) | | `maxAmountMinor` | `Int` | Maximum transfer amount (caps the allocation) | ### `SplitRulePreviewResult` | Field | Type | Description | |-------|------|-------------| | `ruleId` | `Int` | The previewed rule, when previewing a saved one | | `ruleVersion` | `Int!` | Version the preview ran against. Activation checks it, and the rule fingerprint, against the live rule | | `totalAmountMinor` | `Int!` | Amount the preview distributed | | `totalFeeMinor` | `Int!` | Total fee across allocations | | `allocations` | `[SplitRulePreviewAllocation!]!` | Per-payee `grossShareMinor`, `feeMinor`, `netAmountMinor` | | `wouldFailReason` | `String` | Non-null when the rule could not run; no receipt is issued | | `activationReceipt` | `String` | Opaque receipt for `activateSplitRule`. Only issued for a `ruleId` preview | | `activationReceiptExpiresAt` | `Time` | 10 minutes after the preview | ### `SplitExecution` | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Execution ID | | `genericPaymentId` | `Int` | Payment that triggered the split | | `splitRuleId` | `Int` | Rule that ran | | `sourceWalletId` | `Int!` | Wallet funds were distributed from | | `amountMinor` | `Int!` | Total amount distributed | | `currencyCode` | `CurrencyCode!` | Currency | | `status` | `String!` | Execution status | | `ruleVersion` | `Int!` | Snapshot of rule version at execution time | | `requestedAt` | `Time` | When the split was triggered | | `postedAt` | `Time` | When transfers were posted | | `walletTransfers` | `[WalletTransfer!]!` | Individual per-payee transfers | ### Inputs ```graphql input CreateSplitRuleInput { merchantId: ID! type: SplitRuleType # fixed | custom percentBps: Int fixedAmountMinor: Int status: SplitRuleStatus # deprecated — lifecycle-controlled, omit it amountBase: SplitRuleAmountBase # net_settled | gross_payment allocationMode: SplitRuleAllocationMode # weight | absolute remainderPolicy: SplitRuleRemainderPolicy overAllocationPolicy: SplitRuleOverAllocationPolicy remainderWalletId: Int effectiveFrom: Time effectiveTo: Time lines: [CreateSplitRuleLineInput!] } input CreateSplitRuleLineInput { payeeId: Int! allocationType: SplitRuleAllocationType! # percent_bps | fixed_minor | mixed percentBps: Int fixedAmountMinor: Int priority: Int! isEnabled: Boolean minAmountMinor: Int maxAmountMinor: Int } input SplitRulePreviewInput { merchantId: ID! amountMinor: Int! distribution: [SplitRulePreviewDistributionItem!]! # { payeeRef: { id } | { externalId }, amountMinor } ruleId: Int # preview a SAVED rule — this is the form that issues a receipt rule: SplitRulePreviewRuleInput # preview an unsaved shape instead; no receipt } input ActivateSplitRuleInput { id: Int! previewReceipt: String! confirmed: Boolean! } ``` `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`. --- ## Worked example — create, preview, activate A rule that sends 30% of each net-settled payment to payee 101 and 70% to payee 102. > [!NOTE] > 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. #### curl ```bash # Step 0 — login curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Step 1 — create the rule. It is created INACTIVE; do not send `status`. curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation CreateSplit($input: CreateSplitRuleInput!) { createSplitRule(input: $input) { id status version lines { id payeeId percentBps priority } } }", "variables": { "input": { "merchantId": "42", "amountBase": "net_settled", "remainderPolicy": "to_main_wallet", "overAllocationPolicy": "fail", "lines": [ { "payeeId": 101, "allocationType": "percent_bps", "percentBps": 3000, "priority": 1, "isEnabled": true }, { "payeeId": 102, "allocationType": "percent_bps", "percentBps": 7000, "priority": 2, "isEnabled": true } ] } } }' # Step 2 — preview the SAVED rule against a real amount. Returns the receipt. curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "query Preview($input: SplitRulePreviewInput!) { previewSplit(input: $input) { ruleId ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt activationReceiptExpiresAt allocations { payeeId grossShareMinor feeMinor netAmountMinor } } }", "variables": { "input": { "merchantId": "42", "ruleId": 7, "amountMinor": 10000, "distribution": [ { "payeeRef": { "id": 101 }, "amountMinor": 3000 }, { "payeeRef": { "id": 102 }, "amountMinor": 7000 } ] } } }' # Step 3 — activate with the receipt from step 2 (valid 10 minutes). curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation Activate($input: ActivateSplitRuleInput!) { activateSplitRule(input: $input) { activated { id status version } deactivated { id status } } }", "variables": { "input": { "id": 7, "previewReceipt": "", "confirmed": true } } }' ``` #### JavaScript ```js const BASE = 'https://api.fynex.ai'; async function login(email, password) { const res = await fetch(`${BASE}/api/v1/login/dashboard`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new Error(`Login failed: ${res.status}`); } async function gql(query, variables = {}) { const res = await fetch(`${BASE}/dashboard/graphql`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), }); const { data, errors } = await res.json(); if (errors?.length) throw new Error(errors[0].message); return data; } await login('you@example.com', 'your_password'); const merchantId = '42'; // 1. Create — inactive, no `status` field. const { createSplitRule: rule } = await gql( `mutation CreateSplit($input: CreateSplitRuleInput!) { createSplitRule(input: $input) { id status version } }`, { input: { merchantId, amountBase: 'net_settled', remainderPolicy: 'to_main_wallet', overAllocationPolicy: 'fail', lines: [ { payeeId: 101, allocationType: 'percent_bps', percentBps: 3000, priority: 1, isEnabled: true }, { payeeId: 102, allocationType: 'percent_bps', percentBps: 7000, priority: 2, isEnabled: true }, ], }, } ); // 2. Preview the saved rule — this is what issues the activation receipt. const { previewSplit: preview } = await gql( `query Preview($input: SplitRulePreviewInput!) { previewSplit(input: $input) { ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt allocations { payeeId grossShareMinor feeMinor netAmountMinor } } }`, { input: { merchantId, ruleId: rule.id, amountMinor: 10000, distribution: [ { payeeRef: { id: 101 }, amountMinor: 3000 }, { payeeRef: { id: 102 }, amountMinor: 7000 }, ], }, } ); if (preview.wouldFailReason) { throw new Error(`Rule would not run: ${preview.wouldFailReason}`); } // 3. Activate. Deactivates whatever was active, in the same transaction. const { activateSplitRule: result } = await gql( `mutation Activate($input: ActivateSplitRuleInput!) { activateSplitRule(input: $input) { activated { id status version } deactivated { id status } } }`, { input: { id: rule.id, previewReceipt: preview.activationReceipt, confirmed: true } } ); console.log('Active rule:', result.activated.id, '— stood down:', result.deactivated.map((r) => r.id)); ``` #### Python ```python import requests BASE = "https://api.fynex.ai" MERCHANT_ID = "42" session = requests.Session() session.post( f"{BASE}/api/v1/login/dashboard", json={"email": "you@example.com", "password": "your_password"}, ).raise_for_status() def gql(query, variables): resp = session.post(f"{BASE}/dashboard/graphql", json={"query": query, "variables": variables}) resp.raise_for_status() body = resp.json() if "errors" in body: raise RuntimeError(body["errors"][0]["message"]) return body["data"] # 1. Create — inactive. Sending `status` is rejected. rule = gql( """ mutation CreateSplit($input: CreateSplitRuleInput!) { createSplitRule(input: $input) { id status version } } """, { "input": { "merchantId": MERCHANT_ID, "amountBase": "net_settled", "remainderPolicy": "to_main_wallet", "overAllocationPolicy": "fail", "lines": [ {"payeeId": 101, "allocationType": "percent_bps", "percentBps": 3000, "priority": 1, "isEnabled": True}, {"payeeId": 102, "allocationType": "percent_bps", "percentBps": 7000, "priority": 2, "isEnabled": True}, ], } }, )["createSplitRule"] # 2. Preview the saved rule — returns the activation receipt. preview = gql( """ query Preview($input: SplitRulePreviewInput!) { previewSplit(input: $input) { ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt allocations { payeeId grossShareMinor feeMinor netAmountMinor } } } """, { "input": { "merchantId": MERCHANT_ID, "ruleId": rule["id"], "amountMinor": 10000, "distribution": [ {"payeeRef": {"id": 101}, "amountMinor": 3000}, {"payeeRef": {"id": 102}, "amountMinor": 7000}, ], } }, )["previewSplit"] if preview["wouldFailReason"]: raise RuntimeError(f"Rule would not run: {preview['wouldFailReason']}") # 3. Activate within 10 minutes of the preview. result = gql( """ mutation Activate($input: ActivateSplitRuleInput!) { activateSplitRule(input: $input) { activated { id status version } deactivated { id status } } } """, {"input": {"id": rule["id"], "previewReceipt": preview["activationReceipt"], "confirmed": True}}, )["activateSplitRule"] print("Active rule:", result["activated"]["id"], "— stood down:", [r["id"] for r in result["deactivated"]]) ``` --- ## Why did this payee get 90 and not 95? Every split execution writes a **decision record** — a snapshot of the rule and lines as they were when the split ran, what each line contributed, which lines did not fire and the machine-readable reason (`line_disabled`, `no_distribution_weight`, `zero_allocation`), and which policy moved a number afterwards (`clamped_to_min`, `clamped_to_max`, `over_allocation_resolved`). Read it over REST with your seller key: ``` GET /payments-api/v1/payments/{paymentId}/split-decisions ``` `paymentId` is your `externalOrderRef`, the same value the other payment reads take. Records come back newest first, and **more than one 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 run for the payment — it is not a 404. Every amount in a record is an integer in minor units; the payment it explains still reports `amount` in major units, so trust the record for arithmetic. --- ## Editing, replacing and removing rules **Editing the rule that is live.** You cannot. Clone it, edit the copy, preview the copy, activate the copy: ```graphql mutation Replace($id: Int!) { cloneSplitRule(id: $id) { id status version } # a new INACTIVE copy } ``` Then `updateSplitRule` on the clone, `previewSplit(ruleId:)` on the clone, and `activateSplitRule` — which deactivates the original as part of the same transaction. There is no window in which the seller has two active rules, and none in which they have none. **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. **Turning splitting off.** `deactivateSplitRule(id:)`. Setting `status: inactive` through an update is *not* the way — the field is rejected. **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. --- ## Per-payment inline splits For one-off distributions that do not need a persistent rule, pass `payeeDistribution` inside `orderData` at payment creation time. Each element specifies a `payeeId` and an `amount`; the total must sum to the payment `amount`. ```json { "amount": 100.00, "currencyCode": "EUR", "orderData": { "payeeDistribution": [ { "payeeId": 101, "amount": 30.00 }, { "payeeId": 102, "amount": 70.00 } ] } } ``` > [!WARNING] > **The two split surfaces do not agree on units.** Inline `payeeDistribution.amount` is in **major** units — `30.00` is €30.00 — while split rules, previews and executions are in **minor** units, where €30.00 is `3000`. Read the field name: a value ending in `Minor` is minor units, and everything else on the checkout payload is major. Neither surface will reject the wrong one; it is simply off by a hundred. This is documented in detail in the [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) guide. Use persistent split rules when the same distribution applies across many payments; use inline splits for ad-hoc, per-transaction control. ## See also - **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Obtain the dashboard_session cookie required for all GraphQL calls. - **[Payees](https://api.fynex.ai/payments-api/v2/docs#tag/payees)** — Create and manage the payees that appear in your split rule lines. - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Pass payeeDistribution for per-payment inline splits. - **[Wallets](https://api.fynex.ai/payments-api/v2/docs#tag/wallets)** — Inspect the wallet ledger entries produced by split executions. ### `GET /payments-api/v1/payments/{paymentId}/split-decisions` Explain how a payment was split 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. **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. **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. **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. **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. ## Hosted checkout 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. ## When to use it - You want to ship fast. - You don't have an existing card-collection UI. - You're OK redirecting customers to a Fynex domain to complete the payment. If you need to keep the customer on your own domain or already manage card data securely, use [server-to-server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) instead. ## Lifecycle ``` Your backend Fynex Customer browser ───────────── ───── ──────────────── POST /checkout ─────────► creates session returns sessionId, checkoutUrl, expiresAt ◄──── response redirect customer ────────────────────────────────────────► fills card form on hosted page (3DS handled here) Fynex processes payment ◄──── submit redirect → returnUrls.success or .failure poll for status ─────────► (see Polling & SSE guide) verify, fulfil ``` > [!NOTE] > Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). To verify the final payment state — or as a backstop — poll the GraphQL `genericPayment` query or subscribe to SSE events. See the [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) guide. ## Step-by-step 1. **Create a checkout session from your backend** Send `POST /payments-api/v1/checkout` with a Bearer token and a unique `Idempotency-Key` header. #### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/checkout" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-1042", "amount": 49.00, "currencyCode": "GBP", "countryCode": "GB", "returnUrls": { "success": "https://example.com/orders/1042/success", "failure": "https://example.com/orders/1042/failure" }, "description": "Order #1042" }' ``` #### JavaScript ```js import { randomUUID } from 'node:crypto'; export async function createCheckoutSession(order) { const res = await fetch( `${process.env.FYNEX_API}/payments-api/v1/checkout`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: JSON.stringify({ externalOrderRef: order.id, amount: order.amount, currencyCode: order.currency, countryCode: order.countryCode, returnUrls: { success: `https://example.com/orders/${order.id}/success`, failure: `https://example.com/orders/${order.id}/failure`, }, description: `Order #${order.id}`, }), } ); if (!res.ok) throw new Error(await res.text()); return res.json(); } ``` #### Python ```python import os import uuid import requests def create_checkout_session(order: dict) -> dict: res = requests.post( f"{os.environ['FYNEX_API']}/payments-api/v1/checkout", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={ "externalOrderRef": order["id"], "amount": order["amount"], "currencyCode": order["currency"], "countryCode": order["country_code"], "returnUrls": { "success": f"https://example.com/orders/{order['id']}/success", "failure": f"https://example.com/orders/{order['id']}/failure", }, "description": f"Order #{order['id']}", }, timeout=10, ) res.raise_for_status() return res.json() ``` You receive a `201 Created` response: ```json { "sessionId": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "checkoutUrl": "https://pay.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "expiresAt": "2026-04-29T13:30:00Z" } ``` The TTL is set server-side; `expiresAt` tells you when the session will become invalid. 2. **Redirect the customer** 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. ```js res.redirect(302, session.checkoutUrl); ``` 3. **Customer completes the payment on the hosted page** 3DS challenges, if required, are handled internally by the hosted page. Your integration does not need to manage any 3DS redirect. 4. **Customer is redirected back** 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. 5. **Verify the payment status** Poll the GraphQL `genericPayment(id)` query or use SSE to confirm the final state before fulfilling the order. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) for code samples. ## Request fields reference ### Required fields | Field | Type | Description | |-------|------|-------------| | `externalOrderRef` | string | Your order reference. Stored on both the checkout session and the underlying payment. | | `amount` | float | Amount in major units (e.g., `49.00` for £49.00). | | `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. | | `countryCode` | string (2-letter) | ISO 3166-1 alpha-2. Required despite the `omitempty` JSON tag. | | `returnUrls.success` | string | URL the customer is redirected to on successful payment. | | `returnUrls.failure` | string | URL the customer is redirected to on failure. Note the field name is `failure`, not `cancel`. | ### Optional fields | Field | Type | Description | |-------|------|-------------| | `autoSettlement` | bool | When `true`, the payment auto-settles immediately. Default: `false`. | | `orderData.payeeDistribution` | array | Split funds across payees. Each element is `{ "payeeId": , "amount": }`. The amounts must sum to `amount`. See [Split payments](#split-payments). | | `sellerMerchantName` | string | Business name displayed on the hosted checkout page. | | `logoUrl` | string | Logo URL displayed on the hosted checkout page. | | `locale` | string | Language code (e.g., `en`). Falls back to the seller account locale, then `en`. | | `description` | string | Order description shown to the customer (e.g., `"Order #1042"`). | > [!CAUTION] > **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. ## Split payments To distribute the captured amount across multiple payees, include `orderData.payeeDistribution`. Retrieve payee IDs from `GET /payments-api/v1/payees`. ```json { "externalOrderRef": "ORDER-1042", "amount": 100.00, "currencyCode": "GBP", "countryCode": "GB", "returnUrls": { "success": "https://example.com/success", "failure": "https://example.com/failure" }, "orderData": { "totalAmount": 100.00, "payeeDistribution": [ { "payeeId": 101, "amount": 85.00 }, { "payeeId": 102, "amount": 15.00 } ] } } ``` All amounts are in major units. The distribution sum must equal `amount` (or `orderData.totalAmount` if provided). ## 3DS handling The 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. If you need direct control over the 3DS redirect (e.g., your own checkout UI), use [server-to-server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) instead. ## Common pitfalls > [!WARNING] > **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. > [!CAUTION] > **Misleading 401 error: "sellerAccountId is missing in auth context"** > > This error sounds like an authentication problem, but it is almost always caused by a **missing or malformed `Idempotency-Key` header**. The checkout handler reads the seller context via the same helper that validates the header — a missing or non-UUID key surfaces as this misleading 401 before the Bearer token is even checked. Verify your header first before investigating auth issues. - **`Idempotency-Key` reuse with a different body returns `409 Conflict`.** Use a fresh UUID per order. - **`returnUrls.failure` — not `cancelUrl`.** The field is named `failure`. Sending `cancelUrl` has no effect. - **`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. - **Amount is locked at session creation.** To change the amount, create a new session. ## Test cards The hosted checkout page manages 3DS internally and **always runs it** — unlike the server-to-server flow, the hosted page does not expose the `skip3DS` flag. Because 3DS always runs, the PAN you use decides the authentication outcome, and a handle that does not authenticate (`threeDResult` other than `Y`/`A`) **cannot be settled**. For a happy-path success in staging use one of the frictionless-`Y` PANs, with any future expiry date and any 3-digit CVV: | Card number | Brand | |-------------|-------| | `4000 0000 0000 2701` | Visa | | `5200 0000 0000 2235` | Mastercard | > [!IMPORTANT] > Do **not** use `4111 1111 1111 1111` here. It is not one of the authenticating PANs, so on > the hosted checkout it always ends in a decline. It only works on flows that skip 3DS. To exercise a decline or a failed 3DS challenge, use one of the challenge/failure PANs from the [Test cards & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards) guide, which lists the full set. ## See also - **[Checkout widget](https://api.fynex.ai/payments-api/v2/docs#tag/checkout-widget)** — Want to embed Fynex - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment outcomes without webhooks. - **[Captures & refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — Capture, partially capture, or refund a payment after the fact. - **[Server-to-server payments](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Take full control of the payment flow and 3DS redirect. ## Server-to-server Server-to-server (S2S) is the right choice when you already have a card-collection UI and need to **keep customers on your own domain**. You handle the card form; Fynex handles the processor integration, 3DS, and settlement. > [!CAUTION] > S2S means your servers receive PAN/CVV. You are responsible for PCI DSS compliance up to **SAQ D** scope. If that is not workable, use [hosted checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) instead — it keeps you at SAQ A. ## Lifecycle ``` Customer Your backend Fynex ──────── ───────────── ───── fills card form ─► POST /initialize-payment ──────► authorizes via processor returns status + actionUrl? ◄── 202 response if requiresAction: redirect ──────────────────────────────────────────► customer completes 3DS browser bounces back POST /finalize-payment ─────────► captures / finalizes returns final status ◄── 200 result verify status, fulfil order ``` > [!NOTE] > Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). You can also use the GraphQL `genericPayment(id)` query or SSE to poll for the final status — recommended as a backstop. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse). ## Step-by-step 1. **Collect card details** Build a form on your frontend that captures PAN, expiry month/year, CVV, and cardholder name. Submit them to your backend over HTTPS — never log raw card numbers. 2. **Initialize the payment** `POST /payments-api/v1/initialize-payment` with `Authorization: Bearer ` and a unique `Idempotency-Key` (UUID) header. #### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-1042", "amount": 49.00, "paymentType": "card", "paymentMethod": "card", "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": false, "captureMode": "manual", "cardData": { "cardNumber": "4111111111111111", "expMonth": 12, "expYear": 2028, "cvv": "123", "holderName": "Jane Doe" }, "billingDetails": { "country": "GB", "zip": "SW1A1AA", "city": "London", "street": "1 Example Street" }, "returnLinks": [ { "rel": "on_completed", "href": "https://example.com/orders/1042/success", "method": "GET" }, { "rel": "on_failed", "href": "https://example.com/orders/1042/failure", "method": "GET" }, { "rel": "default", "href": "https://example.com/orders/1042/return", "method": "GET" } ] }' ``` #### JavaScript ```js import { randomUUID } from 'node:crypto'; export async function initializePayment(order, card) { const res = await fetch( `${process.env.FYNEX_API}/payments-api/v1/initialize-payment`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: JSON.stringify({ externalOrderRef: order.id, amount: order.amount, paymentType: 'card', paymentMethod: 'card', currencyCode: order.currency, countryCode: order.countryCode, autoSettlement: false, captureMode: 'manual', cardData: { cardNumber: card.number, expMonth: card.expMonth, expYear: card.expYear, cvv: card.cvv, holderName: card.holderName, }, billingDetails: { country: order.billingCountry, zip: order.billingZip, city: order.billingCity, street: order.billingStreet, }, returnLinks: [ { rel: 'on_completed', href: order.successUrl, method: 'GET' }, { rel: 'on_failed', href: order.failureUrl, method: 'GET' }, { rel: 'default', href: order.returnUrl, method: 'GET' }, ], }), } ); if (!res.ok) throw new Error(await res.text()); return res.json(); // 202 Accepted } ``` #### Python ```python import os import uuid import requests def initialize_payment(order: dict, card: dict) -> dict: res = requests.post( f"{os.environ['FYNEX_API']}/payments-api/v1/initialize-payment", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={ "externalOrderRef": order["id"], "amount": order["amount"], "paymentType": "card", "paymentMethod": "card", "currencyCode": order["currency"], "countryCode": order["country_code"], "autoSettlement": False, "captureMode": "manual", "cardData": { "cardNumber": card["number"], "expMonth": card["exp_month"], "expYear": card["exp_year"], "cvv": card["cvv"], "holderName": card["holder_name"], }, "billingDetails": { "country": order["billing_country"], "zip": order["billing_zip"], "city": order["billing_city"], "street": order["billing_street"], }, "returnLinks": [ {"rel": "on_completed", "href": order["success_url"], "method": "GET"}, {"rel": "on_failed", "href": order["failure_url"], "method": "GET"}, {"rel": "default", "href": order["return_url"], "method": "GET"}, ], }, timeout=15, ) res.raise_for_status() return res.json() # 202 Accepted ``` The response is `202 Accepted` for a new payment: ```json { "paymentId": "ORDER-1042", "status": "provider_pending", "amount": 49.00, "currencyCode": "GBP", "requiresAction": false, "actionUrl": "" } ``` When 3DS is required the response looks like: ```json { "paymentId": "ORDER-1042", "status": "provider_pending", "amount": 49.00, "currencyCode": "GBP", "requiresAction": true, "actionUrl": "https://3ds.example.com/challenge/..." } ``` 3. **Handle 3DS (if `requiresAction: true`)** See [3DS handling](#3ds-handling) below for the full flow. Short version: redirect the customer's browser to `actionUrl`, then proceed to finalize once they return. When `requiresAction: false`, skip this step entirely. 4. **Finalize the payment** `POST /payments-api/v1/finalize-payment` with `paymentId` (your `externalOrderRef`) and a new unique `Idempotency-Key`. #### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/finalize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "paymentId": "ORDER-1042" }' ``` #### JavaScript ```js export async function finalizePayment(paymentId) { const res = await fetch( `${process.env.FYNEX_API}/payments-api/v1/finalize-payment`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: JSON.stringify({ paymentId }), } ); if (!res.ok) throw new Error(await res.text()); return res.json(); // 200 OK } ``` #### Python ```python def finalize_payment(payment_id: str) -> dict: res = requests.post( f"{os.environ['FYNEX_API']}/payments-api/v1/finalize-payment", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={"paymentId": payment_id}, timeout=15, ) res.raise_for_status() return res.json() ``` The response is `200 OK`: ```json { "paymentId": "ORDER-1042", "status": "provider_completed", "providerPaymentId": "pay_01J2EXAMPLE", "capturedAmount": 49.00, "currencyCode": "GBP" } ``` 5. **Verify and fulfil** Mark the order paid only when `status` reaches a terminal success value. If `failureCode` is present, the payment was declined — surface the `failureDescription` to the customer as appropriate. Poll `genericPayment(id)` if you need to check status asynchronously. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse). ## Request fields reference ### `POST /initialize-payment` — required fields | Field | Type | Description | |-------|------|-------------| | `externalOrderRef` | string | Your order reference. Returned as `paymentId` in all subsequent responses and used as the path param for capture/refund. | | `amount` | float | Major units (e.g., `49.00`). | | `paymentType` | string | `card`, `bank_transfer`, or `apm`. | | `paymentMethod` | string | `card`, `bank_account`, `google_pay`, or `apple_pay`. Must be compatible with `paymentType`: card type accepts `card`/`google_pay`/`apple_pay`; bank_transfer accepts only `bank_account`; apm type accepts `bancontact`/`multibanco`/`mbway`/`wero`/`swish` (see [Alternative payment methods (APM)](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm) for the redirect flow). | | `currencyCode` | string (3-letter) | Must be one of: `EUR`, `USD`, `GBP`, `DKK`, `NOK`, `SEK`. Upper-cased server-side. | | `countryCode` | string (2-letter) | ISO 3166-1 alpha-2. Length checked; no enum validation. | | `cardData.cardNumber` | string | Required when `paymentMethod=card`. Non-empty check only at this layer — length/Luhn validation occurs downstream. | | `cardData.expMonth` | int | Required when `paymentMethod=card`. Non-nil check only. | | `cardData.expYear` | int | Required when `paymentMethod=card`. Non-nil check only. | | `cardData.cvv` | string | Required when `paymentMethod=card`. Non-empty check only. | ### `POST /initialize-payment` — optional fields | Field | Type | Description | |-------|------|-------------| | `cardData.holderName` | string | Cardholder name. The upstream processor accepts Latin letters (`A-Z`), spaces, apostrophes, dots, and hyphens only. | | `autoSettlement` | bool | Must be `false` if you intend to use the manual capture flow (separate `POST /payments/{id}/capture`). Defaults to `false`. | | `captureMode` | string | `auto` (default) or `manual`. Set to `manual` for a separate capture step. | | `merchantCustomerId` | string | Your identifier for the customer. Stored on the payment as `SellerCustomerRef`. | | `billingDetails` | object | Cardholder billing address. Fields: `firstName`, `lastName`, `email`, `phone`, `addressLine1` (or `street`), `addressLine2`, `city`, `state`, `postalCode` (or `zip`), `countryCode` (or `country`). Optional at the API level, but **the upstream card processor requires `country` and `zip`**. Omitting them returns 502 with `{"error":" ... returned 400"}`. Always populate at least country and zip. | | `deviceSessionId` | string | Optional device-intelligence session id. Create it with `POST /payments-api/v1/device-intelligence/token`, initialize `@sumsub/fisherman` in the customer's browser with the returned `accessToken`, and pass the same `sessionId` here so the compliance transaction can be linked to captured device signals. | | `returnLinks` | array | Where the customer is redirected after a 3DS challenge or other action. **Array** (not the `{success, failure}` object used by `/checkout`) of `{ "rel": , "href": , "method": "GET" }`. Valid `rel` values: `default`, `on_completed`, `on_failed`, `on_cancelled`. If omitted, the API falls back to the return links configured on the seller account; if neither source has at least one valid link, the request returns `400 {"error":"valid returnLinks are required"}`. | | `orderData.payeeDistribution` | array | Split the captured amount across payees. Each element: `{ "payeeId": , "amount": }`. Amounts in major units; sum must equal `amount`. | | `holdPeriod` | int | Hours to keep the authorization (informational). | | `subscription.enabled` | bool | When `true`, the payment opts into the recurring/saved-card flow and the upstream card processor creates a customer record so future `/initialize-payment` calls can charge the same card without re-collecting it. Card-only. Default: `false`. | | `subscription.allowSubsequentMerchantInitiated` | bool | When `true` together with `subscription.enabled`, allows future merchant-initiated charges (recurring billing, top-ups) without the cardholder present. Send `false` for one-tap re-purchase flows where the cardholder is always present. Default: `false`. | | `skip3DS` | bool | When `true`, the upstream payment-handle request is sent with the processor's "skip 3DS" flag and no `threeDs` block — the customer is **not** redirected to a 3DS challenge and the payment auto-finalizes. Default: omitted (standard 3DS flow). Persisted on the payment for audit. **SCA bypass:** in EU/UK, only use this when the payment qualifies for an exemption (merchant-initiated transactions, exempt MOTO, recurring with stored credentials). The flag is honored on every operational mode — do not pass it on Live merchants for fresh customer-present card payments. | > [!CAUTION] > **Fields that do NOT exist on `InitiatePaymentRequest`:** `saveCard`, `customerEmail`, `splitRules` (top-level), `metadata`, `successUrl`, `cancelUrl`. Do not send these. > > **`returnUrl` IS a real top-level field, but it's specific to the `apm` payment type.** It's where the buyer is sent after a redirect-based alternative payment method completes — see [Alternative payment methods (APM)](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm). Card and bank_transfer payments do **not** use `returnUrl`; they use the `returnLinks` array instead. > > **`returnLinks` is an array, not an object.** The `{success, failure}` shape belongs to `/checkout` (`returnUrls`). On `/initialize-payment` the field is `returnLinks: [{rel, href, method}]`. Sending an object — `"returnLinks": {"success": ..., "failure": ...}` — fails JSON decoding and returns `400 {"error":"invalid request body"}`. > [!TIP] > **Testing both 3DS and non-3DS in staging:** the same Bearer token can drive both flows just by toggling `skip3DS`. Send `"skip3DS": true` to skip the redirect (handle returns `PAYABLE`, payment auto-finalizes); omit the field or send `"skip3DS": false` for the standard `requiresAction: true` + `actionUrl` flow. ### `POST /finalize-payment` — fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `paymentId` | string | Yes | The `externalOrderRef` from initialize. Used for lookup. | | `amount` | float | No | Partial capture: provide a value lower than the authorized amount to capture less. Omit to capture the full amount. | | `merchantReference` | string | No | Your internal reference for this capture. Stored at your discretion. | > [!NOTE] > **`Idempotency-Key` on finalize:** the header is required and validated as a UUID, but it is **not used for replay protection** on this endpoint. The payment state machine itself prevents double-finalization — a second call on an already-finalized payment will return an error reflecting the current status. Use the key anyway; the server will reject a missing or malformed one. ## Partial capture If you authorized £100 but only need to capture £80, pass `amount` on `/finalize-payment`: ```json { "paymentId": "ORDER-1042", "amount": 80.00 } ``` The remaining £20 authorization is released to the cardholder's available balance. ## Split payments Pass `orderData.payeeDistribution` on `/initialize-payment` to distribute funds across payees. Retrieve payee IDs from `GET /payments-api/v1/payees`. ```json { "externalOrderRef": "ORDER-1042", "amount": 100.00, "paymentType": "card", "paymentMethod": "card", "currencyCode": "GBP", "countryCode": "GB", "cardData": { "..." : "..." }, "orderData": { "totalAmount": 100.00, "payeeDistribution": [ { "payeeId": 101, "amount": 85.00 }, { "payeeId": 102, "amount": 15.00 } ] } } ``` All amounts in major units. The sum of `payeeDistribution[*].amount` must equal `amount`. ## 3DS handling When the processor requires a 3DS challenge, `/initialize-payment` returns: ```json { "requiresAction": true, "actionUrl": "https://3ds.example.com/challenge/..." } ``` **Browser-based flow:** 1. Redirect the customer's browser to `actionUrl` (full-page redirect, not an iframe). 2. The issuer redirects back to a URL configured in your payment setup after the challenge. 3. Once the customer is back, call `/finalize-payment` with the same `paymentId`. **localStorage bridge pitfall:** Because the redirect is a full-page navigation away from your origin, any in-memory state (React state, session-scoped variables) is lost. If your frontend needs to resume after the redirect, persist the `paymentId` and relevant UI state to `localStorage` or a server-side session before redirecting. Read it back on return and call `/finalize-payment` from there. > [!NOTE] > A deeper 3DS guide — covering flows for Google Pay, Apple Pay, and edge cases — will be published as a separate Wave 2 guide. The above covers the common card path. ## Skipping 3DS For payments that qualify for an SCA exemption (merchant-initiated transactions, exempt MOTO, recurring with stored credentials) — or for staging integration tests where you don't want to drive a browser — pass `"skip3DS": true` on `/initialize-payment`: #### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-1042", "amount": 49.00, "paymentType": "card", "paymentMethod": "card", "currencyCode": "GBP", "countryCode": "GB", "skip3DS": true, "cardData": { "cardNumber": "4111111111111111", "expMonth": 12, "expYear": 2028, "cvv": "123", "holderName": "Jane Doe" }, "billingDetails": { "country": "GB", "zip": "SW1A1AA", "city": "London", "street": "1 Example Street" } }' ``` #### JavaScript ```js await fetch(`${process.env.FYNEX_API}/payments-api/v1/initialize-payment`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: JSON.stringify({ externalOrderRef: order.id, amount: order.amount, paymentType: 'card', paymentMethod: 'card', currencyCode: order.currency, countryCode: order.countryCode, skip3DS: true, cardData: { /* ... */ }, billingDetails: { country: 'GB', zip: 'SW1A1AA', /* ... */ }, }), }); ``` #### Python ```python requests.post( f"{os.environ['FYNEX_API']}/payments-api/v1/initialize-payment", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={ "externalOrderRef": order["id"], "amount": order["amount"], "paymentType": "card", "paymentMethod": "card", "currencyCode": order["currency"], "countryCode": order["country_code"], "skip3DS": True, "cardData": { ... }, "billingDetails": { "country": "GB", "zip": "SW1A1AA", ... }, }, timeout=15, ) ``` **Response shape with `skip3DS: true`:** the upstream payment handle is created in `PAYABLE` state and the payment is auto-finalized by Fynex's upstream status poller (5-second tick), so it typically reaches `provider_completed` within ~5 seconds of `/initialize-payment` returning. The `/initialize-payment` response will contain: ```json { "paymentId": "ORDER-1042", "status": "provider_pending", "amount": 49.00, "currencyCode": "GBP", "requiresAction": false } ``` `actionUrl` is absent. **You do not need to call `/finalize-payment`** — poll `genericPayment(id)` (or wait ~5s for one poller cycle) and the status will be `provider_completed`. > [!CAUTION] > **SCA bypass.** `skip3DS: true` skips Strong Customer Authentication. In EU/UK this is regulated — only use it on payments that genuinely qualify for an exemption. Customer-present card payments on Live merchants almost always require 3DS; do not pass `skip3DS: true` for them. The flag is honored on every operational mode (the API does not gate it by Demo/Live), so it is your responsibility to ensure the request is appropriate. ## Saved cards and recurring payments Saving cards for repeat charges is on the roadmap as a Wave 3 feature. When that guide ships it will cover the card-storage flow for this public API. Do not rely on the legacy `/api/v1/checkout/customer` endpoint documented elsewhere — that is the dashboard's internal path, not part of this public API surface. ## Polling for status `GET /payments-api/v1/payments/{payment_id}` returns the current lifecycle state of a payment. The `payment_id` path segment is your `externalOrderRef` (same convention as `/capture` and `/refund`); if you sent multiple attempts under the same `externalOrderRef`, the **latest** attempt is returned. Auth is the same seller bearer token you already use — no cookie session required. ```bash curl -sS "$FYNEX_API/payments-api/v1/payments/ORDER-1042" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` The response includes the Fynex lifecycle `status`, the originally authorized `amount`, `currencyCode` / `countryCode`, `paymentType` / `paymentMethod`, `externalOrderRef`, the latest `failureCode` / `failureDescription` / `failureStage` if the payment failed, and `createdAt` / `updatedAt` / `failedAt` timestamps. (The raw upstream `providerStatus` is not part of this REST response — it is exposed only on the GraphQL `genericPayment` type.) Treat the following statuses as **terminal**: `provider_completed`, `settled`, `deposit_confirmed`, `refunded`, `failed`, `cancelled`. Everything else is intermediate — keep polling, or wait for the next state-changing call on your side. Alongside the outbound `PaymentCompleted` webhook, this is a reliable way for a server-to-server backend to verify payment outcomes — and a recommended backstop in case a webhook delivery is missed. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) for backoff schedules and resilience patterns. ## Common pitfalls > [!WARNING] > **Do not fulfil the order based on the `/initialize-payment` response alone.** That call returns `202 Accepted` for a new payment — it does not confirm capture. Always call `/finalize-payment` and check `status` before fulfilling. - **`autoSettlement` must be `false` for manual capture.** If you want to call `POST /payments/{id}/capture` separately, initialize with `autoSettlement: false` and `captureMode: "manual"`. The capture endpoint enforces `autoSettlement=false` as a pre-condition. - **`paymentType` and `paymentMethod` must be compatible.** `paymentType: "card"` with `paymentMethod: "bank_account"` returns a 400 validation error. - **`currencyCode` must be one of the supported currencies.** Unlike `/checkout`, `/initialize-payment` enforces the allowlist: EUR, USD, GBP, DKK, NOK, SEK. An unrecognized code returns a 400. - **Idempotency-Key replay on init:** re-sending the same key returns the existing payment as `200 OK` (not `202`). For an active redirect APM with a persisted provider charge, Fynex fetches the existing charge and returns `requiresAction`, `actionUrl`, and `redirectFullPage` again; it does not create a second charge. - **`paymentId` is your `externalOrderRef`.** The path param for `/capture` and `/refund` is the string you passed as `externalOrderRef` on init — not a numeric internal ID. - **Empty or missing `billingDetails` fails at the upstream processor.** The Fynex DTO marks `billingDetails` as optional, but the upstream card processor rejects requests without `country` and `zip` — surfaced to your client as `502 Bad Gateway` with body `{"error":"upstream card processor returned 400"}` and `failureCode: 2002` on the payment record. Always include at least `billingDetails.country` (or `countryCode`) and `billingDetails.zip` (or `postalCode`) on card payments. - **`returnLinks` shape mismatch returns 400.** The field is an **array** of `{rel, href, method}` — not the `{success, failure}` object used by `/checkout`. Sending an object decodes as `400 {"error":"invalid request body"}`. Omitting the field is fine **only** if the seller account has return links configured in the Dashboard; otherwise the request returns `400 {"error":"valid returnLinks are required"}`. Pass `returnLinks` explicitly when you want per-payment overrides. ## See also - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment outcomes without webhooks. - **[Captures & refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — Capture, partially capture, or refund a payment after the fact. - **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make S2S calls resilient to network failures. - **[Apple Pay](https://api.fynex.ai/payments-api/v2/docs#tag/apple-pay)** — Accepting Apple Pay or Google Pay? Use the dedicated guides. - **[Google Pay](https://api.fynex.ai/payments-api/v2/docs#tag/google-pay)** — Accepting Google Pay with server-to-server integration. - **[Alternative payment methods (APM)](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm)** — Accepting Bancontact, Multibanco, MB WAY, Wero, or Swish with `paymentType: "apm"`. ## Apple Pay 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. Fynex accepts Apple Pay two ways: - **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. - **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. Unlike 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. ## Server-to-server API `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`: ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-1042", "amount": 1.23, "paymentType": "card", "paymentMethod": "apple_pay", "currencyCode": "USD", "countryCode": "GB", "returnLinks": [ { "rel": "default", "href": "https://merchant.example/return", "method": "GET" } ], "billingDetails": { "firstName": "John", "lastName": "Doe", "street": "1 Example St", "city": "London", "postalCode": "SW1A1AA", "countryCode": "GB" }, "applePayData": { "label": "Pay with Apple", "requestBillingAddress": true, "paymentToken": { "transactionIdentifier": "", "paymentMethod": { "displayName": "MasterCard 1470", "network": "MasterCard", "type": "credit" }, "decryptedData": { "applicationPrimaryAccountNumber": "", "applicationExpirationDate": "YYMMDD", "currencyCode": "840", "transactionAmount": "123", "onlinePaymentCryptogram": "", "eciIndicator": "2" } } } }' ``` The `applePayData.paymentToken.decryptedData` fields map directly from Apple's `PKPaymentToken.paymentData` after decryption. ### Apple Pay token Every 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. | Field | Required | Notes | |-------|----------|-------| | `paymentToken.transactionIdentifier` | yes | From the Apple Pay token. | | `paymentToken.paymentMethod` | yes | Object presence check. | | `paymentToken.paymentMethod.network` | yes | `Visa`, `MasterCard`, `Amex`, … | | `paymentToken.decryptedData` | yes | Object presence check. | | `decryptedData.applicationPrimaryAccountNumber` | yes | Device PAN (DPAN). | | `decryptedData.applicationExpirationDate` | yes | `YYMMDD`. | | `decryptedData.onlinePaymentCryptogram` | yes | The 3-D Secure cryptogram. | | `decryptedData.version` | no | Apple's `PKPaymentToken` cryptogram version (`EC_v1` / `EC_v2` / `RSA_v1`). Forwarded to the upstream processor when set; not validated. | | `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. | | `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. | ### Header + common body (shared with every paymentMethod) These layers run before the Apple Pay-specific checks: | Field | Format | |-------|--------| | `Idempotency-Key` (HTTP header) | UUID, non-zero | | `externalOrderRef` | non-empty string | | `amount` | number > 0 | | `paymentType` | `"card"` | | `paymentMethod` | `"apple_pay"` | | `currencyCode` | 3-letter ISO, must pass `IsValidCurrencyCode` | | `countryCode` | 2-letter ISO | `paymentType` and `paymentMethod` are additionally checked against each other — an incompatible pair returns `400`. `accountId` is **not** sent by the caller — Fynex resolves the upstream merchant account from the seller's terminal routing. A fresh request returns `202 Accepted`: ```json { "paymentId": "ORDER-1042", "status": "provider_pending", "amount": 1.23, "currencyCode": "USD", "requiresAction": false } ``` When `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. ## Upstream outbound mapping (for debugging processor rejections) The 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. | Fynex DTO path | Upstream outbound path | |----------------|-----------------------| | `applePayData.paymentToken.transactionIdentifier` | `applePay.applePayPaymentToken.token.transactionIdentifier` | | `applePayData.paymentToken.paymentMethod.network` | `applePay.applePayPaymentToken.token.paymentMethod.network` | | `applePayData.paymentToken.decryptedData.version` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.version` | | `applePayData.paymentToken.decryptedData.applicationPrimaryAccountNumber` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.applicationPrimaryAccountNumber` | | `applePayData.paymentToken.decryptedData.applicationExpirationDate` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.applicationExpirationDate` | | `applePayData.paymentToken.decryptedData.onlinePaymentCryptogram` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.paymentData.onlinePaymentCryptogram` (one level deeper) | | `applePayData.paymentToken.decryptedData.eciIndicator` | `applePay.applePayPaymentToken.token.paymentData.decryptedData.paymentData.eciIndicator` (one level deeper) | ### Caveats 1. **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. 2. **`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. 3. **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. 4. **`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. ## Hosted-checkout flow (legacy surface) The 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. ## Prerequisites Before showing the Apple Pay button you need two things in place: 1. **The Apple Pay JS SDK** — load `https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js` before your checkout code runs. 2. **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). ```html ``` ## Flow overview ``` Customer browser Your backend Fynex ───────────────── ───────────── ───── check ApplePaySession available? show "Apple Pay" button customer clicks button ──────────────────────► validate merchant session (call Apple from (production: call Apple your own server — from your server) no Fynex endpoint) ◄── merchantSession completeMerchantValidation (Apple Pay sheet opens) customer authorizes onpaymentauthorized fires applePaymentToken ──────────────────────► POST /api/v1/checkout/ apple-pay-submit ◄── { success, status, requiresAction?, actionUrl? } if requiresAction (3DS): redirect browser to actionUrl (customer authenticates); the hosted page finalizes through the shared checkout contract session.completePayment(ApplePaySession.STATUS_SUCCESS) ``` ## API calls in this flow | Step | Method | Path | |------|--------|------| | 1 | POST | Your own server-side merchant-validation endpoint (calls Apple — there is no Fynex endpoint for this) | | 2 | POST | `/api/v1/checkout/apple-pay-submit` — initializes the payment through the shared checkout pipeline; there is no separate completion call | ## Step-by-step 1. **Check availability and show the button** Gate the Apple Pay button on `ApplePaySession` being present in the browser window. ```js function isApplePayAvailable() { return ( typeof window !== 'undefined' && 'ApplePaySession' in window && ApplePaySession.canMakePayments() ); } if (isApplePayAvailable()) { document.getElementById('apple-pay-button').style.display = 'block'; } ``` 2. **Handle the button click — open an `ApplePaySession`** 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. ```js async function startApplePayPayment({ amount, currencyCode }) { const paymentRequest = { countryCode: 'US', // current limitation — see note below currencyCode, merchantCapabilities: ['supports3DS'], supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], total: { label: 'Your business name', amount: String(amount), }, }; const session = new ApplePaySession(3, paymentRequest); // Step 3: merchant validation session.onvalidatemerchant = async (event) => { try { const merchantSession = await validateMerchantWithServer( event.validationURL ); session.completeMerchantValidation(merchantSession); } catch (err) { session.abort(); throw err; } }; // Step 4: customer authorizes — submit to Fynex session.onpaymentauthorized = async (event) => { const applePayToken = event.payment.token; try { await submitAndCapture({ applePayToken, amount, currencyCode }); session.completePayment(ApplePaySession.STATUS_SUCCESS); } catch (err) { session.completePayment(ApplePaySession.STATUS_FAILURE); throw err; } }; session.begin(); } ``` > [!CAUTION] > **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. 3. **Validate the merchant session (server-side)** 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. For a real integration you must implement a server-side endpoint that: - Accepts the `validationURL` from the browser - Calls Apple's merchant validation endpoint using your Apple Pay merchant certificate and private key - Returns the opaque merchant session object to the browser ```js // Browser: call your own server-side validation endpoint async function validateMerchantWithServer(validationURL) { const res = await fetch('/api/apple-pay/validate-merchant', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ validationURL }), }); if (!res.ok) throw new Error('Merchant validation failed'); return res.json(); // returns the opaque merchant session from Apple } ``` > [!NOTE] > Fynex does **not** expose a merchant-session-validation endpoint. Merchant validation must call Apple's `validationURL` directly from your own server using your Apple Pay merchant certificate and private key — the browser cannot make this call due to CORS. Return the opaque merchant session from your endpoint (as shown in `validateMerchantWithServer` above) and hand it to `session.completeMerchantValidation(...)`. 4. **Submit the Apple Pay token to Fynex** Once the customer authorizes the payment, `onpaymentauthorized` fires with an encrypted `applePayToken`. Submit it to Fynex: #### curl ```bash curl -sS -X POST "$FYNEX_LEGACY_API/api/v1/checkout/apple-pay-submit" \ -H "Content-Type: application/json" \ -b "session_id=$SESSION_COOKIE" \ -d '{ "sessionId": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "token": { "...": "encrypted token from Apple" } }' ``` #### JavaScript ```js async function submitApplePay({ sessionId, token }) { const res = await fetch( `${FYNEX_LEGACY_API}/api/v1/checkout/apple-pay-submit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ sessionId, token }), } ); if (!res.ok) throw new Error(`Submit failed: ${res.status}`); return res.json(); // Response: { success, message, status, failureCode, failureDescription, requiresAction?, actionUrl? } } ``` The response contains: | Field | Type | Notes | |-------|------|-------| | `success` | boolean | Whether the payment was initialized | | `status` | string | The payment's lifecycle status after initialization | | `failureCode` / `failureDescription` | int / string | Set when initialization failed — see [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) | | `requiresAction` | boolean? | `true` when the issuer requires 3DS | | `actionUrl` | string? | Where to send the browser when `requiresAction` is true | 5. **Let the hosted page finish** There is no completion call. `apple-pay-submit` already initialized the payment through the same pipeline a card payment takes, and the response's `status` tells you where it stands. If `requiresAction` is `true`, send the browser to `actionUrl` for 3DS; the hosted checkout page finalizes the payment on return through the shared checkout contract and redirects to your `returnUrls`. The former `complete-apple-pay-payment` route bypassed the platform's antifraud and compliance gates and has been removed. ```js // Putting it together async function submitAndFinish({ sessionId, token }) { const result = await submitApplePay({ sessionId, token }); if (!result.success) { throw new Error(`Apple Pay failed (${result.failureCode}): ${result.failureDescription}`); } if (result.requiresAction && result.actionUrl) { window.location.href = result.actionUrl; // 3DS — the hosted page completes the payment on return return; } return result.status; // e.g. provider_pending / provider_completed — confirm via webhook or GET /payments/{id} } ``` ## Common pitfalls > [!WARNING] > **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. - **Apple Pay only shows in Safari on Apple devices.** Always gate the button on `isApplePayAvailable()`. Chrome and Firefox do not support `ApplePaySession`. - **The country code is currently hardcoded to `US`.** If your merchant account is registered in another country, override `countryCode` in the `ApplePaySession` request. - **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](https://api.fynex.ai/payments-api/v2/docs#tag/3ds) for the full pattern. - **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. ## See also - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — The lowest-PCI path to accepting payments — Fynex-hosted page handles card entry. - **[Server-to-Server Payments](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Direct card submission for integrations that collect card data on their own infrastructure. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes and error response shapes. ## Google Pay Fynex supports three ways to accept Google Pay: - **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. - **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. - **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. > [!CAUTION] > **Availability today (2026-05-13)** > > All three flows are enabled on `staging-api.fynex.ai`. **Production** (`api.fynex.ai`) is not yet running for Direct API; contact Fynex support before you cut over. ## Choosing an integration model #### Hosted Checkout (recommended) - You call `POST /payments-api/v1/checkout`, redirect the customer, receive the outcome by webhook or polling. - Fynex handles `isReadyToPay`, the GP button, the encrypted token, decryption, and the 3DS step-up. - Lowest PCI scope. - No Google Pay Console registration required on your side — Fynex's platform merchant ID is used. #### Encrypted Direct API - You render the Google Pay button using the Google Pay JS SDK on your own page. - You forward `paymentData.paymentMethodData.tokenizationData.token` (a signed string) verbatim to `POST /initialize-payment`. - Fynex hands the signed token to the upstream processor, which decrypts it server-side. - You don't run ECv2 decryption and don't hold private keys. - Recommended when Hosted Checkout doesn't fit your UX. #### Decrypted Direct API - You register your own Google Pay merchant in the Google Pay Business Console and own the DIRECT-mode key pair. - Your frontend calls `paymentsClient.loadPaymentData(...)` with `tokenizationSpecification.type = 'DIRECT'`; your backend decrypts the JWE. - You send the **decrypted** payload (PAN, cryptogram, expiry, …) to `POST /initialize-payment`. - For integrators who already run ECv2 decryption pipelines. If 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. ### Why Encrypted is the default Direct API recommendation Both 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: | Concern | Encrypted | Decrypted | |---|---|---| | Card-data PCI scope | No PAN exposure — opaque ciphertext only (typically SAQ A-EP) | PAN/cryptogram pass through your process memory (SAQ D-Merchant) | | Private-key management | None | You generate, store, rotate an ECv2 EC P-256 key pair | | Server-side crypto code | None — forward the signed string verbatim | You implement ECv2 decryption (signed-then-encrypted, AES-CTR + HMAC-SHA256) | | Google Pay Console setup | Use Fynex's registered gateway (`fondyeu`) — fast | Register your own merchant + public key — slower | | Breach blast radius | Attacker can't decrypt without the gateway's key | Attacker can read decrypted PANs from memory / heap dumps | | Time to integrate | Forward one string, get a payment handle | Build + audit a crypto pipeline | The 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. ## Hosted Checkout The 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. ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/checkout" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-1042", "amount": 49.99, "currencyCode": "GBP", "countryCode": "GB", "description": "Order #1042", "returnUrls": { "success": "https://merchant.example/orders/1042/success", "failure": "https://merchant.example/orders/1042/failure" } }' ``` Response (truncated to the field you care about): ```json { "sessionId": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "checkoutUrl": "https://pay.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "expiresAt": "..." } ``` Redirect the customer to `checkoutUrl`. The page that loads will display: - A card-entry form (always). - A **Google Pay** button — only if all of the following are true: - Fynex has Google Pay enabled for the deploy your checkout session was created on. - The customer's browser supports Google Pay (`PaymentsClient.isReadyToPay()` returns true). - The customer has at least one saved card in their Google account that Google Pay can tokenize. - 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. See [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) for the full request reference, return-URL behavior, and webhook delivery. ### Customer-side flow 1. **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. 2. **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. 3. **Customer clicks Google Pay.** The native Google Pay sheet opens; the customer picks a card and authorizes. 4. **The page POSTs the encrypted GP token to `/api/v1/checkout/google-pay-submit`** along with the `sessionId`. The handler: - 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. - 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. - Receives back a `paymentHandleToken` and `merchantRefNum`. 5. **The page completes the payment through the shared checkout contract** — the same initialize/finalize path a card payment takes, using the `paymentHandleToken` and `merchantRefNum` from step 4. There is no separate `complete-google-pay-payment` call: that legacy route bypassed the platform's antifraud and compliance gates and has been removed. The outcome is mirrored onto the payment (`provider_completed` on success, with `providerPaymentId` set). 6. **Customer is redirected** to your `returnUrls.success` or `.failure` URL, exactly as for a card payment. > [!NOTE] > 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. ## Encrypted Direct API This 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. ### Prerequisites 1. Your seller account has `google_pay` in `allowedPaymentMethods` (check via `GET /payments-api/v1/payment-methods`). 2. 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. 3. Checkout currency supported (`USD`, `EUR`, `GBP`). ### Step 1: Get a token from Google In 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): ```javascript const paymentsClient = new google.payments.api.PaymentsClient({ environment: 'PRODUCTION' }); const paymentDataRequest = { apiVersion: 2, apiVersionMinor: 0, allowedPaymentMethods: [{ type: 'CARD', parameters: { allowedAuthMethods: ['PAN_ONLY', 'CRYPTOGRAM_3DS'], allowedCardNetworks: ['VISA', 'MASTERCARD', 'AMEX', 'DISCOVER'], }, tokenizationSpecification: { type: 'PAYMENT_GATEWAY', parameters: { gateway: 'fondyeu', gatewayMerchantId: '', }, }, }], transactionInfo: { totalPriceStatus: 'FINAL', totalPrice: '49.99', currencyCode: 'GBP', }, merchantInfo: { merchantId: '', merchantName: 'Your Store', }, }; const paymentData = await paymentsClient.loadPaymentData(paymentDataRequest); // paymentData.paymentMethodData.tokenizationData.token is the JSON-encoded // signed-and-encrypted payload — pass it through verbatim. const signedToken = paymentData.paymentMethodData.tokenizationData.token; ``` ### Step 2: Call `/initialize-payment` with the signed token `POST /payments-api/v1/initialize-payment` (headers: `Authorization: Bearer `, `Idempotency-Key: `): ```json { "externalOrderRef": "ORDER-100106", "amount": 49.99, "paymentType": "card", "paymentMethod": "google_pay", "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": true, "googlePayData": { "paymentToken": { "apiVersion": 2, "apiVersionMinor": 0, "paymentMethodData": { "type": "CARD", "description": "Visa •••• 1234", "info": { "cardNetwork": "VISA", "cardDetails": "1234" }, "tokenizationData": { "type": "PAYMENT_GATEWAY", "token": "" } } } }, "billingDetails": { "firstName": "John", "lastName": "Doe", "email": "john.doe@example.com", "addressLine1": "1 Example Street", "city": "London", "countryCode": "GB", "postalCode": "SW1A1AA" }, "returnLinks": [ { "rel": "default", "href": "https://merchant.example/payments/return", "method": "GET" } ] } ``` Validation rules: - Exactly one of `tokenizationData.token` (encrypted) or `tokenizationData.decryptedToken` (decrypted, see below) must be present. Providing both returns `400`. - `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`. - 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"}`. Response and capture flow are the same as the decrypted variant — see [Common response shape](#common-response-shape) below. ## Decrypted Direct API Use 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. ### Prerequisites (in addition to Encrypted) - Your own Google Pay merchant in the Google Pay Business Console. - A DIRECT-mode key pair you registered with Google. Fynex never holds your private key. - A server-side ECv2 decryption library. ### Step 1: Get and decrypt a token from Google Same JS SDK call as the Encrypted variant, but with `tokenizationSpecification.type = 'DIRECT'` and your `publicKey`: ```javascript tokenizationSpecification: { type: 'DIRECT', parameters: { protocolVersion: 'ECv2', publicKey: '', }, } ``` Decrypt `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: ```json { "messageId": "AH2EjtfX...", "messageExpiration": "1715620000000", "paymentMethod": "CARD", "paymentMethodDetails": { "authMethod": "CRYPTOGRAM_3DS", "pan": "4111111111111111", "expirationMonth": 12, "expirationYear": 2028, "cryptogram": "AgAAAAAABk4...", "eciIndicator": "05" } } ``` ### Step 2: Call `/initialize-payment` with the decrypted payload ```json { "externalOrderRef": "ORDER-100106", "amount": 49.99, "paymentType": "card", "paymentMethod": "google_pay", "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": true, "googlePayData": { "paymentToken": { "apiVersion": 2, "apiVersionMinor": 0, "paymentMethodData": { "type": "CARD", "description": "Visa •••• 1234", "info": { "cardNetwork": "VISA", "cardDetails": "1234" }, "tokenizationData": { "type": "DIRECT", "decryptedToken": { "messageId": "AH2EjtfX...", "messageExpiration": "1715620000000", "paymentMethod": "CARD", "paymentMethodDetails": { "authMethod": "CRYPTOGRAM_3DS", "pan": "4111111111111111", "expirationMonth": 12, "expirationYear": 2028, "cryptogram": "AgAAAAAABk4...", "eciIndicator": "05" } } } } } }, "billingDetails": { "...": "same as Encrypted" }, "returnLinks": [ { "rel": "default", "href": "https://merchant.example/payments/return", "method": "GET" } ] } ``` Server-side validation for the decrypted variant: - `pan` is **required**. - `cryptogram` is **required when `authMethod` is `CRYPTOGRAM_3DS`**. `PAN_ONLY` tokens carry no network cryptogram and are accepted on this flow without one. - `expirationMonth` and `expirationYear` are **required**. - `eciIndicator` is recommended for `CRYPTOGRAM_3DS` but not enforced by Fynex. ## Common response shape Both Direct API models share the same response. `POST /initialize-payment` returns `202 Accepted`: ```json { "paymentId": "ORDER-100106", "status": "provider_pending", "amount": 49.99, "currencyCode": "GBP", "requiresAction": true, "actionUrl": "https://example-acs.test/challenge" } ``` - When `requiresAction = true`, redirect the customer to `actionUrl` to complete the 3DS challenge. After the challenge, the payment moves through `provider_pending` to `provider_completed`. - When `requiresAction = false`, the handle is ready for capture. Then `POST /finalize-payment` (reuse the same `Idempotency-Key` or generate a fresh UUID — both work): ```json { "paymentId": "ORDER-100106" } ``` `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). Response `200 OK`: ```json { "paymentId": "ORDER-100106", "status": "provider_completed", "providerPaymentId": "pay_01J2EXAMPLE", "providerCode": "pp_01", "capturedAmount": 49.99, "currencyCode": "GBP", "failureCode": 0, "failureDescription": "" } ``` ## `googlePayData` field reference ### `tokenizationData` — Encrypted (model B) | Field | Type | Required | Description | |---------|--------|----------|--------------------------------------------------------------------------| | `type` | string | yes | Must be `"PAYMENT_GATEWAY"` for the encrypted flow. | | `token` | string | yes | Raw signed token from `paymentData.paymentMethodData.tokenizationData.token`. JSON-encoded string. | ### `tokenizationData` — Decrypted (model C) | Field | Type | Required | Description | |-------------------|--------|----------|--------------------------------------------| | `type` | string | yes | Must be `"DIRECT"` for the decrypted flow. | | `decryptedToken` | object | yes | Decrypted Google Pay payload — see below. | Send **exactly one** of `token` or `decryptedToken` per request. `type` is required and must match the payload kind — mismatches return `400`. ### `decryptedToken` | Field | Type | Required | Description | |----------------------------|--------|----------|--------------------------------------------------------------| | `gatewayMerchantId` | string | no | Echoed verbatim to the upstream processor. | | `messageId` | string | yes (processor contract) | `messageId` from the decrypted payload. Forwarded, but not enforced by the Fynex validator. | | `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. | | `paymentMethod` | string | yes (processor contract) | Always `"CARD"`. Forwarded, but not enforced by the Fynex validator. | | `paymentMethodDetails` | object | yes | See below. Missing returns `400`. | ### `paymentMethodDetails` | Field | Type | Required | Description | |-------------------|--------|----------|-------------------------------------------------------------------| | `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. | | `pan` | string | yes | Decrypted card PAN. Missing returns `400`. | | `expirationMonth` | int | yes (> 0) | 1–12. Zero or missing returns `400`. | | `expirationYear` | int | yes (> 0) | Full year (e.g. `2028`). Zero or missing returns `400`. | | `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. | | `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". | ### `billingDetails` (top level of `InitiatePaymentRequest`) Required for AVS. Minimum fields: `countryCode` (alias `country`) and `postalCode` (alias `zip`). ### Parent-object presence checks Before 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: - `googlePayData` - `googlePayData.paymentToken` - `googlePayData.paymentToken.paymentMethodData` - `googlePayData.paymentToken.paymentMethodData.tokenizationData` - `tokenizationData.token` xor `tokenizationData.decryptedToken` — send exactly one; neither or both returns `400` - `tokenizationData.type == "PAYMENT_GATEWAY"` when `token` is set - `tokenizationData.type == "DIRECT"` when `decryptedToken` is set ### Header + common body (shared with every paymentMethod) | Field | Format | |-------|--------| | `Idempotency-Key` (HTTP header) | UUID, non-zero | | `externalOrderRef` | non-empty string | | `amount` | number > 0 | | `paymentType` | `"card"` | | `paymentMethod` | `"google_pay"` | | `currencyCode` | 3-letter ISO, must pass `IsValidCurrencyCode` | | `countryCode` | 2-letter ISO | `paymentType` and `paymentMethod` are additionally checked against each other — an incompatible pair returns `400`. ## Upstream outbound mapping (for debugging processor rejections) Unlike 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`. | Fynex DTO path | Upstream outbound path | |----------------|-----------------------| | `googlePayData.paymentToken.apiVersion` | `googlePay.googlePayPaymentToken.apiVersion` | | `googlePayData.paymentToken.apiVersionMinor` | `googlePay.googlePayPaymentToken.apiVersionMinor` | | `googlePayData.paymentToken.paymentMethodData.description` | `googlePay.googlePayPaymentToken.paymentMethodData.description` | | `…tokenizationData.type` | `…tokenizationData.type` | | `…tokenizationData.decryptedToken.paymentMethodDetails.pan` | `…tokenizationData.decryptedToken.paymentMethodDetails.pan` | | `…paymentMethodDetails.expirationMonth` | `…paymentMethodDetails.expirationMonth` | | `…paymentMethodDetails.expirationYear` | `…paymentMethodDetails.expirationYear` | | `…paymentMethodDetails.cryptogram` | `…paymentMethodDetails.cryptogram` (sent only when `authMethod` is `CRYPTOGRAM_3DS`, matched case-insensitively) | | `…paymentMethodDetails.eciIndicator` | `…paymentMethodDetails.eciIndicator` (sent only when `authMethod` is `CRYPTOGRAM_3DS`, matched case-insensitively) | ### Caveats 1. **`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. 2. **`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. 3. **`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. 4. **`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. ## 3DS behavior Google Pay tokens carry one of two auth methods: | Auth method | Source | What you'll see | |---|---|---| | `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. | | `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. | > [!CAUTION] > Do not set `skip3DS` for Google Pay in live traffic. SCA exemptions are tied to MIT/MOTO flows, which Google Pay does not match. ## Payment lifecycle (simplified) ``` draft -> provider_pending -> provider_completed -> settled -> failed -> cancelled ``` | Status | Meaning | |-----------------------|--------------------------------------------------------------------------| | `draft` | Payment created, not yet submitted to the provider. | | `provider_pending` | Submitted to the provider; awaiting result, 3DS, or capture. | | `provider_completed` | Authorized and captured (or auth+capture in auto-settlement mode). | | `settled` | Funds reached settlement. | | `failed` | Declined by the provider, the bank, or the 3DS issuer. | | `cancelled` | Cancelled by the customer (e.g. abandoned 3DS). | Direct API payments start at `provider_pending`; `draft` is used internally by Hosted Checkout before submission. Intermediate statuses like `new`, `routed`, `authorized`, `provider_risk_review`, `funds_in_flight`, and `deposit_confirmed` may also surface on polling. Post-settlement refund flow has its own statuses (`refund_pending`, `refunded`, `refund_failed`, `refund_cancelled`) — see [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds). ## Verifying the outcome Same as any payment — three options, in decreasing order of recommendation: 1. **Webhook** (`POST /payments-api/v1/webhooks` to register a URL once; Fynex will post `payment.*` events when state changes). See [Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks). 2. **REST poll** — `GET /payments-api/v1/payments/{payment_id}` with your bearer token. The `payment_id` path parameter is your `externalOrderRef`. Terminal statuses: `provider_completed`, `settled`, `deposit_confirmed`, `refunded`, `failed`, `cancelled`. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse). 3. **GraphQL** — `genericPayment(id: Int!)` on `/dashboard/graphql` (cookie-auth only — for dashboard integrations, not server-to-server). The `paymentMethod` field on the response will be `card` regardless of whether the customer used Google Pay or a typed card; the underlying card is always charged via the upstream card processor. To distinguish Google Pay payments at the data level, check the `providerCode` field on the payment response together with any internal markers your dashboard surfaces. ## Testing - **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). - **3DS**: the upstream sandbox lets you replay both success and failure challenges. ### Test cards Google Pay's TEST environment returns a sandbox PAN that is then authorized on the upstream card processor's sandbox. Google Pay tokens are network-tokenized, so the upstream handle is created with 3DS skipped — Google Pay payments authorize without a separate 3DS redirect. Tokenize any PAN the upstream sandbox recognises; for example the universal Visa test PAN `4111 1111 1111 1111`, or any of the PANs listed in [Test cards & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards). Expiry: any future month/year (4-digit year recommended, e.g. `2028`). CVV: any 3-digit value (4-digit for AmEx). > [!CAUTION] > The card number does not select the authorization outcome on this sandbox. To simulate a > decline, use the processor's sandbox simulation rules — ask your Fynex contact for the > current values. Do not assume a particular PAN maps to "frictionless" vs "challenge" vs > "decline". ## Troubleshooting ### The Google Pay button doesn't appear on the hosted checkout page In order of frequency: 1. **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. 2. **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. 3. **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. 4. **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. ### `/initialize-payment` returns 400 "...either token or decryptedToken, not both" You supplied both `tokenizationData.token` and `tokenizationData.decryptedToken`. Pick one path and remove the other. ### `/initialize-payment` returns 400 "...token or .decryptedToken is required" You supplied a `tokenizationData` block but neither `token` nor `decryptedToken`. Add the matching field for the model you're using. ### `/initialize-payment` returns 400 "cryptogram is required" You'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. ### `/initialize-payment` returns 502 "upstream card processor returned 400" Most often: missing `countryCode` or `postalCode` in the top-level `billingDetails`. AVS scoring is performed against `billingDetails`, not the address embedded inside `googlePayData`. ### `/initialize-payment` returns 409 `Idempotency-Key` reused with different business intent (`amount`, `currencyCode`, `externalOrderRef`, …). Use a fresh UUID v4. ### The customer authorized Google Pay, but the order page says "failed" Look at the payment's `failureStage`: - `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. - `failureStage: "settlement"` — the handle was authorized but the settlement call failed. Usually a transient upstream issue; retry the order with a fresh `externalOrderRef`. - 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. ## Production checklist For the Encrypted Direct API (recommended for non-hosted UIs): 1. Google Pay merchant ID registered in the Google Pay Business Console. 2. Domains that render the Google Pay button whitelisted in the Google Pay Console. 3. Production seller API token issued (`Authorization: Bearer ...`). 4. `google_pay` enabled for the seller account in the Fynex dashboard. 5. `returnLinks` configured (success / failure URLs). 6. Webhook registered for final status delivery, or polling implemented via `GET /payments/{payment_id}`. For the Decrypted Direct API, additionally: 7. Server-side DIRECT-mode decryption library in place that can handle ECv2 payloads, and your own DIRECT-mode key pair registered with Google. ## See also - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — The seller-facing API that creates the checkout session Google Pay runs inside. - **[Apple Pay](https://api.fynex.ai/payments-api/v2/docs#tag/apple-pay)** — Apple Pay is decrypted-flow today — Fynex receives the decrypted token. See the Apple Pay guide for the payload shape. - **[3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds)** — How 3DS step-up works inside Hosted Checkout and Direct API flows. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify the payment outcome from your backend. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes and the per-seller rate-limit headers. ## Alternative payment methods (APM) Alternative payment methods (APMs) are the local, non-card ways buyers pay in their own market — Bancontact in Belgium, Multibanco and MB WAY in Portugal, Wero in Belgium, Germany, and France, and Swish in Sweden. Fynex offers them through a single payment provider that fronts all of these schemes, on a dedicated payment rail (`apm`) that is distinct from the `card` rail that carries card, Apple Pay, and Google Pay. APMs are a sibling to Apple Pay and Google Pay in that they are wallet-like, buyer-initiated methods you surface at checkout — but they are **architecturally different**. There is no card token to decrypt and no card payload to forward. Most methods send the buyer to the scheme (a bank-selection page, an app handoff, or a QR code) to authorize. Multibanco can instead return an Entity and Reference that your own checkout renders for the buyer. Fynex accepts APMs two ways: - **Server-to-server API** — call `POST /payments-api/v1/initialize-payment` with `paymentType: "apm"` and the APM instrument from your own backend. Follow the redirect action Fynex returns, or render the structured payment instructions returned for Multibanco. Bearer-authenticated, the same endpoint used for card / Apple Pay / Google Pay. See [Server-to-server API](#server-to-server-api) below. - **Fynex-hosted checkout** — create a checkout session as you would for a card checkout, and the Fynex-hosted page renders whichever APM tiles are available; the redirect handshake happens inside that page. See [Hosted checkout](#hosted-checkout) below. Both paths use the same asynchronous-settlement behavior. Redirect flows also converge on the same Fynex-hosted return step. ## Instruments and availability Five instruments are supported. Each is only offered for the currency (and, where the scheme is country-bound, country) combinations the provider actually supports it on: | Instrument | Wire value | Currency | Country | Notes | |------------|-----------|----------|---------|-------| | Bancontact | `bancontact` | `EUR` | `BE` | Belgium's domestic card/bank scheme. | | Multibanco | `multibanco` | `EUR` | `PT` | Portuguese reference-number / voucher payment. | | 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. | | Wero | `wero` | `EUR` | `BE`, `DE`, `FR` | European account-to-account wallet. Requests outside these three countries are rejected before routing. | | Swish | `swish` | `SEK` | `SE` | Swedish mobile payment. | A `EUR`/`BE` checkout therefore offers Bancontact **and** Wero; `EUR`/`DE` and `EUR`/`FR` offer Wero; `EUR`/`PT` offers Multibanco and MB WAY; a `SEK`/`SE` checkout offers Swish. Wero is not offered for other EUR countries, including Italy. > [!NOTE] > This currency/country matrix is the *ceiling* of what a checkout can show. The actual set a > given buyer sees is narrower — it is also gated on your account being routable for that > method. See [How hosted-checkout availability is decided](#how-hosted-checkout-availability-is-decided). ### How the buyer completes each method The buyer action differs by method and integration style. It matters especially when you test: - **Bancontact** completes on the redirect page itself — the buyer authorizes there and comes straight back. - **MB WAY** completes on the redirect page by default. Server-to-server, you can instead keep the buyer on your own checkout: send `apmData.phone` and the authorization is pushed to that number's MB WAY app, so there is nothing to redirect to. See [Keeping the buyer on your checkout with MB WAY](#keeping-the-buyer-on-your-checkout-with-mb-way). - **Multibanco** server-to-server initiation can return `paymentInstructions` containing an Entity and Reference. Render both with the response's top-level amount and currency; the buyer completes the payment through Portuguese online banking or an ATM. Fynex-hosted checkout continues to handle the provider redirect flow for you. - **Wero hands off to a Wero code / QR flow.** In live, the buyer approves in their Wero app. In sandbox, you can complete the flow with the Wero UAT test site — see [Testing APMs in sandbox](#testing-apms-in-sandbox). - **Swish hands off to the buyer's Swish app and BankID.** There is no card-number-entry fallback. Sandbox completion needs Swish test credentials provisioned by Fynex / the provider; otherwise the payment can remain `provider_pending` even though your redirect integration is working. > [!IMPORTANT] > **Returned to your site is not the same as paid.** APMs are asynchronous. The buyer can land > back on your `returnUrl` while the scheme is still processing, or while an app-based method > is still waiting for approval. Treat the return as a UX step only; confirm the final outcome > with webhooks or `GET /payments-api/v1/payments/{payment_id}`. ## Testing APMs in sandbox Use a **Demo** account and its token. Whether APM sandboxes are reached is decided by the account's operational mode, not the host — Demo accounts exist on both `https://staging-api.fynex.ai` and `https://api.fynex.ai`. Call the base URL your token was issued for: the two environments keep separate credential stores, so a token only authenticates against its own host. Do not send these test values to production. Your seller account still needs APMs enabled and routable: `GET /payments-api/v1/payment-methods` should include `apm` in `allowedPaymentRails` and the specific instrument in `allowedPaymentMethods`. You can test with either integration style: 1. **Server-to-server:** call `POST /payments-api/v1/initialize-payment` with `paymentType: "apm"`, a unique `externalOrderRef`, a unique `Idempotency-Key`, and the method/currency/country from the table below. The response should be `202 Accepted` with `requiresAction: true`. Redirect methods include `redirectFullPage: true` and an `actionUrl`; Multibanco includes `paymentInstructions` and can also include the redirect. 2. **Hosted checkout:** create a checkout session in staging, open the hosted checkout URL, and choose the APM tile. The tile only appears if the checkout's currency/country and your seller routing both support the method. 3. **Complete the buyer action:** for redirect methods, open `actionUrl` in a top-level browser window (not an iframe). For Multibanco, render the returned Entity and Reference. Then wait for the webhook or poll `GET /payments-api/v1/payments/{payment_id}` until terminal. | Method | Request values | How to complete a sandbox payment | Failure-path test | |--------|----------------|-----------------------------------|-------------------| | 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. | | 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. | | 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. | | 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**. | | Swish | `paymentMethod: "swish"`, `currencyCode: "SEK"`, `countryCode: "SE"` | Swish sandbox completion requires provider-issued Swish test credentials / app access. If your sandbox account has them, complete the Swish app / BankID flow after redirect. | Use the provider-issued Swish failure path, or abandon the flow and verify that your integration handles `provider_pending` correctly. | > [!NOTE] > Bancontact is the only APM with static public card-like test PANs. Multibanco and MB WAY > run on the provider's mock authorizer (approve/deny buttons, plus the `777123` / > `+11111111112` test values above where prompted), Wero uses the Wero UAT consent site, and > Swish test credentials are provisioned separately — the scheme publishes none for sandbox. > If you need Swish credentials, contact Fynex support before scheduling your sandbox test. > The underlying scheme and provider sandboxes are hosted outside Fynex and can be > intermittently flaky. If the API times out or returns `502` before you receive a definitive > payment result, retry the unchanged request with the same `externalOrderRef` and > `Idempotency-Key`; Fynex reconciles the same provider attempt and re-surfaces the current > buyer action (a redirect and/or Multibanco payment instructions). While that reconciliation > is still active, the unchanged retry can return `503` > with `provider create is pending reconciliation`; continue retrying the same request rather > than minting a new key. A `409` with `provider create requires manual reconciliation` means > automated recovery has stopped and Fynex support must resolve the provider charge. After a > definitive terminal `failed` result, start a new payment attempt with a fresh pair. ### Suggested sandbox amounts Use small, realistic amounts such as `10.00` in the method's currency. Avoid huge values in sandbox unless Fynex support has asked you to test a specific limit or edge case. For each scenario, use a fresh `externalOrderRef` and `Idempotency-Key`; reusing an idempotency key is only for retrying the exact same payment attempt. ### What a successful test proves A complete APM sandbox test proves that: - your seller token is valid in staging; - your seller account is configured for the `apm` rail and the chosen instrument; - your `returnUrl` host is allow-listed when you send one; - your frontend follows `actionUrl` as a full-page redirect, or renders complete Multibanco Entity/Reference instructions when returned; and - your system waits for a terminal status via webhook or polling instead of treating the browser return as final payment success. If a method does not appear on hosted checkout or `initialize-payment` rejects it, first check the method/currency/country combination and `GET /payments-api/v1/payment-methods`. An unsupported combination is rejected with `400` before Fynex creates or routes a payment; for example, Wero with `countryCode: "IT"` is invalid. If the buyer returns to your site with `status=pending`, keep polling or wait for your webhook. That is normal for asynchronous APMs. ## Server-to-server API Submit the payment directly from your backend to `POST /payments-api/v1/initialize-payment`, authenticated with your seller `Authorization: Bearer` token. Set `paymentType` to `apm` and `paymentMethod` to one of the five APM instruments. No card, wallet, or billing data is required — the buyer authenticates at the scheme, not on your form. ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-1042", "amount": 49.99, "paymentType": "apm", "paymentMethod": "bancontact", "currencyCode": "EUR", "countryCode": "BE", "returnUrl": "https://merchant.example/checkout/return" }' ``` `paymentMethod` must be one of `bancontact`, `multibanco`, `mbway`, `wero`, or `swish`, and it must be compatible with the currency/country you send — Fynex routes on the currency/country matrix (see [Instruments and availability](#instruments-and-availability)) and on your `apm`-rail terminals. An instrument that isn't compatible with `paymentType: "apm"` returns `400`. An otherwise valid APM whose currency/country is outside its supported matrix also returns `400`, for example `paymentMethod wero is not supported for currency EUR and country IT`. The example's `returnUrl` host (`merchant.example`) must be allow-listed on your seller account first — see the note below. > [!IMPORTANT] > **`returnUrl` is optional only when another valid return-link source exists.** You may omit > it when you send an explicit `returnLinks` array or your seller account already has default > return links. If the request has neither and the seller has no defaults, initialization > returns `400` (`"valid returnLinks are required"`). The Fynex-hosted status page is a > fallback after a valid return configuration has been resolved; omitting `returnUrl` alone > does not create that configuration. > > **When you do send `returnUrl`, this path fails closed.** Your seller account must have an > **allowed-return-hosts whitelist** configured, and the host of `returnUrl` must be on it. A > `returnUrl` whose host isn't allow-listed — *or any `returnUrl` sent before you've configured > any allowed hosts at all* — returns `400` (`"returnUrl cannot be used until allowed return > hosts are configured"`). This fail-closed rule (stricter than the hosted-checkout > success/failure URLs, which are opt-in) is what keeps the later return-step redirect a safe, > pre-validated one rather than an open redirect. Configure your allowed return hosts before > going live. > [!NOTE] > **`returnUrl` alone is all you need.** Fynex derives a single default return link > (`{ "rel": "default", "href": , "method": "GET" }`) from it, so the redirect > provider sends the buyer back to that URL for every outcome. If you need per-outcome > destinations — a different landing page for completed vs. failed vs. cancelled — send an > explicit `returnLinks` array instead, each entry a `{ "rel", "href", "method": "GET" }` > object with `rel` one of `default`, `on_completed`, `on_failed`, or `on_cancelled`. When you > send `returnLinks`, it takes precedence and `returnUrl` is not used to derive a link (it still > drives the Fynex-hosted status-page fallback). You don't need both: `returnUrl` covers the > common single-destination case. ### Response — follow the returned buyer action A fresh request returns `202 Accepted`: ```json { "paymentId": "ORDER-1042", "status": "provider_pending", "amount": 49.99, "currencyCode": "EUR", "requiresAction": true, "actionUrl": "https://redirect.provider.example/checkout/abc123", "redirectFullPage": true } ``` | Field | Type | Meaning for APMs | |-------|------|------------------| | `requiresAction` | boolean | `true` for a successful APM initiation while the buyer still needs to act. | | `actionUrl` | string | Provider redirect URL when available. Send the buyer's browser here for redirect flows. | | `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. | | `redirectFullPage` | boolean | When `true`, redirect the whole page (navigate the top-level window), not an iframe. | | `status` | string | `provider_pending` until the scheme reports back. | | `paymentId` | string | Your `externalOrderRef`; use it to poll the outcome. | For a redirect response, your UI drives the redirect — Fynex does not redirect the buyer for you on this path. Send the browser to `actionUrl` using a full-page navigation when `redirectFullPage` is true. After the buyer completes or abandons at the scheme, they pass back through a Fynex-hosted return step that 302s them to your `returnUrl`. See [The return leg](#the-return-leg) below. For Multibanco, the same response can include inline instructions: ```json { "paymentId": "ORDER-1043", "status": "provider_pending", "amount": 49.99, "currencyCode": "EUR", "requiresAction": true, "paymentInstructions": { "type": "payment_reference", "entity": "11854", "reference": "999999964" }, "actionUrl": "https://redirect.provider.example/checkout/def456", "redirectFullPage": true } ``` Display `entity` and `reference` as plain text, alongside the top-level `amount` and `currencyCode`; never interpret provider values as HTML. Fynex only returns a structured instruction after validating the Multibanco Entity as five ASCII digits and the Reference as nine ASCII digits. Do not treat displaying or copying the instructions as payment success; keep the payment pending until a webhook or payment lookup reports a terminal state. If both instructions and `actionUrl` are present, you may keep the redirect as a fallback. ### Keeping the buyer on your checkout with MB WAY By default MB WAY sends the buyer to a provider-hosted page. To keep them on your own checkout instead, collect their MB WAY phone number and send it as `apmData.phone`: ```json { "externalOrderRef": "ORDER-100106", "amount": 10.50, "paymentType": "apm", "paymentMethod": "mbway", "currencyCode": "EUR", "countryCode": "PT", "apmData": { "phone": "+351912345678" } } ``` The authorization is then pushed to that number's MB WAY app and the response carries an `app_notification` instruction: ```json { "paymentId": "ORDER-100106", "status": "provider_pending", "requiresAction": true, "paymentInstructions": { "type": "app_notification", "phone": "+351912345678" }, "actionUrl": "https://redirect.provider.example/checkout/ghi789", "redirectFullPage": true } ``` There is nothing to render but a waiting state — the buyer approves in their app. Rules: - **`apmData.phone` must be a valid E.164 number** (`+` and 8–15 digits). A malformed value is rejected with `400` before any payment is created. Omitting it is not an error: the payment simply keeps the redirect flow. - **`apmData.phone` is the only source of the number.** `billingDetails.phone` is never used for the push, even a Portuguese one — it is a contact detail collected for something else, and the buyer whose handset rings has not agreed to that by giving you a billing contact. The push goes to the number registered with the buyer's MB WAY account, which need not be your billing contact, so ask for it explicitly. No `apmData.phone`, no push. - **`actionUrl` is still returned** — keep it as a fallback. The push has a short window (below) and the same charge is payable either way. - **The window is 4 minutes.** MB WAY is not a voucher method: if the buyer does not approve in time the payment moves to `failed` and you must start a new one. - **Do not treat the instruction as payment success.** Wait for a terminal status via your webhook or by polling `GET /payments-api/v1/payments/{payment_id}`. - **Send a number the buyer gave you for this payment.** Nothing upstream validates it: a number that is not enrolled with MB WAY is accepted and simply never authorizes, so the only symptom is a payment that expires. Do not push to a number the buyer supplied for some other purpose. This is a server-to-server capability. Fynex-hosted checkout continues to drive MB WAY through the provider redirect, and `apmData` is ignored there. Submitting the same `Idempotency-Key` with the same business intent returns the existing payment (`200 OK`) rather than creating a duplicate; reusing it with a different `amount` / `currencyCode` / `externalOrderRef` / `apmData.phone` returns `409`. `apmData.phone` is part of that intent because it decides where the authorization goes. So if the buyer mistyped their number, **retrying with a corrected `apmData.phone` under the same `Idempotency-Key` is a `409`, not a correction** — the original payment is already pushing to the original number. Start a new payment with a fresh `Idempotency-Key` and `externalOrderRef` instead, and let the first one expire. ## Hosted checkout If you already use Fynex-hosted checkout, you get APMs with no extra integration work. Create the checkout session exactly as you would for a card-only checkout; the Fynex-hosted page renders whichever APM tiles are available for that session (see [How hosted-checkout availability is decided](#how-hosted-checkout-availability-is-decided)), and drives the redirect handshake inside the page. Your integration never sees a provider token and never calls a provider endpoint — you only observe the final outcome via webhook, polling, or the return redirect. See [Hosted checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) for the session-creation reference. ## The return leg Both entry paths — server-to-server and hosted checkout — return the buyer through the same Fynex-hosted return step once they finish authorizing at the scheme: ``` Buyer browser Scheme / provider Fynex ───────────── ───────────────── ───── (server-to-server: your UI sends the buyer to actionUrl; hosted: the Fynex page does it) ─────────────────────► authorize (bank page / app / QR) ─── redirect back ────► Fynex return handler (signed token resolved server-side; looks up the payment's pre-validated destination) ◄───────────────────────────────────────────── 302 to that destination lands on: • hosted-checkout success / failure URL, or • caller returnUrl with ?status=&paymentId=, or • Fynex-hosted status page (no caller destination) ``` The return step carries a signed token Fynex issued when it started the payment; Fynex resolves it **server-side** and 302s to a destination it reads from already-persisted, already-validated payment data: 1. the hosted-checkout **success / failure URL** configured on the payment, or 2. the caller's **`returnUrl`** with `?status=` and `?paymentId=` query params appended, or 3. a **Fynex-hosted status page** when the payment has neither of the above. The destination is never taken from the incoming request beyond the signed token, so the step cannot be turned into an open redirect. A missing, invalid, or expired token renders an "Invalid Link" page rather than redirecting anywhere. > [!NOTE] > An APM payment can still be *pending* when the buyer is returned — account-to-account and > voucher schemes settle asynchronously. The status page (and the `?status=` on your > `returnUrl`) can therefore read `pending`; the payment is finalized later. The return > query uses one of `status=pending`, `status=succeeded`, or `status=failed`, and includes > `paymentId=`. Confirm the real outcome the same way you would for any > payment: register a webhook or poll > `GET /payments-api/v1/payments/{payment_id}` (the returned `?paymentId=` is your > `externalOrderRef`). Do not treat "returned to my URL" as "paid" — wait for a terminal > status. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) and [Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks). ## How hosted-checkout availability is decided On **hosted checkout**, whether an APM tile appears on a given session is the **intersection** of two gates: 1. **The currency/country matrix** above — a method is only a candidate when the checkout's currency (and country, for the country-bound schemes) matches the table. 2. **Routability** — your account must have at least one active `apm`-rail terminal whose currency / country / operational-mode can carry the checkout. Fynex never renders a tile it cannot route, so a buyer can't pick a method that would then fail on submit. If nothing survives both gates, the hosted checkout simply doesn't show an APM block — the buyer falls back to card (or whatever else is configured). There is no broken-tile state. The **server-to-server** path is gated by the same two constraints, but you learn about a mismatch differently: an instrument that doesn't match the currency/country you sent, or that your account can't route, is rejected at `initialize-payment` (`400`, or a routing `502`) rather than silently hidden. Either way, don't hard-code which APMs to offer — discover what your account supports with the payment-methods endpoint, exactly as you would for any other instrument. ## Discovering what's enabled `GET /payments-api/v1/payment-methods` returns the instruments and rails configured on your seller account. Check `allowedPaymentRails` for `apm` and `allowedPaymentMethods` for the specific instrument values before you rely on APMs being available: ```bash curl -sS "$FYNEX_API/payments-api/v1/payment-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` ```json { "sellerAccountId": 42, "allowedPaymentMethods": ["card", "bancontact", "wero"], "allowedCurrencies": ["EUR"], "allowedPaymentRails": ["card", "apm"] } ``` See [Payment methods & capabilities](https://api.fynex.ai/payments-api/v2/docs#tag/payment-methods) for the full response shape. Two things to keep in mind when reading this response for APMs: - **`allowedPaymentMethods` is the account-level ceiling.** It tells you which instruments your account is configured for. It does **not** guarantee a given instrument works on every payment — the per-checkout currency/country matrix and terminal routability still apply (see above). - **Use it to decide what to offer, on either path.** For hosted checkout, it tells you whether to route a buyer to a Fynex-hosted page at all (the tiles themselves are rendered by Fynex). For the server-to-server path, it tells you which `paymentMethod` values are worth presenting in your own UI before you call `initialize-payment`. ## See also - **[Hosted checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — create the checkout session that renders APM tiles for the hosted path. - **[Payment methods & capabilities](https://api.fynex.ai/payments-api/v2/docs#tag/payment-methods)** — discover the `apm` rail and enabled instruments for your account. - **[Apple Pay](https://api.fynex.ai/payments-api/v2/docs#tag/apple-pay)** / **[Google Pay](https://api.fynex.ai/payments-api/v2/docs#tag/google-pay)** — the other wallet-like instruments, on the `card` rail; like APMs they also offer a server-to-server path. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** / **[Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks)** — confirm the final outcome of an asynchronously-settling APM payment. ## 3DS 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. --- ## When 3DS is triggered After `POST /initialize-payment`, inspect the response: ```json { "paymentId": "ORDER-100106", "status": "provider_pending", "amount": 49.99, "currencyCode": "GBP", "requiresAction": true, "actionUrl": "https://acs.issuer-bank.example/challenge?token=XYZ" } ``` When `requiresAction` is `true`, the payment is in `provider_pending` state. The customer must complete the issuer's challenge at `actionUrl` before the payment can be authorized. If `requiresAction` is `false` (frictionless flow), the payment may already be `authorized` or `provider_completed` and no redirect is needed. --- ## 3DS challenge types | Type | Description | |------|-------------| | 3DS 2.0 frictionless | The issuer approves silently based on risk data. `requiresAction` is `false`; no redirect needed. | | 3DS 2.0 step-up | The issuer requires an OTP or biometric. Customer is redirected to `actionUrl` for challenge completion. | | 3DS 1.x fallback | Legacy challenge for cards not enrolled in 3DS 2.0. Same redirect mechanism. | --- ## Integration flow ``` Your Backend Your Frontend Issuer ACS │ │ │ │── POST /initialize-payment ──►│ │ │◄── { requiresAction: true, │ │ │ actionUrl: "https://..." }│ │ │ │ │ │── return actionUrl ──────────►│ │ │ │── redirect browser ─────►│ │ │ │ │ │ (customer completes │ │ │ OTP / biometric) │ │ │ │ │ │◄── redirect to returnUrl ─┤ │ │ │ │◄── signal to finalize ────────│ │ │ │ │ │── POST /finalize-payment ─────► │ │◄── { status: "provider_completed", capturedAmount: ... } │ ``` ### Step-by-step 1. **Your backend** calls `POST /initialize-payment` with the customer's card data, amount, and currency. 2. If `requiresAction: true`, extract `actionUrl` from the response and return it to your frontend. 3. **Your frontend** performs a full-page redirect of the customer's browser to `actionUrl`. Do not use an iframe — issuers reject embedded challenges. 4. The customer completes the 3DS challenge (OTP, biometric, or frictionless silent approval) on the issuer's page. 5. The issuer redirects the customer back to your `returnUrl` (or your app's resume page). 6. **Your frontend** signals your backend to call `POST /finalize-payment` using the same `paymentId` (the `externalOrderRef` from step 1). 7. Your backend calls `POST /finalize-payment` and returns the result to the customer. > [!NOTE] > `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. --- ## State continuity across the redirect The 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. Bridge the gap using `localStorage` (or a server-side session/cookie): **Before the redirect — save what you need:** ```js localStorage.setItem('checkout_payment_id', paymentId); localStorage.setItem('checkout_amount', amount.toString()); localStorage.setItem('checkout_currency', currencyCode); // add any other fields your resume page needs ``` **After the redirect lands on your resume page — restore and finalize:** ```js const paymentId = localStorage.getItem('checkout_payment_id'); const amount = parseFloat(localStorage.getItem('checkout_amount') || '0'); // signal backend to finalize await finalizePayment(paymentId, amount); // clean up localStorage.removeItem('checkout_payment_id'); localStorage.removeItem('checkout_amount'); localStorage.removeItem('checkout_currency'); ``` > [!CAUTION] > 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. --- ## Double-completion guard (React strict mode) In 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. Guard against this with a ref: ```jsx import { useEffect, useRef } from 'react'; function PaymentResumePage() { const hasSubmitted = useRef(false); useEffect(() => { if (hasSubmitted.current) return; hasSubmitted.current = true; const paymentId = localStorage.getItem('checkout_payment_id'); if (!paymentId) return; finalizePayment(paymentId).then((result) => { // handle success or failure }); }, []); return
Processing payment…
; } ``` --- ## Code samples #### curl — initialize ```bash curl -sS -X POST "$FYNEX_API/initialize-payment" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-100106", "amount": 49.99, "paymentType": "card", "paymentMethod": "card", "currencyCode": "GBP", "countryCode": "GB", "autoSettlement": false, "captureMode": "manual", "cardData": { "cardNumber": "4111111111111111", "expMonth": 12, "expYear": 2028, "holderName": "Jane Smith", "cvv": "123" } }' # If requiresAction is true, redirect customer to actionUrl. # Then call /finalize-payment once they return. ``` #### JavaScript — browser redirect + resume ```js // ── Step 1: initialize (call from your backend and return actionUrl to the browser) ── // On your frontend, after receiving actionUrl from your server: async function startPayment({ paymentId, amount, currencyCode, actionUrl }) { if (actionUrl) { // Save state before the full-page redirect localStorage.setItem('checkout_payment_id', paymentId); localStorage.setItem('checkout_amount', String(amount)); localStorage.setItem('checkout_currency', currencyCode); // Redirect customer to the 3DS challenge page window.location.href = actionUrl; return; // execution stops here; browser navigates away } // No 3DS required — proceed directly await notifyBackendToFinalize(paymentId, amount); } // ── Step 2: resume page (your returnUrl lands here) ── // Runs after the issuer redirects the customer back to your site. async function onPaymentReturn() { const paymentId = localStorage.getItem('checkout_payment_id'); const amount = parseFloat(localStorage.getItem('checkout_amount') || '0'); if (!paymentId) { console.error('No pending payment found in localStorage'); return; } try { const result = await notifyBackendToFinalize(paymentId, amount); // result.status will be 'provider_completed' on success displaySuccessUI(result); } catch (err) { displayErrorUI(err); } finally { localStorage.removeItem('checkout_payment_id'); localStorage.removeItem('checkout_amount'); localStorage.removeItem('checkout_currency'); } } ``` #### Python — finalize ```python import os import uuid import requests FYNEX_API = os.environ["FYNEX_API"] # e.g. https://api.fynex.ai/payments-api/v1 FYNEX_TOKEN = os.environ["FYNEX_TOKEN"] def finalize_payment(payment_id: str, amount: float | None = None) -> dict: """ Call after the customer completes the 3DS challenge. payment_id is the externalOrderRef from /initialize-payment. amount is optional; omit to capture the full authorized amount. """ body: dict = {"paymentId": payment_id} if amount is not None: body["amount"] = amount response = requests.post( f"{FYNEX_API}/finalize-payment", json=body, headers={ "Authorization": f"Bearer {FYNEX_TOKEN}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, timeout=30, ) response.raise_for_status() return response.json() # Example: # result = finalize_payment("ORDER-100106", amount=49.99) # print(result["status"]) # "provider_completed" # print(result["capturedAmount"]) # 49.99 ``` --- ## Triggering (and skipping) 3DS On this sandbox, whether a payment goes through a 3DS challenge is controlled by the **`skip3DS` request flag — not by the card number**: | Request | Behaviour | |---------|-----------| | `"skip3DS": false` (or omitted) | 3DS requested → `requiresAction: true` + `actionUrl`. Complete the challenge, then `/finalize-payment`. | | `"skip3DS": true` | No 3DS → `requiresAction: false`, the payment authorizes without a redirect. | Use any of the sandbox Visa PANs from the [Test cards & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards) guide (e.g. `4111 1111 1111 1111`) with any future expiry — the same card works for both the 3DS and the no-3DS flow depending on `skip3DS`. --- ## Failure cases If 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. The `/finalize-payment` response will carry a non-success `status` plus `failureCode` and `failureDescription`: ```json { "paymentId": "ORDER-100106", "status": "failed", "failureCode": 2001, "failureDescription": "Card declined by issuer" } ``` Common failure scenarios: | Scenario | Resulting status | Recommended UX | |----------|-----------------|----------------| | Customer clicks "Cancel" on issuer page | `cancelled` | Show a "Payment cancelled" message with option to retry | | Wrong OTP / too many attempts | `failed` | Show error; allow customer to try a different card | | Issuer hard decline | `failed` | Show generic decline message; do not expose issuer reason verbatim | | Session timeout | `failed` | Prompt customer to start checkout again | > [!NOTE] > Detect user-cancellation from the error shape rather than treating it as a generic failure. The `status` field will be `cancelled` rather than `failed`. Show a neutral "Payment not completed" message rather than an error for cancellations. ## See also - **[Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Full reference for POST /initialize-payment and POST /finalize-payment. - **[Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle)** — All payment statuses and how they transition. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes and error response shapes. ## Captures & refunds 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. ## Prerequisites - The payment must have been created with `autoSettlement: false` (for capture). - You need the same `externalOrderRef` string you sent on `/initialize-payment` — this is the `{externalOrderRef}` path parameter. > [!TIP] > The `{externalOrderRef}` path parameter is the **string you chose** when you created the payment (e.g. `ORDER-1042`). It is not a numeric ID. --- ## Captures ### When to capture Capture is only available when: - `autoSettlement` was `false` on the original `/initialize-payment` request, **and** - The payment status is `authorized` or `provider_completed`. Any other status returns `409 Conflict` with `"invalid status transition"`. ### Full capture #### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" ``` #### JavaScript ```js import { randomUUID } from 'node:crypto'; const res = await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/capture`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json', }, }); const data = await res.json(); ``` #### Python ```python import os, uuid, requests res = requests.post( f"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/capture", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), "Content-Type": "application/json", }, ) data = res.json() ``` **Response (200 OK):** ```json { "paymentId": "ORDER-1042", "status": "provider_completed", "providerCode": "", "providerPaymentId": "pay_01J2EXAMPLE", "amount": 49.99, "currencyCode": "GBP" } ``` ### Partial capture Pass an `amount` smaller than the authorized total. The remainder is released automatically. #### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "amount": 30.00 }' ``` #### JavaScript ```js import { randomUUID } from 'node:crypto'; await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/capture`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 30.00 }), }); ``` #### Python ```python import os, uuid, requests requests.post( f"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/capture", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), "Content-Type": "application/json", }, json={"amount": 30.00}, ) ``` > [!CAUTION] > Capture once for the final amount. You cannot issue a second capture on the same payment. > Capture is also blocked while a refund is pending or after any successful refund on that payment. ### Capture request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `amount` | `float64` | No | Major units (e.g. `30.00`). Omit for full-amount capture. Must be `> 0` and `<=` authorized amount. | ### Capture response fields | Field | Type | Description | |-------|------|-------------| | `paymentId` | `string` | Your `externalOrderRef` | | `status` | `string` | `GenericPaymentStatus` post-capture | | `providerCode` | `string` | Upstream processor identifier | | `providerPaymentId` | `string` | Provider's reference for the transaction | | `amount` | `float64` | Captured amount in major units | | `currencyCode` | `string` | 3-letter ISO currency | | `failureCode` | `int` | Present if processor declined | | `failureDescription` | `string` | Human-readable decline reason | --- ## Refunds ### When to refund Refund is only available when: - Payment status is `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled`, **and** - The payment is not already `refunded` or `refund_pending`. Attempting a new refund outside these states returns `409 Conflict`. If your original `POST /refund` timed out while the refund is still pending, retry with the **same** `Idempotency-Key` to replay the existing refund row. Using a different key is treated as a new refund attempt and stays blocked until the pending refund reaches `succeeded`, `failed`, or `cancelled`. ### Full refund #### curl ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" ``` #### JavaScript ```js import { randomUUID } from 'node:crypto'; await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/refund`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json', }, }); ``` #### Python ```python import os, uuid, requests requests.post( f"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/refund", headers={ "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), "Content-Type": "application/json", }, ) ``` ### Partial refund ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "amount": 10.00 }' ``` Issue 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. ### What customers see Refunds 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. ### Refund response (200 OK) ```json { "id": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b", "paymentId": "ORDER-1042", "status": "pending", "amount": 10.00, "currencyCode": "GBP", "providerRefundId": "rfnd_01J2EXAMPLE", "createdAt": "2026-05-11T12:34:56Z", "updatedAt": "2026-05-11T12:34:56Z" } ``` The status will be `pending` immediately after the request. Once the provider processes it, the refund moves to `succeeded` or `failed`. ### Refund response fields | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Unique ID of this refund ledger row | | `paymentId` | `string` | Your `externalOrderRef` for the parent payment | | `status` | `string` | `pending`, `succeeded`, `failed`, or `cancelled` | | `amount` | `float64` | Refunded amount in major units | | `currencyCode` | `string` | 3-letter ISO currency | | `providerRefundId` | `string` | Provider's reference for the refund (present once issued) | | `failureCode` | `int` | Present if the provider rejected the refund | | `failureDescription` | `string` | Human-readable failure reason | | `createdAt` | `string` | When the refund was created | | `updatedAt` | `string` | When the refund row was last updated | | `completedAt` | `string` | When the refund reached a terminal state (present once complete) | --- ## Status reference The full `GenericPaymentStatus` enum, in lifecycle order: | Status | Description | |--------|-------------| | `draft` | Payment record created but not yet submitted | | `new` | Submitted to the routing engine | | `routed` | Assigned to a processor terminal | | `provider_pending` | Submitted to the upstream processor | | `authorized` | Funds reserved — ready to capture | | `provider_completed` | Provider confirmed capture — ready to refund | | `funds_in_flight` | Settlement in progress | | `settled` | Funds settled — ready to refund | | `deposit_confirmed` | Deposit confirmed — ready to refund | | `refund_pending` | Refund submitted to the processor | | `refunded` | Refund completed | | `refund_failed` | Processor rejected the refund | | `refund_cancelled` | Refund was cancelled | | `failed` | Payment failed | | `cancelled` | Payment was cancelled | **Capture window:** `authorized` → `provider_completed` → *(capture)* → `funds_in_flight` → `settled` **Refund window:** `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled` → `refund_pending` → `refunded` when fully refunded, back to a refundable captured state after a successful partial refund, or `refund_failed`/`refund_cancelled` when the attempt fails/cancels. > [!CAUTION] > The statuses `captured`, `partially_captured`, and `partially_refunded` do **not** exist. If you see these names in older code or documentation, they are incorrect. --- ## Idempotency Both endpoints require an `Idempotency-Key` UUID header. Always send one — network glitches can cause duplicate submissions without it. The key is forwarded to the upstream processor as its own capture/refund idempotency key, so provider-side duplicates are also prevented. See [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency) for the full retry pattern. --- ## Dynamic webhook URL Both `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. The dynamic URL must: - Be HTTPS (`http://` rejected with `webhook_url_not_https`) - Be ≤ 1024 chars (`webhook_url_too_long`) - Resolve via DNS (`webhook_url_dns_failed`) - Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`) - Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth) The 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=`, `X-Fynex-Timestamp: `). See the [Webhooks tag](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks) for the full signature-verification flow. ### Example ```bash curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "amount": 10.00, "webhookUrl": "https://merchant.example.com/webhooks/refunds/ORDER-1042" }' ``` --- ## Status codes | Status | When | |--------|------| | `200` | Success | | `400` | Missing/invalid `Idempotency-Key`; bad body or amount | | `401` | Missing or invalid bearer token | | `404` | Payment not found for this `externalOrderRef` and seller | | `409` | Status precondition not met; already refunded; capture attempted while/after refunding; `autoSettlement` was `true` | | `502` | Upstream processor error | --- ## Verify the result Capture 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. If you'd rather poll, query the payment via REST or GraphQL: ```graphql # id is Int! — use the numeric internal ID. # genericPayment looks a payment up by its numeric id; there is no # externalOrderRef argument on the GraphQL query. query { genericPayment(id: 42) { status amount } } ``` Or use the SSE stream on the hosted checkout page. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse). ## See also - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Poll payment status or subscribe to server-sent events. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — Interpret 409s and provider failure codes. - **[Disputes & chargebacks](https://api.fynex.ai/payments-api/v2/docs#tag/disputes)** — If a customer disputes a payment, see the Disputes & chargebacks guide. ## Recurring > [!CAUTION] > **There is no dedicated recurring-billing API yet.** The *first* card payment can opt into the saved-card / merchant-initiated (MIT) flow via the `subscription` block on `POST /initialize-payment` (see [Server-to-Server → Saved cards and recurring payments](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)). What's missing is the lifecycle surface: no public endpoint to charge a previously-stored card by reference, and no subscription create/manage/cancel API. If you need full recurring/subscription billing now, contact your Fynex representative — and do not rely on any `/api/v1/checkout/*` endpoints; they are not part of this API. --- ## What recurring payments will do Recurring payments let you charge a returning customer without presenting a card form again. The customer provides their card once; Fynex stores an encrypted token on its side and returns a reference your backend uses for future charges — your servers never hold raw card data. | Concept | What it is | |---------|------------| | MIT | Merchant-Initiated Transaction — a charge your server triggers without the customer present. | | Saved card | An encrypted, PCI-scoped card token held on Fynex's infrastructure. | | `merchantCustomerId` | A string you choose (typically your internal customer/user ID) that future charges reference. | > [!NOTE] > **PCI scope.** Even though raw card data stays on Fynex's infrastructure, instructing Fynex to store credentials and triggering MIT charges moves your integration into a higher-scrutiny PCI DSS category (SAQ D / Merchant Level 2). Consult your QSA before going live with recurring billing. --- ## Current state - `POST /initialize-payment` **does** accept a `subscription` block (`subscription.enabled`, `subscription.allowSubsequentMerchantInitiated`) plus a `merchantCustomerId`. Setting these on the first payment opts it into the upstream processor's saved-card / MIT flow. See [Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server). - What's **not** yet available: a public endpoint to charge a previously-stored card *by reference* (no "charge saved card" call, no `storeCredential` field), and no subscription lifecycle API (create / update schedule / cancel). Storage is driven by the `subscription` block above, not a separate call. - The dedicated recurring-billing surface is in development. **To use recurring billing today:** contact your Fynex representative to discuss options for your account. --- ## What to prepare (before the API ships) You can make the eventual migration smoother by structuring your data now: | Data point | Why | |------------|-----| | A stable `merchantCustomerId` per customer | Will key the customer's saved cards once the API ships. | | First-payment reference (`externalOrderRef`) | Links the initial card capture to the customer record. | | Customer consent record | MIT charging requires documented cardholder agreement for stored-credential reuse. | --- ## See also - **[Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Take the initial card payment today via `POST /initialize-payment`. - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Collect the first payment without handling card data. - **[Concepts](https://api.fynex.ai/payments-api/v2/docs#tag/concepts)** — Key domain concepts: GenericPayment, SellerAccount, operationalMode, and more. ## Payout methods 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. > [!NOTE] > 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. Payout methods have two API surfaces: | Surface | Auth | Operations | |---------|------|------------| | REST `/payments-api/v1/payees/{payee_id}/payout-methods[/{method_id}]` | Bearer token | Full CRUD: list, create, update, delete | | GraphQL `/dashboard/graphql` | `dashboard_session` cookie | Full CRUD: create, update, delete | > [!NOTE] > 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. --- ## Prerequisite ordering 1. **Create a payee** — a payout method must be attached to an existing payee. See [Payees](https://api.fynex.ai/payments-api/v2/docs#tag/payees). 2. **Create a payout method** on that payee — supply the bank account details documented below. 3. **Request a payout** — pass the `payoutMethodId` and a `payeeId` when calling `POST /payments-api/v1/payouts`. See [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts). > [!NOTE] > `payee_id` in the REST path is the **numeric integer ID** returned by `createPayee` or from the payee list — not a UUID or string. --- ## Payout method fields (from `schema.graphql`) ### `type PayoutMethod` | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Numeric payout method ID — pass this as `payoutMethodId` when creating a payout | | `payeeId` | `Int!` | ID of the payee this method belongs to | | `type` | `String!` | Account type identifier. The only value the platform uses is `bank_account` (the default). Card destinations are not supported. | | `bankAccountType` | `String!` | Destination identifier format: `iban`, `uk_local`, `us_local`, or `swift`. Fynex selects the eventual payout rail; clients do not select it. | | `currency` | `String!` | Three-letter ISO 4217 currency code (e.g. `EUR`, `GBP`) | | `bankCountry` | `String` | Two-letter ISO country code for the destination bank. | | `iban` | `String` | IBAN for SEPA / SWIFT destinations | | `bic` | `String` | BIC / SWIFT code | | `accountNumber` | `String` | Account number for domestic rails (eight digits for UK Faster Payments) | | `sortCode` | `String` | Six-digit sort code for UK Faster Payments | | `routingNumber` | `String` | ABA routing number for US local accounts. | | `bankName` | `String` | Bank name for reference | | `accountName` | `String` | Account holder name | | `status` | `String!` | Account status (e.g. `active`) | | `createdAt` | `Time!` | Creation timestamp | | `updatedAt` | `Time!` | Last update timestamp | --- ## REST — List payout methods for a payee **Auth: Bearer token** (`Authorization: Bearer `) ``` GET /payments-api/v1/payees/{payee_id}/payout-methods ``` Returns 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`. #### curl ```bash curl -sS "https://api.fynex.ai/payments-api/v1/payees/7/payout-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` #### JavaScript ```js const payeeId = 7; const res = await fetch( `${process.env.FYNEX_API}/payees/${payeeId}/payout-methods`, { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` }, } ); const data = await res.json(); console.log(data); // { items: [...], totalCount: N } ``` #### Python ```python import os, requests payee_id = 7 res = requests.get( f"{os.environ['FYNEX_API']}/payees/{payee_id}/payout-methods", headers={"Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}"}, ) res.raise_for_status() print(res.json()) ``` --- ## REST — Create a payout method for a payee **Auth: Bearer token** (`Authorization: Bearer `) ``` POST /payments-api/v1/payees/{payee_id}/payout-methods ``` Registers 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. Returns `201 Created` with the payout method. A payee that does not exist, or belongs to another seller, returns `404`. ### Request body fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `currency` | `String` | Yes | ISO 4217 currency code: `EUR`, `GBP`, or `USD`. | | `type` | `String` | No | Defaults to `bank_account` (the only supported value). | | `bankAccountType` | `String` | No | `iban` (default), `uk_local`, `us_local`, or `swift`. | | `bankCountry` | `String` | Conditional | Required for local and SWIFT formats. Use `GB` for `uk_local` and `US` for `us_local`. | | `iban` | `String` | Conditional | Required when `bankAccountType` is `iban`; optional account identifier for `swift`. | | `bic` | `String` | No | BIC / SWIFT code. | | `accountNumber` | `String` | No | Account number for domestic rails; exactly eight digits for `uk_local`. | | `sortCode` | `String` | No | Sort code for UK Faster Payments; exactly six digits after spaces/hyphens are removed. | | `routingNumber` | `String` | No | Nine-digit ABA routing number for `us_local`. | | `bankName` | `String` | No | Bank name for reference. | | `accountName` | `String` | No | Account holder name. | #### curl ```bash curl -sS -X POST "https://api.fynex.ai/payments-api/v1/payees/7/payout-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "currency": "EUR", "bankAccountType": "iban", "iban": "DE89370400440532013000", "bic": "DEUTDEFF", "accountName": "Acme Supplies Ltd" }' ``` --- ## REST — Update a payout method **Auth: Bearer token** (`Authorization: Bearer `) ``` PATCH /payments-api/v1/payees/{payee_id}/payout-methods/{method_id} ``` Updates 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. ### Request body fields (all optional) | Field | Type | Description | |-------|------|-------------| | `bankAccountType` | `String` | New destination identifier format. | | `bankCountry` | `String` | New two-letter bank country. | | `iban` | `String` | New IBAN. | | `bic` | `String` | New BIC. | | `accountNumber` | `String` | New account number. | | `sortCode` | `String` | New sort code. | | `routingNumber` | `String` | New ABA routing number. | | `bankName` | `String` | New bank name. | | `accountName` | `String` | New account holder name. | | `status` | `String` | `active` or `inactive`. | #### curl ```bash curl -sS -X PATCH "https://api.fynex.ai/payments-api/v1/payees/7/payout-methods/501" \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "accountName": "Acme Supplies Ltd" }' ``` --- ## REST — Delete a payout method **Auth: Bearer token** (`Authorization: Bearer `) ``` DELETE /payments-api/v1/payees/{payee_id}/payout-methods/{method_id} ``` Deletes a payout method your seller account owns. The method must belong to the payee in the path, otherwise `404`. Returns `{ "deleted": true }`. #### curl ```bash curl -sS -X DELETE "https://api.fynex.ai/payments-api/v1/payees/7/payout-methods/501" \ -H "Authorization: Bearer $FYNEX_TOKEN" ``` --- ## GraphQL — Full CRUD **Auth: `dashboard_session` cookie** — see [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth) for the login flow. | Operation | Type | Permission | |-----------|------|------------| | `payoutMethods(payeeId, limit, offset)` | Query | `PAYOUTMETHODS_READ` | | `payoutMethod(id: Int!)` | Query | `PAYOUTMETHODS_READ` | | `createPayoutMethod(input: CreatePayoutMethodInput!)` | Mutation | `PAYOUTMETHODS_CREATE` | | `updatePayoutMethod(id: Int!, input: UpdatePayoutMethodInput!)` | Mutation | `PAYOUTMETHODS_UPDATE` | | `deletePayoutMethod(id: Int!)` | Mutation | `PAYOUTMETHODS_DELETE` | ### `CreatePayoutMethodInput` fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `payeeId` | `Int!` | Yes | ID of the payee to attach this method to | | `currency` | `String!` | Yes | ISO 4217 currency code: `EUR`, `GBP`, or `USD`. | | `type` | `String` | No | Account type. Defaults to `bank_account`, the only value the platform uses — it does not affect routing. | | `bankAccountType` | `String` | No | Destination identifier format: `iban` (default), `uk_local`, `us_local`, or `swift`. | | `bankCountry` | `String` | Conditional | Required for local and SWIFT formats. | | `iban` | `String` | Conditional | Required for `iban`; may identify a `swift` destination instead of `accountNumber`. | | `bic` | `String` | No | BIC/SWIFT code | | `accountNumber` | `String` | No | Account number — required for domestic rails | | `sortCode` | `String` | No | Six-digit sort code — required for UK Faster Payments | | `routingNumber` | `String` | No | Nine-digit ABA routing number — required for `us_local`. | | `bankName` | `String` | No | Bank name | | `accountName` | `String` | No | Account holder name | ### `UpdatePayoutMethodInput` fields All fields are optional. Supply only the fields you want to change. | Field | Type | Description | |-------|------|-------------| | `bankAccountType` | `String` | New destination identifier format | | `bankCountry` | `String` | New two-letter bank country | | `iban` | `String` | New IBAN | | `bic` | `String` | New BIC | | `accountNumber` | `String` | New account number | | `sortCode` | `String` | New sort code | | `routingNumber` | `String` | New ABA routing number | | `bankName` | `String` | New bank name | | `accountName` | `String` | New account holder name | | `status` | `String` | Update status (e.g. set to `inactive`) | --- ## Create a payout method — code samples #### curl ```bash # Step 1 — login and save the cookie curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Step 2 — create the payout method (IBAN example) curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation CreatePayoutMethod($input: CreatePayoutMethodInput!) { createPayoutMethod(input: $input) { id payeeId currency iban isDefault status } }", "variables": { "input": { "payeeId": 7, "currency": "EUR", "type": "bank_account", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX", "bankName": "Commerzbank AG", "accountName": "Acme Supplies Limited" } } }' ``` #### JavaScript ```js const BASE = 'https://api.fynex.ai'; // Assumes login was already called and the dashboard_session cookie is present async function createPayoutMethod(input) { const res = await fetch(`${BASE}/dashboard/graphql`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ` mutation CreatePayoutMethod($input: CreatePayoutMethodInput!) { createPayoutMethod(input: $input) { id payeeId currency iban isDefault status } } `, variables: { input }, }), }); const { data, errors } = await res.json(); if (errors?.length) throw new Error(errors[0].message); return data.createPayoutMethod; } const method = await createPayoutMethod({ payeeId: 7, currency: 'EUR', type: 'bank_account', iban: 'DE89370400440532013000', bic: 'COBADEFFXXX', bankName: 'Commerzbank AG', accountName: 'Acme Supplies Limited', }); console.log(method); // { id: 3, payeeId: 7, currency: 'EUR', iban: 'DE89370400440532013000', isDefault: true, status: 'active' } ``` #### Python ```python import requests BASE = "https://api.fynex.ai" session = requests.Session() # Login first session.post( f"{BASE}/api/v1/login/dashboard", json={"email": "you@example.com", "password": "your_password"}, ).raise_for_status() mutation = """ mutation CreatePayoutMethod($input: CreatePayoutMethodInput!) { createPayoutMethod(input: $input) { id payeeId currency iban isDefault status } } """ variables = { "input": { "payeeId": 7, "currency": "EUR", "type": "bank_account", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX", "bankName": "Commerzbank AG", "accountName": "Acme Supplies Limited", } } resp = session.post( f"{BASE}/dashboard/graphql", json={"query": mutation, "variables": variables}, ) resp.raise_for_status() body = resp.json() if "errors" in body: raise RuntimeError(body["errors"][0]["message"]) print(body["data"]["createPayoutMethod"]) ``` --- ## Other operations ### Update a payout method ```graphql mutation UpdatePayoutMethod($id: Int!, $input: UpdatePayoutMethodInput!) { updatePayoutMethod(id: $id, input: $input) { id iban bic isDefault status updatedAt } } ``` ### Delete a payout method ```graphql mutation DeletePayoutMethod($id: Int!) { deletePayoutMethod(id: $id) } ``` Returns `true` on success. You cannot delete a method that is currently referenced by a pending payout — cancel or complete the payout first. ### List payout methods via GraphQL ```graphql query ListPayoutMethods($payeeId: Int!, $limit: Int, $offset: Int) { payoutMethods(payeeId: $payeeId, limit: $limit, offset: $offset) { items { id currency type iban accountNumber isDefault status } totalCount } } ``` --- ## Common pitfalls | Pitfall | Resolution | |---------|------------| | `payee_id` treated as UUID | The `payee_id` path parameter is a **numeric integer** from the `Payee.id` field. | | Creating a method before the payee exists | `createPayoutMethod` requires a valid `payeeId`. Create the payee first (see [Payees](https://api.fynex.ai/payments-api/v2/docs#tag/payees)). | | 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. | | 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. | | Using Bearer auth on `/dashboard/graphql` | The GraphQL endpoint only accepts the `dashboard_session` cookie. Bearer tokens are for the REST surface only. | ## See also - **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Obtain a dashboard_session cookie before calling any GraphQL mutation. - **[Payees](https://api.fynex.ai/payments-api/v2/docs#tag/payees)** — Create and manage the payees that payout methods are attached to. - **[Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts)** — Send money from a seller wallet to a payee ## Polling & SSE Fynex delivers **outbound webhooks** for payment and refund events — see [Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks) for the live signature-verified contract. **Polling** and **Server-Sent Events (SSE)**, documented here, are complementary: they let you learn when a payment succeeded, a refund completed, or a payout settled, and they make a solid backstop in case a webhook delivery is missed (payouts in particular do not yet emit webhooks, so poll for those). Fynex's own dashboard uses polling at 5-second intervals, so the pattern is battle-tested. ## Choose your verification path | Scenario | Best fit | Why | |---|---|---| | 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. | | 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 | | You're sending payouts | **Polling** `GET /payouts/{id}` | Settlement is asynchronous (minutes for SEPA, hours for SWIFT) | | 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 | ## Polling pattern ### Recommended cadence - **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. - **Steady-state monitoring**: then progressively slower depending on urgency. - **Stop polling** once the resource reaches a terminal state (`succeeded`, `failed`, `cancelled`, `refunded`, `completed`). - **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](https://api.fynex.ai/payments-api/v2/docs#tag/errors). ### Payment status (REST, recommended for server-to-server) `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. The response carries the Fynex lifecycle `status`, the originally authorized `amount` in major units, currency/country, payment type/method, `externalOrderRef`, and the latest `failureCode` / `failureDescription` / `failureStage` (if any). (The raw upstream `providerStatus` is not part of this REST response — it is exposed only on the GraphQL `genericPayment` type.) #### curl ```bash while :; do RES=$(curl -sS "$FYNEX_API/payments-api/v1/payments/ORDER-1042" \ -H "Authorization: Bearer $FYNEX_TOKEN") STATUS=$(echo "$RES" | jq -r '.status') echo "$(date -u +%H:%M:%S) status=$STATUS" case "$STATUS" in provider_completed|settled|deposit_confirmed|refunded|failed|cancelled) break;; esac sleep 5 done ``` #### JavaScript ```js async function waitForPayment(externalOrderRef, { intervalMs = 5000, maxMs = 10 * 60 * 1000 } = {}) { const terminal = new Set([ 'provider_completed', 'settled', 'deposit_confirmed', 'refunded', 'failed', 'cancelled', ]); const deadline = Date.now() + maxMs; while (Date.now() < deadline) { const res = await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/${encodeURIComponent(externalOrderRef)}`, { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const payment = await res.json(); if (terminal.has(payment.status)) return payment; await new Promise(r => setTimeout(r, intervalMs)); } throw new Error(`Payment ${externalOrderRef} did not reach a terminal state in time`); } ``` #### Python ```python import time import requests TERMINAL = { "provider_completed", "settled", "deposit_confirmed", "refunded", "failed", "cancelled", } def wait_for_payment(external_order_ref: str, *, interval: float = 5.0, max_seconds: int = 10 * 60): deadline = time.monotonic() + max_seconds while time.monotonic() < deadline: res = requests.get( f"{FYNEX_API}/payments-api/v1/payments/{external_order_ref}", headers={"Authorization": f"Bearer {FYNEX_TOKEN}"}, timeout=10, ) res.raise_for_status() payment = res.json() if payment["status"] in TERMINAL: return payment time.sleep(interval) raise TimeoutError(f"Payment {external_order_ref} did not settle in time") ``` `status` follows the [Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) state machine. Stop polling once you see a terminal value; treat intermediate states like `provider_pending` as "keep waiting." ### Payment status (GraphQL, for cookie-auth contexts) If 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](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth) for the login flow. ```graphql query GenericPayment($id: Int!) { genericPayment(id: $id) { id externalOrderRef status providerPaymentId failureCode failureDescription amount currencyCode } } ``` For pure server-to-server backends, prefer the REST endpoint above — no extra session flow, same bearer token you already have. ### Payout status (REST) `GET /payments-api/v1/payouts/{id}` is fully bearer-token authenticated. Poll until `status` is one of `completed` / `failed` / `cancelled`. #### curl ```bash while :; do RES=$(curl -sS "$FYNEX_API/payments-api/v1/payouts/503" \ -H "Authorization: Bearer $FYNEX_TOKEN") STATUS=$(echo "$RES" | jq -r '.status') echo "$(date -u +%H:%M:%S) status=$STATUS" case "$STATUS" in completed|failed|cancelled) break;; esac sleep 5 done ``` #### JavaScript ```js async function waitForPayout(id, { intervalMs = 5000, maxMs = 30 * 60 * 1000 } = {}) { const deadline = Date.now() + maxMs; while (Date.now() < deadline) { const res = await fetch(`${process.env.FYNEX_API}/payments-api/v1/payouts/${id}`, { headers: { Authorization: `Bearer ${process.env.FYNEX_TOKEN}` }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const payout = await res.json(); if (['completed', 'failed', 'cancelled'].includes(payout.status)) { return payout; } await new Promise(r => setTimeout(r, intervalMs)); } throw new Error(`Payout ${id} did not reach a terminal state in time`); } ``` #### Python ```python import time import requests def wait_for_payout(payout_id: int, *, interval: float = 5.0, max_seconds: int = 30 * 60): deadline = time.monotonic() + max_seconds while time.monotonic() < deadline: res = requests.get( f"{FYNEX_API}/payments-api/v1/payouts/{payout_id}", headers={"Authorization": f"Bearer {FYNEX_TOKEN}"}, timeout=10, ) res.raise_for_status() payout = res.json() if payout["status"] in {"completed", "failed", "cancelled"}: return payout time.sleep(interval) raise TimeoutError(f"Payout {payout_id} did not settle in time") ``` ### Polling resilience - **Use HTTPS keep-alive** to avoid TLS handshake on every poll. - **Add jitter** (e.g. ±20% of the interval) if you have many concurrent pollers. - **Surface a deadline** to your operator. A payout that's still `processing` after an hour deserves a human look. - **Cache the last status** locally so you only re-render UI on state changes. - **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](https://api.fynex.ai/payments-api/v2/docs#tag/errors). ## SSE: real-time browser updates for hosted checkout When 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: ``` GET https://staging-api.fynex.ai/checkout/{session_id}/events ``` The 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. ### Browser snippet ```js const evt = new EventSource( `https://staging-api.fynex.ai/checkout/${sessionId}/events`, ); evt.addEventListener('status', (e) => { const data = JSON.parse(e.data); console.log('Payment status changed:', data.status); // SSE status values: pending | payable | completed | failed | timeout if (['completed', 'failed', 'timeout'].includes(data.status)) { evt.close(); handleTerminal(data); } }); evt.onerror = () => { // EventSource auto-reconnects with exponential backoff. console.warn('SSE disconnected; auto-reconnecting'); }; ``` > [!NOTE] > 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. ### When to use SSE vs polling - **SSE** if you control the customer's browser session and want sub-second feedback on a single payment. - **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.). ## Common pitfalls - **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. - **Don't poll forever.** Set a deadline. A payment stuck at `provider_pending` for hours signals an upstream issue that needs human attention, not more polling. - **Don't forget terminal-state caching.** Once a payment reaches `succeeded` or `failed`, the state is permanent — write it to your own database and stop polling that record. - **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. ## See also - **[Captures & refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — Trigger state transitions, then poll to confirm them. - **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make your retry logic safe so polling never replays a charge. ## Reconciliation 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). > [!NOTE] > **GraphQL only — cookie session required.** All reconciliation queries are on `POST /dashboard/graphql` and require permission `RECONCILIATIONS_READ` on the session. See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth). --- ## Use cases - **Daily settlement file matching** — pull all reconciliation records for the previous day and compare reported vs actual amounts to detect shortfalls. - **Dispute investigation** — fetch a single `reconciliation(id)` to see the full fact list (fees reported vs expected) for a specific deposit event. - **Dispute reserve calculation** — use `reconciliationStatistics` to get aggregate counts of pending, successful, and discrepancy records over a date range. --- ## GraphQL operations | Operation | Signature | Permission | |-----------|-----------|------------| | List reconciliations | `reconciliations(limit: Int, offset: Int, status: String, dateFrom: Time, dateTo: Time): ReconciliationConnection!` | `RECONCILIATIONS_READ` | | Get single record | `reconciliation(id: Int!): ReconciliationSettlementDeposit` | `RECONCILIATIONS_READ` | | Aggregate statistics | `reconciliationStatistics(dateFrom: Time, dateTo: Time): ReconciliationStatistics!` | `RECONCILIATIONS_READ` | > [!NOTE] > 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. --- ## Type reference ### `ReconciliationConnection` The list query returns a connection type rather than a plain array: | Field | Type | Description | |-------|------|-------------| | `items` | `[ReconciliationSettlementDeposit!]!` | Page of reconciliation records | | `totalCount` | `Int!` | Total number of records matching the filter | ### `ReconciliationSettlementDeposit` The primary reconciliation entity — one record per settlement deposit event. | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Record ID | | `bankingTransferReference` | `String!` | Reference of the corresponding bank transfer | | `depositAmountReported` | `Float!` | Amount the processor reported in the settlement file | | `depositAmountExpected` | `Float!` | Amount Fynex calculated based on processed payments | | `depositAmountActual` | `Float!` | Amount actually received in the banking deposit | | `status` | `String!` | Reconciliation status (`pending`, `success`, `discrepancy`, etc.) | | `createdAt` | `Time!` | When the record was created | | `updatedAt` | `Time!` | Last update time | | `genericSettlementReport` | `ReconciliationSettlementReport` | Linked processor settlement report | | `bankingDeposit` | `ReconciliationBankingDeposit` | Linked banking deposit | | `facts` | `[ReconciliationFact!]!` | Line-item fee discrepancy details | ### `ReconciliationSettlementReport` | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Report ID | | `paymentProviderCode` | `String!` | Processor identifier | | `operationalMode` | `String!` | `demo` or `live` | | `periodStart` | `Time` | Settlement period start | | `periodEnd` | `Time` | Settlement period end | | `bankingTransferReference` | `String!` | Bank transfer reference | | `fundingPaymentCurrency` | `String!` | Currency of the funding payment | | `totalFundingPaymentAmountReported` | `Float!` | Total reported funding amount | | `totalFundingPaymentAmountExpected` | `Float!` | Total expected funding amount | | `totalPurchaseFeeAmountReported` | `Float!` | Total purchase fees as reported | | `totalOtherFeeAmountReported` | `Float!` | Other fees as reported | | `status` | `String!` | Report status | ### `ReconciliationFact` Each fact represents a single line-item discrepancy found during reconciliation. | Field | Type | Description | |-------|------|-------------| | `id` | `Int!` | Fact ID | | `factType` | `String!` | Category of discrepancy | | `discrepancyPlace` | `String!` | Where in the flow the discrepancy was detected | | `feeAmountReported` | `Float!` | Fee amount from processor file | | `feeAmountExpected` | `Float!` | Fee amount Fynex expected | | `feeAmountActual` | `Float!` | Fee amount actually applied | | `normalizedRecord` | `ReconciliationNormalizedRecord` | The payment transaction this fact relates to | ### `ReconciliationStatistics` | Field | Type | Description | |-------|------|-------------| | `successCount` | `Int!` | Reconciliations with no discrepancy | | `pendingCount` | `Int!` | Reconciliations not yet matched | | `discrepancyCount` | `Int!` | Reconciliations where amounts do not match | | `totalReportedAmount` | `Float!` | Sum of all reported deposit amounts | | `totalActualAmount` | `Float!` | Sum of all actual deposit amounts | --- ## Code samples #### curl ```bash # Step 1 — login curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Step 2 — list reconciliations for a date range curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "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 } } }", "variables": { "dateFrom": "2025-01-01T00:00:00Z", "dateTo": "2025-01-31T23:59:59Z", "limit": 50, "offset": 0 } }' ``` #### JavaScript ```js const BASE = 'https://api.fynex.ai'; async function login(email, password) { const res = await fetch(`${BASE}/api/v1/login/dashboard`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new Error(`Login failed: ${res.status}`); } async function gql(query, variables = {}) { const res = await fetch(`${BASE}/dashboard/graphql`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), }); const { data, errors } = await res.json(); if (errors?.length) throw new Error(errors[0].message); return data; } await login('you@example.com', 'your_password'); const { reconciliations } = await gql( `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 facts { factType discrepancyPlace feeAmountReported feeAmountExpected } } } }`, { dateFrom: '2025-01-01T00:00:00Z', dateTo: '2025-01-31T23:59:59Z', limit: 50, offset: 0, } ); console.log(`Total: ${reconciliations.totalCount}`); for (const r of reconciliations.items) { const diff = r.depositAmountActual - r.depositAmountExpected; console.log(`${r.id} [${r.status}] diff: ${diff.toFixed(2)}`); } ``` #### Python ```python import requests BASE = "https://api.fynex.ai" session = requests.Session() session.post( f"{BASE}/api/v1/login/dashboard", json={"email": "you@example.com", "password": "your_password"}, ).raise_for_status() 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 facts { factType discrepancyPlace feeAmountReported feeAmountExpected feeAmountActual } } } } """ resp = session.post( f"{BASE}/dashboard/graphql", json={ "query": query, "variables": { "dateFrom": "2025-01-01T00:00:00Z", "dateTo": "2025-01-31T23:59:59Z", "limit": 50, "offset": 0, }, }, ) resp.raise_for_status() result = resp.json() if "errors" in result: raise RuntimeError(result["errors"][0]["message"]) data = result["data"]["reconciliations"] print(f"Total records: {data['totalCount']}") for r in data["items"]: diff = r["depositAmountActual"] - r["depositAmountExpected"] print(f"ID {r['id']} [{r['status']}]: diff={diff:+.2f}, ref={r['bankingTransferReference']}") if r["facts"]: for fact in r["facts"]: print(f" Fact: {fact['factType']} @ {fact['discrepancyPlace']}") ``` --- ## Reconciliation workflow 1. **Fetch daily statistics** with `reconciliationStatistics(dateFrom, dateTo)` to get a quick health check: `successCount`, `pendingCount`, and `discrepancyCount`. 2. **List pending and discrepancy records** by passing `status: "pending"` or `status: "discrepancy"` to `reconciliations`. Focus your investigation on these. 3. **Inspect a specific record** with `reconciliation(id)` to get the full `facts` list showing where fees diverge between reported, expected, and actual. 4. **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. 5. **Escalate discrepancies** to your Fynex account manager if `depositAmountActual` differs from `depositAmountExpected` and the `facts` list does not explain the gap. --- ## Filtering tips - `dateFrom` and `dateTo` accept RFC 3339 timestamps (`2025-01-15T00:00:00Z`). - 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. - Combine `limit` + `offset` for pagination. The `totalCount` field tells you how many pages to expect. ## See also - **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Obtain the dashboard_session cookie required for all GraphQL calls. - **[Wallets](https://api.fynex.ai/payments-api/v2/docs#tag/wallets)** — Inspect wallet ledger entries that correspond to reconciled settlements. - **[Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle)** — Understand the status transitions a payment goes through before it settles. ## Disputes > [!CAUTION] > **This feature is not yet available via the API.** The Fynex dashboard includes a disputes & 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. --- ## What are disputes and chargebacks? A **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: - The captured amount being returned to the cardholder. - A chargeback fee charged to the merchant by the card scheme. - A formal process in which you may submit evidence to contest the reversal. Chargebacks have strict response deadlines (typically 7–30 days depending on the card scheme). Acting quickly with strong evidence is the most effective defence. --- ## Current state The Fynex disputes surface is **under active development**. Today: - The `/online-payments/disputes-and-chargebacks` dashboard page exists but is an informational/manual-support surface only. - There is no `disputes` GraphQL query or REST endpoint available. - There is no evidence-submission or lifecycle-management API. **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. --- ## What to prepare (before the API ships) Even without an API, you can reduce chargeback risk and improve dispute outcomes by preserving the following data at payment time: | Data point | How to capture it | |------------|-------------------| | Order details | Store item names, quantities, and amounts tied to the Fynex payment reference. | | Customer email | Collect at checkout; include in your order record. | | Customer IP address | Log from your server at the point the payment is submitted. | | 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. | | Delivery / fulfilment proof | Keep shipping tracking numbers, download logs, or service-completion records linked to each order. | --- ## Planned API surface When the disputes API ships, expect the following shape (subject to change): ```graphql # Planned — not yet available query disputes($limit: Int, $offset: Int) { disputes(limit: $limit, offset: $offset) { id status # under_review | accepted | contested | won | lost amount currencyCode reason deadline genericPayment { id externalOrderRef } createdAt } } mutation submitDisputeEvidence($disputeId: ID!, $evidence: DisputeEvidenceInput!) { submitDisputeEvidence(disputeId: $disputeId, evidence: $evidence) { id status } } ``` Planned lifecycle states: | State | Meaning | |-------|---------| | `under_review` | Fynex has received the chargeback notification and is reviewing. | | `accepted` | You have accepted the chargeback (amount returned to cardholder). | | `contested` | Evidence submitted; awaiting card scheme ruling. | | `won` | Card scheme ruled in your favour; funds retained. | | `lost` | Card scheme ruled for the cardholder; funds reversed. | --- ## Interim resolution path While 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 & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) for how to issue a refund programmatically. ## See also - **[Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — Issue partial or full refunds as an interim resolution before a chargeback escalates. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — Understand Fynex error shapes and status codes. - **[Concepts](https://api.fynex.ai/payments-api/v2/docs#tag/concepts)** — Key domain concepts: GenericPayment, SellerAccount, operationalMode, and more. ## Verification patterns 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. --- ## The three options ### 1. Polling Your backend calls the Fynex API on a schedule and inspects the resource's `status` field until it reaches a terminal state (`succeeded`, `failed`, `cancelled`, `completed`). - **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. - **Available today**: yes — the Fynex dashboard itself uses 5-second polling for payments lists. - **Works from**: any server, scheduled job, or serverless function. ### 2. SSE (Server-Sent Events) The 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). - **Entry point**: `GET https://pay.fynex.ai/checkout/{session_id}/events` - **Available today**: yes — used internally by the hosted checkout page. - **Works from**: browser only. The session ID is the credential; bearer tokens are not used. ### 3. Outbound webhooks Fynex POSTs a signed JSON event to a seller-controlled HTTPS endpoint when a payment or refund changes state. > [!NOTE] > **Available today.** Outbound webhooks are live and signature-verified — see the [Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks) guide for the full contract (configuration, HMAC-SHA256 signature, allowlist, retries). Payout events do not yet emit webhooks, so keep polling for payouts. --- ## Decision matrix Rows 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. | Use case | Polling | SSE | Webhooks | |---|---|---|---| | Real-time hosted-checkout result on the customer's browser | OK | **Recommended** | N/A | | Server-to-server payment authorization → finalize sequence | **Recommended** | Not suitable | OK | | Long-running payout settlement (minutes to hours) | **Recommended** | Not suitable | OK | | Daily reconciliation batch run | **Recommended** | Not suitable | Not suitable | | Webhook backup / dead-letter queue | **Recommended** | Not suitable | OK | | Mobile app payment status update | **Recommended** | Not suitable | OK | | Background fraud review handling | **Recommended** | Not suitable | **Recommended** | | Failed-payment alerting in operations dashboard | **Recommended** | Not suitable | **Recommended** | **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. --- ## Tradeoff analysis ### Polling **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. **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. **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](https://api.fynex.ai/payments-api/v2/docs#tag/errors). **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. **Implementation complexity**: low. A loop with a sleep, a deadline, and a terminal-state check is a dozen lines in any language. **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. --- ### SSE **Latency**: sub-second. Events are pushed as they are emitted by the server, with no polling interval in between. **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. **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. **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). **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. --- ### Webhooks **Latency**: seconds from the state transition. Push eliminates the polling gap entirely, and the event arrives without the customer's browser being open. **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. **Reliability**: depends on your implementation. Webhook delivery is inherently at-least-once with retries; without idempotency handling, you will process duplicate events. A dead-letter queue is essential for production. Reliability also depends on Fynex's retry policy (expected: backoff for at least 24 hours). **Implementation complexity**: the highest of the three. A correct webhook handler must: verify the HMAC-SHA256 signature on every request, deduplicate by `X-Fynex-Event-Id`, return a fast 200 (process async), handle retries gracefully, and monitor the dead-letter queue. **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. --- ## Recommended starting point per integration profile ### "I'm building a one-page checkout for my web shop" Use **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. ### "I have a B2B integration where a sales team calls the API directly" Use **server-to-server + polling on the backend**. Initialize and finalize payments from your own server; poll `genericPayment(id)` or the payout endpoint until terminal state. SSE is irrelevant here (no customer browser session). When webhooks ship, add them as a supplement. ### "I'm running a marketplace with thousands of payouts a day" Use **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. ### "I have a serverless architecture (Lambda, Cloud Functions) and don't want long-running pollers" Schedule 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. --- ## Hybrid patterns ### Polling + SSE for hosted checkout The most robust hosted-checkout implementation uses both patterns in parallel: 1. **Browser**: opens `EventSource` on `/checkout/{session_id}/events` for instant UI feedback. 2. **Backend**: polls `genericPayment(id)` (or receives the finalize response and verifies it) before writing the order-fulfilled record. The 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. ### Polling with exponential backoff A practical backoff schedule for payout settlement: ``` 0–10 s → poll every 1 s (immediate confirmation window) 10–70 s → poll every 5 s (typical fast-path settlement) 70 s–30 m → poll every 30 s (slow provider / queued) > 30 m → circuit-break, alert, stop polling ``` #### JavaScript ```js async function pollWithBackoff(id, fetchFn, isTerminal) { const schedule = [ { until: 10_000, interval: 1_000 }, { until: 70_000, interval: 5_000 }, { until: 30 * 60_000, interval: 30_000 }, ]; const start = Date.now(); while (true) { const elapsed = Date.now() - start; const stage = schedule.find(s => elapsed < s.until); if (!stage) throw new Error(`Circuit-break: ${id} not terminal after 30 min`); const resource = await fetchFn(id); if (isTerminal(resource.status)) return resource; await new Promise(r => setTimeout(r, stage.interval)); } } ``` #### Python ```python import time SCHEDULE = [ (10, 1), # 0–10 s: every 1 s (70, 5), # 10–70 s: every 5 s (30 * 60, 30), # 70 s–30 m: every 30 s ] TERMINAL = {"completed", "failed", "cancelled", "succeeded"} def poll_with_backoff(resource_id, fetch_fn): start = time.monotonic() while True: elapsed = time.monotonic() - start interval = next( (iv for limit, iv in SCHEDULE if elapsed < limit), None, ) if interval is None: raise TimeoutError(f"Circuit-break: {resource_id} not terminal after 30 min") resource = fetch_fn(resource_id) if resource["status"] in TERMINAL: return resource time.sleep(interval) ``` ### Polling backed by webhook fallback With outbound webhooks live, the recommended pattern is: **webhook delivers fast, polling catches what the webhook missed**. - Webhook arrives: update your local record immediately. - 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. - Deduplicate webhook deliveries so a late webhook and a poll that already updated the record don't conflict. --- ## Webhooks today Outbound webhooks are live and align with the status state machines. See the [Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks) guide for the authoritative contract; in summary: - **Payment / refund events** are delivered as Fynex fires them on state transitions (e.g. `PaymentCompleted`). - **Payout events** do not yet emit webhooks — poll for payout status. - **Delivery semantics**: at-least-once with retry-and-backoff; your receiver must return HTTP `200`. - **Signature verification**: each request carries an `X-Fynex-Signature` header containing an HMAC-SHA256 digest of the raw request body keyed with your webhook signing secret, plus an `X-Fynex-Timestamp`. - **Deduplication**: store and check the delivery's event identifier before processing. > [!NOTE] > The recommended default for server-side integrations is **"webhook + polling fallback"**: take webhooks as the fast path and poll as a backstop for any delivery that was missed. Polling remains fully production-ready on its own (and is the only option for payouts). ## See also - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Implementation guide: code snippets, cadence recommendations, and common pitfalls for both patterns. - **[Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks)** — The live outbound-webhook contract: configuration, HMAC-SHA256 signature verification, allowlist, and retries. - **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make your polling and retry logic safe so re-checks never replay a charge. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes and error shapes returned by the Fynex API. ## Checkout widget 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. > [!IMPORTANT] > 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. The widget exposes two modes: | Mode | Method | Use case | |------|--------|----------| | **Embed** | `Fynex.embed()` | Inline form inside a container element on your page. | | **Popup** | `Fynex.popup()` | Modal overlay on top of your page. | > [!NOTE] > For test/demo-mode sessions, the widget is an alternative to the full-page redirect described in [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#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. --- ## How it works ``` Your backend Fynex API Customer browser ──────────── ───────── ──────────────── POST /checkout ────► creates session returns sessionId ◄──── pass sessionId to frontend load widget.js Fynex.embed({ sessionId }) │ iframe → /checkout/{id}?mode=embed │ customer fills form (3DS handled inside iframe) │ postMessage to your page onSuccess(data) / onFailure(data) Your backend ────► poll genericPayment(id) to verify final state ``` --- ## Step-by-step 1. **Create a test/demo checkout session from your backend** 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](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) for the full request reference. ```bash curl -sS -X POST https://api.fynex.ai/payments-api/v1/checkout \ -H "Authorization: Bearer $FYNEX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "externalOrderRef": "ORDER-2099", "amount": 79.00, "currencyCode": "GBP", "countryCode": "GB", "returnUrls": { "success": "https://example.com/orders/2099/success", "failure": "https://example.com/orders/2099/failure" } }' # Response: { "sessionId": "6f9b84e1-...", "checkoutUrl": "...", "expiresAt": "..." } ``` Pass the `sessionId` to your frontend — do **not** expose your Bearer token to the browser. 2. **Include the widget script** 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. ```html ``` No API key or auth header is needed for the script itself — it is a public static asset. 3. **Launch the widget** Choose **embed** (inline) or **popup** (modal) depending on your UX preference. **Embed mode** — renders the checkout form inside a container element you control: ```html
``` **Popup mode** — opens a modal overlay over your page: ```html ``` 4. **Verify the payment server-side** 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. ```js // On your server, after onSuccess fires and the browser POSTs your endpoint: const { genericPayment } = await gql( `query ($id: Int!) { genericPayment(id: $id) { id status } }`, { id: paymentId } ); if (genericPayment.status === 'settled' || genericPayment.status === 'provider_completed') { fulfillOrder(); } ``` See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) for a complete server-side verification reference. --- ## API reference ### `Fynex.embed(opts)` → `{ destroy() }` | Option | Type | Required | Description | |--------|------|----------|-------------| | `sessionId` | string | Yes | The checkout session ID returned by `POST /payments-api/v1/checkout`. | | `container` | string \| Element | Yes | CSS selector (e.g. `'#payment-form'`) or a DOM element reference. | | `onSuccess` | function(data) | No | Called when the payment completes successfully. | | `onFailure` | function(data) | No | Called when the payment fails. | Returns an object with a `destroy()` method that removes the iframe and cleans up event listeners. ### `Fynex.popup(opts)` → `{ close() }` | Option | Type | Required | Description | |--------|------|----------|-------------| | `sessionId` | string | Yes | The checkout session ID. | | `onSuccess` | function(data) | No | Called when the payment completes successfully. | | `onFailure` | function(data) | No | Called when the payment fails. | | `onClose` | function() | No | Called when the overlay is dismissed (close button or click-outside). | Returns an object with a `close()` method that dismisses the overlay programmatically. ### Events (postMessage) The 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: {} }`. --- ## Common pitfalls > [!WARNING] > **Do not fulfil orders from `onSuccess` alone.** The callback fires on a client-side `postMessage`. Always verify the payment state server-side via the GraphQL `genericPayment` query before dispatching goods or services. > [!CAUTION] > **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. - **`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()`. - **`sessionId` is required.** Both `embed` and `popup` throw immediately if `sessionId` is missing or falsy. - **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`. - **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. - **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. ## See also - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Full-page redirect alternative — lowest integration effort. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Server-side payment verification after onSuccess fires. - **[Server-to-server payments](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Full control over the payment flow without any hosted page. ## Onboarding 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`. > [!NOTE] > 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](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth) for a standalone reference. --- ## Overview ``` Phase 1 — REST Phase 2 — GraphQL (optional) Phase 3 — REST + KYB ────────────── ──────────────────────────── ──────────────────── POST /onboarding/start createOrganization (staff only) POST /api/v1/kyb/initiate ↓ (cookie set) ↓ ↓ (KYB token) PATCH /onboarding/step ×N createLegalEntityByUuid KYB Web SDK (customer fills form) ↓ ↓ (legalEntityId) ↓ POST /verify-email createOrganizationProfileByUuid Fynex staff review + approve ↓ ↓ ↓ POST /onboarding/complete additional entities registered operationalMode → Live ↓ (org + legal entity + profile + Demo seller, sandbox provisioned) ``` --- ## Phase 1 — Sign-up (draft → verified → complete) Sign-up is a short, resumable wizard over REST. Four calls, all on `https://api.fynex.ai`, all cookie-authenticated after the first: | # | Call | What it does | |---|------|--------------| | 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. | | 2 | `PATCH /api/v1/onboarding/step` | Saves one wizard step: `{ "step": "", "data": { … } }`. Steps must be submitted in order; a step may be re-submitted to edit it. | | 3 | `POST /api/v1/verify-email` | Consumes the e-mailed code: `{ "code": "123456" }`. `POST /api/v1/verify-email/resend` sends a fresh one. | | 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. | > [!NOTE] > `start` already sets the session cookie — there is no separate sign-in step > during sign-up. `POST /api/v1/login/dashboard` is for **returning** sessions. ### Step names and what each collects The step sequence depends on `legalEntityType`, chosen in the first step: ``` legal_entity_type → personal_details → business_details → business_solutions → extra_info (Company) legal_entity_type → personal_details → individual_details → extra_info (Individual) ``` Every `data` object is a partial of one form; send only the fields the step collects. The fields are camelCase and match the wizard: `legalEntityType` (`Company` or `Individual`), `firstName`, `lastName`, `phone`, `role`, `businessName`, `industry`, `businessModel`, `country` (ISO 3166-1 alpha-2), `businessWebsite`, `registrationNumber`, `registeredAddress`, `estimatedMonthlyVolume`, `averageTransactionSize`, `paymentFlowsDescription`. `country` is screened against the supported jurisdictions when it is submitted; an unsupported country answers `422 countryNotSupported`. #### curl ```bash # 1 — start the draft; -c saves the session cookie, -b sends it on later calls curl -sS -c cookies.txt -X POST https://api.fynex.ai/api/v1/onboarding/start \ -H "Content-Type: application/json" \ -d '{"email": "alice@example.com", "password": "s3cur3-P@ssw0rd"}' # 2 — the wizard steps, in order curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \ -H "Content-Type: application/json" \ -d '{"step": "legal_entity_type", "data": {"legalEntityType": "Company"}}' curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \ -H "Content-Type: application/json" \ -d '{"step": "personal_details", "data": {"firstName": "Alice", "lastName": "Smith", "phone": "+447700900000", "role": "owner"}}' curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \ -H "Content-Type: application/json" \ -d '{"step": "business_details", "data": {"businessName": "Acme Ltd", "industry": "SaaS", "country": "GB", "registrationNumber": "12345678"}}' curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \ -H "Content-Type: application/json" \ -d '{"step": "business_solutions", "data": {"businessModel": "saas"}}' curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \ -H "Content-Type: application/json" \ -d '{"step": "extra_info", "data": {"businessWebsite": "https://acme.example"}}' # 3 — the code from the verification e-mail curl -sS -b cookies.txt -X POST https://api.fynex.ai/api/v1/verify-email \ -H "Content-Type: application/json" \ -d '{"code": "123456"}' # 4 — complete: organization, legal entity, profile and a Demo seller account curl -sS -b cookies.txt -X POST https://api.fynex.ai/api/v1/onboarding/complete # Response: { "userId": "42", "organizationUuid": "…", "legalEntityIds": [77], # "organizationProfileIds": [12], "permissionTypes": [...] } ``` #### JavaScript ```js const BASE = 'https://api.fynex.ai'; const post = (path, body, method = 'POST') => fetch(`${BASE}${path}`, { method, credentials: 'include', // stores and sends the HttpOnly cookie headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); await post('/api/v1/onboarding/start', { email: 'alice@example.com', password: 's3cur3-P@ssw0rd' }); for (const [step, data] of [ ['legal_entity_type', { legalEntityType: 'Company' }], ['personal_details', { firstName: 'Alice', lastName: 'Smith', phone: '+447700900000', role: 'owner' }], ['business_details', { businessName: 'Acme Ltd', industry: 'SaaS', country: 'GB', registrationNumber: '12345678' }], ['business_solutions', { businessModel: 'saas' }], ['extra_info', { businessWebsite: 'https://acme.example' }], ]) { await post('/api/v1/onboarding/step', { step, data }, 'PATCH'); } await post('/api/v1/verify-email', { code: codeFromEmail }); const { organizationUuid, legalEntityIds } = await (await post('/api/v1/onboarding/complete')).json(); ``` What `complete` leaves you with is a working **Demo** account: the sandbox auto-provisioning that runs on completion adds wallets, a demo card terminal, a demo counterparty payee, a pre-set split rule and three test customers, and issues the account's first API key pair (retrieve the secret from **Integration → API keys** in the dashboard — see [Getting your API token](#getting-your-api-token)). Nothing here moves real money; Phase 3 is what takes the account live. --- ## Phase 2 — Company creation (GraphQL chain) > [!IMPORTANT] > **Most integrations don't need this phase.** `POST /api/v1/onboarding/complete` (Phase 1) > **already creates** the organization, legal entity, and organization profile for you in a > single transaction — its response returns `organizationUuid`, `legalEntityIds` and > `organizationProfileIds`. The mutations below are only for advanced cases where you manage > **additional** legal entities or profiles under one account. To get an **API token**, you > don't run these — see [Getting your API token](#getting-your-api-token) below. Company 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`. ``` createOrganization ──► organizationUuid (staff surface only — see Step 1) │ ▼ createLegalEntity ──► legalEntityId (takes organizationUuid) │ ▼ createOrganizationProfile (takes both organizationUuid + legalEntityId) ``` > [!CAUTION] > **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. ### Helper: send a GraphQL mutation All three calls below share the same shape. Use this wrapper or your preferred GraphQL client. #### curl (reusable) ```bash gql() { curl -sS -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d "$1" } ``` #### JavaScript (reusable) ```js async function gql(query, variables = {}) { const res = await fetch('https://api.fynex.ai/dashboard/graphql', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), }); const body = await res.json(); if (body.errors?.length) throw new Error(body.errors[0].message); return body.data; } ``` #### Python (reusable) ```python def gql(session, query, variables=None): resp = session.post( 'https://api.fynex.ai/dashboard/graphql', json={'query': query, 'variables': variables or {}}, ) resp.raise_for_status() body = resp.json() if 'errors' in body: raise RuntimeError(body['errors'][0]['message']) return body['data'] ``` --- ### Step 1 — Create an organization > [!CAUTION] > **`createOrganization` is not exposed on `/dashboard/graphql`.** The mutation exists on the > Fynex staff surface only; a dashboard session calling it receives > `Cannot query field "createOrganization" on type "Mutation"`. Your organization is created by > `POST /api/v1/onboarding/complete` (Phase 1), which returns its `organizationUuid`. To add an > **additional** organization under the same account, ask your Fynex representative for it and > continue from Step 2 with the `organizationUuid` they give you. For reference, the operation as it exists on the staff surface — it takes a single `displayName` and returns the `organizationUuid` the next two mutations need: ```graphql # Not available on /dashboard/graphql — staff surface only. Shown for reference. mutation CreateOrganization($input: CreateOrganizationInput!) { createOrganization(input: $input) { organizationUuid displayName } } ``` **Input: `CreateOrganizationInput`** | Field | Type | Required | Description | |-------|------|----------|-------------| | `displayName` | String | Yes | Your company trading name as it appears in Fynex. | The samples below address the organization two ways. **Prefer the `…ByUuid` mutations**, which take the `organizationUuid` that `complete` returns: ```graphql mutation CreateLegalEntityByUuid($input: CreateLegalEntityByUuidInput!) { createLegalEntityByUuid(input: $input) { id name status } } ``` ```graphql mutation CreateOrganizationProfileByUuid($input: CreateOrganizationProfileByUuidInput!) { createOrganizationProfileByUuid(input: $input) { id statementDescriptor } } ``` The legacy `createLegalEntity` and `createOrganizationProfile` mutations were removed. Use the UUID-only `…ByUuid` mutations above and pass the `organizationUuid` returned by onboarding (or supplied by your Fynex representative). --- ### Step 2 — Create a legal entity `createLegalEntity` registers the incorporated company behind the organization. Requires the `organizationUuid` from step 1. ```graphql mutation CreateLegalEntity($input: CreateLegalEntityByUuidInput!) { createLegalEntityByUuid(input: $input) { id name legalName status } } ``` **Input: `CreateLegalEntityByUuidInput`** | Field | Type | Required | Description | |-------|------|----------|-------------| | `organizationUuid` | ID | Yes | UUID returned by `createOrganization`. | | `name` | String | Yes | Short display name for the legal entity. | | `type` | LegalEntityType | Yes | Corporate form, e.g. `limited_company`, `sole_trader`. | | `legalName` | String | Yes | Full registered legal name. | | `registrationCountry` | CountryCode | Yes | ISO 3166-1 alpha-2, e.g. `GB`. | | `registrationNumber` | String | Yes | Companies House / state registration number. | | `incorporationDate` | Time | No | RFC 3339 date, e.g. `"2019-03-15T00:00:00Z"`. | | `globalTaxId` | String | No | VAT / EIN / tax identification number. | #### curl ```bash gql "{ \"query\": \"mutation (\$input: CreateLegalEntityByUuidInput!) { createLegalEntityByUuid(input: \$input) { id name status } }\", \"variables\": { \"input\": { \"organizationUuid\": \"$ORG_UUID\", \"name\": \"Acme Ltd\", \"type\": \"limited_company\", \"legalName\": \"Acme Limited\", \"registrationCountry\": \"GB\", \"registrationNumber\": \"12345678\", \"incorporationDate\": \"2019-03-15T00:00:00Z\" } } }" # Response: { "data": { "createLegalEntity": { "id": 3, "name": "Acme Ltd", "status": "pending" } } } LEGAL_ENTITY_ID=3 ``` #### JavaScript ```js const { createLegalEntityByUuid } = await gql( `mutation ($input: CreateLegalEntityByUuidInput!) { createLegalEntityByUuid(input: $input) { id name status } }`, { input: { organizationUuid, name: 'Acme Ltd', type: 'limited_company', legalName: 'Acme Limited', registrationCountry: 'GB', registrationNumber: '12345678', incorporationDate: '2019-03-15T00:00:00Z', }, } ); const legalEntityId = createLegalEntityByUuid.id; // e.g. 3 ``` #### Python ```python data = gql(session, ''' mutation($input: CreateLegalEntityByUuidInput!) { createLegalEntityByUuid(input: $input) { id name status } } ''', { 'input': { 'organizationUuid': organization_uuid, 'name': 'Acme Ltd', 'type': 'limited_company', 'legalName': 'Acme Limited', 'registrationCountry': 'GB', 'registrationNumber': '12345678', 'incorporationDate': '2019-03-15T00:00:00Z', } }) legal_entity_id = data['createLegalEntityByUuid']['id'] # e.g. 3 ``` --- ### Step 3 — Create an organization profile `createOrganizationProfile` links the legal entity to the organization and sets operational parameters. This completes the company-creation chain. ```graphql mutation CreateOrganizationProfile($input: CreateOrganizationProfileByUuidInput!) { createOrganizationProfileByUuid(input: $input) { id countryCode statementDescriptor timezone } } ``` **Input: `CreateOrganizationProfileByUuidInput`** | Field | Type | Required | Description | |-------|------|----------|-------------| | `organizationUuid` | ID | Yes | From step 1. | | `legalEntityId` | Int | Yes | From step 2. | | `countryCode` | CountryCode | Yes | Country of primary operation, e.g. `GB`. | | `statementDescriptor` | String | Yes | Text that appears on the customer's bank statement (max ~22 chars). | | `timezone` | String | Yes | IANA timezone, e.g. `Europe/London`. The dashboard defaults to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. | | `industry` | String | No | Business industry / MCC category. | | `platformRoleId` | Int | No | Assigned by Fynex — leave unset unless instructed. | #### curl ```bash gql "{ \"query\": \"mutation (\$input: CreateOrganizationProfileByUuidInput!) { createOrganizationProfileByUuid(input: \$input) { id statementDescriptor } }\", \"variables\": { \"input\": { \"organizationUuid\": \"$ORG_UUID\", \"legalEntityId\": $LEGAL_ENTITY_ID, \"countryCode\": \"GB\", \"statementDescriptor\": \"ACME LTD\", \"timezone\": \"Europe/London\" } } }" ``` #### JavaScript ```js const { createOrganizationProfileByUuid } = await gql( `mutation ($input: CreateOrganizationProfileByUuidInput!) { createOrganizationProfileByUuid(input: $input) { id statementDescriptor } }`, { input: { organizationUuid, legalEntityId, countryCode: 'GB', statementDescriptor: 'ACME LTD', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, } ); ``` #### Python ```python import datetime data = gql(session, ''' mutation($input: CreateOrganizationProfileByUuidInput!) { createOrganizationProfileByUuid(input: $input) { id statementDescriptor } } ''', { 'input': { 'organizationUuid': organization_uuid, 'legalEntityId': legal_entity_id, 'countryCode': 'GB', 'statementDescriptor': 'ACME LTD', 'timezone': 'Europe/London', } }) profile_id = data['createOrganizationProfile']['id'] ``` --- ## Getting your API token Your API bearer token belongs to a **seller account**. Create one with the `createSellerAccount` mutation — it mints and returns the token immediately. You need a `legalEntityId` and an `organizationProfileId` (both came back in your login response), plus a `name` and `currency`. ```bash curl -sS -b cookies.txt -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation Create($input: CreateSellerAccountInput!) { createSellerAccount(input: $input) { id name status operationalMode authorizationToken } }", "variables": { "input": { "legalEntityId": 3, "organizationProfileId": 5, "name": "Acme Ltd", "currency": "GBP" } } }' # data.createSellerAccount.authorizationToken is your /payments-api/v1 Bearer token ``` The dashboard equivalent is the **Integration** page (reveal / copy, or **Generate Token**). To rotate, call `regenerateSellerAccountToken(id:)` or use the dashboard's **Regenerate** button. > [!IMPORTANT] > A new seller account is created in `Demo` mode and **activated automatically**, so the token > works immediately for **sandbox** payments. **KYB approval** is what flips the account to `Live` > for real-money processing (with a Fynex-assigned live terminal). If a sandbox call returns > `403 seller account is not active`, the token is still valid — the account just isn't active > yet. See [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication). --- ## Phase 3 — KYB (Know Your Business) Fynex 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. All three KYB endpoints require the `dashboard_session` cookie. | Endpoint | Purpose | |----------|---------| | `POST /api/v1/kyb/initiate` | Start a KYB application. Returns `{ token, status }`. | | `GET /api/v1/kyb/token` | Refresh an existing KYB access token (tokens expire). | | `GET /api/v1/kyb/status` | Poll the current KYB state. | 1. **Initiate KYB and obtain the KYB access token** #### curl ```bash curl -sS -b cookies.txt \ -X POST https://api.fynex.ai/api/v1/kyb/initiate \ -H "Content-Type: application/json" # Response: { "token": "_act-...", "status": "pending" } ``` #### JavaScript ```js const res = await fetch('https://api.fynex.ai/api/v1/kyb/initiate', { method: 'POST', credentials: 'include', }); const { token, status } = await res.json(); ``` #### Python ```python resp = session.post('https://api.fynex.ai/api/v1/kyb/initiate') resp.raise_for_status() kyb = resp.json() kyb_token = kyb['token'] ``` 2. **Embed the KYB provider's Web SDK in your UI** 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: ```js // When the SDK calls your token refresh handler: const refreshRes = await fetch('https://api.fynex.ai/api/v1/kyb/token', { credentials: 'include', }); const { token: newToken } = await refreshRes.json(); return newToken; // return to the SDK ``` A minimal SDK integration: ```html
``` 3. **Poll KYB status** After the customer submits the form, Fynex staff review the application. Poll `/api/v1/kyb/status` until the status is terminal. ```js const statusRes = await fetch('https://api.fynex.ai/api/v1/kyb/status', { credentials: 'include', }); const { status } = await statusRes.json(); // status values: "init" | "pending" | "approved" | "rejected" ``` When approved, your `SellerAccount.operationalMode` flips from `Demo` to `Live`. > [!CAUTION] > **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. --- ## Common pitfalls | Pitfall | Fix | |---------|-----| | `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. | | `step` answers `409 invalidDraftState` | Steps must be submitted in order; you skipped one, or the draft is already completed. | | `createOrganizationProfile` fails mid-chain | No automatic rollback. Contact your Fynex representative to clean up orphaned records before retrying. | | KYB token expired | Call `GET /api/v1/kyb/token` and pass the refreshed token to the SDK. | | 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. | | `timezone` missing from `createOrganizationProfile` | Required field. Use `Intl.DateTimeFormat().resolvedOptions().timeZone` in a browser or an IANA string server-side. | ## See also - **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Full reference for session cookies and the /dashboard/graphql endpoint. - **[Going Live](https://api.fynex.ai/payments-api/v2/docs#tag/going-live)** — Checklist and final steps once KYB is approved and operationalMode is live. - **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Start accepting payments immediately after your account is active. ## Going live You've built and tested your integration in staging. Before processing real payments, work through every section of this checklist. Each item maps to a specific system behavior; none of the items here are aspirational. Just signed up? Start with [Onboarding & KYB](https://api.fynex.ai/payments-api/v2/docs#tag/onboarding) before working through this checklist. ## 1. Token management Get your production bearer token yourself from the **Integration** page of the production dashboard (`https://dashboard.fynex.ai`) — log in, select your seller account, reveal the token, and copy it (see [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)). Production and staging tokens are independent; the production token becomes available once your KYB approval is complete (see [section 7](#7-kyb-and-operational-mode)). If you don't have a production dashboard login yet, ask your Fynex representative to set you up. Once you have a production token: - [ ] Store it in a secrets manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, or equivalent). Never commit it to source control or write it to application logs. - [ ] Load it into your application via an environment variable (`FYNEX_TOKEN`). Keep staging and production tokens in separate secret paths/namespaces. - [ ] Document a token rotation procedure. Rotation is atomic — you replace the token in one step with no grace period. Plan for a brief maintenance window or use a blue/green secret-swap pattern so in-flight requests finish before the old token is retired. #### curl (env-var loading) ```bash # Verify the token resolves before deploying curl -sS "$FYNEX_API/payments-api/v1/payment-methods" \ -H "Authorization: Bearer $FYNEX_TOKEN" | jq . ``` #### JavaScript ```js // Load token from environment — never hard-code const token = process.env.FYNEX_TOKEN; if (!token) throw new Error('FYNEX_TOKEN is not set'); ``` #### Python ```python import os token = os.environ.get("FYNEX_TOKEN") if not token: raise RuntimeError("FYNEX_TOKEN is not set") ``` Token rotation is available via the GraphQL mutation `regenerateSellerAccountToken(id: Int!)` on `/dashboard/graphql`. The mutation returns a new `SellerAccount` with the updated `authorizationToken`. Update your secret store immediately after calling it — the old token stops working at the same moment. ## 2. Endpoint switch-over Update `FYNEX_API` (and any dashboard URLs) across your codebase: | | Staging | Production | |---|---------|------------| | API base | `https://staging-api.fynex.ai/payments-api/v1` | `https://api.fynex.ai/payments-api/v1` | | Customer dashboard | `https://staging-dashboard.fynex.ai` | `https://dashboard.fynex.ai` | Drive these from a single environment variable so you can switch environments without changing application code: ```bash # staging FYNEX_API=https://staging-api.fynex.ai # production FYNEX_API=https://api.fynex.ai ``` - [ ] Confirm `FYNEX_API` is set to the production base URL in your production environment. - [ ] Confirm no staging URL is hard-coded anywhere in your payment flow code. - [ ] Smoke-test `GET $FYNEX_API/payments-api/v1/payment-methods` with the production token before routing real traffic. ## 3. Status verification (polling / SSE) Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). Polling or SSE remain available — and are recommended as a backstop — to verify the final payment state before fulfilling orders or releasing goods. (Payouts do not yet emit webhooks; poll for payout status.) - [ ] Your fulfilment logic reads payment status from the API — it does not rely solely on a redirect URL or a query parameter that the customer could manipulate. - [ ] Your polling loop has a maximum number of attempts and a fallback (e.g., mark the order as "pending review" after 10 minutes of inconclusive polling). - [ ] SSE (`/checkout/{session_id}/events`) is only available while the customer's browser is on the hosted checkout page. For server-side verification, use the GraphQL `genericPayment(id)` query. See the [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) guide for code samples and status transition reference. ## 4. Idempotency persistence - [ ] Every `POST` request (`/initialize-payment`, `/finalize-payment`, `/checkout`, `/payments/{id}/capture`, `/payments/{id}/refund`, `/payouts`) sends a unique `Idempotency-Key` UUID. - [ ] Idempotency keys are **persisted with the order** in your database before the request is sent. A pod restart, crash, or retry must reuse the original key — not generate a new one. - [ ] Keys are generated from a cryptographically secure source (e.g., `crypto.randomUUID()` in Node, `uuid.uuid4()` in Python). Do not use `Math.random()` or sequential IDs. - [ ] You treat `200 OK` from a repeated create as a successful idempotency replay. A `409 Conflict` means the key was reused with different financial fields and must be handled as an error, not as success. See [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency) for the full replay contract. ## 5. Logging and monitoring Wire alerts on these signals: - [ ] HTTP error rate on Fynex API calls above 1% sustained over 5 minutes. - [ ] `502 Bad Gateway` rate above 0.5% (signals an upstream provider issue). - [ ] Payment status remaining in a non-terminal state (e.g., `new`, `routed`, `provider_pending`) for more than 15 minutes. - [ ] Capture-to-refund ratio outside your expected range. - [ ] Decline rate rising more than 5% week-over-week. > [!CAUTION] > Do not log the raw card number (PAN), CVV, or full track data at any point in your > pipeline. Audit your log pipeline — structured logging frameworks can inadvertently > serialize entire request bodies. Log the Fynex `paymentId` (the numeric ID on the `GenericPayment` object) alongside your internal order ID on every payment event. This is what Fynex support will ask for when you raise an issue. > [!NOTE] > There are no `X-Fynex-Request-Id` or `X-Fynex-Trace-Id` response headers in the > current API. Use the `paymentId` from the response body as your primary correlation key. ## 6. Customer experience - [ ] **Decline messaging** — use the human-readable `failureDescription` field from the payment response to show the customer why a payment failed. Do not display the numeric `failureCode` directly; treat it as an opaque internal identifier. - [ ] **Retry with a different card** — after a decline, your UI should offer a clear path to re-enter card details. Generate a fresh `Idempotency-Key` for the retry attempt. - [ ] **3DS handling** — if your server-to-server integration receives `requiresAction: true` with a `redirectUrl`, redirect the customer immediately and preserve the original `Idempotency-Key` for the finalize call. Do not generate a new key after the 3DS redirect. See [Server-to-server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) for the full flow. - [ ] **Error state UI** — distinguish between "payment failed" (terminal) and "payment status unknown" (polling timed out). Show different messages and offer appropriate next steps. See [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) for the full error shape and status code reference. ## 7. KYB and operational mode Each seller account has an `OperationalMode` field that is either `Demo` or `Live` (the GraphQL `SellerAccountOperationalMode` enum is case-sensitive — capitalized). Payments processed in `Demo` mode do not move real funds. 1. **Complete KYB** — your Fynex representative will guide you through the KYB (Know Your Business) submission via the KYB provider's flow. You can check KYB status via the GraphQL `kybStatus` query. 2. **Wait for approval** — Fynex staff review and approve the KYB submission. 3. **Flip operational mode** — after KYB approval, contact your Fynex representative to switch your seller account from `Demo` to `Live`. This flip is staff-mediated today; there is no self-service control for it in the seller dashboard. 4. **Confirm the mode** — query your seller account via GraphQL to verify `operationalMode` is `Live` before routing real traffic. > [!WARNING] > Do not route real customer payments to an account still in `Demo` mode. The API will > accept the request but no real funds will be processed or settled. ## 8. PCI scope | Integration type | PCI scope | |-----------------|-----------| | Hosted checkout | SAQ A — card data never touches your servers | | Server-to-server | SAQ D — your servers handle card numbers; full PCI assessment required | - [ ] PCI scope confirmed with your compliance team. - [ ] Customer-facing privacy policy mentions Fynex as a payment processor. - [ ] Logs never contain PAN, CVV, or full track data (see [section 5](#5-logging-and-monitoring)). ## 9. Sandbox test cards > [!CAUTION] > Test cards work **only on a Demo account** — that is decided by the account's operational > mode, not by which host you call. Do not ship code that hard-codes them; on a Live account > they are declined, or worse, charged if the number happens to belong to a real card. Fynex's sandbox is backed by an upstream card processor's test environment, where the PAN selects the 3DS authentication outcome. `4000 0000 0000 2701` (Visa) and `5200 0000 0000 2235` (Mastercard) authenticate frictionlessly and settle — use those for a success. On any flow that runs 3DS (the hosted checkout always does), a PAN that does not authenticate cannot be settled. See the [Test cards & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards) guide for the full set of sandbox card numbers and decline codes. Use any future expiry date and any 3-digit CVV in the sandbox. ## 10. Cutover plan Run a cautious rollout rather than flipping all traffic at once: 1. **Internal soft-launch** — route only your own team's test orders through production. Verify a real card payment, a capture, and a refund end-to-end. 2. **5% canary** — route 5% of live traffic to Fynex for 24 hours. Monitor decline rate, error rate, and latency p99. 3. **Ramp to 50%** — after 48 hours of clean canary metrics, increase to 50%. 4. **Full cut-over** — after a further 72 hours of clean data, move to 100%. Keep a feature flag that can re-route payments back to your previous processor. Leave it in place for at least two weeks post-cutover. If anything goes wrong during ramp-up, the rollback is a single config change. ## 11. Post-launch review Schedule a review 7 days after full cut-over: - [ ] Decline rate — compare to your baseline from staging and industry benchmarks. - [ ] Dispute / chargeback rate — should be near zero in the first week; investigate any spike immediately. - [ ] Refund rate — track against your expected return rate. - [ ] Support ticket volume — identify any friction in the payment flow from customer complaints. - [ ] Reconciliation — verify settlement amounts match your expected revenue. > [!NOTE] > If anything looks off, reach out at **support@fynex.ai** with your seller account ID > and the relevant `paymentId` values and we'll investigate. ## See also - **[Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)** — How bearer tokens work, how to rotate them, and auth failure modes. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment outcomes without webhooks. - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — Status codes, error shapes, and how to handle declines. - **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Safe retry patterns and key persistence requirements. ## Troubleshooting 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. > [!NOTE] > 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. --- ## 1. Authentication & tokens ### Q: I'm getting `401 Unauthorized` on every request Check the `Authorization` header format. The correct form is: ```http Authorization: Bearer ``` Common mistakes: omitting the word `Bearer`, adding extra whitespace, wrapping the token in quotes, or using a staging token against the production base URL (`https://api.fynex.ai`) or vice versa. Staging tokens only work against `https://staging-api.fynex.ai`. A quick smoke-test is `GET /payment-methods` — a `200 OK` confirms the token is valid and the seller account is active. ### Q: The 401 response body is plain text, not JSON — my client is crashing trying to parse it That is expected. Errors thrown by the `SellerAccountAuthMiddleware` use Go's `http.Error()`, which returns `text/plain`. Examples: `seller authorization token is required`, `unauthorized`. Once a request passes authentication and enters a handler, all subsequent error responses are JSON `{"error": "..."}`. Handle the 401 case in your HTTP client before you attempt JSON parsing. See [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) for the full error-shape reference. ### Q: I get a 401 with the message `"sellerAccountId is missing in auth context"` on `POST /checkout` even though my Bearer token is correct This 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. See [Request Headers](https://api.fynex.ai/payments-api/v2/docs#tag/headers) and [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency). ### Q: How do I rotate my token? Use the GraphQL mutation `regenerateSellerAccountToken` on `/dashboard/graphql` (cookie-session authenticated). Supply your seller account numeric ID: ```graphql mutation RotateToken($merchantId: ID!) { regenerateSellerAccountToken(merchantId: $merchantId) { id authorizationToken } } ``` > [!CAUTION] > 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. See [Authentication & Tokens](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) for the full rotation steps. ### Q: Do tokens expire? No. Tokens have no expiry by default. The only way to invalidate a token is to rotate it via `regenerateSellerAccountToken`. --- ## 2. Payment creation — `/initialize-payment` and `/checkout` ### Q: I sent `successUrl` and `cancelUrl` but they were silently ignored Top-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**: - **`POST /checkout`** (hosted checkout) — nest URLs under `returnUrls` as an object: ```json { "returnUrls": { "success": "https://example.com/orders/123/success", "failure": "https://example.com/orders/123/failure" } } ``` - **`POST /initialize-payment`** (server-to-server) — pass `returnLinks` as an **array** of `{rel, href, method}`: ```json { "returnLinks": [ { "rel": "on_completed", "href": "https://example.com/orders/123/success", "method": "GET" }, { "rel": "on_failed", "href": "https://example.com/orders/123/failure", "method": "GET" }, { "rel": "default", "href": "https://example.com/orders/123/return", "method": "GET" } ] } ``` 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`. See [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) and [Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server). ### Q: `/initialize-payment` returns `400 {"error":"valid returnLinks are required"}` The 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. ### Q: I sent a `metadata` field on `/initialize-payment` but it doesn't appear in the response `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. ### Q: `/initialize-payment` rejects my currency code, but `/checkout` accepts it `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. ### 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` The 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`. The 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. See [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency) for the full retry semantics. ### Q: My idempotency key gets a `409` — can I reuse it? Idempotency 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. Same key + different body on `/initialize-payment` returns `409 Conflict` with a concrete reason — for example: ```json { "error": "Idempotency-Key reused with a different currencyCode: original=USD, request=EUR" } ``` The 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. See [Idempotency & retries / Reusing a key with a different body](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency). See [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency). ### Q: Do I need to call `/finalize-payment` even when `requiresAction` is `false`? Almost always: yes. `/initialize-payment` returns `202 Accepted` for a new payment but does not confirm capture. Call `/finalize-payment` and check the `status` field before fulfilling the order. The single exception is the `skip3DS: true` server-to-server path. There the upstream payment handle is created already in `PAYABLE` state, and Fynex's upstream status poller auto-finalizes the payment on its next tick (~5 seconds). For those payments you do not need to call `/finalize-payment` yourself — poll `genericPayment(id)` until `status` reaches `provider_completed` or a terminal failure. See [Server-to-Server / Skipping 3DS](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server). ### Q: I'm getting `502 {"error":"upstream card processor returned 400"}` (the payment's `failureCode` is `2002`) — what's missing? The 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: ```json "billingDetails": { "country": "GB", "zip": "SW1A1AA" } ``` If you also see `fieldErrors` in the upstream body for `billingDetails.state`, that field is required by the upstream processor for US/CA cards. ### Q: I get `502 {"error":"no active terminal found for seller account"}` even though my seller account is active The 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. First 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`. If 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). --- ## 3. 3DS handling (server-to-server) ### Q: After the 3DS redirect I'm losing my checkout state — the resume page has no payment ID The 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: ```js // Before redirect localStorage.setItem('checkout_payment_id', paymentId); localStorage.setItem('checkout_amount', String(amount)); // On return page (your returnUrl) const paymentId = localStorage.getItem('checkout_payment_id'); await finalizePayment(paymentId); localStorage.removeItem('checkout_payment_id'); localStorage.removeItem('checkout_amount'); ``` The Fynex hosted dashboard uses `checkout_*` keys for this purpose. If you share an origin with the dashboard, use distinct key names to avoid collisions. See [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds). ### Q: React strict-mode is firing my finalize call twice and I'm getting a double-capture error React 18+ strict mode invokes effects twice in development. Guard your `/finalize-payment` call with a `useRef` flag: ```jsx const hasSubmitted = useRef(false); useEffect(() => { if (hasSubmitted.current) return; hasSubmitted.current = true; finalizePayment(paymentId); }, []); ``` See [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds). ### Q: How do I know whether 3DS will be triggered before I redirect the customer? You don't know in advance — it depends on the card issuer's risk decision. After calling `POST /initialize-payment`, inspect the response: if `requiresAction` is `true`, the customer must complete the 3DS challenge at `actionUrl`. If `requiresAction` is `false`, the payment may already be in `authorized` or `provider_completed` state and you can proceed directly to `/finalize-payment`. ### Q: Can I use an iframe for the 3DS challenge page? No. Card issuers reject embedded challenges. The redirect to `actionUrl` must be a full-page browser navigation (`window.location.href = actionUrl`). ### Q: I get `failureCode: 2002` and a 502 `{"error":"upstream card processor returned 400"}` after calling `/finalize-payment` The upstream response (visible in the platform-side payment exchange logs) carries a field-level error on `paymentHandle`, reporting that the handle is in a state from which a payment cannot be taken. This 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: - 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`. - 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). If 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 & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards). --- ## 4. Captures & refunds ### Q: Capture returns `409 "invalid status transition from to capture"` Capture has two hard pre-conditions: 1. The payment must have been created with `autoSettlement: false` (and `captureMode: "manual"`). If `autoSettlement` was `true`, a `409` with `"manual settlement required"` is returned. 2. The current payment status must be `authorized` or `provider_completed`. Any other status — including `settled`, `provider_pending`, or `failed` — returns the invalid-transition 409. 3. No refund can be pending or already successful for the payment. If a refund is pending, capture returns `409 "capture is not allowed while a refund is in progress"`; if any refund succeeded, capture returns `409 "capture is not allowed after a successful refund"`. Check the payment's current status before calling capture. Use the GraphQL `genericPayment(id)` query to fetch it. ### Q: Refund returns `409 "refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments"` The payment has not been captured or settled yet, or it is in a non-retryable terminal state. Refunds are available in `provider_completed`, `settled`, or `deposit_confirmed`; failed/cancelled refund attempts can also be retried from `refund_failed` or `refund_cancelled`. ### Q: What is `provider_completed`? I expected `captured` There is no `captured` status in the Fynex payment lifecycle. After a successful capture, the payment moves to `provider_completed`. The statuses `captured`, `partially_captured`, and `partially_refunded` do not exist. See the [Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle) guide for the full status enum. ### Q: The capture/refund path parameter takes my numeric internal ID, right? No. 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. ```bash # Correct — use your externalOrderRef string curl -X POST "$FYNEX_API/payments/ORDER-1042/capture" ... # Wrong — do not use a numeric internal ID curl -X POST "$FYNEX_API/payments/12345/capture" ... ``` ### Q: I issued a partial refund successfully, but a second partial refund returns `409` After the first refund call, the payment moves to `refund_pending`. You cannot issue another refund until the first one completes. A successful partial refund returns the parent payment to a refundable captured state; a full cumulative refund reaches `refunded`; failed/cancelled attempts move to `refund_failed`/`refund_cancelled` and can be retried. --- ## 5. Payouts ### Q: My `Idempotency-Key` header is being ignored on `POST /payouts` `POST /payouts` does not read the `Idempotency-Key` HTTP header for deduplication. Pass idempotency as a body field named `idempotencyKey` instead: ```json { "walletId": 15, "amountMinor": 199900, "currencyCode": "GBP", "idempotencyKey": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b" } ``` All other POST endpoints in this API use the `Idempotency-Key` header. Payouts are the sole exception. See [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts) and [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency). ### Q: I'm getting `409 "insufficient balance"` but I can see funds in the wallet Check 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. ### Q: Payout amounts — should I send major or minor units? Payouts use **minor units** (`amountMinor`). For £19.99 send `1999`. This differs from every other endpoint in the API, which use major units. Double-check the field name: the payout body field is `amountMinor`, not `amount`. ### Q: The `failureMessage` field on `GET /payouts/{id}` is always empty — is that a bug? Yes, this is a known implementation gap. `failureMessage` appears in the response schema but is never populated by the server. Use `failureCode` to detect and classify payout failures; do not rely on `failureMessage` for message text. --- ## 6. Verification & polling ### Q: How do I know when a payment succeeds if there are no webhooks? Outbound webhooks are not yet available. Two options: - **Polling:** query the GraphQL `genericPayment(id)` endpoint (requires a dashboard session cookie) until `status` reaches a terminal value. - **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. The Fynex dashboard polls every 5 seconds. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse). ### Q: I'm polling for `status === "succeeded"` and never getting a match The status `succeeded` does not exist in the `GenericPaymentStatus` enum. Terminal success statuses are `settled` and `deposit_confirmed` (after settlement) or `provider_completed` (immediately after capture, before settlement). Check for these values instead. The full status enum is documented in [Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle). ### Q: The customer was redirected to my `returnUrls.success` page — can I fulfil the order now? No. 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. ### Q: How long should I poll before giving up? For card payments: 10–15 minutes is a reasonable outer bound. Bank transfers may take longer. If the payment stays in a non-terminal state beyond your deadline, surface a `pending_review` UX state and alert your operations team — do not keep polling indefinitely. Once a payment reaches a terminal state (`settled`, `deposit_confirmed`, `failed`, `cancelled`, `refunded`), write it to your database and stop polling that record. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse). --- ## 7. GraphQL / Dashboard API ### Q: GraphQL returns `401` with my Bearer token `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: ```bash curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Then send GraphQL requests with the saved cookie curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ payees(limit: 5) { id displayName } }"}' ``` See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth). ### Q: Sign-up succeeded but my next GraphQL request gets `401` `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 & onboarding](https://api.fynex.ai/payments-api/v2/docs#tag/onboarding)). For an existing account, `POST /api/v1/login/dashboard` obtains the cookie. See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth). ### Q: My dashboard session disappeared and all GraphQL requests started returning `401` Dashboard 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. ### Q: Where is the GraphQL Playground? A 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. --- ## 8. Apple Pay & Google Pay (legacy surface) ### Q: Apple Pay merchant validation is failing in production The 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. See [Apple Pay](https://api.fynex.ai/payments-api/v2/docs#tag/apple-pay). ### Q: Apple Pay only shows in Safari — is that expected? Yes. `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()`: ```js function isApplePayAvailable() { return typeof window !== 'undefined' && 'ApplePaySession' in window && ApplePaySession.canMakePayments(); } ``` ### Q: Google Pay tokenization is being rejected by Fynex As 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. ### Q: `isReadyToPay()` returns `true` but the Google Pay sheet shows no payment methods `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. ### Q: The Google Pay flow returned `authorizationLink` — what do I do with it? A 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. See [Google Pay](https://api.fynex.ai/payments-api/v2/docs#tag/google-pay) and [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds). --- ## Cross-reference: common error messages | HTTP status | Message | Cause | Guide | |-------------|---------|-------|-------| | `400` | `Idempotency-Key header is required` | Missing `Idempotency-Key` header on a POST endpoint | [Headers](https://api.fynex.ai/payments-api/v2/docs#tag/headers) | | `400` | `Idempotency-Key must be a valid UUID` | Header value is not a UUID v4 | [Idempotency](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency) | | `400` | `currencyCode is required` | Missing `currencyCode` field | [Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) | | `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](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm) | | `400` | `invalid request body` | JSON parse failed — check `Content-Type: application/json` and body syntax | [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) | | `401` | `seller authorization token is required` (plain text) | Missing or malformed `Authorization` header | [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) | | `401` | `sellerAccountId is missing in auth context` | Missing or invalid `Idempotency-Key` on `POST /checkout` | [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) | | `403` | `resource does not belong to this seller` | Token is valid but the resource belongs to a different seller account | [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) | | `409` | `invalid status transition from to capture` | Payment not in `authorized` or `provider_completed` state | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) | | `409` | `capture is not allowed while a refund is in progress` | A refund has been reserved/submitted and has not terminalized yet | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) | | `409` | `capture is not allowed after a successful refund` | A refund already succeeded for this payment, so capture is closed | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) | | `409` | `refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments` | Payment is not in a refundable captured state, or the previous refund attempt is still pending/already fully refunded | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) | | `409` | `insufficient balance` | Wallet `availableBalanceMinor` is below the requested payout amount | [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts) | | `409` | `payout with this idempotency key already exists` | Replay of a successful payout — fetch the existing payout instead of creating a new one | [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts) | | `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](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) | | `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](https://api.fynex.ai/payments-api/v2/docs#tag/3ds) | | `502` | (any other) | Upstream processor error — safe to retry with the same idempotency key | [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) | ## See also - **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — Full HTTP status code and error body reference with recovery guidance. - **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — How to verify payment and payout status without webhooks. - **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make all mutating calls safe to retry with idempotency keys. - **[Authentication & Tokens](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)** — Bearer token provisioning, rotation, and common auth errors. ## GraphQL auth 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`. This endpoint is the right choice when you need operations that have no REST equivalent: full payee CRUD, payout method management, wallet queries, split rules, reconciliation data, and token rotation. --- ## When to use the GraphQL endpoint Use `/dashboard/graphql` (cookie-session auth) when you need to: - Create, update, or delete payees and their payout methods. - Query wallets, reconciliation statements, or split executions. - Rotate the bearer token that your server-to-server REST integration uses (`regenerateSellerAccountToken`). - Access any query or mutation not exposed on the REST surface. > [!NOTE] > Sellers who build **custom dashboard integrations** — or who need programmatic access to payee/wallet data — are the primary audience for this guide. --- ## Authentication flow 1. **Obtain a session cookie** by posting credentials to the login endpoint: ```http POST /api/v1/login/dashboard Content-Type: application/json { "email": "you@example.com", "password": "your_password" } ``` 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. 2. **Send GraphQL requests** to `/dashboard/graphql` with the cookie attached. Set `Content-Type: application/json` and include the cookie on every request. 3. **Log out** when done (optional but recommended for server-side scripts): ```http POST /api/v1/logout ``` This clears the cookie server-side. > [!CAUTION] > **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 & onboarding](https://api.fynex.ai/payments-api/v2/docs#tag/onboarding)). For an existing account, `POST /api/v1/login/dashboard` is the way to a cookie. --- ## Code samples — login and first query #### curl ```bash # Step 1 — login and save the cookie curl -sc cookies.txt \ -X POST https://api.fynex.ai/api/v1/login/dashboard \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}' # Step 2 — send a GraphQL query using the saved cookie curl -b cookies.txt \ -X POST https://api.fynex.ai/dashboard/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "query ListPayees($limit: Int, $offset: Int) { payees(limit: $limit, offset: $offset) { id displayName role status } }", "variables": { "limit": 20, "offset": 0 } }' ``` #### JavaScript ```js const BASE = 'https://api.fynex.ai'; // Step 1 — login; browser (or same-origin server) sends/receives the cookie automatically async function login(email, password) { const res = await fetch(`${BASE}/api/v1/login/dashboard`, { method: 'POST', credentials: 'include', // required — sends and stores the HttpOnly cookie headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new Error(`Login failed: ${res.status}`); return res.json(); // { userId, permissions, onboardingStatus } } // Step 2 — send any GraphQL operation async function gql(query, variables = {}) { const res = await fetch(`${BASE}/dashboard/graphql`, { method: 'POST', credentials: 'include', // cookie is attached automatically headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), }); const { data, errors } = await res.json(); if (errors?.length) throw new Error(errors[0].message); return data; } // Usage await login('you@example.com', 'your_password'); const { payees } = await gql( `query ($limit: Int, $offset: Int) { payees(limit: $limit, offset: $offset) { id displayName role status } }`, { limit: 20, offset: 0 } ); console.log(payees); ``` #### Python ```python import requests BASE = "https://api.fynex.ai" # requests.Session persists cookies across calls automatically session = requests.Session() # Step 1 — login resp = session.post( f"{BASE}/api/v1/login/dashboard", json={"email": "you@example.com", "password": "your_password"}, ) resp.raise_for_status() # raises on 4xx/5xx # Step 2 — send a GraphQL query query = """ query ListPayees($limit: Int, $offset: Int) { payees(limit: $limit, offset: $offset) { id displayName role status } } """ resp = session.post( f"{BASE}/dashboard/graphql", json={"query": query, "variables": {"limit": 20, "offset": 0}}, ) resp.raise_for_status() data = resp.json() if "errors" in data: raise RuntimeError(data["errors"][0]["message"]) print(data["data"]["payees"]) ``` --- ## Session properties | Property | Value | |----------|-------| | Cookie name | `dashboard_session` | | Cookie flags | HttpOnly, SameSite (not accessible from JavaScript) | | Session TTL | 24 hours (in-memory; not clustered — a server restart invalidates all sessions) | | Auth required | Every request to `/dashboard/graphql` must carry the cookie | | Multiple sessions | Each login creates a new session; logout clears both `dashboard_session` and `backoffice_session` | --- ## Receipts are bound to the session Some mutations refuse to act on a bare id and require a **receipt** issued by an earlier call — `activateSplitRule` takes the `activationReceipt` that `previewSplit(ruleId:)` returned. That receipt is signed with a key derived from the `dashboard_session` that ran the preview, and it also names the actor, the rule and a fingerprint of the exact rule snapshot previewed. It is valid for **10 minutes**. The consequence for a scripted integration: preview and activate must happen **inside the same session**. A receipt presented from a different cookie — after a re-login, from a second worker, or after a server restart invalidated the session — is refused with *"invalid split activation preview receipt"*, and an expired one with *"split activation preview receipt expired; run preview again"*. Editing the rule in between invalidates it too. Hold one session for the whole preview → activate sequence, and treat any receipt error as "preview again", never as "retry the activation". See **[Split payments](https://api.fynex.ai/payments-api/v2/docs#tag/splits)** for the full lifecycle. --- ## GraphQL endpoint details | Property | Value | |----------|-------| | URL | `https://api.fynex.ai/dashboard/graphql` | | Method | `POST` | | Content-Type | `application/json` | | Body shape | `{ "query": "...", "variables": { ... } }` | | Error shape | `{ "errors": [{ "message": "..." }] }` | A GraphQL Playground is available at `GET /dashboard/playground` (no auth gate on the playground page itself — useful for manual exploration). --- ## Rotating your bearer token from GraphQL `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. ```graphql mutation RotateToken($merchantId: ID!) { regenerateSellerAccountToken(merchantId: $merchantId) { id authorizationToken } } ``` Supply 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. > [!CAUTION] > 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. --- ## Common pitfalls | Pitfall | Resolution | |---------|------------| | Calling `/dashboard/graphql` with a Bearer token | This endpoint does not support bearer auth. Use the `dashboard_session` cookie instead. | | 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. | | Session lost on server restart | Sessions are held in-memory on a single node. A restart invalidates all active sessions — clients must re-authenticate. | | Cookie not sent by the browser | Ensure you use `credentials: 'include'` on every `fetch` call (or the equivalent in your HTTP client). | | `401` on the GraphQL endpoint | Either the cookie is absent, expired (>24 h), or was invalidated by a server restart. Re-login to get a fresh cookie. | | `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. | ## See also - **[Authentication & Tokens (Bearer)](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)** — Bearer token auth for the server-to-server REST API. - **[Payees](https://api.fynex.ai/payments-api/v2/docs#tag/payees)** — Create and manage payees via REST and GraphQL. - **[Payout Methods](https://api.fynex.ai/payments-api/v2/docs#tag/payout-methods)** — Register bank accounts as payout destinations for your payees. --- # Fynex Billing API — integration reference Base URL: `https://api.fynex.ai/billing-api/v1` in production, `https://staging-api.fynex.ai/billing-api/v1` for sandbox work. The examples below use `$FYNEX_API_BASE` for whichever of the two you are on, as the rest of these guides do. Your key decides which data you see: a `sk_test_…` key is test mode wherever you send it, a `sk_live_…` key is live. This REST API is the only supported external billing surface; internal GraphQL is never part of the contract. ## Call convention Send `Authorization: Bearer sk_test_…` or `Authorization: Bearer sk_live_…` on every operation. Publishable keys, dashboard sessions, cookies, legacy tokens and query-string credentials are rejected. The secret key defines the seller tenant; another seller's id is indistinguishable from a missing id (`404`). All responses include `X-Request-Id`; provide it to support. Clients must ignore unknown response fields and tolerate unknown enum values. Amounts are integer minor units with the resource's ISO 4217 `currency`. Quantities, rates and percentages are decimal strings. Times are RFC 3339 UTC; date-only invoice filters mean UTC midnight. Do not use floating-point money. Lists use keyset paging: request `limit` (1–100, default 20), then pass `nextCursor` back as `cursor` while `hasMore` is true — the same loop on every list. Contracts walk forward by ascending id; every other list is newest first. The original directional names (`beforeId`/`nextBeforeId`, `afterId`/`nextAfterId`) still work and are still returned. | Status | Meaning | Retry | | --- | --- | --- | | `400` | Invalid id, filter, paging or date parameter | Fix request | | `401` / `403` | Invalid key / inactive seller | Fix credentials or account | | `404` | Missing or foreign resource | No | | `422` | Valid request cannot apply in current state | Wait for state change | | `429` | Seller budget exhausted | Wait `Retry-After` | | `5xx` | Fynex fault | Retry with exponential backoff and jitter | Error bodies are `{"error":"message"}`. Rate-limit headers are advisory: read `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `Retry-After` instead of hard-coding a quota. ## Operations | Method and path | Use | | --- | --- | | `GET /contracts` | Find contracts (`cursor`, `limit`; walks forward). | | `GET /contracts/{contractId}/usage` | Current open-period metric usage. Empty metrics is valid. | | `GET /contracts/{contractId}/credits` | Complete balances and paged immutable ledger. | | `GET /credits` | Seller-wide credit balances. May return `501` when credits are disabled. | | `GET /invoices` | Newest-first invoices; filter by contract, customer, origin, status, type, correction, and issue range. | | `GET /invoices/{invoiceId}` | Invoice plus immutable lines and frozen buyer identity. | | `GET /invoices/{invoiceId}/pdf` | Rendered legal document. | | `POST /invoices/{invoiceId}/send` | Create or return the invoice's hosted collection link. | | `GET /subscriptions` / `GET /subscriptions/{subscriptionId}` | Recurring billing state and price. | | `POST /prices/evaluate` | What a pricing configuration charges at given quantities — the billing engine, pure. | The generated [OpenAPI document](./openapi.json) is the typed source of truth for every parameter and response field. The interactive version is [`/billing-api/v1/docs`](./docs). ## Invoice collection `POST /invoices/{invoiceId}/send` takes no idempotency header: the invoice id is itself the collection anchor, so a transport retry — and any concurrent call — converges on one payment link and does not send another invitation. The action is unavailable (`422`) for a bank-transfer invoice, draft/paid/voided/written-off invoice, or one with no collectible balance. Invoices and credit-ledger rows are immutable. Corrections are separate credit notes; never expect a historical invoice to be edited or deleted. To determine whether an invoice is settled, use `status` and `paidVia`, not `collectibleMinor` or `creditSettledMinor`. Billing emits signed webhooks on the shared notify pipe: `InvoiceIssued`, `InvoiceSent`, `InvoicePaid`, `InvoiceOverdue`, `InvoiceVoided`, `InvoiceWrittenOff`, `CreditApplied`, `CreditDepleted`, and the ten `BillingSubscription*` lifecycle events — staged in the same transaction as the state change, delivered with `X-Fynex-Signature` (HMAC-SHA256 of the raw body) and `X-Fynex-Timestamp`, and retried until 2xx. The body is an envelope (`eventId`, `eventType`, `sellerAccountUuid`, `occurredAt`) with the event's fields under `payload`; every event is typed in the OpenAPI document's `webhooks` block. Polling `GET /invoices` with a bounded issue-date range remains the right tool for reconciliation. ## Metered usage This API reads the meter; the write half is `/billing-api/v1/usage`, same secret key (it also answers on the older `/usage/v1` prefix). `POST /billing-api/v1/usage/metrics` registers a meter (`name`, `unit`, `aggregation`: sum/max/min/last/count/count_unique — the last requires `uniqueKey`, the metadata key counted distinct, capped at 1000 values per period). `PUT /billing-api/v1/usage/contracts/{contractId}/metrics/{metricName}/price` sets its pricing — scheme `per_unit`, `graduated`, `volume`, `tiered`, `package` or `percentage`, with included units, rollover and commitment floors/caps; rates there are decimal strings in MAJOR units, unlike every amount this API returns. `POST /billing-api/v1/usage/events` reports one event (`contractId`, `metric`, `quantity` as a decimal string, `occurredAt`, `idempotencyKey`), `:batch` reports many, `/billing-api/v1/usage/csv` takes a file. A `201` with `"metered": false` means the event was stored but no active subscription schedule covers `occurredAt` — it will not be billed. There are no write endpoints for contracts, subscriptions, invoices or credits. ## Compatibility and lifecycle The `/v1` path is the major-version contract. Additive fields and endpoints may appear without a version change; removing or renaming a field, changing a type or meaning, adding a required request value, narrowing an enum, or changing money semantics requires a new major version and a published migration notice. Invoice statuses are `draft`, `issued`, `sent`, `paid`, `overdue`, `voided`, and `written_off`; subscriptions expose their current status and scheduled cancellation or price change fields. --- # Fynex Billing API The Billing API is the public, versioned REST surface for billing data: invoices, subscriptions, contracts, metered usage, and credit balances. Your systems read the documents the billing engine issues, raise collection on them, and write the metering that drives them — see **What v1 does, and what it does not** below for exactly where that line falls. All endpoints live under the path prefix: ``` /billing-api/v1 ``` The host depends on the environment you were onboarded to — see **Base URL** in the Quickstart. Throughout these guides examples use `$FYNEX_API_BASE`, which you set once to your host plus that prefix. Authenticate every request with your seller secret key (`sk_test_…` or `sk_live_…`) in the `Authorization` header. **New here? Read Concepts first** — five objects and the relationships between them, in two minutes. **Then the Quickstart**, which goes from issuing a key to listing invoices and downloading a PDF. Then: | Guide | What it covers | | --- | --- | | Concepts | The five objects, how they relate, and the three questions their names do not answer. | | Quickstart | Your first calls, and what each failure status means. | | Authentication | Key format, environments, rotation, failure contract. | | Errors | Status contract, rate limits, what is safe to retry. | | Sandbox & testing | Hosts, keys, test cards, how to exercise date-driven flows without waiting, and the flags a sandbox needs on. | | Pagination & Amounts | The keyset paging loop; minor units and decimal strings. | | Endpoint reference | Every operation in detail: parameters, response fields, failure modes, worked examples. | | Workflows | Collecting on an invoice, polling for settlement, reconciling a period, watching usage and credit. | | Usage ingestion | The write half of metered billing: metrics, pricing schemes, events, inbound webhooks, and the price preview. | | Webhooks | The billing event catalog: invoice lifecycle and credit events, signing, retries. | | Code examples | Paging and retry loops in Python, Node, Go and shell; generating a typed client. | ## What v1 does, and what it does not The line runs between **documents** and **metering**, not between reads and writes. **Every object this API lists can also be created through it.** The line that remains is between *creating* a document and *editing* one: contracts are amended by appending a version, invoices are corrected by issuing a credit note, and neither is ever changed in place. **Six surfaces are writable.** - **Contracts.** `POST /contracts` creates one in `draft` for a customer you own (with a required `Idempotency-Key`), and `POST /contracts/{contractId}/amendments` appends a version — a status move, new dates, a replaced component set — guarded by the `expectedBaseVersion` you last read. Closing a contract does not cancel its subscriptions: cancel those first — see **Contracts**. - **Invoices.** `POST /invoices` composes, numbers and (by default) sends a document in one call, with a required `Idempotency-Key`; it is immutable from the moment it is issued — see **Invoices**. `POST /invoices/{invoiceId}/send` raises or re-raises the collection link. - **Subscriptions.** `POST /contracts/{contractId}/subscriptions` creates one on a contract you own (with a required `Idempotency-Key`), and `/subscriptions/{subscriptionId}/cancel`, `/pause`, `/resume`, `/end-trial` and `/change-plan` drive its lifecycle — see **Subscriptions**. Marking a subscription past-due or recovered is not yours to do: that is the collection loop's verdict. - **Credit top-ups.** `POST /contracts/{contractId}/credits/top-up` grants a credit lot on a contract you own, with a required `Idempotency-Key` whose body *is* compared — see **Credits**. It is the only way credit enters the ledger; the deductions, expiries and reversals that take it out are the engine's rows. - **Customers.** `POST /customers` resolves one of your customers by e-mail, creating the record when the address is new — the `sellerCustomerId` every contract, credit grant and invoice refers to. No `Idempotency-Key`: the address is the key, so the call always answers `200`. - **Metering**, described in full two paragraphs down. Read-only does not mean static. On a seller who has them enabled, background passes issue invoices for due subscription terms and closed usage, and charge sent invoices against the customer's saved card. So a list can grow and a document can reach `paid` with no call of yours — see **Sandbox & testing** for the switches that decide it. **Metering is fully writable.** Registering a metric, pricing it, sending events and previewing what a configuration charges are all part of this API — see the two paragraphs below and the **Usage ingestion** guide. Nothing about metered billing requires the dashboard. Issued documents stay immutable either way: corrections are new credit notes, never edits. **Billing emits webhooks** — `InvoiceIssued`/`Sent`/`Paid`/`Overdue`/`Voided`/ `WrittenOff` and `CreditApplied`/`CreditDepleted` — through the same signed delivery pipe as payment events: one receiver handles both. See the **Webhooks** guide. Polling `GET /invoices` remains the right tool for reconciliation. **Metered usage is reported under `/billing-api/v1/usage`**: register a metric, price it, then send events singly, in batches or by CSV. That is what `GET /billing-api/v1/contracts/{contractId}/usage` reads back. The **Usage ingestion** guide documents it end to end. Two related surfaces are documented separately and are **not** duplicated here: - **Payments API** (`/payments-api/v1`) — payments, payouts, wallets, webhooks, and **top-up invoices**. - **Payment Links API** (`/api/v1/payment-links`) — standalone payment-link management. Billing invoices create their own collection links via `POST /billing-api/v1/invoices/{invoiceId}/send`; the resulting hosted payment page URL is returned on the invoice itself. --- # Overview ## Resources | Resource | What it is | | --- | --- | | Contract | The commercial agreement between you and one of your customers. Invoices, subscriptions, usage and credits all hang off a contract. | | Invoice | An immutable, numbered billing document. Once issued it is never edited — corrections are separate credit-note documents. | | Subscription | A recurring billing relationship on a contract: cadence, price, trial, pause and cancellation state. | | Usage | Metered consumption aggregated per contract and billing period, with plan allowances and caps. | | Credits | Stored-value balances (prepayments, promotional credit) and their append-only ledger. | ## Versioning The path prefix (`/billing-api/v1`) is the API version. Backwards-compatible additions (new fields, new endpoints) happen within a version; breaking changes get a new prefix. Unknown response fields must be ignored by clients. ## Identifiers Billing resources use numeric `id`s (`int64`), unique per resource type. Contract, invoice, and subscription ids are safe to store and to use in URLs. Your tenant identity is derived from the API key — it is never passed as a parameter, and objects belonging to other sellers are indistinguishable from missing ones (`404`). ## Immutability Issued invoices are append-only legal records. There is no update or delete: a wrong invoice is voided or corrected by a credit note, and both documents remain visible in listings. Credit ledger entries are likewise append-only. --- # Concepts Five objects, and the relationships between them are not obvious from their names. Read this once and the rest of the reference follows; skip it and the most likely mistake is inventing a call that cannot exist. ``` ┌──────────────┐ │ Customer │ who you bill └──────┬───────┘ │ one customer, many contracts ┌──────▼───────┐ │ Contract │ the agreement: currency, term └──────┬───────┘ ┌────────────────┼────────────────┐ │ │ │ ┌────────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ Subscription │ │ Usage │ │ Credits │ │ recurring fee │ │ metered qty │ │ stored value│ └────────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └───────┬────────┘ │ drawn down at issue │ billed by a run │ ┌──────▼───────┐ │ │ Invoice │◄─────────────────┘ │ immutable │ └──────────────┘ ``` **Contract is the anchor.** Everything else hangs off one. A subscription, a usage meter, a credit balance and an invoice all name a contract, and the contract carries the currency they must all agree on. **Invoice is a document, not a record you edit.** Once issued it has a number and is immutable. A correction is a new document — a credit note — and both stay visible in every listing. There is no `PATCH` and no `DELETE`, which means an agent cannot be told to "fix" an invoice: the only correct move is to issue a correcting one. ## Three questions the object names do not answer ### Can an invoice exist without a contract? **No.** Every invoice names a contract, and issuing one without it is refused before anything is written. **But you do not have to create the contract first.** An ad-hoc invoice — a one-off with `origin: adhoc` — provisions what it needs from the bill-to you type: the customer is found or created by email, and a live contract in the same currency is reused if one exists, created if not. So a one-off stays one call. Send the same recipient a second one-off and it lands on the same customer and the same contract rather than minting duplicates. This provisioning is deliberately **only** for ad-hoc documents. A usage or recurring invoice with no contract is a caller mistake, and inventing a contract for it would detach that revenue from the agreement it belongs to. ### Does a subscription generate invoices automatically? **No — not by itself.** Two different mechanisms are easy to confuse: - The **lifecycle pass** advances a subscription's *state* on its dates: a trial activates, a cancellation takes effect after its notice period, a fixed end expires it, and a term that has rolled over gets the term now in force. It issues no documents at all. - The **billing run** raises the money. It bills each revenue model on a contract — recurring, then usage, then project work, then any one-time line — each through its own engine, each producing its own document so the invoice's `origin` stays truthful. So a subscription's period turning over does not, on its own, produce an invoice. Something has to run the billing — and on a seller who has the issuance passes switched on (`billing.recurring.invoice_pass`, `billing.usage.invoice_pass`), that something is a schedule, not a person. Both default to off. Write your integration so a document appearing without you is normal, not an anomaly. **Double billing is prevented by claims, not by memory.** A subscription period lands on a unique `(subscription, period_start)` row, usage lines and project work flip guarded status columns, and every claim's invoice carries its id. Re-running a billing run finishes only what is missing; an ambiguous commit is repaired by looking the claim up, never by issuing again. A model that fails does not roll back a sibling's document — a numbered invoice cannot be un-issued — and the run reports per model what happened. ### Do credits apply before or after tax? **After.** Credit is a payment method, not a discount. It draws down the invoice's grand total — the amount *including* tax — and never reduces the taxable base. The tax the document reports is the tax on the full price, whatever the customer's balance was. Two consequences worth knowing before you model this: - A credit-funded invoice is neither *expected* nor *received* cash. It is its own thing, and reporting treats it that way. - **An invoice collected by bank transfer draws no credit at all.** The buyer was told to wire the grand total; shrinking the collectible underneath that instruction would make every full-face wire arrive as an overpayment. The balance stays on the ledger for the next link-collected document. ## Three words that are one letter of confusion apart The platform has three roles, and two of their field names differ by a single word while meaning entirely different things: The direction of the money is what separates them: ``` Customer ──── pays ────► Seller ──── pays ────► Payee sellerCustomerId sellerAccountId payeeId your buyer YOU, the key holder who you disburse to Billing API both APIs Payments API ``` | Word | Who | Lives on | | --- | --- | --- | | **Seller** (`sellerAccountId`) | **You** — the account holder the API key belongs to. Every object in both APIs is scoped to one. | Both APIs | | **Customer** (`sellerCustomerId`) | Who **pays you** — the party your invoices are addressed to. | This API | | **Payee** (`payeeId`) | Who **you pay** — a counterparty receiving money through split payments and payouts. | Payments API | `sellerAccountId` and `sellerCustomerId` look like siblings and are not: the first is your own identity, the second is your customer's. A marketplace operator is all three sentences at once — they **are** a Fynex seller, they **have** customers who pay invoices, and they **have** payees who receive splits and payouts. If you think in the words *vendor*, *merchant* or *supplier*: the merchant running the account is the **seller**; a vendor or supplier you disburse money to is a **payee**; the buyer you bill is a **customer**. The settlement-trail endpoint (`GET /invoices/{invoiceId}/settlement`) is where the two APIs meet: a customer's payment on this side becomes a payee's payout on the other. ## Where money and quantities live | Thing | On the wire | | --- | --- | | An amount of money | Integer **minor units**, field ends `Minor` — `4999` is €49.99 | | A rate or percentage | Integer **basis points**, field ends `Bps` — `275` is 2.75% | | A metered quantity | Decimal **string** — `"1250.5"` | | A per-unit rate | Decimal **string** in MAJOR units — `"0.004"`, the one deliberate exception, because a rate is routinely finer than a minor unit | Everything on a contract shares that contract's currency. There is no conversion anywhere in this API: a mismatch is refused rather than converted. --- # Quickstart This walks from a fresh API key to a working integration. Every example is copy-pasteable; replace the key and ids with your own. ## 1. Get a key In the Fynex dashboard, open **Integration** in the left-hand menu and issue a **secret** key from the API keys card. - `sk_test_…` — authenticates while your account is in demo mode. - `sk_live_…` — authenticates once your account is live. An account is in exactly one of those modes, so exactly one of the two key environments works at any time. Keys are stored only as hashes, so a key is shown once, at creation. If you lose it, regenerate — there is no recovery. Keep keys server-side: they carry full read access to your billing data. ## 2. Set your base URL Every endpoint sits under the path `/billing-api/v1`. The host depends on the environment you were onboarded to, so the examples below use a variable: ```bash export FYNEX_API_BASE="https://staging-api.fynex.ai/billing-api/v1" export FYNEX_API_KEY="sk_test_4f6f..." ``` | Environment | Host | | --- | --- | | Staging | `https://staging-api.fynex.ai` | | Production | Confirm your production host with Fynex before going live — it is assigned per deployment, and a `sk_live_` key is only accepted by the environment that issued it. | You are reading these docs on the same host the API runs on, so `/billing-api/v1/openapi.json` and `/billing-api/v1/docs` are always reachable relative to wherever this page is served. ## 3. Make your first call `GET /contracts` is the safest first request — it needs no ids and tells you whether your key, environment, and network path all work: ```bash curl -s $FYNEX_API_BASE/contracts \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "contracts": [ { "contractId": 42, "contractNumber": "UK2607AA", "version": 1, "sellerCustomerId": 7, "currency": "EUR", "status": "active", "startDate": "2026-01-01", "customerName": "Ada Lovelace", "customerCompanyName": "Harbour Group BV" } ] } ``` `contractId` is the key to every per-contract endpoint that follows. If instead you get: - `401` — the header is missing or the key is wrong. The format is `Authorization: Bearer sk_live_…`; a bare key without `Bearer ` fails. - `403` — the key is valid but the seller account is not active. Check the account status in the dashboard. - an empty `contracts` array — the key works, but this account has no contracts yet. ## 4. List invoices ```bash curl -s "$FYNEX_API_BASE/invoices?limit=5" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "invoices": [ { "id": 4180, "contractId": 42, "sellerCustomerId": 7, "origin": "usage", "invoiceNumber": "UK2607AA-2608AAB", "jurisdiction": "UK", "invoiceType": "standard", "status": "sent", "currency": "EUR", "issueDate": "2026-08-01T00:00:00Z", "dueDate": "2026-08-15T00:00:00Z", "subtotalMinor": 10000, "taxTotalMinor": 2000, "grandTotalMinor": 12000, "creditAppliedMinor": 0, "collectibleMinor": 12000, "creditSettledMinor": 0, "reverseCharge": false, "paymentLinkId": 991 } ], "hasMore": true, "nextCursor": 4180, "nextBeforeId": 4180 } ``` Two things to internalize right now, because they cause most integration bugs: - **Amounts are integers in minor units.** `12000` in EUR is €120.00. Never parse them as floats. - **`grandTotalMinor` is the document total, `collectibleMinor` is what collection asks the customer for.** They differ when stored credit funded part of the invoice. Neither drops to zero once the invoice is paid — to decide "does this customer still owe us money", read `status`. - **`creditSettledMinor` is not a payment total.** It counts only stored credit drawn down, so a card-paid invoice reports `0` there. ## 5. Get one invoice with its lines ```bash curl -s $FYNEX_API_BASE/invoices/4180 \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "invoice": { "id": 4180, "invoiceNumber": "UK2607AA-2608AAB", "...": "..." }, "lines": [ { "position": 1, "description": "API calls over allowance", "quantity": "250", "unitPriceMinor": 40, "discountMinor": 0, "netAmountMinor": 10000, "taxCategory": "standard", "taxRate": "20", "taxAmountMinor": 2000, "sourceRef": "usage:api_calls:2026-07" } ] } ``` `quantity` and `taxRate` are decimal **strings** — they carry fractional precision that a JSON number would round. Feed them to a decimal type, not a float. ## 6. Download the PDF ```bash curl -s $FYNEX_API_BASE/invoices/4180/pdf \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -o invoice-UK2607AA-2608AAB.pdf ``` The response is `application/pdf` bytes with a `Content-Disposition` filename. It renders from the document's frozen payload, so a PDF fetched today and one fetched next year are identical. ## Next - **Workflows** shows the end-to-end recipes: collecting on an invoice, reconciling a month, watching usage against limits. - **Pagination & Amounts** covers the paging loop you will need past 20 rows. - **Errors** covers the status contract, rate limits, and retry policy. --- # Authentication Every Billing API request must carry a seller secret key: ``` Authorization: Bearer sk_live_4f6f... ``` Keys come in two environments, and the environment must match your account's current mode: a Fynex seller account operates either in demo mode or live mode, never both at once. While the account is in demo mode only `sk_test_…` keys authenticate; after go-live only `sk_live_…` keys do, and the old test key answers `401` from then on. There is no second, parallel dataset behind the two prefixes — the API always returns your account's own billing records. Keys are issued and rotated in the Fynex dashboard, on the **Integration** page, and are stored server-side only as SHA-256 hashes; a lost key cannot be recovered, only regenerated. Only `sk_test_…` and `sk_live_…` secret keys authenticate this public API. Publishable `pk_…` keys, dashboard sessions, legacy seller tokens, cookies and query-string credentials are rejected. This is a server-to-server API: never call it from a browser or mobile app, and rotate or revoke a compromised key in the dashboard immediately. No Billing-specific token store or session is created; authorization reuses the existing hashed API-key record and its active/revoked and environment checks. Failure contract: - `401` — the header is missing, malformed, or the key is unknown or revoked. - `403` — the key is valid but the seller account is not active. The seller account behind the key is the authoritative tenant: every object this API returns belongs to that seller, and ids belonging to another tenant answer `404`. Never embed secret keys in client-side code; calls must originate from your servers. --- # Sandbox & testing Everything here works without touching live money. The goal: your first successful call within minutes, and a full trial-to-renewal subscription exercised without waiting a month of wall-clock time. ## Hosts | Environment | Host | | --- | --- | | Staging | `https://staging-api.fynex.ai` | | Production | Assigned per deployment — confirm yours with Fynex before go-live. | The environment that matters is your **account's**, not the host's: a Fynex account operates in demo mode or live mode, and test behaviour follows the account. Demo accounts exist on both hosts. Both hosts are also declared in the OpenAPI document itself, in its `servers` block — production first, the sandbox second, each labelled. A generated client or an agent that reads only the spec can therefore pick the sandbox rather than defaulting to the one host it happens to see. Each entry is the bare origin; the `/billing-api/v1` prefix is already part of every path in the document, so join a server with a path exactly as written and do not add the prefix twice. ## Keys **No account yet? Mint a sandbox in one call.** `POST /sandbox/accounts` with a JSON body creates an anonymous demo seller — wallets, a demo terminal, a pre-set split rule, three test customers — and returns its `sk_test_` key: ```bash curl -X POST https://staging-api.fynex.ai/sandbox/accounts \ -H "Content-Type: application/json" -d '{}' ``` The door is open on the sandbox host (`staging-api.fynex.ai`); a host that has it switched off answers `503` to this call and shows no `/sandbox` page. The secret is shown once; the keys are time-limited — `expiresAt` in the response says exactly when they stop working. An anonymous sandbox has no dashboard login — it is a key, not a user — and it is a test environment: never enter real personal or bank details. The `/sandbox` page on the API host has the full contract. For your own account, issue a **secret** key in the Fynex dashboard: **Integration** in the left-hand menu, then the API keys card. While your account is in demo mode the key is `sk_test_…`; after go-live only `sk_live_…` keys authenticate and the test key answers `401` from then on. A key is shown once, at creation — store it server-side and never in a browser. The same key authenticates every surface in these docs: the Billing API and usage ingestion under `/billing-api/v1`, and the Payments API. ## Test cards On a demo account, hosted payment pages (including the links `POST /invoices/{invoiceId}/send` creates) accept these Visa sandbox numbers: | Card number | Notes | | --- | --- | | `4111 1111 1111 1111` | Universal Visa test card | | `4530 9100 0001 2345` | Visa | | `4037 1122 3300 0001` | Visa | For all of them: any future expiry, any 3-digit CVV, Latin cardholder name. Paying an invoice's link with one of these drives the document through `sent → paid` exactly as a live card would, so it is the way to test the settlement-polling loop end to end. ## Time travel Subscription flows — trial ending, first charge, renewal — run on billing dates, and a sandbox that makes you wait 30 real days for a renewal is not a sandbox. **Each contract can be given a test clock of its own and moved forward**, with a `sk_test_` key and no help from us: | Call | What it does | | --- | --- | | `GET /contracts/{contractId}/test-clock` | Reads what the billing engines treat as *now* for that one contract. | | `POST /contracts/{contractId}/test-clock/advance` | Moves it forward to `to`, then runs that contract's subscription lifecycle and recurring invoicing as of it. | **Test-mode (demo) accounts only.** A live account answers `403` on both: a live subscription's renewal is real revenue and a real document to a real customer. **One contract at a time.** The clock belongs to the contract in the path. Advancing it never moves a sibling contract — yours or another account's — and no scheduled pass sees a changed clock. That is why this is safe to publish. **Forward only, capped at 366 days.** `to` must be after the contract's current clock. Re-sending the instant the clock already sits at is a **no-op**: `200` with `advanced: false`, nothing renewed and nothing issued twice, so the call is safe to retry. A target in the past is `400` — the invoices an advance mints are numbered and immutable, so a clock cannot be wound back. ### Worked example: a monthly subscription renewing in one call Create the subscription today (`42` is a contract that already carries at least one invoice — see the note below): ```bash curl -s -X POST "$FYNEX_API_BASE/contracts/42/subscriptions" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"frequency":"monthly","priceMinor":4999,"currency":"EUR","startDate":"2026-01-01"}' ``` Ask where the contract's clock is: ```bash curl -s "$FYNEX_API_BASE/contracts/42/test-clock" \ -H "Authorization: Bearer $FYNEX_API_KEY" # {"contractId":42,"now":"2026-01-01T09:15:00Z","simulated":false} ``` Advance it past the end of the first term — 31 days: ```bash curl -s -X POST "$FYNEX_API_BASE/contracts/42/test-clock/advance" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to":"2026-02-01T00:00:00Z"}' ``` ```json { "clock": { "contractId": 42, "now": "2026-02-01T00:00:00Z", "simulated": true }, "advanced": true, "lifecycle": { "renewed": 1, "trialsActivated": 0, "cronDisabled": false }, "invoices": { "issued": 1, "sent": 1, "skipped": 0, "failed": 0, "passDisabled": false } } ``` The renewal is now visible on the two reads that matter — the subscription's new term, and the document it earned: ```bash curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \ -H "Authorization: Bearer $FYNEX_API_KEY" # currentPeriodStart moves to 2026-02-01, currentPeriodEnd to 2026-03-01 curl -s "$FYNEX_API_BASE/invoices?contractId=42" \ -H "Authorization: Bearer $FYNEX_API_KEY" # one more invoice, origin "recurring", for the new term ``` `GET /contracts/{contractId}/invoices/upcoming` before the advance, and the issued document after it, should agree — which is the cheapest end-to-end check that your contract is configured the way you think it is. ### What it does not advance The clock moves the **subscription state machine** and the **recurring invoice**. It does *not*: - close metered usage periods or mint usage invoices, - send dunning e-mail for a subscription that went `past_due`, - run off-session auto-charge on an issued invoice, - move the issue date: an invoice raised by an advance is dated at REAL time (so its number stays in the real month's gapless sequence) while its period is the simulated term — read the period, not the date, to check the renewal. Those stay on real time in their own scheduled passes, and every advance repeats the list in its `notAdvanced` field. Do not write a test that waits on one of them after an advance. ### When an advance renews but issues nothing Two operator switches decide whether the halves above run at all, and the response says which one stopped: | Response field | Switch | Symptom | | --- | --- | --- | | `lifecycle.cronDisabled` | `billing.subscription.lifecycle_cron` | Nothing renews, no matter how far the clock moves. | | `invoices.passDisabled` | `billing.recurring.invoice_pass` | The term renews, and no document appears on `GET /invoices`. | Both default to **off** — see the table below. There is also one data precondition the clock cannot supply: the recurring lane continues a contract's existing invoicing pattern, so a contract with **no prior invoice** is reported in `invoices.skipped` rather than issued. Raise the first document once with `POST /invoices` (or let a contract you already invoiced carry the subscription), and every later advance issues by itself. The staff-operated clock is a different thing and is not this. It shifts the lifecycle dates of test-mode *payment-link* subscriptions (the recurring hosted-checkout product), estate-wide, and never touches the billing subscriptions this reference describes. ## What a fresh sandbox has switched off Several billing capabilities sit behind operator feature flags that default to **off**, because each one moves money or issues documents on a schedule. A sandbox where they are off looks broken — subscriptions never charge, usage periods never close into invoices, no invoice is ever raised, credits report `501` — when it is merely unconfigured. If a flow below does nothing, ask Fynex to confirm the flag before debugging your integration: | Flag | What it gates | | --- | --- | | `billing.subscription.lifecycle_cron` | The pass that ends trials, charges renewals and applies scheduled plan changes. | | `billing.usage.close_pass` | The pass that closes usage periods and mints usage invoice lines. | | `billing.recurring.invoice_pass` | The pass that turns due subscription terms into issued invoices. | | `billing.usage.invoice_pass` | The pass that composes closed usage lines into issued invoices. Separate from the recurring one on purpose — a seller may be ready to auto-bill terms and not metered usage, or the reverse. | | `billing.proration.invoice_binding` | Whether a subscription's unbilled mid-term proration is put on the invoice its term is billed on — a charge as an extra line, a credit as a discount on the recurring line, any remainder as a non-expiring `proration` credit lot. While off, a mid-term change is still recorded against the contract but never reaches a document. | | `billing.invoice.auto_charge` | Off-session collection: the loop that charges a customer's saved card for a sent or overdue invoice. Without it a document is only ever paid by someone visiting its link or making a transfer. | | `payment_links.recurring_billing` | Recurring charging through hosted payment links. | | `billing.credits` | Stored-value credits; while off, the credit endpoints answer `501` and invoices draw no credit down. | **Closing a period is not the same as billing it.** The close pass and the invoice passes are different switches: with only `billing.usage.close_pass` on, `GET /contracts/{contractId}/usage` moves and periods close, and no invoice ever appears on `GET /invoices`. That pairing is the single most common reason a sandbox looks like it has stopped halfway. ## A sandbox session that proves the loop The target this page is written against is the one in our own onboarding PRD: **a first transaction under 30 minutes, self-serve** — from a fresh sandbox key to an invoice on `GET /invoices` without talking to anyone at Fynex. The steps below are that path; if one of them cannot be done inside that budget, the gap is ours, not yours. 1. `GET /contracts` — key, host and network path all work. 2. Register a metric and price it (see **Usage ingestion**), send a few events, and watch `GET /contracts/{contractId}/usage` move. 3. Create a subscription in the dashboard with a trial that has already ended, and let the lifecycle pass run — the renewal invoice appears on `GET /invoices`. 4. `POST /invoices/{invoiceId}/send`, pay the link with a test card, and poll the invoice to `paid`. That is the whole billing loop — metering, rating, issuance, collection, settlement — without a real card or a real month. --- # Errors Errors are JSON with a single field: ```json {"error": "invoice not found"} ``` (Authentication failures from the gateway layer may answer with a plain-text body; treat any non-2xx as failed regardless of body shape.) Every response, including `401`, `429`, and server errors, carries `X-Request-Id: req_`. Include this value in a support request; it is the safe correlation handle for a request, not your API key or customer data. | Status | Meaning | | --- | --- | | `400` | The request is malformed: an unparseable id, an unknown filter value, an invalid parameter. | | `401` | Missing or invalid API key. | | `403` | The key is valid but the seller account is not active. | | `404` | The object does not exist — or belongs to another seller. | | `422` | The object exists but the action is not applicable to it (for example, sending an invoice that is collected by bank transfer). | | `429` | Rate limit exceeded. Honour `Retry-After` and the `X-RateLimit-*` headers. | | `501` | The capability is not enabled for this deployment (for example, credits before stored value is switched on). | | `500` | Server fault. Safe to retry idempotent (GET) requests with backoff. | ## Rate limiting Requests are rate-limited per seller, on a budget dedicated to this API — billing traffic and payments-api traffic do not throttle each other. The default budget is **1000 requests per hour per seller**, with a burst of 100 so a single client cannot spend the whole hour in one instant. The number is set per environment, so read `X-RateLimit-Limit` rather than hard-coding it. Responses normally carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (whole seconds until the budget refills), plus the current standards-track structured fields `RateLimit-Policy` (`"seller";q=1000;qu="requests";w=3600`) and `RateLimit` (`"seller";r=940;t=2100`). Both spellings carry the same numbers. Note these are not the `RateLimit-Limit`/`RateLimit-Remaining`/`RateLimit-Reset` triple from an earlier revision of that draft, which is not sent. Read them rather than assuming a number: the budget is set per environment, and `X-RateLimit-Limit` is the authoritative value for yours. Treat them as advisory — during a limiter outage requests are allowed through without the headers, so a client that requires them will break exactly when the platform is already degraded. Three write operations carry an **extra** per-seller quota on top of that budget, each in its own bucket, because each accepted call spends something that cannot be handed back: | Operation | Default quota | Why | | --- | --- | --- | | `POST /contracts` | 20 requests per rolling 24 hours (refusals and replays count) | Bounds how fast one key opens contracts; a create allocates a number from a series shared across sellers. | | `POST /contracts/{contractId}/amendments` | 200 requests per rolling 24 hours (a stale `expectedBaseVersion` counts) | Every accepted amendment appends a contract version — up to 100 line items — to a history that is append-only and has no delete. | | `POST /invoices` | 500 requests per rolling 24 hours (refusals and replays count) | Issuing allocates a number from your own gapless series, so a runaway loop burns your month of numbers. | All three refuse with the same `429`, `Retry-After` and `X-RateLimit-*` headers as the surface-wide limit. A rejected request answers `429` with `Retry-After` in seconds. Wait that long — retrying sooner only deepens the overage. Bulk work is what actually hits this. Reconciling a month walks pages of up to 100 invoices and may pull a PDF per document, so a few hundred requests in one burst is normal. Pace bulk exports (a short sleep between pages costs far less than being throttled mid-walk), and if a legitimate workload cannot fit the budget, ask Fynex to raise it rather than working around it with parallel keys. ### When the write quotas fail closed Unlike the surface-wide budget, those three operations — `POST /contracts`, `POST /contracts/{contractId}/amendments` and `POST /invoices` — **fail closed**. While the limiter itself is unreachable they answer `503` with `Retry-After` instead of letting an uncounted burst of writes through. Nothing was created, amended or issued, so the retry is safe: wait the header out and repeat the call with the **same `Idempotency-Key`**, which is what stops the retry from opening a second contract or issuing a second document. Amendments carry no `Idempotency-Key` — resend the same `expectedBaseVersion`, and a version that did land answers `422` rather than appending a duplicate. Two things this does *not* mean. It is the limiter FAILING, not the quota being absent: a deployment that switches a quota off (its request budget set to `0`) is simply unmetered on that operation, exactly as before, and never answers `503` for this reason. And the quotas count **requests**, not successes — a refusal or a replay spends one too, so a client retrying a `400` in a loop can exhaust its day without ever writing anything. --- # Pagination Every list endpoint pages by keyset, with the same two parameters: - `limit` — page size, 1–100 (default 20). - `cursor` — where to continue from. Omit it (or pass `0`) for the first page. Each page reports how to continue: ```json { "invoices": [ ... ], "hasMore": true, "nextCursor": 4177 } ``` Pass `nextCursor` back as `cursor` until `hasMore` is `false`. That loop is the same code on every list in this API. Keyset paging is stable under concurrent inserts: new rows appear on the first page of a fresh iteration and never shift the pages of an iteration already in flight. ## Direction is a property of the list, not of the parameter The lists do not all walk the same way, and that part is deliberate: | List | Order | A cursor means | | --- | --- | --- | | `/invoices`, `/subscriptions` | Newest first | ids **below** the cursor | | The credit ledger on `/contracts/{contractId}/credits` | Newest first | ids **below** the cursor | | `/contracts` | Ascending contract id | ids **above** the cursor | Contracts walk forward because the id is a stable identity to iterate, not a recency ranking. What used to differ as well was the parameter NAME — `beforeId` on some lists, `afterId` on others — so anyone who wrote a working loop for invoices wrote a broken one for contracts. `cursor` and `nextCursor` are the same on all of them; only the documented order changes. The credit ledger pages the `entries` array only: the balances in the same response are always complete. Seller-wide credit balances are one row per currency and credit type, so that list is genuinely bounded and does not page. ## The original parameter names `beforeId` / `nextBeforeId` and `afterId` / `nextAfterId` still work, unchanged, on the lists that had them, and every response still carries the original field beside `nextCursor` with the same value. Existing integrations need no change. Sending both `cursor` and the original name with **different** values is a `400` rather than a silent choice between them. ## Dates `issuedFrom` and `issuedTo` accept a calendar date (`2026-08-01`) or a full RFC 3339 timestamp. A bare date is read as **UTC midnight**, and the range is half-open — `issuedFrom` inclusive, `issuedTo` exclusive — so adjacent months never double-count a document. If your books close in a non-UTC zone, send the timestamp form with your offset rather than the bare date. ## Amounts All monetary amounts are **integers in the currency's minor units** (`grandTotalMinor: 12050` is €120.50 for a EUR document). Quantities and tax rates are decimal strings. Never parse amounts as floating point. --- # Endpoint reference Every operation, with its exact parameters, response shape and failure modes. Every one requires `Authorization: Bearer sk_live_…` (or `sk_test_…`); all amounts are integers in minor units; all list responses page. Base: `$FYNEX_API_BASE` = `https:///billing-api/v1` ## How numbers ride on the wire One rule, four rows, and the field name tells you which row applies. Read the suffix before you read the value. | Value | Type | Field name | Example | | --- | --- | --- | --- | | Money amount | Integer, **minor units** | ends `Minor` | `4999` is €49.99 | | Rate / percentage | Integer, **basis points** | ends `Bps` | `275` is 2.75% | | Quantity | Decimal **string** | — | `"1250.5"` | | Tax rate | Decimal **string**, percent | — | `"20.0"` | Money is never a float and never a decimal string: a `…Minor` field is an integer count of the currency's smallest unit, paired with the resource's ISO 4217 `currency`. Reading `4999` as euros overstates the amount a hundredfold, and reading `275` as a percentage does the same to a rate — which is why the unit is repeated in every field's own description rather than stated once here. Quantities go the other way and travel as strings on purpose: metered consumption is routinely finer than an integer, and JSON numbers are IEEE floats. Per-unit **rates** are the one deliberate exception to minor units — they are decimal strings in major units, because a rate is often smaller than one minor unit (`"0.004"`). The convention is enforced, not merely documented: a `…Minor` or `…Bps` field that publishes as a float, or whose description never names its unit, fails the build. | | Operation | Purpose | | --- | --- | --- | | 1 | `GET /contracts` | The entry point — ids for every per-contract read | | 1a | `POST /contracts` | Create a contract (`Idempotency-Key` required; extra per-seller quota) | | 1b | `POST /contracts/{contractId}/amendments` | Append a contract version — status, dates, components, counterparty (extra per-seller quota) | | 2 | `GET /contracts/{contractId}/usage` | Metered consumption this period | | 3 | `GET /contracts/{contractId}/credits` | One contract's stored value + ledger | | 4 | `GET /credits` | Stored-value balances across all contracts | | 4a | `POST /contracts/{contractId}/credits/top-up` | Grant credit on a contract (`Idempotency-Key` required) | | 5 | `GET /invoices` | Invoice list, filtered and paged | | 5a | `POST /invoices` | Compose and issue an invoice (`Idempotency-Key` required; extra per-seller quota) | | 6 | `GET /invoices/{invoiceId}` | One invoice with its lines | | 7 | `GET /invoices/{invoiceId}/pdf` | The rendered document | | 8 | `POST /invoices/{invoiceId}/send` | Create the payment link (emails the customer) | | 9 | `GET /subscriptions` | Subscription list | | 10 | `GET /subscriptions/{subscriptionId}` | One subscription | | 10a | `POST /contracts/{contractId}/subscriptions` | Create a subscription (`Idempotency-Key` required) | | 10b | `POST /subscriptions/{subscriptionId}/cancel` · `/pause` · `/resume` · `/end-trial` · `/change-plan` | Drive a subscription's lifecycle | | 11 | `POST /usage/events` · `:batch` | Report metered consumption | | 12 | `GET`/`POST /usage/metrics` · `GET /usage/metrics/{metricName}` | The meter registry | | 13 | `PUT`/`GET /usage/contracts/{contractId}/metrics/{metricName}/price` | Price a metric on a contract | | 14 | `POST /prices/evaluate` | Preview what a config charges — the billing engine, pure | | 15 | `POST /usage/csv` | Bulk intake from a periodic export | | 18 | `GET /invoices/export` | A period's invoices as one CSV/NDJSON file — month-end, without the paging loop | | 19 | `GET /invoices/{invoiceId}/settlement` | Which payments and payouts settled this invoice | | 20 | `POST /customers` | Create or resolve a customer — the `sellerCustomerId` everything else needs | | 21 | `GET /contracts/{contractId}/invoices/upcoming` | What the contract's next invoice would carry — an estimate, nothing written | | 16 | `GET /usage/dead-letters` · `{deadLetterId}/redrive` · `{deadLetterId}/discard` | Events an intake refused, and what to do with them | | 17 | `GET`/`POST /usage/webhook-endpoints` · `{endpointId}/rotate-secret` · `{endpointId}/revoke` | Inbound webhook endpoints and their signing secrets | --- ## 1. `GET /contracts` Lists the contracts your key can see. Every per-contract endpoint takes an id from here. | Parameter | In | Default | Notes | | --- | --- | --- | --- | | `cursor` | query | — | Keyset cursor. Only contracts with an id **above** this value — this list walks forward. Omit for the first page; pass back the `nextCursor` of the previous page. | | `afterId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. | | `limit` | query | `20` | 1–100. | Contracts page **forward** (ascending id) because the id is a stable identity to walk, not a recency ranking. Invoices, subscriptions and the credit ledger page **backward** (newest first) — see the Pagination reference (`billing-pagination.md`), which also states the keyset stability guarantee and the amounts convention. ```bash curl -s "$FYNEX_API_BASE/contracts?limit=50" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "contracts": [ { "contractId": 42, "contractNumber": "UK2607AA", "version": 1, "sellerCustomerId": 7, "currency": "EUR", "status": "active", "startDate": "2026-01-01", "endDate": "2026-12-31", "customerName": "Ada Lovelace", "customerPhone": "+31 6 1234 5678", "customerCompanyName": "Harbour Group BV", "customerCompanyCountry": "NL" } ], "hasMore": true, "nextCursor": 42, "nextAfterId": 42 } ``` | Field | Meaning | | --- | --- | | `contractId` | The key for endpoints 2 and 3. | | `contractNumber` | Document number (`UK2607AA`). Empty for contracts predating numbering. | | `version` | Contract version; you always get the current one. | | `sellerCustomerId` | Your customer on this contract. Also an invoice filter. | | `currency` | Contract currency (ISO 4217). | | `status` | Contract lifecycle state, e.g. `active`. | | `startDate` / `endDate` | `YYYY-MM-DD`. `endDate` absent on open-ended contracts. | | `customerName` / `customerPhone` | The customer's name and phone as agreed on the contract. Absent when not stated. | | `customerCompanyName` / `customerCompanyCountry` | The customer's company and its registration country as agreed on the contract. Absent when not stated. | **Failures:** `400` malformed `cursor`/`afterId`/`limit`, or both cursor names sent with different values · `401` bad key · `403` inactive seller. --- ## 1a. `POST /contracts` Creates a contract, in `draft`, for one of your customers. The contract is the anchor every other object refers to — subscriptions, credit, metered prices and invoices — and it bills in one currency for its whole life. **`Idempotency-Key` is a required header**: 1–128 characters from `A–Z a–z 0–9 _ . : -`, one per contract you intend to create. Keys are scoped to your seller account. Keys beginning with a prefix the billing engine uses for its own ledger rows (`proration:`, `proration-grant:`, `redeem:`, `subscription:`, `credit_note:`, `refund:`, `usage-adjustment:`) are refused with `400` — they are not yours to use. The same key returns the contract the first call created (its *current* version) and answers `200` instead of `201`. **The body is compared**, in two halves. `sellerCustomerId` and `currency` are compared against the contract's current version — neither is amendable, so the two versions agree. Everything an amendment *can* move is compared against **version 1**, the version this key actually created, so a contract that was legitimately re-dated or re-priced does not turn every later retry into a refusal: - `startDate` and `endDate`, present or absent; - `lineItems` **line by line** — `componentType`, `componentConfig` and `quantity` — and in any order, not merely how many there are. Two components of the same type at different quantities are two different contracts, and a count alone answered the second with the first; - `customerName`, `customerPhone`, `customerCompanyName` and `customerCompanyCountry`, each compared *only when your request states it* — a blank one is inherited from the customer record, which is exactly what the first call did. A difference in any of them answers `422` naming the field. ```bash curl -s -X POST "$FYNEX_API_BASE/contracts" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Idempotency-Key: 5c2f7c40-1a3e-4c9b-9d1f-2b6e5a0c7d31" \ -H "Content-Type: application/json" \ -d '{ "sellerCustomerId": 7, "currency": "EUR", "startDate": "2026-10-01" }' ``` | Field | Notes | | --- | --- | | `sellerCustomerId` | Required; one of your customers (endpoint 20). Another seller's id is `400`. | | `currency` | Required. ISO 4217, one of `EUR`, `USD`, `GBP`, `DKK`, `NOK`, `SEK`. Immutable: every subscription, credit lot and invoice under the contract must use it. | | `startDate` | Required. `YYYY-MM-DD`, up to a year in the past and ten years ahead; the window is measured in whole days. | | `endDate` | Optional `YYYY-MM-DD`; omit for open-ended. Not before `startDate`, and not more than ten years ahead. | | `lineItems` | Optional components, at most 100: `componentType` (`recurring`, `usage`, `one_time`, `milestone`, `project`, `marketplace`, `credit`, `adjustment`, `hybrid`), `quantity` (decimal string), `componentConfig` (JSON the engine reads for that type, at most 16 KiB). There is no `priceRef`, and a `componentConfig` carrying `milestoneDefinitionRef`, `projectRef` or `splitRuleRef` is `400` — those name objects this API does not publish. Omit for a contract billed only by its subscriptions and metered prices. | | `customerName` / `customerPhone` / `customerCompanyName` / `customerCompanyCountry` | The counterparty's details *as agreed on this contract*; blank ones default from the customer record. 128 / 32 / 255 / 56 characters. | Answers the same contract shape endpoint 1 lists — `id`, `version` (`1`), `status` (`draft`), `currency`, `startDate`, `endDate`, `contractNumber` when numbering is enabled. **Failures:** `400` (a missing `sellerCustomerId`, `currency` or `startDate`, unknown currency, malformed date, a date outside the window, over-long detail, an unavailable `componentConfig` reference, a `sellerCustomerId` that is not yours — the same answer whether it is another seller's or no customer at all — missing or malformed `Idempotency-Key`) · `422` (`Idempotency-Key` already used for a different contract) · `429` · `503` · `401` / `403`. `429` here has **two** ceilings behind it. The surface-wide per-seller rate limit applies as everywhere else, and creating a contract carries an *extra* per-seller quota of its own — **20 create requests per rolling 24 hours** by default — requests, not contracts: a refused or replayed call counts too. That quota bounds how fast one key can open contracts; the budget above is sized for bulk reads. It is deliberately not a guarantee about the shared `CC+YYMM` number namespace, which 20 a day could not give: contract numbering is a rollout switch that is off by default, and where it is on the allocator fails the create rather than reusing a number. Both ceilings answer `429` with `Retry-After`; an integration that opens contracts as customers sign up will never see either. The quota fails closed — **`503`** with `Retry-After` while its limiter is unreachable, and nothing was created. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed). --- ## 1b. `POST /contracts/{contractId}/amendments` Appends a **version** to a contract you own. Nothing on a contract is edited in place: a status move, new dates, a replaced component set or a corrected counterparty detail is a new version, and every earlier one stays readable. `expectedBaseVersion` is the concurrency guard — send the `version` you last read. If the contract has moved on since, the amendment answers `422`; re-read and decide again. There is no `Idempotency-Key`: a repeat of a successful amendment fails the version check by construction. ```bash curl -s -X POST "$FYNEX_API_BASE/contracts/42/amendments" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"expectedBaseVersion": 1, "status": "active"}' ``` | Field | Notes | | --- | --- | | `expectedBaseVersion` | Required, positive: the version you last read. | | `status` | `draft`, `active`, `suspended` or `closed`; omit to keep. Illegal transitions (anything out of `closed`, `draft` straight to `suspended`) are `422`. | | `startDate` / `endDate` | `YYYY-MM-DD`; omit to keep. An existing end date cannot yet be cleared. | | `lineItems` | Omit to carry the current set forward; send the full new set to replace it; `[]` clears it. At most 100. | | `customerName` / `customerPhone` / `customerCompanyName` / `customerCompanyCountry` | Send to replace, omit to keep, `""` to clear. | Answers the new version (`version` is `expectedBaseVersion + 1`) in the same contract shape as endpoint 1. **Failures:** `400` (malformed field, unknown `status`) · `404` (contract is not yours) · `422` (stale `expectedBaseVersion`, closed contract, illegal status transition) · `429` · `503` · `401` / `403`. `429` here has **two** ceilings behind it, as on endpoint 1a. The surface-wide per-seller rate limit applies as everywhere else, and amending carries an *extra* per-seller quota of its own — **200 amend requests per rolling 24 hours** (a stale `expectedBaseVersion` counts too) by default — because every accepted amendment appends a version, with up to 100 line items, to a history that is append-only and has no delete. Like the create quota it fails closed — **`503`** with `Retry-After` while its limiter is unreachable, and nothing was appended. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed). --- ## 2. `GET /contracts/{contractId}/usage` The contract's **current open** billing periods, one entry per metric. This is a live read, not a closed-period figure — reconcile money against invoices. ```bash curl -s "$FYNEX_API_BASE/contracts/42/usage" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "contractId": 42, "metrics": [ { "metricName": "api_calls", "used": "10250", "includedUnits": "10000", "capQuantity": "50000", "capMode": "hard", "percentOfCap": "20.5", "percentOfPlan": "102.5", "periodStart": "2026-08-01", "periodEnd": "2026-08-31" } ] } ``` | Field | Meaning | | --- | --- | | `used` | Metered so far this period. Decimal **string**. | | `includedUnits` | Plan allowance for the period. | | `capQuantity` / `capMode` | The ceiling and how it is enforced: `hard` blocks past it, `soft` only alerts. Absent when no limit policy exists. | | `percentOfPlan` | Above `100` means overage — what will be rated onto the next usage invoice. | | `percentOfCap` | Distance to the enforcement ceiling. Warn customers well before `100`. | | `periodStart` / `periodEnd` | Open period bounds. Absent when a metric has a policy but no usage yet. | **Failures:** `400` non-numeric id · `404` unknown contract, or one belonging to another seller (the two are indistinguishable by design). A real contract with nothing metered answers `200` with `"metrics": []` — a valid state, not an error. --- ## 3. `GET /contracts/{contractId}/credits` One contract's stored-value balances plus a page of its append-only ledger. | Parameter | In | Default | Notes | | --- | --- | --- | --- | | `contractId` | path | — | Required. | | `cursor` | query | — | Keyset cursor over the ledger: entries with an id **below** this value. Pass back the previous page's `nextCursor`. | | `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. | | `limit` | query | `20` | 1–100. Applies to `entries` only — `balances` is always complete. | ```bash curl -s "$FYNEX_API_BASE/contracts/42/credits?limit=50" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "balances": [ {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true} ], "entries": [ { "id": 1201, "sellerCustomerId": 7, "kind": "topup", "creditType": "purchased", "signedDeltaMinor": 250000, "currency": "EUR", "lotId": 900, "invoiceId": null, "expiresAt": null, "reason": "annual prepayment", "occurredAt": "2026-08-01T09:30:00Z" } ], "hasMore": true, "nextCursor": 1201, "nextBeforeId": 1201 } ``` | Field | Meaning | | --- | --- | | `kind` | `topup` (granted or bought), `deduction` (consumed into an invoice), `expiry` (a lapsed lot removed). | | `signedDeltaMinor` | Positive for `topup`, negative for the other two. | | `lotId` | The grant a deduction or expiry consumed. | | `invoiceId` | The invoice a deduction funded — the link back to the document. | | `isLiability` | True for **paid-for** types (`purchased`, `enterprise`, `proration`): unearned revenue you owe as service. Granted credit is not a liability, and only granted credit can expire. | **Failures:** `400` bad id or paging · `404` unknown/foreign contract · `501` stored value is not enabled for this deployment. Replaying the ledger from the beginning always reproduces the balance: corrections are new rows, never edits. --- ## 4. `GET /credits` Your stored-value position across every contract, one row per currency and credit type. Bounded, so it does not page. ```bash curl -s "$FYNEX_API_BASE/credits" -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "balances": [ {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true}, {"currency": "EUR", "creditType": "promotional", "balanceMinor": 5000, "isLiability": false} ] } ``` Credit types: `promotional`, `purchased`, `manual`, `gift`, `enterprise`, `ai_token`, `marketplace`, `proration`. `proration` is minted only by the billing engine (unserved time returned on a mid-term downgrade) and cannot be granted by hand. Finance usually wants liabilities separated from granted credit — do not sum them blindly. **Failures:** `401` · `403` · `501` (capability off). --- ## 4a. `POST /contracts/{contractId}/credits/top-up` Grants a credit **lot** on a contract you own — prepaid balance the invoice engine draws down before any payment rail is asked for money. The ledger is append-only, and this is the only way credit enters it: the deductions, expiries and reversals that take it out are the engine's own rows and have no public route. **`Idempotency-Key` is a required header**: 1–128 characters from `A–Z a–z 0–9 _ . : -`, one per grant you intend to make. Keys are scoped to your seller account. The same key returns the grant the first call created and answers `200` instead of `201`. Keys beginning with a prefix the billing engine uses for its own ledger rows (`proration:`, `proration-grant:`, `redeem:`, `subscription:`, `credit_note:`, `refund:`, `usage-adjustment:`) are refused with `400` — they are not yours to use. The engine's own `proration-grant:` lots live in the same index, and the recurring lane adopts the lot it finds under that key as the term's grant — which is exactly why no caller may put one there. Unlike the subscription create, **the body is compared**, and what is compared is everything about the lot you decide: `contractId`, `amountMinor`, `currency`, `creditType`, `expiresAt` (present or absent), `sellerCustomerId` and `reason`. A difference in any of them answers `422` naming the field rather than handing back the earlier grant: a top-up has a natural shape, and silently returning the first one would hide a second grant that never happened. Normalisation is not a difference — casing on `currency` and `creditType`, and surrounding whitespace on `reason`, replay as the same grant. ```bash curl -s -X POST "$FYNEX_API_BASE/contracts/42/credits/top-up" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Idempotency-Key: 3f0b6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e" \ -H "Content-Type: application/json" \ -d '{ "creditType": "purchased", "amountMinor": 250000, "currency": "EUR", "reason": "annual prepayment" }' ``` | Field | Notes | | --- | --- | | `creditType` | `promotional`, `purchased`, `manual`, `gift`, `enterprise`, `ai_token` or `marketplace`. `purchased` and `enterprise` were PAID FOR — they are a liability you owe as service. | | `amountMinor` | The lot, in **minor units**, 1 to 10^12. Always positive: this is the grant, not a movement. | | `currency` | ISO 4217, and it must equal the contract's current currency — credit is never converted. A different one is `400`. | | `expiresAt` | RFC 3339 instant the lot lapses; omit for credit that never expires. Must be in the future, and is refused outright on `purchased` and `enterprise` credit — money someone paid must not evaporate on a calendar date. | | `reason` | Required, at most 512 characters. Recorded on the ledger row. | | `sellerCustomerId` | Optional attribution for per-customer reporting; must be one of your customers (endpoint 20). The contract is still the balance anchor. | Answers the same `CreditLedgerEntry` shape endpoint 3 lists — `id`, `kind` (`topup`), `creditType`, `signedDeltaMinor`, `currency`, `lotId`, `expiresAt`, `reason`, `occurredAt`. A top-up **is** a lot, so `lotId` is absent on it; the deductions that later consume it name this entry's `id`. **Failures:** `400` (unknown `creditType`, `amountMinor` outside the range, a `currency` other than the contract's, a `reason` over 512 characters, a `sellerCustomerId` that is not yours, missing or malformed `Idempotency-Key`) · `404` (contract is not yours) · `422` (`Idempotency-Key` already used for a different grant) · `429` · `401` / `403` · `501` (capability off). --- ## 5. `GET /invoices` The workhorse. Newest first, keyset paged, with filters that let you fetch a bounded slice instead of walking history. | Parameter | In | Default | Notes | | --- | --- | --- | --- | | `contractId` | query | — | One contract. | | `sellerCustomerId` | query | — | One of your customers, across contracts. | | `origin` | query | — | `recurring`, `usage`, `milestone`, `project`, `one_time`, `adhoc`, `marketplace`. `subscription` is a deprecated alias of `recurring` and resolves to the same set. | | `status` | query | — | `draft`, `issued`, `sent`, `paid`, `overdue`, `voided`, `written_off`. | | `invoiceType` | query | — | `standard`, `credit_note`, `simplified`, `modified`. | | `corrects` | query | — | Credit notes issued against this invoice **number**. | | `issuedFrom` | query | — | Inclusive lower bound. `YYYY-MM-DD` (UTC midnight) or RFC 3339. | | `issuedTo` | query | — | **Exclusive** upper bound, so adjacent periods never double-count. | | `cursor` | query | — | Keyset cursor: rows with an id **below** this value — this list walks backward. Pass back the previous page's `nextCursor`. | | `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. | | `limit` | query | `20` | 1–100. | `origin`, `status` and `invoiceType` reject an unrecognized value with `400`, so a typo cannot masquerade as "no such invoices". `contractId`, `sellerCustomerId` and `corrects` are matched as given — a well-formed but wrong value legitimately returns an empty page. ```bash curl -s "$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01&issuedTo=2026-09-01&status=sent&limit=100" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "invoices": [ { "id": 4180, "contractId": 42, "sellerCustomerId": 7, "origin": "usage", "invoiceNumber": "UK2607AA-2608AAB", "jurisdiction": "UK", "invoiceType": "standard", "status": "sent", "currency": "EUR", "issueDate": "2026-08-01T00:00:00Z", "dueDate": "2026-08-15T00:00:00Z", "subtotalMinor": 10000, "taxTotalMinor": 2000, "grandTotalMinor": 12000, "creditAppliedMinor": 0, "collectibleMinor": 12000, "creditSettledMinor": 0, "outstandingMinor": 12000, "reverseCharge": false, "paymentLinkId": 991 } ], "hasMore": true, "nextCursor": 4180, "nextBeforeId": 4180 } ``` List rows omit `customerName` / `customerEmail` — those live in the frozen payload and reading them per row would mean parsing every document. Use `sellerCustomerId` to group, or fetch the single invoice (endpoint 6). **Failures:** `400` unknown enum value, malformed date or paging · `401` · `403`. --- ## 5a. `POST /invoices` Composes, numbers and — unless `send` is `false` — sends an invoice in one call. The document is **issued the moment this answers `201`**: numbered gaplessly under your agreement, its totals recomputed server-side, its payload frozen. There are no drafts on this API and no edits afterwards. **Credit notes are not issued here.** A correction is raised *against* the document it corrects — it mirrors that document's lines and retires its payment link — none of which can be reconstructed from a hand-composed body, so `invoiceType: credit_note` is `400` and there is no `originalInvoiceNumber` field on this request. Corrections are made in the dashboard. **`Idempotency-Key` is a required header**, same shape as endpoint 1a. The same key returns the document the first call issued and answers `200` — with no `paymentLinkUrl` and no `deliveryStatus`, because delivery is not replayed. **The body is compared**, and what is compared is everything that decides *which* document this is: - `contractId`, `sellerCustomerId`, `currency`, `invoiceType`, `jurisdiction`; - `dueDate` and `dateOfSupply`, present or absent, and an `exemptionReason` or `legalNotice` your request states (an omitted one is the engine's to decide — it supplies its own on an exempt or reverse-charge document — and it decides the same way twice); - the document's own text: `notes`, `poReference`, `paymentTerms`; - the whole `buyer` block as it was frozen onto the document — the same money addressed to somebody else is a different legal record — and the `seller` block *as the engine used it*: under Fynex-collection issuance the issuer is substituted for the one you sent, so a retry that states a different seller still replays; - the net total of the lines before tax (`quantity × unitPriceMinor − discountMinor`, summed), how many lines there are, and **each line's `description`, `quantity` and `unitPriceMinor`** — two lines whose net happens to match (`2 × 5000` and `4 × 2500`) are still different lines; - **each line's `discountMinor`, `taxCategory`, `taxRatePercent` and `taxable`** — these decide the TAX on a line whose net amount is identical, which the net total above cannot see, so a retry that drops `taxRatePercent` from `20` to `0` is a different document, not a replay. A difference in any of them answers `422` naming the field, and no second document is numbered. `send` is deliberately **not** compared: it chooses whether to deliver the document, not what the document says, so a retry that flips it still replays. ```bash curl -s -X POST "$FYNEX_API_BASE/invoices" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Idempotency-Key: 8d1e4b2a-6f3c-4e7a-9b0d-1c2e3f4a5b6c" \ -H "Content-Type: application/json" \ -d '{ "contractId": 42, "sellerCustomerId": 7, "jurisdiction": "UK", "currency": "EUR", "dueDate": "2026-11-01", "buyer": {"name": "Ada Lovelace", "email": "ada@example.com", "addressLine1": "1 Analytical Row", "city": "London", "postalCode": "E1 6AN", "country": "GB"}, "lineItems": [{"description": "Seats, October", "quantity": "10", "unitPriceMinor": 4999, "taxCategory": "standard", "taxRatePercent": "20"}] }' ``` | Field | Notes | | --- | --- | | `contractId` / `sellerCustomerId` | Both required and both yours (endpoints 1a and 20). Nothing is provisioned here: an unknown contract is `404`, a foreign customer `400`. | | `jurisdiction` | Required; which tax regime the document is composed under. Exactly `UK`, `US` or `EU` — the input is upper-cased first, so `uk` is accepted and stored as `UK`. | | `invoiceType` | `standard` (default), `simplified` (only below the jurisdiction's retail threshold) or `modified` (UK retail above it, VAT-inclusive line prices). `credit_note` is refused with `400`. | | `currency` | Must equal the contract's — a different one is `400`. | | `dueDate` / `dateOfSupply` | `YYYY-MM-DD`. | | `buyer` | The addressee: `name`, `email`, `addressLine1`, `city`, `postalCode`, `country` required — `email` is where the document is delivered, and a request without one is `400`; `phone`, `addressLine2`, `region`, `businessType`, `vatNumber`, `companyRegistrationNumber`, `taxExemptionCertificateNumber`, `ein`, `salesTaxId` optional. | | `seller` | Optional. Ignored under Fynex-collection issuance, where Fynex is the issuing party; used as the issuing identity only for a merchant-issued seller. | | `lineItems` | 1–200 lines: `description`, `quantity` (decimal string), `unitPriceMinor` (required, integer minor units), `discountMinor`, `taxCategory`, `taxRatePercent` (decimal string, 0–100), `taxable`, `sourceRef`. A UK/EU line must carry a `taxCategory` (`standard`, `reduced`, `zero`, `exempt`) and its rate must agree with it; a US line carries `taxable` and no category. No `catalogItemId` — send explicit prices. | | `poReference` / `notes` / `paymentTerms` / `exemptionReason` / `legalNotice` | Free text, 128 / 2000 / 256 / 64 / 2000 characters. | | `send` | Default `true`: create the hosted payment link and e-mail the buyer. `false` issues without delivering; `POST /invoices/{invoiceId}/send` delivers later. | Answers `{"invoice": …, "paymentLinkUrl": …, "deliveryStatus": …}` — the same invoice shape endpoint 6 returns, plus the link when one was created. **A delivery failure does not undo issuance**: the answer is still `201` with the document and no link. `deliveryStatus` says what *this* call's delivery attempt did, because an absent `paymentLinkUrl` alone cannot tell three different situations apart — and only one of them is yours to act on: | Value | Meaning | | --- | --- | | `sent` | The collection link was created and e-mailed; `paymentLinkUrl` carries it. | | `not_requested` | No delivery was attempted: `send` was `false`, or the document was settled from stored credit at issue and has nothing left to collect. Nothing is wrong. | | `failed` | The document **is** issued and immutable, and delivery did not happen. Retry it with `POST /invoices/{invoiceId}/send`, which tells you why (a test-mode recipient the sandbox rule refuses is the usual reason). | The field is absent on an idempotent replay, which attempts no delivery of its own and must not restate the first call's outcome. **Failures:** `400` (input the engine refuses, `invoiceType: credit_note`, a missing `buyer.email`, a `currency` other than the contract's, a foreign `sellerCustomerId`, missing or malformed `Idempotency-Key`) · `404` (contract is not yours) · `422` (`Idempotency-Key` already used for a different document; a merchant-issued `seller` block missing the legal name, address or tax details a compliant document needs — the detail names the fields; a UK/EU exemption without the issuer's VAT number) · `429` · `503` · `401` / `403`. The incomplete-issuer case answered `400` before 2026-09; it is a state of the seller's profile, not of the request, so it now sits with the other `422`s. `429` here has **two** ceilings behind it, as on endpoint 1a. The surface-wide per-seller rate limit applies as everywhere else, and issuing carries an *extra* per-seller quota of its own — **500 issue requests per rolling 24 hours** (refusals and replays count too) by default. It is far larger than the contract create's on purpose: issuing allocates a gapless number from *your own* `AGREEMENT-YYMM` series (the `AAA…ZZZ` order suffix within it), so a runaway integration burns a month of your numbers rather than a namespace shared with other sellers. Both answer `429` with `Retry-After` and the `X-RateLimit-*` headers. The quota fails closed — **`503`** with `Retry-After` while its limiter is unreachable, and nothing was issued. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed). --- ## 6. `GET /invoices/{invoiceId}` One invoice, its lines, and the customer identity as recorded at issue. ```bash curl -s "$FYNEX_API_BASE/invoices/4180" -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "invoice": { "id": 4180, "customerName": "Acme GmbH", "customerEmail": "ap@acme.example", "invoiceNumber": "UK2607AA-2608AAB", "status": "sent", "grandTotalMinor": 12000, "collectibleMinor": 12000, "creditSettledMinor": 0, "outstandingMinor": 12000, "paidVia": "", "…": "every field from the list shape" }, "lines": [ { "position": 1, "description": "API calls over allowance", "quantity": "250", "unitPriceMinor": 40, "discountMinor": 0, "netAmountMinor": 10000, "taxCategory": "standard", "taxRate": "20", "taxAmountMinor": 2000, "sourceRef": "usage:api_calls:2026-07" } ] } ``` ### The four money fields, precisely | Field | Is | Is **not** | | --- | --- | --- | | `grandTotalMinor` | The legal document total | | | `collectibleMinor` | What collection asks the customer for — grand total less credit applied at issue | A balance. It does **not** drop to zero when paid, and it is blind to later credit notes, write-offs and adjustments. | | `creditSettledMinor` | The part discharged by stored credit | A payment total. A card-paid invoice reports `0`. | | `outstandingMinor` | The receivable: the document total plus every adjustment ledger entry against it, less what has been settled. A voided document reports `0`. This is the figure to age in an AR report. | Guaranteed present. It is omitted when the adjustment ledger is unavailable — treat its absence as **unknown**, never as `0`. | "Has this been paid?" is answered by `status` (and `paidVia` for how), never by arithmetic on the amounts. `collectibleMinor` and `outstandingMinor` agree on a document nothing has happened to. They diverge the moment one does: credit-note a `12000` invoice by `5000` and `collectibleMinor` still reads `12000` while `outstandingMinor` reads `7000`. Ageing `collectibleMinor` over-states receivables. `customerName` / `customerEmail` are who the document was addressed to **at issue** — a customer renaming themselves later does not rewrite an issued legal record. Empty when the payload carries no buyer. (The contract list's `customerName` is different: it is the currently agreed value on the contract, not a frozen document field.) ### Correction and tax fields Present only when they apply, so treat every one as optional. | Field | Meaning | | --- | --- | | `originalInvoiceNumber` | On a credit note: the invoice number it corrects. Find corrections the other way round with `?corrects=`. | | `voidedAt` / `voidReason` | The document was cancelled before money moved. The invoice is not deleted — an issued invoice is a legal record — so this is a status plus an audited reason. | | `writtenOffAt` / `writeOffReason` | Collection was abandoned. Kept separate from the void fields so the two corrections stay distinguishable. | | `dateOfSupply` | The tax point, when it differs from `issueDate`. | | `reverseCharge` | True when the reverse-charge mechanism applies — the buyer accounts for the VAT. | | `exemptionReason` | Exemption code, when the document is exempt. | | `legalNotice` | Jurisdiction-mandated text printed on the document. | | `paidVia` | How a paid document settled: `payment_link`, `bank_transfer`, or `credit` (stored credit covered it in full). | | `paymentLinkId` | The collection link, once sent. Use the URL from `POST …/send`, not this id. | On a line, `taxable` is the US-jurisdiction flag; UK/EU documents use `taxCategory` and `taxRate` instead. **Failures:** `400` non-numeric id · `404` unknown or foreign invoice. --- ## 7. `GET /invoices/{invoiceId}/pdf` The rendered document, `application/pdf`, with a `Content-Disposition` filename derived from the invoice number. ```bash curl -s "$FYNEX_API_BASE/invoices/4180/pdf" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -o UK2607AA-2608AAB.pdf ``` Rendered from the frozen payload, so the same invoice produces the same document today and next year. Every issued document renders — including voided ones and credit notes, which are part of the audit trail. Rendering is the most expensive operation here; when bulk-downloading, pace the loop (see Errors → rate limiting). **Failures:** `400` · `404` · `500` if the stored payload no longer validates (a server-side integrity fault; retrying will not fix it). --- ## 8. `POST /invoices/{invoiceId}/send` Materializes the collection instrument: creates the invoice's hosted payment link and returns its URL. > **This emails your customer.** Whenever the document carries a buyer email, > the first call delivers the payment invitation. There is no per-request > suppression in v1, so do not call it merely to obtain a URL for internal > use — the first call is customer-facing and cannot be undone. ```bash curl -s -X POST "$FYNEX_API_BASE/invoices/4180/send" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "invoice": { "id": 4180, "status": "sent", "paymentLinkId": 991, "…": "…" }, "paymentLinkUrl": "https://pay.example-host/p/7bc1f2a9e4d5" } ``` **Repeat calls are safe, including concurrently, and take no idempotency header.** The invoice id is itself the collection anchor: a unique payment-link association makes concurrent calls converge on one link. Repeating a successful call returns that same link and does not send another email. Use the returned URL — never construct one. The hosted host is per-deployment configuration. `422` cases, none retryable without a state change: | Message | Meaning | | --- | --- | | `invoice is collected by bank transfer and cannot create a payment link` | The document instructs a wire to a named account; a simultaneously payable card link could let both succeed. | | `invoice has nothing left to collect` | Zero total, or stored credit covers it. | | `invoice is not in a sendable state` | `draft`, or already `paid`, `voided` or `written_off`. A settled invoice never hands out a payment link. | | `payment-link: sandbox emails can only go to *@sandbox.fynex.ai, *@example.test, or your own verified email` | The seller is in test mode and the invoice's customer address is neither a reserved test domain nor an address the seller itself owns. Test mode never e-mails a real customer. | **Failures:** `400` · `404` · `422` (above) · `401` / `403`. --- ## 9. `GET /subscriptions` | Parameter | In | Default | Notes | | --- | --- | --- | --- | | `contractId` | query | — | One contract. | | `cursor` | query | — | Keyset cursor, newest first: rows with an id **below** this value. Pass back the previous page's `nextCursor`. | | `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. | | `limit` | query | `20` | 1–100. | ```bash curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "subscriptions": [ { "id": 310, "contractId": 42, "status": "active", "billingFrequency": "monthly", "priceMinor": 9900, "currency": "EUR", "anchorDate": "2026-01-01T00:00:00Z", "startDate": "2026-01-01T00:00:00Z", "currentPeriodStart": "2026-08-01T00:00:00Z", "currentPeriodEnd": "2026-08-31T00:00:00Z", "autoRenew": true, "prorationPolicy": "by_day", "noticePeriodDays": 0, "trialRequiresPaymentMethod": false, "createdAt": "2026-01-01T09:00:00Z", "updatedAt": "2026-08-01T00:05:00Z" } ], "hasMore": false } ``` --- ## 10. `GET /subscriptions/{subscriptionId}` Same shape, one record. `subscriptionId` is the path parameter; ids come from endpoint 9. ### Cadence fields `billingFrequency` is one of `daily`, `weekly`, `bi_weekly`, `monthly`, `quarterly`, `semi_annual`, `annual` or `custom`. The annual cadence is named `annual` — there is no yearly value, so a branch written against one never fires. On a custom cadence, `customUnit` (`day`, `week`, `month`, `year`) and `customEvery` (the multiplier) describe the repeat: `customUnit: "week"` with `customEvery: 2` bills fortnightly. Both are absent on the named frequencies. ### Reading a subscription correctly | Question | Read | | --- | --- | | What are they paying? | `priceMinor` + `currency` + `billingFrequency`. There is no separate plan object — those three fields *are* the plan. | | Is a change coming? | `pendingPriceMinor` / `pendingPriceChangeAt`. Showing only `priceMinor` misinforms a customer who already requested a downgrade. | | Are they leaving? | `cancelRequestedAt` / `cancelEffectiveAt`. Service continues until the effective date, so `status` alone is not the answer. | | Are they paying yet? | `status: "trial"` plus `trialEnd`. `trialEndBehavior` says which way it ends — `convert` or `cancel` — and `trialRequiresPaymentMethod` whether a method must be on file first. | | Did a charge fail? | `status: "past_due"`. **Nothing retries it automatically** — this is a queue for you to work. | | Paused? | `pausedAt`, with `pauseEndsAt` for a scheduled auto-resume. | | What proration was agreed? | `prorationPolicy` — `by_day`, `full_period` or `next_period`; absent when never stated. It **selects what an immediate `change-plan` does while the proration engine is enabled** for the platform: `by_day` (and an unstated term) puts the new price in force today and posts an adjustment for the unserved remainder, `full_period` puts the new price in force today and bills the **whole** current period at it — the days already elapsed included — posting no adjustment, `next_period` keeps the old price for the rest of the current period and applies the new one from the next. The engine is **off by default**, and while it is off no adjustment is posted and a mid-cycle change lands immediately whatever this says — so read it as the contract's written intent until an operator turns proration on. Where invoice binding is enabled for the environment as well, the posted adjustment reaches the term's next invoice as a line, a discount, or a `proration` credit lot. | States: `trial` → `active` ⇄ `past_due` / `paused` → `canceled` | `expired`. **Failures:** `400` · `404`. --- ## 10a. `POST /contracts/{contractId}/subscriptions` Creates a subscription on a contract you own. `contractId` is the path parameter; the body is the plan and the term. **`Idempotency-Key` is a required header**: 1–128 characters from `A–Z a–z 0–9 _ . : -`, one per subscription you intend to create — a UUID is the usual choice. Keys are scoped to your seller account, not to the contract. Keys beginning with a prefix the billing engine uses for its own ledger rows (`proration:`, `proration-grant:`, `redeem:`, `subscription:`, `credit_note:`, `refund:`, `usage-adjustment:`) are refused with `400` — they are not yours to use. The same key always returns the subscription the first call created and answers `200` instead of `201`; the body of a retry is not compared. A key replayed against a *different* contract answers `422` rather than returning the other contract's subscription. Two concurrent first calls with one key produce one subscription, which both callers receive. ```bash curl -s -X POST "$FYNEX_API_BASE/contracts/42/subscriptions" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Idempotency-Key: 9c1d0b3a-7e55-4f10-9f2b-6f2a1c1e6a1e" \ -H "Content-Type: application/json" \ -d '{ "frequency": "monthly", "priceMinor": 9900, "currency": "EUR", "startDate": "2026-10-01", "trialEnd": "2026-10-15", "trialEndBehavior": "convert", "noticePeriodDays": 30, "prorationPolicy": "by_day" }' ``` | Field | Notes | | --- | --- | | `frequency` | `daily`, `weekly`, `bi_weekly`, `monthly`, `quarterly`, `semi_annual`, `annual` or `custom`. `custom` needs `customUnit` (`day`/`week`/`month`/`year`) and `customEvery`. | | `priceMinor`, `currency` | The plan: one full period's charge in minor units (1 to 10^12), in the contract's currency. Immutable currency. | | `startDate`, `anchorDate` | `YYYY-MM-DD`. `anchorDate` defaults to `trialEnd` for a trial, `startDate` otherwise. A past `startDate` — up to a year back — is caught up by the next lifecycle pass. | | `trialEnd`, `trialEndBehavior`, `trialRequiresPaymentMethod` | A future `trialEnd` starts the subscription in `trial`; `convert` (default) or `cancel` decides how it ends. | | `autoRenew`, `endDate` | `autoRenew` defaults to `true`. `false` fixes the term and requires `endDate`; `endDate` with `autoRenew` true is refused. | | `noticePeriodDays` | Notice a cancellation requires, 0–365; `0` cancels at the current term's end. | | `prorationPolicy` | `by_day`, `full_period` or `next_period`. Selects what an immediate `change-plan` does once the proration engine is enabled — prorate the remainder, bill the whole current period at the new price (elapsed days included, no adjustment), or hold the old price until the next period. Inert while the engine is off (the default); where invoice binding is enabled as well, the posted adjustment is applied to the term's next invoice. | Answers the same `Subscription` shape as endpoint 10. **Failures:** `400` (input outside the ranges above, a `currency` other than the contract's, missing or malformed `Idempotency-Key`) · `404` (contract is not yours) · `422` (`Idempotency-Key` already used on another contract) · `429` · `401` / `403`. ## 10b. Lifecycle actions All `POST`, all on `/subscriptions/{subscriptionId}/…`, all returning the updated subscription. A request the current state cannot take answers `422` with the reason — pausing a trial, resuming an active subscription, cancelling twice — and so does a lost race against the lifecycle pass (`subscription changed concurrently; re-read it and retry`). `404` means the subscription is not yours. | Path | Body | From | Effect | | --- | --- | --- | --- | | `/subscriptions/{subscriptionId}/cancel` | — | `trial`, `active`, `past_due`, `paused` | `cancelRequestedAt` set; serves until `cancelEffectiveAt` (notice) or term end. Terminal. | | `/subscriptions/{subscriptionId}/pause` | `{"pauseUntil": "YYYY-MM-DD"}` optional | `active` | Billing suspended; `pauseEndsAt` when a date was given. Beyond the seller's pause policy → `422`. | | `/subscriptions/{subscriptionId}/resume` | — | `paused` | New term from today; the pause is not billed. | | `/subscriptions/{subscriptionId}/end-trial` | — | `trial` | Converts now, per `trialEndBehavior` and `trialRequiresPaymentMethod`. | | `/subscriptions/{subscriptionId}/change-plan` | `{"priceMinor": 12900, "currency": "EUR", "atTermEnd": true}` | `active` | `atTermEnd: true` schedules (`pendingPriceMinor`) and is always honoured; `false` leaves the timing to the subscription's `prorationPolicy` (`next_period` holds the old price until the next period, anything else applies the new one now — see the policy table above). Same price, or a change already pending → `422`. The currency cannot change — a different one is `400`. | Marking a subscription past-due or recovered has no public route: that is the collection loop's verdict, not the integrator's. --- ## 11–13. The metered-usage write surface Registering a metric (`name`, `unit`, `aggregation`), pricing it on a contract (`contractId`, `metricName` path parameters; the config carries the scheme, tiers, allotment and commitment), and reporting events (`POST /usage/events`, one event; `POST /usage/events:batch`, many; `quantity` as a decimal string, `occurredAt`, `idempotencyKey`) are documented end to end in the **Usage ingestion** guide — parameters, the four pricing schemes, the `metered: false` trap, and the failure table. The operations live in this spec; the guide is their reference. ## 14. `POST /prices/evaluate` Previews what a pricing configuration charges — through the same engine that bills, so the number matches the invoice to the cent. Pure: nothing is stored, no contract is referenced, and the config in the request is the same document `PUT /usage/contracts/{contractId}/metrics/{metricName}/price` stores. ```bash curl -s -X POST "$FYNEX_API_BASE/prices/evaluate" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "config": { "scheme": "graduated", "currency": "EUR", "rounding": "half_up", "unitPrice": "0", "tiers": [ {"upTo": "10000", "unitPrice": "0.01", "flatAmount": "0"}, {"upTo": null, "unitPrice": "0.005", "flatAmount": "0"} ] }, "quantities": ["12000"] }' ``` ```json { "results": [{ "quantity": "12000", "amountMinor": 11000, "ratedAmountMinor": 11000, "currency": "EUR", "billableUnits": "12000", "includedUnitsApplied": "0", "carryOver": "0", "usageCapApplied": false, "spendCapApplied": false, "minimumApplied": false, "lines": [ {"tierIndex": 0, "units": "10000", "unitPrice": "0.01", "flatAmount": "0", "amount": "100"}, {"tierIndex": 1, "units": "2000", "unitPrice": "0.005", "flatAmount": "0", "amount": "10"} ] }] } ``` Send several `quantities` (up to 100) to plot a price curve in one call; `carriedOverUnits` seeds a rollover carry so the preview shows an existing contract's next period. `amountMinor` is the money after the usage cap, spend cap and minimum — the flags say which of them acted. Lines are exact decimals in **major** units and always sum to the rated amount. **Failures:** `400` — a malformed config or quantity, with the engine's own validation message · `401` · `403`. ## 15. `POST /usage/csv` Bulk intake for a periodic export. `multipart/form-data` with two parts. | Part | Notes | | --- | --- | | `file` | The CSV itself. Without an idempotency-key column, keys derive from the file digest and line number — so re-uploading the same file is a no-op, not a double charge. | | `mapping` | A `CSVMapping` JSON object naming which column carries which field. | `quantityColumn` is required. Contract, metric and occurred-at may each come from a column (`contractIdColumn`, `metricColumn`, `occurredAtColumn`) **or** from a whole-file default (`defaultContractId`, `defaultMetric`, `defaultOccurredAt`) — one or the other, never both for the same field. `idempotencyKeyColumn` is optional. ```json {"defaultContractId":42,"defaultMetric":"api_calls","quantityColumn":"calls","occurredAtColumn":"day"} ``` **Returns** `{"ingested":498,"duplicates":0,"deadLettered":2}`. A row the pipeline refuses is dead-lettered rather than failing the upload, so a partial success is normal — **read `deadLettered` on every response.** A file that answers `200` with rows in the queue is not a file that billed. **Failures:** `400` — not multipart, a mapping that is not exactly one `CSVMapping` object, or a file-level rejection · `401` · `403` · `413` — the file is over the per-call limit; split it. --- ## 16. `GET /usage/dead-letters` Events an intake refused, newest first, each with the reason and the payload as submitted. This is the queue to watch: a `2xx` from an intake does not mean every row inside it was accepted. | Field | Notes | | --- | --- | | `id` | Use it to redrive or discard. | | `source` | Which intake produced it: `events`, `batch`, `csv`, `feed`, `webhook`. | | `reason` | Why it was refused, in the intake's words. Fix this before redriving. | | `status` | `pending`, `redriven` or `discarded`. Only `pending` can be acted on. | | `attempts` | How many redrives have been tried. | | `receivedAt` | RFC 3339. | | `payload` | The event as submitted, so it can be corrected and resent. | **Failures:** `401` · `403`. ### `POST /usage/dead-letters/{deadLetterId}/redrive` `deadLetterId` is the `id` from the listing above. Resubmits the stored payload. Returns `{"status":"ingested","eventId":91424}`, or `{"status":"rejected"}` when it fails again — the letter stays `pending`, its `reason` is updated and `attempts` increments. Fix the cause first; a redrive with the same problem fails the same way. **Failures:** `401` · `403` · `404` — unknown id, or one belonging to another seller. ### `POST /usage/dead-letters/{deadLetterId}/discard` `deadLetterId` is the `id` from the listing above. Closes the letter without metering it, for a row that should never have been sent. Returns `{"status":"discarded"}`. **Not reversible** — the usage it described will not be billed. **Failures:** `401` · `403` · `404`. --- ## 17. `POST /usage/webhook-endpoints` Registers an endpoint your own system posts usage to, and mints the secret that signs those deliveries. **Body:** `{"description":"billing events → ERP staging"}` — optional, your own label. **Returns** `201` with the endpoint, the `secret`, and a `secretNote` restating the terms: ```json {"endpoint":{"id":12,"token":"whk_7f21c0","status":"active","createdAt":"2026-09-14T10:22:31Z"}, "secret":"whsec_9c2f…","secretNote":"Copy this secret now: …"} ``` **The secret is in this response and nowhere else.** It is stored encrypted and no endpoint reads it back. Copy it now; if it is lost, rotate. The `token` is the path segment your sender posts to — `POST /usage/v1/webhooks/{token}`. It is not a credential on its own: the HMAC signature is what authenticates a delivery. That receiver stays on the `/usage/v1` prefix deliberately and is not part of this key-authed surface, because its signature covers the body and the body must be read before the sender is known. **Failures:** `400` · `401` · `403`. ### `GET /usage/webhook-endpoints` Every endpoint you registered, revoked ones included. Secrets are never in a listing. **Failures:** `401` · `403`. ### `POST /usage/webhook-endpoints/{endpointId}/rotate-secret` `endpointId` is the endpoint's `id`. Issues a new secret and returns it once, on the same display-once terms as creation. **The old secret stops verifying immediately** — cut the sender over in the same change, or deliveries fail in between. **Failures:** `401` · `403` · `404`. ### `POST /usage/webhook-endpoints/{endpointId}/revoke` `endpointId` is the endpoint's `id`. Closes the endpoint: deliveries to its token are refused from here on. Not reversible — register a new endpoint instead. The record stays in the listing so the history remains readable. **Failures:** `401` · `403` · `404`. --- ## 18. `GET /invoices/export` The month-end pull: every invoice issued in the window, as one file, so reconciliation does not start with writing a paging loop. | Parameter | In | Notes | | --- | --- | --- | | `issuedFrom` | query | **Required.** Start of the period, inclusive (`YYYY-MM-DD` or RFC 3339). | | `issuedTo` | query | **Required.** End of the period, exclusive — adjacent months never double-count a document. | | `format` | query | `csv` (default) or `ndjson`. | | `contractId`, `origin`, `status`, `invoiceType` | query | The list's filters, same names, same validation. | Both dates are required on purpose: there is no default window, because "the file I exported" must never silently mean a window the server chose. An inverted or empty range is rejected. **CSV** carries one row per invoice under this header, in this order: ``` id,invoiceNumber,contractId,sellerCustomerId,origin,invoiceType,status, jurisdiction,currency,issueDate,dueDate,dateOfSupply,subtotalMinor, taxTotalMinor,grandTotalMinor,creditAppliedMinor,collectibleMinor, creditSettledMinor,corrects,reverseCharge,paidVia,voidedAt,writtenOffAt, outstandingMinor ``` (One line in the file; wrapped here to fit.) Money columns are integer **minor units** and their names say so (`grandTotalMinor` — `4999` is €49.99); do not divide by 100 in the spreadsheet without also keeping the raw column. Timestamps are RFC 3339 UTC; `corrects` names the invoice a credit note corrects, empty otherwise. `outstandingMinor` is the receivable — the column to age in an AR report, rather than `collectibleMinor`, which is frozen at issue. An **empty cell** means the adjustment ledger was unavailable for that row: the balance is unknown, and it is never written as `0`. The rest of the row is unaffected, so a ledger outage costs you that one column and not the reconciliation. **New columns are appended, never inserted.** Reconciliation files are loaded by pipelines that address columns positionally, so reordering an existing export would silently shift every one of them. Read by name if you can; if you read by position, a later Fynex release may make the row longer but will not move a column you already read. **NDJSON** carries one list-API invoice object per line — exactly the shape `GET /invoices` serves, so a pipeline that parses the list parses the export with no new code. Rows come newest first, the same order as the list. A window holding more than 100,000 documents answers `400` with instructions to narrow the range — refused rather than silently truncated, because a reconciliation file that looks complete and is not is how a document goes missing from someone's books. **Failures:** `400` — a missing or inverted date range, an unknown filter value, or a window over the row cap · `401` · `403`. --- ## 19. `GET /invoices/{invoiceId}/settlement` The join between billing and money movement: which payment attempts hit this invoice's collection link, and which payouts carried them out. Billing and split payments live on one platform — this is the endpoint that finally connects them. **Returns**, newest payment first: ```json { "invoiceId": 4180, "invoiceNumber": "UK2607AA-2608AAB", "paidVia": "payment_link", "paymentLinkId": 512, "payments": [{ "paymentId": 991, "status": "settled", "amountMinor": 12000, "currency": "EUR", "createdAt": "2026-08-17T09:26:00Z", "payouts": [{ "payoutId": 55, "status": "completed", "state": "finalized", "attributedMinor": 12000, "currency": "EUR", "completedAt": "2026-08-20T06:00:00Z" }] }] } ``` Failed payment attempts are reported with their status — "the customer tried twice and the second one settled" is part of the story. A payment with an empty `payouts` array has settled but not yet been scheduled into a payout. A payment can be split across payouts; `attributedMinor` is each payout's slice, and the attributions sum to what left. `state` is the attribution's own lifecycle: `reserved` (selected for a payout), `finalized` (paid out) or `released` (returned to the pool). Three invoices legitimately have an empty trail, and the response carries a `note` saying which case it is rather than an ambiguous empty list: an invoice that was **never sent** (no collection link exists), one settled by a **matched bank deposit** (the money arrived outside the payment-link rail), and one **discharged entirely by stored credit** (no cash moved at all). Payout and payment detail beyond the ids lives on the Payments API — this endpoint gives you the ids to look them up with. **Failures:** `400` — a malformed id · `401` · `403` · `404` — unknown id, or an invoice belonging to another seller. --- ## 20. `POST /customers` Resolves one of your customers by e-mail, creating the record when the address is new. The returned `id` is the `sellerCustomerId` every contract, credit top-up and invoice refers to. **There is no `Idempotency-Key` here, and that is the rule rather than an omission: the e-mail address is the key.** Find-or-create on (your seller account, e-mail) is enforced in the database, so calling this twice with the same address returns the same customer and creates nothing the second time — which is exactly the guarantee a client key would otherwise have to supply. Because of that the answer is always `200`, never `201`. The directory resolves the address without reporting whether *this* call minted the record, and a `201` would be a guess. Read the response as "this is your customer", not as "this customer is new". ```bash curl -s -X POST "$FYNEX_API_BASE/customers" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "customerRef": "crm-8842", "name": "Ada Lovelace", "companyName": "Analytical Engines Ltd", "currency": "EUR", "customerType": "company" }' ``` ```json { "id": 7, "email": "ada@example.com", "customerRef": "crm-8842", "name": "Ada Lovelace", "companyName": "Analytical Engines Ltd", "currency": "EUR", "customerType": "company", "createdAt": "2026-09-01T10:15:00Z" } ``` | Field | Notes | | --- | --- | | `email` | Required, and the key this call resolves on. A plain address — `Ada ` is `400`. | | `customerRef` | Your own identifier, at most 128 characters. A ref already held by a **different** customer of yours answers `422`. | | `name`, `phone`, `companyName`, `companyCountry` | Optional contact details, at most 64 / 32 / 255 / 56 characters. | | `currency` | The customer's default billing currency (ISO 4217). A contract's own currency still wins. | | `customerType` | `individual` or `company`; omit when you do not know. | Details are applied **fill-if-empty**: a value another surface already recorded — checkout, or an earlier call — is never overwritten, and an omitted field changes nothing. To correct a stored detail, use the dashboard. **Failures:** `400` (missing or malformed `email`, an over-length field, an unsupported `currency`, an unknown `customerType`) · `422` (`customerRef` already belongs to a different customer) · `429` · `401` / `403`. ## 21. `GET /contracts/{contractId}/invoices/upcoming` What the contract's next document would carry, projected through the **same engines that bill it**: the subscription terms that fall due, the unbilled mid-term amendments the engine would bind to them, each open metered period rated by the engine `POST /prices/evaluate` answers with, a per-seat period rated from the contract's seat schedule, and the stored credit that would be drawn down. Until this existed the answer had to be assembled in the client from four reads and its own arithmetic — the one implementation that is not authoritative, which is the mistake `POST /prices/evaluate` was introduced to end for a single price. This is the same argument for a whole document. **Nothing is written.** No amendment pool is emptied, no credit lot is minted, no claim is taken: it is the read-only twin of the issuance pass. | Parameter | In | Default | Notes | | --- | --- | --- | --- | | `contractId` | path | — | The contract to project. | | `at` | query | now | Project as of this instant — a date (`YYYY-MM-DD`) or an RFC 3339 timestamp. A malformed value is a `400`, never a silently substituted "now". | ```bash curl -s "$FYNEX_API_BASE/contracts/42/invoices/upcoming" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "contractId": 42, "asOf": "2026-09-20T12:00:00Z", "nextIssueDate": "2026-10-01", "currency": "EUR", "lines": [ { "source": "subscription", "description": "Subscription — 2026-09-01 to 2026-10-01", "quantity": "1", "unitPriceMinor": 10000, "amountMinor": 10000, "periodStart": "2026-09-01", "periodEnd": "2026-10-01" }, { "source": "usage", "description": "api.requests usage — 2026-09-01 to 2026-10-01", "quantity": "12000", "amountMinor": 4800, "periodStart": "2026-09-01", "periodEnd": "2026-10-01", "note": "the period is still open, so the quantity keeps accruing until it closes" } ], "subtotalMinor": 14800, "grandTotalMinor": 14800, "creditAvailableMinor": 5000, "creditAppliedEstimateMinor": 5000, "collectibleEstimateMinor": 9800, "notes": [ "every amount is pre-tax: the recurring lane copies its tax treatment from a prior invoice on this contract at issue time, and this projection does not resolve it", "metered lines are rated with a zero rollover carry, as the finance summary is; the close pass resolves the real carry chain when the period closes" ], "estimate": true } ``` | Field | Notes | | --- | --- | | `asOf` | The instant projected. Metered usage accrues after it. | | `nextIssueDate` | The earliest period end that falls due — the day the next document would appear. Absent when nothing is due. | | `currency` | The one currency the totals are in. A line in another currency is listed and **excluded** from the totals with a note; money is never converted to produce a nicer figure. | | `source` | `subscription`, `proration`, `usage` or `seat` — which engine produced the line. | | `quantity` | Decimal string: `1` for a recurring term, the period's aggregated quantity for usage, the period-weighted seat count for seats. | | `unitPriceMinor` | Present only when the line **has** a single unit price. Absent for tiered, graduated, volume, package and percentage usage lines: their price varies by band or is not per unit at all, and an average here would let you recompute a different amount. | | `amountMinor` | Pre-tax and **signed**. A mid-term downgrade credit is negative because that is what it does to the document's face value; on the issued invoice it rides as a discount on the recurring line. | | `periodStart`, `periodEnd` | The span the line covers. | | `note` | Why this particular line is an estimate, or why it is zero — a term still in trial, a period still accruing, a credit that lands on another line. | | `subtotalMinor` | Sum of the included lines, pre-tax. | | `grandTotalMinor` | The projected document total. It **equals** `subtotalMinor`: tax is not projected (see below). | | `creditAvailableMinor` | The contract's drawable stored value in this currency. Zero while the credits capability is off. | | `creditAppliedEstimateMinor` | What the drawdown would consume: the available balance bounded by the document total. | | `collectibleEstimateMinor` | What a payment rail would be asked for after credit — the figure your buyer would see. | | `notes` | Every reason the figures are estimates and every exclusion made: the absent tax, an unpriced metric, a foreign-currency line, a capability switched off. Read them; they are not decoration. | | `estimate` | Always `true`. | **Every figure is an estimate, and the response says so.** Metered usage keeps accruing after `asOf`; the period close resolves a rollover carry this projection assumes is zero; an unbilled amendment can still move before the recurring pass binds it. Reconcile against the issued document, never the other way round. **Amounts are pre-tax, deliberately.** The recurring lane does not compute tax either — it **copies** the treatment (category, rate, taxable flag) from a prior invoice on the same contract when it issues, and resolving that needs the document history this read does not touch. Guessing a rate would put a number on your screen no jurisdiction agreed to, so no tax is projected and `grandTotalMinor` equals `subtotalMinor`. Read an issued document's tax from `GET /invoices/{invoiceId}`. **Failures:** `400` (a malformed `contractId` or `at`) · `404` (no such contract, or not yours — the two are deliberately the same answer) · `429` · `401` / `403` · `501` when the projection is not enabled on the deployment. ## 22. `GET /contracts/{contractId}/test-clock` and `POST /contracts/{contractId}/test-clock/advance` **Test-mode (demo) accounts only.** A live account answers `403` on both: a live subscription's renewal is real revenue and a real document sent to a real customer, and no key may simulate that. Subscription flows run on billing **dates**. A sandbox that makes you wait 31 real days for the first renewal is not a sandbox, so a contract can be given a clock of its own and moved forward. ### `GET …/test-clock` ```bash curl -s "$FYNEX_API_BASE/contracts/42/test-clock" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "contractId": 42, "now": "2026-02-01T00:00:00Z", "simulated": true, "frozenAt": "2026-01-01T09:15:00Z", "updatedAt": "2026-01-01T09:16:12Z" } ``` | Field | Notes | | --- | --- | | `contractId` | The contract this clock belongs to. Clocks are **per contract**: advancing one never moves another, yours or anyone else's. | | `now` | What the billing engines treat as *now* for this contract. | | `simulated` | `false` while the contract still runs on real time — a contract that has never been advanced has no clock row, and reading does not create one. | | `frozenAt` | Real time when the clock was created; absent while `simulated` is `false`. With `now` it says how far the contract has been pushed. | | `updatedAt` | When the clock last moved; absent while `simulated` is `false`. | ### `POST …/test-clock/advance` ```bash curl -s -X POST "$FYNEX_API_BASE/contracts/42/test-clock/advance" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "2026-02-01T00:00:00Z"}' ``` ```json { "clock": { "contractId": 42, "now": "2026-02-01T00:00:00Z", "simulated": true, "frozenAt": "2026-01-01T09:15:00Z", "updatedAt": "2026-01-01T09:16:12Z" }, "advanced": true, "lifecycle": { "trialsActivated": 0, "trialsCanceled": 0, "trialsPastDue": 0, "renewed": 1, "resumed": 0, "canceled": 0, "expired": 0, "skipped": 0, "cronDisabled": false }, "invoices": { "issued": 1, "sent": 1, "skipped": 0, "failed": 0, "passDisabled": false }, "notAdvanced": [ "usage period close (billing.usage.close_pass)", "usage invoicing (billing.usage.invoice_pass)", "dunning e-mail for a past-due subscription", "off-session auto-charge of an issued invoice (billing.invoice.auto_charge)" ] } ``` `to` is an RFC 3339 instant. It must be **after** the contract's current clock — read it from `GET …/test-clock` — and at most **366 days** after it. **Re-sending the instant the clock already sits at is a no-op**: `200` with `advanced: false`, nothing renewed and nothing issued twice. That makes the call safe to retry. A target in the past, or more than 366 days ahead, is `400`: a clock never runs backwards, because the documents it causes are immutable and numbered. The advance then runs, **for that one contract only**, the two engines the scheduled passes use: | Response block | What ran | | --- | --- | | `lifecycle` | The subscription state machine: trials ending, pauses elapsing, scheduled cancellations and fixed ends taking effect, and terms rolling over. Each renewal emits a `subscription_renewed` event on the contract's revenue timeline, exactly as the cron's would. Counts are subscriptions, not amounts. | | `invoices` | The recurring invoice lane: the renewed term's document, composed from the contract's previous invoice, numbered, and sent. At most one per advance. `skipped` covers a term already claimed by an earlier advance or by the real cron, a contract with no prior invoice to continue from, no customer on the contract, or a currency disagreement. | `lifecycle.cronDisabled` and `invoices.passDisabled` name an operator switch that stopped half the loop — `billing.subscription.lifecycle_cron` and `billing.recurring.invoice_pass` respectively. If either is `true`, ask Fynex to enable the flag rather than debugging your integration; see [Sandbox & testing](/billing-api/v1/docs/sandbox). **`notAdvanced` is the honest part.** This clock moves the subscription state machine and the recurring invoice. It does **not** close usage periods, mint usage invoices, send dunning e-mail, or run off-session auto-charge — those stay on real time in their own passes. Do not build a test that waits on one of them after an advance. **Failures:** `400` (a malformed `contractId`, a missing or unparseable `to`, a target at or before the current clock, or one more than 366 days ahead) · `403` (a live-mode account, or an account whose mode cannot be confirmed) · `404` (no such contract, or not yours — deliberately the same answer) · `429` · `401` · `500`. --- ## Dashboard-only routes not on this surface The platform's **cost model** (fynex-billing#277: `CostRateCard`, the cost engine and its dry run) is served on the dashboard surface `/billing/v1` only — `POST|GET /billing/v1/cost-rate-cards`, `GET …/{id}`, `POST …/{id}/activate`, `POST /billing/v1/costs/evaluate` — behind the backoffice `billing:read` / `billing:update` permissions. Nothing of it is published here yet; the object's shape is still moving, and a public route is a one-way door. Reference: `docs/billing-cost-model.md`. ## Status codes, at a glance | Code | When | | --- | --- | | `200` | Success. Also a replayed write: the same `Idempotency-Key` again, or a customer that already existed. | | `201` | Created — the first call of a write that minted something (`POST /contracts`, `POST /contracts/{contractId}/amendments`, `POST /invoices`, `POST /contracts/{contractId}/subscriptions`, `POST /contracts/{contractId}/credits/top-up`). | | `400` | Malformed id, paging value, date, or an unrecognized `origin`/`status`/`invoiceType`. | | `401` | Missing or invalid key. Body may be plain text. | | `403` | Valid key, seller account not active. | | `404` | Unknown id — or one belonging to another seller. | | `422` | The object exists but the action does not apply — `POST /send`, the subscription writes, an `Idempotency-Key` already spent on a different credit grant, contract or invoice, a `customerRef` already held by another customer, an amendment whose `expectedBaseVersion` is stale or whose status transition is illegal. | | `429` | Rate limited. Honour `Retry-After`. `POST /contracts`, `POST /contracts/{contractId}/amendments` and `POST /invoices` each carry an extra per-seller quota on top of the surface-wide budget. | | `500` | Server fault. Retry GETs with backoff. | | `503` | The per-seller write quota's limiter is unreachable, so those three writes fail closed. Honour `Retry-After` and retry — see [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed). | | `413` | The request body exceeds 1 MiB. | | `501` | Capability not enabled for this deployment (credits). | Every error body is `{"error": "…"}` except the auth layer's, which may be plain text — treat any non-2xx as failed regardless of body shape. --- # Workflows End-to-end recipes for the things integrators actually build. Each one names the endpoints, the order, and the failure modes worth handling. ## Collect payment on an invoice Invoices are issued by the billing engine (subscription renewals, usage period close) or by your team in the dashboard. This API turns an issued invoice into something a customer can pay. ```bash curl -s -X POST $FYNEX_API_BASE/invoices/4180/send \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "invoice": { "id": 4180, "status": "sent", "paymentLinkId": 991, "...": "..." }, "paymentLinkUrl": "https://pay.example-host/p/7bc1f2a9e4d5" } ``` The URL points at the Fynex hosted payment page for your deployment (the host is configured per environment, so do not hard-code it — always use the URL this response returns). Send it to your customer, embed it, or redirect to it. When the document is configured for email delivery, Fynex also emails the customer — calling this endpoint is what triggers that. **Retrying is safe, and needs no idempotency header.** The invoice's unique payment-link association is the durable safeguard: an invoice that already has a link returns that same link rather than minting a second, even for simultaneous calls, and does not re-send the email. **The payment carries the document.** Whichever rail collects an invoice — the hosted link or off-session auto-charge — the resulting payment is stamped with what it settled, so you never have to reconstruct it from an order reference: - The payment link's `metadata` holds `billingInvoiceId`, `invoiceNumber`, `contractId`, `agreementNumber` and `sellerCustomerId`. The contract and its agreement number reach the payments side nowhere else. - The payment row carries `billing_invoice_id`, and so does its checkout session — a direct answer to "which invoice did this payment collect", with no join through the link. - The acquirer's order description reads `Invoice ` (Paysafe `description`, Solidgate `order_description`), which is what makes the document visible in acquirer dashboards and dispute correspondence. The merchant reference is deliberately unchanged: it is the duplicate-detection and settlement-mapping key and stays machine-shaped. - `PaymentCompleted` carries `invoiceNumber` and `contractId` when the payment collected an invoice, so it can be joined to `InvoicePaid` and to the document itself. Three cases answer `422`, and they are not retryable — the state has to change first: | Message | Meaning | | --- | --- | | `invoice is collected by bank transfer and cannot create a payment link` | The document instructs the buyer to wire money to a named account. A simultaneously payable card link would let a transfer and a card charge both succeed. | | `invoice has nothing left to collect` | Zero total, or stored credit already covers it. There is nothing to ask the customer for. | | `invoice is not in a sendable state` | Not issued yet (`draft`), or already `paid`, `voided` or `written_off`. A settled invoice never hands out a payment link. It is also the catch-all: if `status` reads `issued` and send still answers this, the document itself is faulty (a collection snapshot that is missing or unreadable) — nothing you send will fix it, so raise it with Fynex rather than retrying. | ## Know when an invoice is paid `InvoicePaid` on the webhook pipe is the push signal (see **Webhooks**); polling remains right for reconciliation. Do not assume the settlement was something you or the buyer did. A seller can enable off-session collection (`billing.invoice.auto_charge`), and then a sent or overdue invoice is charged against the customer's saved card by a background loop — `paidVia` still reads `payment_link`, because it settles on the same rail. Sellers can likewise have documents raised for them by the recurring and usage invoice passes. Both are per-seller operator switches, off until turned on, and both mean `GET /invoices` can change with no call of yours: reconcile against the list for a period rather than against the documents you happen to have touched. ```bash curl -s $FYNEX_API_BASE/invoices/4180 \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` Read `invoice.status`: | Status | Meaning | | --- | --- | | `draft` | Not yet issued. Not a legal document; not collectible. | | `issued` | Numbered and legally real; no collection instrument yet. | | `sent` | A payment link exists; awaiting payment. | | `paid` | Settled. `paidVia` says how: `payment_link` (hosted checkout), `bank_transfer` (a matched deposit) or `credit` (stored credit covered it in full). | | `overdue` | Past its `dueDate` and still unpaid. | | `voided` | Cancelled before money moved; `voidedAt` and `voidReason` explain. | | `written_off` | Collection abandoned; `writtenOffAt` and `writeOffReason` explain. | Treat this list as open: a status you do not recognize should not crash your integration. Branch on the ones you handle and fall through for the rest. Poll open invoices every few minutes at most; a fixed 15-minute sweep over `GET /invoices` is usually better than polling each document. Bank transfers settle when the deposit is matched, which is hours-to-days — do not build a tight loop waiting for one. A credit note (`invoiceType: "credit_note"`) is a **separate document** that corrects an earlier one, named in `originalInvoiceNumber`. It does not change the original: to compute what a customer owes, sum the invoice and its credit notes rather than expecting the original to shrink. Fetch them with `?corrects=`. ## Reconcile an invoice against its payout "The customer paid — where is the money?" is one call per invoice: ```bash curl -s "$FYNEX_API_BASE/invoices/4180/settlement" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` Read it in this order: each entry in `payments` is one attempt on the invoice's collection link, failed ones included — the newest `settled` one is your money. Inside it, each entry in `payouts` is a payout's claim on that payment: `state: "finalized"` with a `completedAt` means it left; `reserved` means it is scheduled; an empty `payouts` array means the payment settled but has not been picked into a payout yet. `attributedMinor` is that payout's slice — a payment can be split across payouts, and the slices sum to what left. An invoice settled by bank transfer or stored credit has no link trail, and the response's `note` says which case you are looking at — do not treat an empty `payments` list as an error. Payout detail beyond the id lives on the Payments API; this call gives you the id to look it up with. ## Reconcile a period **For month-end, take the file.** One call returns every invoice of the window as CSV (or NDJSON — the list API's objects, one per line), with money in integer minor units and column names that say so. Both dates are required; there is deliberately no default window: ```bash curl -s "$FYNEX_API_BASE/invoices/export?issuedFrom=2026-08-01&issuedTo=2026-09-01" \ -H "Authorization: Bearer $FYNEX_API_KEY" -o invoices_2026-08.csv ``` The paged list below remains right when you are feeding an application rather than closing books. Bound it by issue date rather than walking history until you cross the boundary — the range is half-open (`issuedFrom` inclusive, `issuedTo` exclusive), so adjacent months never double-count a document: ```bash # First page of August curl -s "$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01&issuedTo=2026-09-01&limit=100" \ -H "Authorization: Bearer $FYNEX_API_KEY" # Next page: pass the previous response's nextCursor curl -s "$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01&issuedTo=2026-09-01&limit=100&cursor=4081" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` Filters worth combining: - `contractId=42` — one customer relationship. - `sellerCustomerId=7` — every document addressed to one of your customers, across contracts. This is how you pull a customer statement without walking the whole period. - `status=sent` — one lifecycle state (`draft`, `issued`, `sent`, `paid`, `overdue`, `voided`, `written_off`). - `invoiceType=credit_note` — corrections only. The full set is `standard`, `credit_note`, `simplified` (retail below the threshold) and `modified` (UK retail above it, VAT-inclusive line prices). - `corrects=UK2607AA-2608AAB` — the credit notes issued against one invoice number, without scanning history for them. - `origin=usage` — the subsystem that produced the document: `recurring`, `usage`, `milestone`, `project`, `one_time`, `adhoc`, `marketplace`. (`subscription` is a deprecated alias of `recurring`: still accepted, no longer produced, and it resolves to the same set as `recurring` — see below.) `origin`, `status` and `invoiceType` reject an unrecognized value with `400`, so a typo cannot masquerade as "no such invoices". `contractId` and `corrects` are matched as given: a well-formed but wrong value legitimately returns an empty page, so check those two against `GET /contracts` and the invoice number you meant. `origin=recurring` and `origin=subscription` return the same rows. The second is the pre-schema name, and documents issued before the rename still carry it — filtering either way includes both, so a monthly report cannot silently lose the older half. `customerName` and `customerEmail` are returned on the single-invoice read and on send, but **not** on list rows: they live in the document's frozen payload, and reading them for a whole page would mean parsing every document. To group a report by customer, filter with `sellerCustomerId` rather than fetching each invoice — that keeps a monthly reconciliation to a handful of requests instead of one per document. For accounting, the figures per document are `grandTotalMinor` (what was invoiced), `creditAppliedMinor` (funded by stored credit rather than cash), and `collectibleMinor` (the grand total less that credit — what collection asks the customer for). Two traps worth stating plainly: - **`creditSettledMinor` is not a payment total.** It counts only the stored credit drawn down; a card- or transfer-paid invoice reports `0` there. - **`collectibleMinor` does not drop to zero when an invoice is paid.** It describes the ask, not the outcome. So "has this been paid?" is answered by `status` (and `paidVia` for how), never by arithmetic on the amounts. Because paging is keyset, not offset, documents issued while you are walking the pages never shift rows into or out of a page you already fetched. Start a fresh walk to pick them up. ## Watch usage against plan limits For metered contracts, the snapshot answers "where is this customer in the current period": ```bash curl -s $FYNEX_API_BASE/contracts/42/usage \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "contractId": 42, "metrics": [ { "metricName": "api_calls", "used": "10250", "includedUnits": "10000", "capQuantity": "50000", "capMode": "hard", "percentOfCap": "20.5", "percentOfPlan": "102.5", "periodStart": "2026-08-01", "periodEnd": "2026-08-31" } ] } ``` Useful signals: - `percentOfPlan` above 100 means the customer is into overage — that is what will be rated onto the next usage invoice. - `capQuantity` with `capMode: "hard"` is the ceiling enforcement uses. Warn customers well before `percentOfCap` reaches 100 rather than at it. - Quantities are decimal strings; compare with a decimal type. An unknown `contractId` answers `404`. A real contract with no metered usage answers `200` with an empty `metrics` array — that is a valid state, not an error. Usage aggregates continuously and closes at period end, so a snapshot is a live read, not a final invoice figure. Reconcile money against invoices. ## Reconcile stored credit Balances across all contracts: ```bash curl -s $FYNEX_API_BASE/credits \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` ```json { "balances": [ {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true} ] } ``` `isLiability: true` marks credit that was **paid for** — `purchased`, `enterprise` and the engine-minted `proration` (unserved time returned on a mid-term downgrade) — which is unearned revenue you still owe service against. Everything you granted (`promotional`, `manual`, `gift`, `ai_token`, `marketplace`) is not a liability. Finance wants these separated, so do not sum them blindly. Paid-for credit also never expires; granted credit may. One contract's balances plus its ledger: ```bash curl -s "$FYNEX_API_BASE/contracts/42/credits?limit=50" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` The ledger is append-only and newest-first. `kind` is `topup` (credit granted or bought), `deduction` (consumed into an invoice) or `expiry` (a lapsed lot removed); `signedDeltaMinor` is positive for topups and negative for the other two, and `invoiceId` links a deduction to the invoice it funded. Corrections are new rows, never edits, so replaying the ledger from the beginning always reproduces the current balance. Page it the same way as invoices: `limit` sets the page, and while `hasMore` is true you pass `nextCursor` back as `cursor`. A long-lived contract's history exceeds one page, and a ledger you cannot read to the end cannot be reconciled. ## See what the next invoice will carry `GET /contracts/{contractId}/invoices/upcoming` projects the contract's next document — before it exists, and without writing anything. ```bash curl -s "$FYNEX_API_BASE/contracts/42/invoices/upcoming" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` The `lines` array is the document as it would be composed, each line tagged with the engine that produced it: `subscription` (a term falling due, priced at a scheduled plan change when one takes effect by the term's end), `proration` (an unbilled mid-term amendment the engine would bind to that term — a charge as an extra line, a credit as a discount on the recurring one), `usage` (an open metered period rated by the same engine `POST /prices/evaluate` answers with) and `seat` (a per-seat period rated from the contract's seat schedule, to date). `nextIssueDate` is the day the document would appear, and `collectibleEstimateMinor` is what your buyer would be asked for after stored credit is drawn down. **Every figure is an estimate, and `estimate: true` says so on every response.** Metered usage keeps accruing after `asOf`; the period close resolves a rollover carry this projection assumes is zero; an amendment can still move before the recurring pass binds it. Amounts are also **pre-tax**: the engine copies a document's tax treatment from a prior invoice on the same contract when it issues, so no tax is projected here and `grandTotalMinor` equals `subtotalMinor`. Read the `notes` array — it names every reason the figures are estimates and every exclusion made (an unpriced metric, a foreign-currency line, a capability switched off), which is what stops a number in this response from being read as a commitment. Use it to show a customer what is coming, to check a plan change landed before the term bills, or to catch an unpriced metric while there is still time to price it. Reconcile against the **issued** document (`GET /invoices/{invoiceId}`), never the other way round. ## Track subscription state ```bash curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` To answer "is this customer currently being billed, and for how much", read `status`, `priceMinor` + `currency`, and `billingFrequency`. There is no separate plan object — those three fields *are* the plan. Watch for: - `pendingPriceMinor` / `pendingPriceChangeAt` — a scheduled change (usually a downgrade) that takes effect at term end. Showing only `priceMinor` will misinform a customer who already requested a change. - `cancelRequestedAt` / `cancelEffectiveAt` — cancellation with notice. The subscription keeps serving until the effective date, so `status` alone is not "are they leaving". - `trialEnd` with `status: "trial"` — not yet paying. - `status: "past_due"` — collection failed. **What happens next depends on a switch the seller controls**, so do not hard-code either answer: - With off-session collection enabled (`billing.invoice.auto_charge`), a failed charge is retried on a bounded schedule — a few attempts, spaced apart — and the subscription is moved to `past_due` only once that budget is spent. It can come back on its own when a later payment succeeds, which is what `BillingSubscriptionRecovered` announces. Do not treat `past_due` as terminal and do not start your own retry loop against it; you would be charging the same card twice. - Without it, nothing retries: `past_due` is a queue for you to work, and the way out is the customer paying the outstanding invoice or a new card being added. In both cases the events to watch are `BillingSubscriptionPastDue` and `BillingSubscriptionRecovered`, and the invoice's `outstandingMinor` is what says how much is actually still owed. - `pauseEndsAt` — an auto-resume date for a paused subscription. Lifecycle changes (cancel, pause, plan change) are dashboard operations in this version. ## Retries and rate limits Reads are safe to retry. So is `POST .../send`: the invoice can hold only one payment link, so a retry returns the existing one. - `429` — you exceeded the per-seller budget. Wait the `Retry-After` seconds; do not retry sooner. `X-RateLimit-Remaining`, when present, lets you slow down before you hit it — the limiter fails open during an outage, and then no rate-limit headers are sent at all. - `5xx` — retry with exponential backoff and jitter. - `4xx` other than `429` — do not retry; the request has to change. A retry loop that ignores `Retry-After` turns one throttled request into a sustained overage. If you are paging a large history, a small delay between pages is cheaper than being throttled mid-walk. --- # Usage ingestion Metered billing has two halves. This API reads the meter — `GET /billing-api/v1/contracts/{contractId}/usage` returns where a contract stands in its open period. **Writing to that meter happens on a different prefix**, `/billing-api/v1/usage`, documented here. Same API, same credential: `Authorization: Bearer sk_test_…` / `sk_live_…`, same seller tenancy, same `{"error": "…"}` envelope. > **A second, older path exists.** Metering shipped before this API did and > took the top-level prefix `/usage/v1`. Every route below still answers there > too, unchanged — but `/billing-api/v1/usage/…` is the documented one, and new > integrations should use it. The single exception is the inbound webhook > receiver in §5, which stays on `/usage/v1` on purpose: it is authenticated by > payload signature rather than by your key, and every operation under > `/billing-api/v1` is key-gated. The loop is: **register a metric → price it → send events → read the meter → the period closes into an invoice.** > **One convention differs here, and it matters.** Everything under > `/billing-api/v1` reports money in integer minor units. A *price > configuration* is the exception: unit prices and tier amounts are decimal > strings in **major** units (`"0.0001"` is one hundredth of a cent per unit), > because per-unit rates are routinely finer than a minor unit. The rate is > exact; the rounding to minor units happens once, at invoicing, by the policy > the config names. Amounts you read back on an invoice are minor units as > usual. --- ## 1. Register a metric A metric is the meter's identity: what is counted, in what unit, and how several events in one period combine. ```bash curl -s -X POST $FYNEX_API_BASE/usage/metrics \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"api_calls","unit":"call","aggregation":"sum","description":"Billable API requests"}' ``` ```json { "name": "api_calls", "version": 1, "unit": "call", "aggregation": "sum", "description": "Billable API requests" } ``` | Field | Rules | | --- | --- | | `name` | Registry identifier — this is what events reference. | | `unit` | Free text, at most 32 characters (`call`, `GB`, `token`, `seat`). | | `aggregation` | `sum`, `max`, `min`, `last`, `count` or `count_unique`. How the period's events collapse into one billable quantity. | | `uniqueKey` | `count_unique` only: the event-metadata key whose distinct values are counted — a user id, an endpoint, a tenant. Required there, forbidden elsewhere. | | `description` | Optional, at most 256 characters. | Answers `201` with the registered metric, or `400` naming the field that was wrong. A `count_unique` metric answers "how many *different* users called", where `count` answers "how many calls". Its events **must** carry the `uniqueKey` field in `metadata` (`400` otherwise), and at most **1,000 distinct values** count per contract and period: past that, new values stop counting and the balance is flagged as overflowed — the figure becomes a floor. If your cardinality is legitimately higher, meter with `count` and dimension in your own analytics instead. Metrics are versioned: `version` increments when a registration changes the definition, and events are attributed to the version current when they arrive. `uniqueKey` is part of that definition, like `unit` and `aggregation`: it cannot be edited in place — registering the same name again with a different `uniqueKey` creates version N+1, and events keep being attributed to whichever version is in force when they arrive. There is no time-weighted aggregation — GB-hours, active-seat-days and the like. Compute the weighted figure on your side and send it as the event quantity of a `sum` metric; a `max` or `last` metric over a gauge reading is the other honest shape. - `GET /billing-api/v1/usage/metrics` — every metric you have registered. - `GET /billing-api/v1/usage/metrics/{name}` — one metric, `404` when it is not registered. ## 2. Price the metric on a contract Pricing is per contract and per metric, and it is versioned by date — a new configuration does not rewrite history, it takes effect from a date you state. ```bash curl -s -X PUT "$FYNEX_API_BASE/usage/contracts/42/metrics/api_calls/price" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "effectiveFrom": "2026-09-01", "config": { "scheme": "graduated", "currency": "EUR", "rounding": "half_up", "unitPrice": "0", "tiers": [ {"upTo": "10000", "unitPrice": "0.004", "flatAmount": "0"}, {"upTo": null, "unitPrice": "0.002", "flatAmount": "0"} ], "allotment": {"includedUnits": "1000", "rollover": "none"}, "commitment": {"minimumMinor": 5000, "spendCapMinor": 500000} } }' ``` ### The seven pricing schemes | `scheme` | How the quantity is charged | | --- | --- | | `per_unit` | `units × unitPrice`. One rate, no bands. | | `graduated` | Every band charges its own rate for the units inside it — progressive, like income tax. | | `volume` | The band the **total** quantity lands in prices **all** units at that band's rate. | | `tiered` | Stairstep: the band the total lands in charges its `flatAmount`, whatever the exact count. | | `package` | Per started block: `ceil(quantity / packageSize) × unitPrice`, where `unitPrice` is the price of one package. A partial package charges in full — 1,001 units at "per 1,000" is two packages. | | `percentage` | `percentBps` basis points (100 = 1%) of the aggregated quantity, which for this scheme is itself a monetary amount in major units — a GMV or transaction-value meter. | | `per_seat` | `unitPrice` per seat per FULL period, prorated to the day: `unitPrice × seatDays ÷ periodDays`. Rated from a recorded seat history rather than from ingested events — see [Per-seat pricing](#per-seat-pricing) below. | `graduated` and `volume` are the classic trap — same bands, same quantity, different money. Read the two rows above before choosing. There is no `flat` scheme on purpose: a fixed per-period fee is what a subscription price is, and a usage floor is `commitment.minimumMinor` on any scheme. ### The rest of the configuration | Field | Meaning | | --- | --- | | `currency` | ISO 4217. Must match the contract's currency. | | `rounding` | Applied once, when the exact decimal amount collapses to minor units: `half_up`, `half_even`, `up` or `down`. No default — state it. | | `unitPrice` | The `per_unit` rate, the `package` scheme's price per package, and the `per_seat` scheme's price per seat per full period. Decimal string in major units; ignored by banded schemes. | | `packageSize` | The `package` scheme's block size in units, decimal string. Required there, forbidden elsewhere. | | `percentBps` | The `percentage` scheme's rate in integer basis points (100 = 1%). Required there, forbidden elsewhere. | | `tiers[].upTo` | The band's inclusive upper bound in units. `null` marks the last, unbounded band. Bands are ordered and contiguous from zero. | | `tiers[].unitPrice` | The rate for `graduated` and `volume`. | | `tiers[].flatAmount` | The flat charge for `tiered`. | | `allotment.includedUnits` | The first N units of each period rate to zero. | | `allotment.rollover` | `none`, or `rollover` to carry an unused allowance into the next period. | | `commitment.minimumMinor` | Floor: the period bills at least this much, in minor units. | | `commitment.spendCapMinor` | Ceiling on the money the period can bill, in minor units. | | `commitment.usageCap` | Ceiling on the billable quantity, as a decimal string. | Answers `200` with the stored configuration and its `version`. `GET` on the same path returns the current one, or `404` with `this metric has no price configured` — which is the check to run before you start sending events. > A **usage limit policy** is a separate control from `commitment.usageCap`: > it is what `GET /billing-api/v1/contracts/{contractId}/usage` reports as > `capQuantity` / `capMode`, and a `hard` policy **rejects ingestion past the > cap** (see the failure table below). A `soft` one only alerts. ### Per-seat pricing `per_seat` prices what a customer HOLDS rather than what it consumes, so it is the one scheme that is not rated from ingested events. It reads a **seat history**: an append-only record of how many seats the contract holds and from when, so a period with mid-cycle changes can be priced day by day. Configure it against the reserved metric **`platform.seat.active`**: ```bash curl -s -X PUT "$FYNEX_API_BASE/usage/contracts/42/metrics/platform.seat.active/price" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "effectiveFrom": "2026-01-01", "config": { "scheme": "per_seat", "currency": "EUR", "rounding": "half_up", "unitPrice": "10.00" } }' ``` **The metric and the scheme are reserved for each other.** `per_seat` is configurable **only** on `platform.seat.active`, and `platform.seat.active` accepts **only** `per_seat` — either mismatch answers `400`. The pairing is not housekeeping: a `per_seat` price on an ordinary metric is a period the close pass can never rate (it has a quantity, and this scheme has no use for one), so it would simply never bill; and any other scheme on the seat metric would rate your seats from whatever quantity happened to be ingested against it rather than from the recorded seat history. `unitPrice` is the price of ONE seat for a FULL period. `allotment`, `allotment.rollover` and `commitment.usageCap` are **refused** on this scheme — they are denominated in "units", and the unit here is a day-weighted seat integral rather than a consumption counter, so the same number would not mean the same thing. `commitment.minimumMinor` and `commitment.spendCapMinor` work exactly as they do everywhere else. **How a period is priced.** Seats are integrated over **UTC calendar days**, and each day is charged at the seat count that day ENDS with — the count in effect after every seat change effective on that day or earlier: ``` amount = unitPrice × seatDays ÷ periodDays ``` Two consequences worth stating, because they are what everyone asks: - A seat added on a day is charged for that day. - A seat added **and** removed on the same day is charged for nothing. The day is not charged once per change; it is charged once, at the count the day ends with — so a same-day add and remove cannot double count. **Worked example.** A contract on €10.00 per seat, January 2026 (31 days). It opens the month with 3 seats, goes to 5 on the 11th, and back to 4 on the 21st: | Days | Seats | Seat-days | | --- | --- | --- | | Jan 1–10 (10) | 3 | 30 | | Jan 11–20 (10) | 5 | 50 | | Jan 21–31 (11) | 4 | 44 | | **Total** | | **124** | 124 seat-days ÷ 31 days = **4 seat-equivalents**, so the period bills €40.00 — not €50.00 (the peak) and not €30.00 (the opening). Re-rating the same history always produces the same amount; a correction is a new seat change, never an edit of a recorded one. **How a per-seat period is billed.** Exactly like every other metric's, and on the same schedule: when the period has ended, it closes into one usage invoice line and the invoicing lane puts that line on a document. Two things are worth knowing because they are visible on the artifacts: - The line's `quantity` is the period's **seat-days** — 124 in the example above, not 4. Seat-days is the exact integer the amount is derived from; the divisor (`periodDays`) and the seat-equivalent figure (`seatEquivalents`) are in the line's `ratingBreakdown` beside the day-run trace. The amount on the line is already the prorated money, so a document reads €40.00 whatever the quantity column says. - A period with **no** seats recorded still closes, at zero — the same way a metric with no events does, so a `commitment.minimumMinor` trues up on silence rather than being skipped. Because seats are not ingested, this lane is driven by the price document rather than by arriving events: a contract is billed for seats from the first period its per-seat price is effective for, on the contract's own billing cycle. Periods that ended before the price existed are never billed retroactively. **Recording seat changes is not on this API yet.** Seats are recorded and read on the dashboard surface — `POST` and `GET /billing/v1/contracts/{contractId}/seats` — which needs a dashboard session, not a seller key. A seller-key route for seat changes is a later slice; publishing one is a one-way door and the wire shape of a seat event is exactly what would move. Until then, a `per_seat` price you configure here is priced from the history recorded there, and `GET /billing/v1/contracts/{contractId}/seats?periodStart=…&periodEnd=…` returns the day-by-day breakdown above. ### Try a price before you commit to it `POST /billing-api/v1/prices/evaluate` runs a configuration through the same engine that bills, at any quantities you name — pure, nothing stored, no contract required. It is how to see what `graduated` and `volume` do to the same bands before agreeing a term, and it is what the number shown to a customer during negotiation should come from. ```bash curl -s -X POST "$FYNEX_API_BASE/prices/evaluate" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"config": { …the same config… }, "quantities": ["0", "5000", "12000"]}' ``` Each result carries `amountMinor` (what the period would bill, after the caps and the minimum), the flags saying which of those limits acted, and the exact per-band `lines`. Up to 100 quantities per call, so a whole price curve is one request. A `per_seat` configuration answers `422` here: that scheme is rated from the contract's seat schedule and is not evaluable per quantity — there is no quantity that expresses *when* the seats changed. Read the period's amount and its day-by-day breakdown from `GET /billing/v1/contracts/{contractId}/seats?periodStart=…&periodEnd=…` instead. ## 3. Send events ```bash curl -s -X POST $FYNEX_API_BASE/usage/events \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contractId": "42", "metric": "api_calls", "quantity": "1250", "occurredAt": "2026-09-14T10:22:31Z", "idempotencyKey": "req-2026-09-14-8f21", "metadata": {"region": "eu-west-1"} }' ``` ```json { "status": "ingested", "eventId": 91422, "metered": true } ``` | Field | Rules | | --- | --- | | `contractId` | The contract the usage belongs to, as a string. | | `metric` | A registered metric name. | | `quantity` | Decimal **string** — a JSON number is a float, and metered quantities must not pass through one. | | `occurredAt` | RFC 3339. When the usage happened, not when you sent it: this is what places the event in a billing period. | | `idempotencyKey` | Your own key for this event. Resending the same key with the same payload is a no-op. | | `metadata` | Optional JSON object, carried for your own audit. | `201` when the event was recorded, `200` with `"status": "duplicate"` when the idempotency key was already seen. Retrying is always safe. Two policies worth knowing before you design your keys: - **The dedup window is unbounded.** Keys are unique per seller for the lifetime of the data — not a rolling window — so a key seen once is a duplicate forever. Derive keys from the event's identity (source record id, timestamp), never from a counter you might reset. - **Backdating has no lower bound; the future is refused.** `occurredAt` may be arbitrarily far in the past — there is no backdating window to configure or to fall outside of. The only temporal rejection is an `occurredAt` ahead of now. Two edges worth knowing: an event older than the contract's first billing period is stored but stays unassigned until aggregation picks it up, and an event landing in a **closed** period follows the next rule down. - **A late event never rewrites a closed period.** Ingestion compares `occurredAt` against the metric's close watermark: an event older than the last closed period is accepted and flagged for the correction workflow instead of silently changing an amount that may already be on an issued invoice. Corrections are operator-reviewed, so systematically late feeds should be fixed at the source rather than relied on. ### `metered: false` is the failure that looks like success An accepted event is stored, but it only reaches the meter if the contract has an active subscription whose schedule covers `occurredAt`. When it does not, the response is still `201`, `metered` is `false`, and `message` says so: ```json { "status": "ingested", "eventId": 91423, "metered": false, "message": "event stored but not attributed to a billing period; it will not appear on the usage meter until an active subscription's schedule covers occurredAt" } ``` **Check this flag.** Ignoring it is how a month of usage goes unbilled with every call answering 2xx. ### Batches ```bash curl -s -X POST "$FYNEX_API_BASE/usage/events:batch" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"events":[ {...}, {...} ]}' ``` ```json { "ingested": 2, "duplicates": 1, "rejected": 1, "items": [ {"index": 0, "status": "ingested", "eventId": 91424, "metered": true}, {"index": 1, "status": "duplicate", "eventId": 91380, "metered": true}, {"index": 2, "status": "rejected", "reason": "unknown metric \"api_call\""}, {"index": 3, "status": "ingested", "eventId": 91425, "metered": true} ] } ``` Answers `200` and reports each event by its `index`; a rejected item does not stop the others. Two whole-batch failures exist: `413` when the batch holds more than **500 events** (split it — nothing in a refused batch is recorded), and `500` on an infrastructure fault — retry the entire batch, the idempotency keys make that safe. ### CSV `POST /billing-api/v1/usage/csv` takes `multipart/form-data` with two parts: `file` (the CSV) and `mapping` (one JSON object describing which column is which). It is the path for backfills and ERP exports rather than live traffic. ## 4. Read the meter back Ingested and metered usage appears on this API: ```bash curl -s "$FYNEX_API_BASE/contracts/42/usage" \ -H "Authorization: Bearer $FYNEX_API_KEY" ``` The snapshot is a live read of the open period — it is not a closed figure. When the period closes, the rated usage becomes invoice lines, and those are what you reconcile money against. ## 5. Receiving usage from a third party If the system that produces the usage is not yours to change — an ERP, a vendor's platform — register an inbound endpoint and give them its URL instead of your API key: ```bash curl -s -X POST $FYNEX_API_BASE/usage/webhook-endpoints \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"description":"Acme ERP nightly meter"}' ``` The response carries the endpoint and **the signing secret, once** — it is not retrievable later. The sender then posts usage to `POST /usage/v1/webhooks/{token}`, signing the body with that secret. Manage them with `GET /billing-api/v1/usage/webhook-endpoints`, `POST /billing-api/v1/usage/webhook-endpoints/{id}/rotate-secret` and `POST /billing-api/v1/usage/webhook-endpoints/{id}/revoke`. **These are inbound.** They are how usage gets *in*; they are not notifications about your billing. There are no outbound Billing webhooks in v1 — for invoice settlement, poll. ## 6. When an event cannot be processed Events that arrive over asynchronous paths — inbound webhooks, CSV rows — cannot answer the sender, so a failure is parked instead of lost: - `GET /billing-api/v1/usage/dead-letters` — what is parked, with the reason and the attempt count. - `POST /billing-api/v1/usage/dead-letters/{id}/redrive` — reprocess one after fixing the cause (registering the missing metric, for instance). - `POST /billing-api/v1/usage/dead-letters/{id}/discard` — abandon one deliberately. Synchronous calls (`/events`, `/events:batch`) never dead-letter: the caller is present, so the rejection comes back as an HTTP error. ## Failure contract | Status | When | | --- | --- | | `400` | The event is malformed, `occurredAt` is in the future, or a **hard** usage cap is already exceeded — the event is not recorded. | | `401` | Missing or invalid key. This surface answers only "not authorized", never the account's state. | | `404` | Unknown metric, or a contract that is not yours. | | `409` | The `idempotencyKey` was used before with a **different** payload. Same key, same payload is a duplicate (`200`), not a conflict. | | `413` | The batch holds more than 500 events. Nothing in it is recorded — split it and resend. | | `500` | Infrastructure fault. Retry — idempotency keys make that safe. | Error bodies are the same `{"error": "…"}` envelope as the rest of billing. --- # Webhooks Billing emits signed webhook events through the same delivery pipe as the Payments API: one endpoint registration, one signature scheme, one retry policy — a receiver built for payment events handles billing events with no new integration. Events are recorded **in the same database transaction** as the state change they describe. A delivered event can never describe a state that was rolled back, and each transition emits exactly once — retries of your endpoint receive the same event, never a second one. ## Event catalog | Event | Fires when | | --- | --- | | `InvoiceIssued` | A document is issued — numbered, legally real. | | `InvoiceSent` | The collection link was created (`POST /invoices/{invoiceId}/send`). | | `InvoicePaid` | The document settled — by hosted payment, matched bank transfer, or stored credit covering it in full. `paidVia` says which. | | `InvoiceOverdue` | A sent document passed its due date unpaid. | | `InvoiceVoided` | The document was cancelled before money moved. | | `InvoiceWrittenOff` | Collection was abandoned. | | `CreditApplied` | An issued invoice drew stored credit down. | | `CreditDepleted` | That drawdown consumed the contract's last available credit in the invoice's currency — top up, or the next invoice asks the customer for cash. | ## Subscription lifecycle | Event | Fires when | | --- | --- | | `BillingSubscriptionStarted` | A subscription was created on a contract. | | `BillingSubscriptionActivated` | It became active — a trial converted, or a term began. | | `BillingSubscriptionRenewed` | A term rolled over into the next one. | | `BillingSubscriptionPastDue` | Collection failed, or a trial ended with no payment method. | | `BillingSubscriptionRecovered` | It came back from past due. | | `BillingSubscriptionPaused` | Billing is suspended; no invoices are raised while it is. | | `BillingSubscriptionResumed` | Billing continues **on the original schedule** — the period is not restarted. | | `BillingSubscriptionCancelScheduled` | A cancellation was requested and takes effect after its notice period. | | `BillingSubscriptionCanceled` | The cancellation took effect. | | `BillingSubscriptionExpired` | A fixed end date was reached. | **Why the `Billing` prefix.** The unprefixed `Subscription*` events on this same pipe — `SubscriptionCreated`, `SubscriptionCharged`, `SubscriptionCancelled`, `SubscriptionPastDue` — describe **payment-link recurring subscriptions**, a different object with its own lifecycle. Two products, one word. Subscribing to those will not tell you anything about a billing subscription or its contract; subscribe to the prefixed family instead. **One exception, and you have to know it: `SubscriptionTrialWillEnd`.** The prefixed family has no trial-ending event — a billing subscription's trial reminder is emitted under that unprefixed name instead, one day before the trial ends, with a payload of its own: ```json { "source": "billing", "subscriptionId": 15, "contractId": 74, "trialEnd": "2026-09-17", "amountMinor": 820000, "currency": "EUR" } ``` `source` is how you tell it apart: `billing` on the shape above, `payment_links` on the larger shape a payment-link subscription's own trial reminder carries. It is the OpenAPI discriminator for the two, so a generated client picks the right type from it. (`contractId` is present on the billing shape only and remains a valid tell for a receiver written before `source` existed — a payment-link subscription has no contract.) The Payments API's webhook catalog publishes both under the one event name. **If you want a trial-ending warning for a billing subscription, you must subscribe to the unprefixed event** — the prefixed family does not carry one. Metered-usage threshold alerts (`UsageAlertFired`) were already delivered on this pipe and are unchanged. ## The delivery body Every delivery — whatever fired — is an envelope with the event's payload nested inside it. The four envelope fields are the same for every family: ```json { "eventId": 918204, "eventType": "InvoicePaid", "sellerAccountUuid": "6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55", "occurredAt": "2026-08-20T14:02:11Z", "payload": { } } ``` `eventId` is the delivery's stable identity: deduplicate on it. `occurredAt` is when the state changed, not when the delivery was attempted — a retry repeats the original value. `sellerAccountUuid` is your Fynex account, never your customer. Every event is also published in the OpenAPI document under its top-level `webhooks` key, so a generated client carries the body type rather than `map[string]any`. ## Payloads Invoice events carry the document's public identity and money facts — the same fields the API serves, and nothing it does not: ```json { "invoiceId": 4180, "invoiceNumber": "UK2607AA-2608AAB", "contractId": 42, "sellerCustomerId": 7, "origin": "usage", "status": "paid", "paidVia": "payment_link", "currency": "EUR", "grandTotalMinor": 12000, "collectibleMinor": 12000, "dueDate": "2026-08-15T00:00:00Z", "occurredAt": "2026-08-20T14:02:11Z" } ``` Buyer name and email are deliberately absent — webhook bodies land in the receiver's logs, and the single-invoice read serves them to a caller who holds the key. Treat the event as the signal and `GET /invoices/{invoiceId}` as the source of truth. A subscription event carries the subscription's identity and where it landed: ```json { "subscriptionId": 15, "contractId": 74, "status": "paused", "billingFrequency": "monthly", "currency": "EUR", "priceMinor": 820000, "currentPeriodStart": "2026-08-17T00:00:00Z", "currentPeriodEnd": "2026-09-17T00:00:00Z", "occurredAt": "2026-08-28T11:04:22Z" } ``` The event type says what happened and `status` says where it landed; there is no `previousStatus`. Nothing in the body identifies the customer — look them up through the contract if you need to. Credit events: ```json { "contractId": 42, "invoiceId": 4181, "invoiceNumber": "UK2607AA-2608AAC", "currency": "EUR", "appliedMinor": 250000, "remainingMinor": 0, "occurredAt": "2026-09-01T00:05:00Z" } ``` `remainingMinor` is the contract's available credit after the movement — it is `0` on every `CreditDepleted` by definition. ## Delivery, signing, retries Deliveries are HTTP POSTs to the webhook endpoints configured for your account, signed with your endpoint's secret: ``` X-Fynex-Signature: sha256= X-Fynex-Timestamp: ``` Verify the signature against the **raw** request body before parsing, and reject a delivery whose timestamp is far from your own clock — that is what bounds replay of a body someone captured. Deliveries are retried with backoff until your endpoint answers 2xx; your handler must therefore be idempotent. Deduplicate on the envelope's `eventId`, which is stable across every retry of the same event. Endpoint registration, secret rotation and the retry schedule are the Payments API's webhook machinery — see its **Webhooks** guide; nothing about it is billing-specific. ## Polling still works The polling guidance in **Workflows** remains valid and is the right tool for reconciliation: a month-end close should read `GET /invoices` for the period rather than reconstruct it from events. Webhooks are for the moment an invoice being paid *unlocks something* — activating a seller, releasing a listing, restoring access — where minutes of polling latency is real waiting. --- # Code examples Working snippets for the two pieces every integration needs — walking pages and retrying correctly — plus a typed-client shortcut. ## Generate a client from the spec The fastest path is not to hand-write a client at all. The spec is standard OpenAPI 3.1: ```bash curl -s "$FYNEX_API_BASE/openapi.json" -o fynex-billing.json # TypeScript types npx openapi-typescript fynex-billing.json -o fynex-billing.d.ts # Python / Go / Java / … via openapi-generator openapi-generator generate -i fynex-billing.json -g python -o ./fynex-billing-client ``` Generated models keep minor-unit amounts as integers and decimal quantities as strings, which is what you want. If your generator maps `quantity` or `taxRate` to a float, override it to a decimal type. ## Python: page through invoices ```python import os import time import requests BASE = os.environ["FYNEX_API_BASE"] # e.g. https://api.fynex.ai/billing-api/v1 SESSION = requests.Session() SESSION.headers["Authorization"] = f'Bearer {os.environ["FYNEX_API_KEY"]}' def request(method, path, **kwargs): """One request with the retry policy: honour Retry-After on 429, back off on 5xx, never retry other 4xx.""" delay = 1.0 for attempt in range(6): response = SESSION.request(method, f"{BASE}{path}", timeout=30, **kwargs) if response.status_code == 429: # Retry-After is authoritative; retrying sooner just deepens the overage. time.sleep(int(response.headers.get("Retry-After", "1"))) continue if response.status_code >= 500: time.sleep(delay) delay *= 2 continue if not response.ok: # 400/401/403/404/422 — the request must change, so retrying is pointless. raise RuntimeError(f"{response.status_code}: {response.json().get('error')}") return response raise RuntimeError("giving up after repeated throttling or server errors") def iter_invoices(**filters): """Yield every invoice, newest first, following the keyset cursor.""" before_id = None while True: params = {"limit": 100, **filters} if before_id: params["cursor"] = before_id page = request("GET", "/invoices", params=params).json() for invoice in page["invoices"]: yield invoice if not page["hasMore"]: return before_id = page["nextCursor"] outstanding = [ inv for inv in iter_invoices(origin="usage") if inv["status"] in ("issued", "sent") and inv["collectibleMinor"] > 0 ] total_minor = sum(inv["collectibleMinor"] for inv in outstanding) print(f"{len(outstanding)} open invoices, {total_minor / 100:.2f} outstanding") ``` The last line divides by 100 only because the example is EUR. Currencies have different minor-unit scales — divide by the scale of the invoice's own `currency`, or better, keep the integer and format at the edge. ## Node: collect on an invoice ```javascript const BASE = process.env.FYNEX_API_BASE; // https://api.fynex.ai/billing-api/v1 async function call(method, path, options = {}) { for (let attempt = 0; attempt < 6; attempt++) { const response = await fetch(`${BASE}${path}`, { method, ...options, headers: { Authorization: `Bearer ${process.env.FYNEX_API_KEY}`, ...options.headers, }, }); if (response.status === 429) { const wait = Number(response.headers.get("Retry-After") ?? 1); await new Promise((r) => setTimeout(r, wait * 1000)); continue; } if (response.status >= 500) { await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); continue; } const body = await response.json(); if (!response.ok) throw new Error(`${response.status}: ${body.error}`); return body; } throw new Error("giving up after repeated throttling or server errors"); } // Safe to retry as-is: the invoice's durable link association makes repeated // and simultaneous calls converge on the same payment link. const { invoice, paymentLinkUrl } = await call("POST", `/invoices/${invoiceId}/send`); console.log(`Invoice ${invoice.invoiceNumber}: ${paymentLinkUrl}`); ``` ## Go: usage against plan limits ```go type MetricUsage struct { MetricName string `json:"metricName"` Used string `json:"used"` // decimal string IncludedUnits string `json:"includedUnits"` // decimal string CapQuantity string `json:"capQuantity,omitempty"` CapMode string `json:"capMode,omitempty"` PercentOfPlan string `json:"percentOfPlan,omitempty"` PeriodStart string `json:"periodStart,omitempty"` PeriodEnd string `json:"periodEnd,omitempty"` } type ContractUsage struct { ContractID int64 `json:"contractId"` Metrics []MetricUsage `json:"metrics"` } func overageMetrics(ctx context.Context, client *http.Client, baseURL, key string, contractID int64) ([]MetricUsage, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/contracts/%d/usage", baseURL, contractID), nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { var apiErr struct { Error string `json:"error"` } _ = json.NewDecoder(resp.Body).Decode(&apiErr) return nil, fmt.Errorf("usage snapshot: %d: %s", resp.StatusCode, apiErr.Error) } var usage ContractUsage if err := json.NewDecoder(resp.Body).Decode(&usage); err != nil { return nil, err } var over []MetricUsage for _, m := range usage.Metrics { // Decimal, not float: these quantities carry fractional precision. pct, err := decimal.NewFromString(m.PercentOfPlan) if err != nil || m.PercentOfPlan == "" { continue // no plan allowance configured for this metric } if pct.GreaterThan(decimal.NewFromInt(100)) { over = append(over, m) } } return over, nil } ``` ## Shell: download every PDF for a month ```bash #!/usr/bin/env bash set -euo pipefail BASE="${FYNEX_API_BASE:?set FYNEX_API_BASE}" AUTH="Authorization: Bearer ${FYNEX_API_KEY:?set FYNEX_API_KEY}" cursor="" while :; do url="$BASE/invoices?limit=100" [ -n "$cursor" ] && url="$url&cursor=$cursor" page="$(curl -sf -H "$AUTH" "$url")" # Every issued document renders, including void ones and credit notes — # they are part of the audit trail. Filter here if you only want live ones. echo "$page" | jq -r '.invoices[] | "\(.id) \(.invoiceNumber)"' | while read -r id number; do curl -sf -H "$AUTH" "$BASE/invoices/$id/pdf" -o "invoices/$number.pdf" done [ "$(echo "$page" | jq -r '.hasMore')" = "true" ] || break cursor="$(echo "$page" | jq -r '.nextCursor')" sleep 1 # stay clear of the per-seller rate limit while bulk-downloading done ``` ## Testing your integration Point everything at a `sk_test_…` key while your account is still in demo mode. Going live is two steps, not one: Fynex switches the account to live mode, and you swap in an `sk_live_…` key. From that moment the test key no longer authenticates (`401`), so make the key a configuration value rather than a constant. The billing records themselves belong to the account — a live key does not reveal a second, hidden dataset. Worth exercising before you ship: - A `404` path (a contract id that is not yours) — confirm you surface it as "not found" rather than crashing on a missing field. - A `429` path — force it by looping requests, and confirm your client waits for `Retry-After` instead of hot-looping. - A multi-page walk — seed more rows than your page size, or set `limit=1`, and confirm you follow `nextCursor` to the end. --- # Contracts A contract is the commercial agreement every other billing object hangs off: subscriptions bill on it, credit is held against it, metered usage is priced under it and every invoice names it. It belongs to one of your customers (a `sellerCustomerId` from `POST /customers`) and bills in one currency for its whole life — credit and invoices under it are never converted. ## Versions, not edits A contract is a stable id plus an append-only history of **versions**. Nothing is ever updated in place: every change — a status move, a new end date, a different set of components — is a new version appended on top of the last, and every earlier version stays readable. `GET /contracts` shows the current version of each contract you own, with `version` saying how many there are. That is what an amendment is: **a compare-and-swap on the version number**. You send the `expectedBaseVersion` you last read; if the contract has moved on since, the amendment answers `422` and you re-read before deciding again. Two integrations amending the same contract can therefore never silently overwrite each other. ## Status `draft` → `active` → `suspended` ⇄ `active` → `closed`. A contract starts in `draft`. `closed` is terminal: nothing about a closed contract can be amended. A transition the state machine does not allow (anything out of `closed`, or `draft` straight to `suspended`) answers `422`. **Closing a contract does not cancel its subscriptions.** The status is a record of the commercial relationship; no billing engine reads it, so a subscription on a closed contract keeps renewing and keeps issuing invoices. Cancel each one with `POST /billing-api/v1/subscriptions/{subscriptionId}/cancel` FIRST, then close the contract. ## Endpoints - `GET /billing-api/v1/contracts` — the current version of every contract you own, keyset-paged by contract id. - `POST /billing-api/v1/contracts` — create one, in `draft`, for one of your customers. **`Idempotency-Key` is required**: the same key returns the contract the first call created (`200` instead of `201`); the same key answers `422`, naming the field, when the request differs in its `sellerCustomerId`, `currency` or the `lineItems` themselves — compared line by line on `componentType`, `componentConfig` and `quantity`, order-insensitively — or in the `startDate` or `endDate` as the key first created it. It also carries an EXTRA per-seller quota of its own — 20 create requests a rolling day by default (a refused or replayed request counts too), answered as `429` with `Retry-After` — bounding how fast one key can open contracts, each of which allocates a number from a sequence shared across sellers. - `POST /billing-api/v1/contracts/{contractId}/amendments` — append a version: a status move, new dates, a replaced component set, or the counterparty's details as agreed. `expectedBaseVersion` is required and is the concurrency guard; there is no `Idempotency-Key`, because a repeat of a successful amendment fails the version check by construction. This one carries an EXTRA per-seller quota too — 200 amend requests a rolling day by default (a stale `expectedBaseVersion` counts too) — because every accepted amendment appends a version that can never be deleted. - `GET /billing-api/v1/contracts/{contractId}/usage` and `GET /billing-api/v1/contracts/{contractId}/credits` — what has been metered and what stored value is held under the contract. Both write quotas, and the invoice issue quota, **fail closed** — while the limiter is unreachable they answer `503` with `Retry-After` and the write does not happen; see [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed). ## Components `lineItems` on a contract are its **components**: what the agreement says is being sold, each with a `componentType`, a `quantity` (decimal string) and an optional `componentConfig` the engine reads for that type. They are snapshotted per version — an amendment either carries the current set forward (omit `lineItems`) or replaces it whole (send the full new set; `[]` clears it). There is no partial edit, for the same reason there is no partial edit of anything else here: the version is the audit trail. A contract may have no components at all. Its billing is then defined by the subscriptions attached to it (`POST /contracts/{contractId}/subscriptions`) and the metered prices set under it. ## What the seller key may not do Contracts created through this API record no dashboard user as their author (`createdBy` is `0`): the seller key is the actor, and the audit trail names the key's seller account. Contract PDFs, tax profiles and the backoffice review of a contract stay dashboard operations. --- # Invoices A billing invoice is an immutable, numbered document. The relational fields you see in responses (totals, dates, status) mirror a frozen, schema-valid payload snapshotted at issue time; the PDF renders from that snapshot. ## Lifecycle `draft → issued → sent → paid`, with `overdue` for a sent document still unpaid past its due date, and three corrective exits: - `voided` — the document was cancelled before money moved (`voidedAt`, `voidReason`). - written off — collection was abandoned (`writtenOffAt`, `writeOffReason`). - credit note — a separate document (`invoiceType: "credit_note"`) naming the original in `originalInvoiceNumber`. `paidVia` records how a paid document settled: `payment_link` (the card rail — either the buyer visiting the hosted page, or an off-session charge on their saved card when the seller has auto-collection enabled), `bank_transfer` (a deposit on the collection account named on the document) or `credit` (stored credit covered it in full at issue). A document can therefore reach `paid` without you doing anything, and can appear on `GET /invoices` without anyone creating it: whether terms and metered usage are invoiced on a schedule, and whether saved cards are charged off-session, are per-seller operator switches. See **Sandbox & testing** for the switches themselves. ## Numbering Every issued document carries TWO numbers, and they are not interchangeable. `invoiceNumber` (`UK2607AB-2607AAC`) is the document's identity: unique across every Fynex invoice family, allocated from the shared per-(agreement, month) sequence, and **also the bank payment reference** — it is the only string a buyer may quote on a transfer, and it is what `bankTransfer.paymentDescription` returns. `customerDocumentNumber` (`0000042`) is the document's position in that customer's own series: cumulative for the life of the (seller, customer) pair, never reset, and counted independently for each customer. Credit notes take the next position like any other document, and a voided document keeps the one it had. It is a convenience reference printed as "Your document no." — unique only within the pair, so it is never a lookup key and never a payment reference. Absent on documents issued before the series existed; those are not backfilled. ## Numbering An invoice number is `-`: your agreement number, a dash, the issue month as `YYMM`, and a three-letter order within that month (`AAA`, `AAB`, …) — for example `UK2607AA-2608AAB` is the second document issued in August 2026 under agreement `UK2607AA`. The series is allocated inside the issuing transaction, so it is gapless per month, and it is shared with top-up invoices so the two document families can never collide. Numbers are what `corrects` and `originalInvoiceNumber` reference, so store them verbatim — do not parse meaning out of the order suffix. `customerName` and `customerEmail` carry who the document was addressed to at issue — taken from the frozen payload, so a customer later renaming themselves does not rewrite an issued legal record. They are returned by the single-invoice read and by `send`, and omitted from list rows. ## Endpoints - `GET /billing-api/v1/invoices` — list, newest first, paged with `cursor`/`limit`. Filters: `contractId`, `sellerCustomerId`, `status`, `invoiceType` (`standard`, `credit_note`, `simplified`, `modified`), `origin` (`recurring`, `usage`, `milestone`, `project`, `one_time`, `adhoc`, `marketplace`; `subscription` is a deprecated alias of `recurring`), `corrects` (credit notes against an invoice number), `issuedFrom`/`issuedTo` (half-open date range). - `POST /billing-api/v1/invoices` — compose, number and (by default) send a document in one call, behind a required `Idempotency-Key`. It is issued and immutable the moment the call answers `201`; the same key replays the document with `200` and no link, and a key whose request differs in its contract, customer, currency, `invoiceType`, `dueDate`, net line total or line count answers `422` naming the field. There are no drafts on this API: send explicit lines with `unitPriceMinor`. `invoiceType` accepts `standard` (the default), `simplified` and `modified`; **`credit_note` is refused with `400`** — a correction is raised against the document it corrects, which this request cannot express, and there is no `originalInvoiceNumber` field on it. Correct a mistake in the dashboard. - `GET /billing-api/v1/invoices/{invoiceId}` — one invoice with its lines. - `GET /billing-api/v1/invoices/{invoiceId}/pdf` — the rendered PDF. - `POST /billing-api/v1/invoices/{invoiceId}/send` — materialize the collection instrument: creates the invoice's hosted payment link and returns the invoice together with `paymentLinkUrl`. **It emails your customer** whenever the document carries a buyer email; there is no per-request suppression in v1, so the first call is customer-facing. The invoice is itself the collection anchor, so retries and simultaneous calls return the one existing link and do not send another email — no idempotency header is needed. Not applicable (`422`) to bank-transfer documents, to documents outside `issued`/`sent`/`overdue` (a `draft`, or one already `paid`, `voided` or `written_off`), or to documents with nothing left to collect. ## Amounts `grandTotalMinor` is the legal document total. `collectibleMinor` is what collection asks the customer for: the grand total less `creditAppliedMinor` (stored credit drawn down at issue). `creditSettledMinor` reports only the part discharged by that stored credit — it is **not** a payment total, and a card- or transfer-paid invoice reports `0`. Neither amount changes when the invoice is paid: `status` and `paidVia` are what tell you the outcome. `outstandingMinor` is the one to age in an AR report. `collectibleMinor` is frozen at issue and stays there, so it keeps asking for the full amount after a credit note, a write-off or any other adjustment; `outstandingMinor` is derived from the adjustment ledger on every read and reports what is genuinely still owed (`0` for a voided document). It is omitted when the adjustment ledger is unavailable — absence means **unknown**, not `0`. --- # Subscriptions A subscription is one recurring billing relationship on a contract. All date fields are UTC calendar dates — billing runs on dates, not instants. ## States `trial → active ⇄ past_due / paused → canceled | expired` - `trial` — running a free trial until `trialEnd`. `trialEndBehavior` says which way it ends: `convert` (becomes a paying subscription) or `cancel` (lapses). `trialRequiresPaymentMethod` tells you whether a payment method must be on file first. - `active` — billing normally within `currentPeriodStart…currentPeriodEnd`. - `past_due` — a renewal charge failed. **No automatic retry runs**: recovery is your action or a dashboard operation. Treat this status as "act now", not as "Fynex is handling it". - `paused` — billing suspended (`pausedAt`, optional auto-resume at `pauseEndsAt`). - `canceled` / `expired` — terminal. A cancellation with notice keeps serving until `cancelEffectiveAt`. ## Price and plan There is no separate plan object: the plan reference is `priceMinor + currency + billingFrequency` (with `customUnit`/`customEvery` for custom cadences). A scheduled downgrade appears as `pendingPriceMinor`/`pendingPriceChangeAt` until the renewal pass applies it. ## Endpoints Read: - `GET /billing-api/v1/subscriptions` — list, newest first. Filter: `contractId`. Pages with `cursor`/`limit`, newest first. - `GET /billing-api/v1/subscriptions/{subscriptionId}` — one subscription. Create: - `POST /billing-api/v1/contracts/{contractId}/subscriptions` — a new subscription on a contract you own. **`Idempotency-Key` is required** (1–128 characters, `A–Z a–z 0–9 _ . : -`): the same key always returns the subscription the first call created (`200` instead of `201`), whatever the body of the retry; use one key per subscription you intend to create. Keys are scoped to your seller account — the same key on a different contract answers `422`; a key under a billing-engine prefix (`proration:`, `redeem:`, `subscription:`, …) answers `400`. Body: `frequency` (`daily`, `weekly`, `bi_weekly`, `monthly`, `quarterly`, `semi_annual`, `annual`, `custom` with `customUnit`/`customEvery`), `priceMinor` + `currency` (the contract's), `startDate` (up to a year in the past), optional `anchorDate`, `trialEnd` + `trialEndBehavior` (`convert`/`cancel`), `autoRenew` (default `true`; an explicit `false` needs `endDate`, and `endDate` is refused otherwise), `noticePeriodDays` (0–365), `prorationPolicy` (`by_day`/`full_period`/`next_period` — selects what an immediate `change-plan` does once the proration engine is enabled: prorate the unserved remainder, bill the whole current period at the new price with the elapsed days included and no adjustment, or keep the old price until the next period. Inert while the engine is off, which is the default. Where invoice binding is enabled for the environment, the recorded proration is applied to the term's next invoice). ```bash curl -X POST "$FYNEX_API_BASE/contracts/42/subscriptions" \ -H "Authorization: Bearer $FYNEX_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"frequency":"monthly","priceMinor":9900,"currency":"EUR","startDate":"2026-10-01","noticePeriodDays":30}' ``` Lifecycle, all `POST /billing-api/v1/subscriptions/{subscriptionId}/…`: | Action | Body | From | Effect | | --- | --- | --- | --- | | `cancel` | — | `trial`, `active`, `past_due`, `paused` | Sets `cancelRequestedAt`; serves until `cancelEffectiveAt` (notice period) or term end. Terminal. | | `pause` | `{"pauseUntil": "YYYY-MM-DD"}` optional | `active` | Suspends billing; `pauseEndsAt` when a date was given. Longer than the seller's pause policy → `422`. | | `resume` | — | `paused` | New term from today; the pause is not billed. | | `end-trial` | — | `trial` | Converts now per `trialEndBehavior` / `trialRequiresPaymentMethod`. | | `change-plan` | `{"priceMinor": 12900, "currency": "EUR", "atTermEnd": true}` | `active` | Scheduled (`pendingPriceMinor`) or immediate. `atTermEnd: true` always schedules; with `false` the subscription's `prorationPolicy` decides while the proration engine is on — `next_period` keeps the old price for the rest of this period and applies the new one from the next, `by_day` applies it now and posts an adjustment for the unserved remainder, `full_period` applies it now and bills the whole current period at the new price, elapsed days included, with no adjustment. Same price, or a change already pending → `422`. Currency cannot change. | Every action returns the updated subscription. A request the current state cannot take — pausing a trial, resuming an active subscription, cancelling twice — answers `422` with the reason; so does a lost race against the lifecycle pass (`subscription changed concurrently; re-read it and retry`). `404` means the subscription is not yours. Marking past-due or recovered is the collection loop's job and has no public route. --- # Usage Metered usage is ingested by your systems (or Fynex connectors), aggregated per contract, metric, and billing period, and rated into usage invoices at period close. This API exposes the aggregated view. ## Endpoints - `GET /billing-api/v1/contracts` — the contracts visible to your key, the entry point for every per-contract read. Paged by ascending contract id: while `hasMore` is true, pass the returned `nextCursor` back as `cursor`. ```json { "contracts": [ { "contractId": 42, "contractNumber": "UK2607AA", "version": 1, "sellerCustomerId": 7, "currency": "EUR", "status": "active", "startDate": "2026-01-01", "customerName": "Ada Lovelace", "customerCompanyName": "Harbour Group BV" } ], "hasMore": false } ``` - `GET /billing-api/v1/contracts/{contractId}/usage` — the current open billing periods of every metric on the contract: ```json { "contractId": 42, "metrics": [ { "metricName": "api_calls", "used": "10250", "includedUnits": "10000", "capQuantity": "50000", "capMode": "hard", "percentOfCap": "20.5", "percentOfPlan": "102.5", "periodStart": "2026-08-01", "periodEnd": "2026-08-31" } ] } ``` `used` counts everything metered this period; `includedUnits` is the plan allowance; `capQuantity`/`capMode` describe the limit policy when one is configured. Quantities are decimal strings. An unknown contract id answers `404`; a real contract with nothing metered answers `200` with an empty `metrics` array. > [!IMPORTANT] > **This is a live reading, not the figure the customer will be invoiced.** > The period is still open, so `used` moves with every event that arrives — > including events for work already done that reach us late. It is also > pre-invoice: allowances, credits, discounts and rounding are applied when the > period closes and the invoice is produced, and none of them are reflected > here. > > Show it to a customer as "usage so far", never as an amount owed, and > reconcile against the invoice the period produced rather than against this > endpoint. A dashboard that quotes this number as the bill will disagree with > the bill. This endpoint reads a meter; something has to fill it. Writing usage — registering a metric, pricing it, and sending events — happens under `/billing-api/v1/usage`, with the same seller secret key, and is documented in full under **Usage ingestion**. In short: `POST /billing-api/v1/usage/metrics` to declare the meter, `PUT /billing-api/v1/usage/contracts/{contractId}/metrics/{metricName}/price` to price it, and `POST /billing-api/v1/usage/events` (or `:batch`, or `/csv`) to report consumption. ## What this endpoint keeps, and for how long **This is a meter, not a history API.** It answers with the contract's **currently open** billing periods — one entry per metric with a limit policy or metered usage. It has no date range and no paging, and a period that has closed is no longer in the response. If you need consumption over time, record what you read while the period is open, or take it from the invoice the period produced: the invoice is the durable record of what was billed. **Stored events are retained indefinitely.** Nothing prunes them — there is no retention window on ingested usage and no job that deletes it, so an idempotency key you used a year ago is still recognised and resending that event is still a no-op. Two practical consequences: - Idempotency keys must stay unique for the lifetime of your integration, not just for a period. Derive them from something durable — a row id, an export digest — rather than from a timestamp that repeats. - Correcting metered history is done by issuing a correction against the period (see **Usage corrections**), never by deleting events. There is no delete. Documents are the exception: invoices and credit notes carry a statutory retention period per jurisdiction, which is a legal minimum on how long they are kept, not a window after which this endpoint stops answering. --- # Credits Stored value is a capability your deployment switches on. While it is off, every endpoint below answers `501` with `billing credits are not enabled` — and that is the honest answer, because with it off the billing engine does not draw credit into invoices at all, so any balance shown would never be consumed. Credits are stored value held against a contract — customer prepayments, promotional grants, goodwill. Balances are derived from an append-only ledger; drawdown happens automatically when an invoice is issued against a contract holding credit (visible on the invoice as `creditAppliedMinor`). ## Endpoints - `GET /billing-api/v1/credits` — your balances across all contracts, one row per currency and credit type: ```json { "balances": [ {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true} ] } ``` `creditType` is one of `promotional`, `purchased`, `manual`, `gift`, `enterprise`, `ai_token`, `marketplace` or `proration`. `isLiability` marks the paid-for types (`purchased`, `enterprise`, `proration`) — unearned revenue you owe as service. Those never expire; granted credit may. `proration` is minted by the engine rather than granted by you: when a mid-term subscription amendment credits your customer more than the invoice it lands on can absorb, the remainder becomes a `proration` lot — money the customer already paid for service not rendered — and the next document draws it down. - `GET /billing-api/v1/contracts/{contractId}/credits` — one contract's balances (always complete) plus a page of its ledger history. `limit` sets the page size (1–100, default 20); while `hasMore` is true, pass the returned `nextCursor` back as `cursor` to walk older entries: ```json { "balances": [ ... ], "hasMore": true, "nextCursor": 1180, "nextBeforeId": 1180, "entries": [ { "id": 1201, "kind": "topup", "creditType": "purchased", "signedDeltaMinor": 250000, "currency": "EUR", "invoiceId": null, "reason": "annual prepayment", "occurredAt": "2026-08-01T09:30:00Z" } ] } ``` Entries are newest-first. `kind` is `topup`, `deduction`, `expiry` or `reversal` — the last one returns a deduction to the customer when the invoice it funded is cancelled. `signedDeltaMinor` is positive for `topup` and `reversal`, negative for `deduction` and `expiry`, and `invoiceId` links a deduction (and the reversal that undoes it) to the document it funded. - `POST /billing-api/v1/contracts/{contractId}/credits/top-up` — grant a credit lot. This is the only way credit enters the ledger; `deduction`, `expiry` and `reversal` rows are the engine's and have no public route. ```bash curl -s -X POST "$FYNEX_API_BASE/contracts/42/credits/top-up" \ -H "Authorization: Bearer $FYNEX_API_KEY" \ -H "Idempotency-Key: 3f0b6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e" \ -H "Content-Type: application/json" \ -d '{"creditType":"purchased","amountMinor":250000,"currency":"EUR","reason":"annual prepayment"}' ``` `Idempotency-Key` is required, and here the body **is** compared: the same key with a different contract, `amountMinor` or `currency` answers `422` instead of returning the first grant, because a top-up that never happened must not look like one that did. A matching replay answers `200` with the original entry; the first call answers `201`. `currency` must equal the contract's own — credit is never converted. An `expiresAt` is allowed only on granted credit: `purchased` and `enterprise` were paid for, and money someone paid must not evaporate on a calendar date. `sellerCustomerId` is optional attribution and must name one of your customers (`POST /billing-api/v1/customers`); the contract remains the balance anchor. Correcting a grant is not an edit — the ledger is append-only. Contact support for a correcting entry.