# Polling & SSE

Fynex delivers **outbound webhooks** for payment and refund events — see [Webhooks](https://docs.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 (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 `lifecycleStatus` is `refunded`, `failed` or `cancelled` (or `captured` if you don't need settlement progress) — see [Payment Lifecycle](https://docs.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle); for a payout, `completed`, `failed` or `cancelled`. A payout in `manual_reconciliation` is parked for an operator and will not move on its own — stop polling it and raise it with Fynex.
- **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://docs.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 `lifecycleStatus` (alongside `status`, the deprecated `legacyStatus` and, once captured, `settlementStatus` — see [Payment Lifecycle](https://docs.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle)), the originally authorized `amount` in major units, currency/country, payment type/method, `externalOrderRef`, and the latest `failureCode` / `failureDescription` / `failureStage` (if any). (The raw upstream `providerStatus` is not part of this REST response — it is exposed only on the GraphQL `genericPayment` type.)

```bash
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](https://docs.fynex.ai/payments-api/v2/docs#tag/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:

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

### 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://docs.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`.

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

### 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://docs.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://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

```js
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](https://docs.fynex.ai/payments-api/v2/docs#tag/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](https://docs.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — Trigger state transitions, then poll to confirm them.
- **[Idempotency & retries](https://docs.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make your retry logic safe so polling never replays a charge.
