Skip to content
In this section

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

  • 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 lifecycleStatus is refunded, failed or cancelled (or captured if you don't need settlement progress) — see Payment Lifecycle; for a payout, completed, failed or cancelled. A payout in manual_reconciliation is parked for an operator and will not move on its own — stop polling it and raise it with Fynex.
  • 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.

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&currency=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.

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 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.

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 lifecycleStatus pending for hours signals an upstream issue that needs human attention, not more polling.
  • Don't forget terminal-state caching. Once a payment's lifecycleStatus is refunded, failed or cancelled, it is final (bar the one exception in Payment Lifecycle) — 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