Polling & SSE
Track payment, payout, and refund status using polling or Server-Sent Events — a reliable way to know "what just happened", complementing the outbound PaymentCompleted webhook.
Fynex delivers outbound webhooks for payment and refund events — see Webhooks for the live signature-verified contract. Polling and Server-Sent Events (SSE), documented here, are complementary: they let you learn when a payment succeeded, a refund completed, or a payout settled, and they make a solid backstop in case a webhook delivery is missed (payout webhooks are opt-in, so poll for payouts unless your endpoint subscribes to PayoutCompleted and PayoutFailed). Fynex's own dashboard uses polling at 5-second intervals, so the pattern is battle-tested.
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. For a payment, stop once
lifecycleStatusisrefunded,failedorcancelled(orcapturedif you don't need settlement progress) — see Payment Lifecycle; for a payout,completed,failedorcancelled. A payout inmanual_reconciliationis parked for an operator and will not move on its own — stop polling it and raise it with Fynex. - Stay under the per-seller rate limit. Every authorized
/payments-api/v1/*response carriesX-RateLimit-LimitandX-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 aRetry-Afterheader — honor it. See Rate limiting.
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 lifecycleStatus (alongside status, the deprecated legacyStatus and, once captured, settlementStatus — see Payment Lifecycle), the originally authorized amount in major units, currency/country, payment type/method, externalOrderRef, and the latest failureCode / failureDescription / failureStage (if any). (The raw upstream providerStatus is not part of this REST response — it is exposed only on the GraphQL genericPayment type.)
while :; do
RES=$(curl -sS "$FYNEX_API/payments-api/v1/payments/ORDER-1042" \
-H "Authorization: Bearer $FYNEX_TOKEN")
STATUS=$(echo "$RES" | jq -r '.lifecycleStatus')
echo "$(date -u +%H:%M:%S) lifecycleStatus=$STATUS"
case "$STATUS" in
captured|refunded|failed|cancelled) break;;
esac
sleep 5
done
lifecycleStatus follows the Payment Lifecycle state machine. Treat pending and under_review as "keep waiting"; authorized means a manual-capture payment is waiting for your capture.
Every payment at once (REST)
When you do not hold the ids — after a restart, or to answer "what came in today" — list them:
curl -sS "$FYNEX_API/payments?createdFrom=2026-10-16T00:00:00Z&status=captured,refunded" \
-H "Authorization: Bearer $FYNEX_TOKEN"
The list is newest first, filterable by status (the lifecycleStatus values), a createdFrom/createdTo range, currency, an inclusive amountMin/amountMax range in that currency's major units (amountMin=10.00¤cy=GBP), and payeeId — the payments whose split credited that payee, which a payment joins once its split has run after settlement. Each entry carries the same fields as GET /payments/{payment_id}, without allowedActions, settlementStatus and promotion. Pass nextCursor back as cursor while hasMore is true.
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 for the login flow.
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.
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
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
processingafter an hour deserves a human look. - Cache the last status locally so you only re-render UI on state changes.
- Honor
Retry-Afteron 429. When a poll returns 429, sleep for at leastRetry-Afterseconds (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.
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://pay.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
const evt = new EventSource(
`https://pay.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
lifecycleStatuspendingfor hours signals an upstream issue that needs human attention, not more polling. - Don't forget terminal-state caching. Once a payment's
lifecycleStatusisrefunded,failedorcancelled, it is final (bar the one exception in Payment Lifecycle) — 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 — Trigger state transitions, then poll to confirm them.
- Idempotency & retries — Make your retry logic safe so polling never replays a charge.