Troubleshooting & FAQ
Common errors, gotchas, and frequently asked questions for Fynex API integrators — organized by topic for quick scanning.
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 payoutid), 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:
Authorization: Bearer <your_token>
Common mistakes: omitting the word Bearer, adding extra whitespace, wrapping the token in quotes, or sending a token to a host other than the one that issued it — staging and production tokens are not interchangeable. A quick smoke-test is GET /payment-methods — a 200 OK confirms the token is valid and the seller account is active.
Q: How do I tell a missing key from a wrong one?
Branch on code in the JSON 401 body: missing_api_key means the Authorization header was absent or had no token after Bearer; invalid_api_key means the token was not recognised — including a token sent to the other environment's host. Both bodies are JSON, like every other error.
See 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 and Idempotency & retries.
Q: How do I rotate my token?
Use the GraphQL mutation regenerateSellerAccountToken on /dashboard/graphql (cookie-session authenticated). Supply your seller account numeric ID:
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 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 underreturnUrlsas an object:
{
"returnUrls": {
"success": "https://example.com/orders/123/success",
"failure": "https://example.com/orders/123/failure"
}
}
POST /initialize-payment(server-to-server) — passreturnLinksas an array of{rel, href, method}:
{
"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 and 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 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:
{ "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.
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 lifecycleStatus 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 GET /payments-api/v1/payments/{payment_id} until lifecycleStatus is captured (authorized for a manual-capture payment), failed or cancelled. See Server-to-Server / Skipping 3DS.
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:
"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.
Hosted checkout sessions and payment links are checked when they are created: one no terminal of the account can take is refused there with 422 and code currency_not_supported (see Errors). The check accepts a session when any offered method can route it, so a 1003 on a hosted checkout can still mean the customer picked a method that cannot (a card on an account that routes this currency only through a local payment method, or a card whose issuing country has no terminal on an account that routes by card country), or that the account's terminals changed after the session was created.
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:
// 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.
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:
const hasSubmitted = useRef(false);
useEffect(() => {
if (hasSubmitted.current) return;
hasSubmitted.current = true;
finalizePayment(paymentId);
}, []);
See 3DS Authentication.
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's lifecycleStatus may already be authorized or captured 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-paymentand immediately calls/finalize-paymentwithout visitingactionUrlin a browser. The sandbox 3DS page has a "complete" button — openactionUrlfirst, 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).
The opposite timing has its own code. If the customer never answers the challenge and the authentication session expires first, the payment fails with failureCode: 3003 (authentication not completed, failureStage: checkout) — whether or not /finalize-payment is called afterwards. Nothing was authorized; replaying the request cannot revive the session, so ask the customer to pay again. And if the customer did answer the challenge but their bank refused the authentication, the payment fails with 2001 and the description "Card authentication failed (3-D Secure)".
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.
4. Captures & refunds
Q: Capture or refund returns 409 "payment action is not allowed for the current payment status"
Every capture or refund the payment's state does not allow returns this one message. Read the payment with GET /payments-api/v1/payments/{payment_id} and check:
- Capture needs a manual-capture payment — created with
autoSettlement: false, or withautoSettlementleft out on an account whose default is not automatic capture (captureModealone does not decide this) — whoselifecycleStatusisauthorized, with no refund pending or already succeeded. - Refund needs
lifecycleStatuscaptured,settlementStatusother thanin_flight, no refund stillpending, refunds enabled for your account, and an amount no larger than what is left to refund.
The full conditions are under What you can do next in Payment Lifecycle.
Q: What is provider_completed? I expected captured
provider_completed is a previous status value, which status still carries until the switch announced in the changelog. Its lifecycle status is captured: read lifecycleStatus instead. A partial capture and a partial refund also leave lifecycleStatus at captured. See Payment Lifecycle.
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.
# 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 refund is pending and the payment stays captured. You cannot issue another refund until the first one is no longer pending. A successful partial refund leaves the payment captured and refundable; once refunds add up to the whole captured amount it becomes refunded; a failed or cancelled refund can be retried.
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:
{
"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 and Idempotency & retries.
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?
Major, like every other endpoint — but as a decimal string: "amount": "19.99". The integer amountMinor (1999) still works as a deprecated fallback, and amount wins when both are sent. Responses return both fields.
Q: When is failureMessage on GET /payouts/{id} populated?
Whenever failureCode is. It carries the seller-safe catalogue text for that code (for example "The bank rejected the payout."), so you can display it as-is; branch on failureCode, not on the message text. Both fields are absent while the payout has not failed. Older integrations that treated the field as always empty can start reading it — nothing else about the response changed.
6. Verification & polling
Q: How do I know when a payment succeeds?
Three options, in order of preference:
- Webhooks: Fynex POSTs a signed
PaymentCompletedevent to every endpoint registered on your account (X-Fynex-Signature,X-Fynex-Timestamp). Only a200counts as delivered; anything else is retried up to 3 attempts in total and then marked failed. Deduplicate oneventIdand treat the event as the signal, the API read as the source of truth. See Webhooks. - Polling:
GET /payments-api/v1/payments/{payment_id}(the path value is yourexternalOrderRef) with the same bearer token untillifecycleStatusiscaptured,failedorcancelled. No dashboard session is needed. - SSE: if the customer is sitting on a hosted checkout page, subscribe to the server-sent events stream at
GET /checkout/{session_id}/eventsfrom the browser.
See Polling & SSE for cadence and backoff.
Q: I'm polling for status === "succeeded" and never getting a match
succeeded is a refund status, not a payment status. A successful payment has lifecycleStatus captured — branch on that. See 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 lifecycleStatus is refunded, failed or cancelled (or captured if you don't need settlement progress), write it to your database and stop polling that record.
See 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:
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 } }"}'
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). For an existing account, POST /api/v1/login/dashboard obtains the cookie.
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.
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():
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 and 3DS Authentication.
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 |
400 |
Idempotency-Key must be a valid UUID |
Header value is not a UUID v4 | Idempotency |
400 |
currencyCode is required |
Missing currencyCode field |
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 |
400 |
invalid request body |
JSON parse failed — check Content-Type: application/json and body syntax |
Errors |
401 |
authorization token is required (code missing_api_key) |
Missing or malformed Authorization header |
Authentication |
401 |
invalid authorization token (code invalid_api_key) |
Token not recognised, or issued by the other environment's host | Authentication |
401 |
sellerAccountId is missing in auth context |
Missing or invalid Idempotency-Key on POST /checkout |
Hosted Checkout |
403 |
resource does not belong to this seller |
Token is valid but the resource belongs to a different seller account | Authentication |
409 |
payment action is not allowed for the current payment status |
The payment's state does not allow this capture or refund — see the capture & refund question above | Payment Lifecycle |
409 |
insufficient balance |
Wallet availableBalanceMinor is below the requested payout amount |
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 |
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 |
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 |
failureCode: 3003 |
payment row failureStage: checkout |
The customer never completed the 3DS challenge and the authentication session expired — ask them to pay again | Errors |
502 |
(any other) | Upstream processor error — safe to retry with the same idempotency key | Errors |
See also
- Errors — Full HTTP status code and error body reference with recovery guidance.
- Polling & SSE — How to verify payment and payout status without webhooks.
- Idempotency & retries — Make all mutating calls safe to retry with idempotency keys.
- Authentication & Tokens — Bearer token provisioning, rotation, and common auth errors.