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