# Integration flows

> Three ways to consume /api/verify (short wait, polling, and SSE) plus idempotency replay. Pick whichever matches how your app handles latency.

`POST /api/verify` defaults to **synchronous**: the call blocks until the upstream finishes, then returns the receipt. For most callers (a single
on-demand verification triggered by a user click) that's fine.

When you can't or don't want to hold the request open (mobile network, server with strict request budgets, queue-of-many architecture), use one of the three async-aware flows below. All three share the same submit endpoint and result envelope; what differs is **how the client waits**.

## Short Wait

Submit with `waitMs` to ask the server to block up to _waitMs_ milliseconds. If the verification finishes before the deadline, you get the same `200`/`502` response a sync call would return. If it doesn't, you get `202` + a request id and the URLs you'd use to poll or stream.

```bash
curl -sX POST https://links.et/api/verify \
  -H "x-api-key: vk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "url": "https://transactioninfo.ethiotelecom.et/receipt/ABCD1234EF",
    "waitMs": 3000
  }'
```

### Completed inside the wait window: `200`

```json
{
	"ok": true,
	"processingStatus": "completed",
	"requestId": "550e8400-e29b-41d4-a716-446655440000",
	"providerKey": "telebirr",
	"resolvedUrl": "...",
	"receipt": { "...": "..." },
	"cached": false,
	"httpStatus": 200,
	"error": null
}
```

### Still running at the deadline: `202`

```json
{
	"processingStatus": "queued",
	"requestId": "550e8400-e29b-41d4-a716-446655440000",
	"statusUrl": "/api/verify/550e8400-e29b-41d4-a716-446655440000",
	"eventsUrl": "/api/verify/550e8400-e29b-41d4-a716-446655440000/events"
}
```

The verification keeps running on the server. Follow up via polling or SSE; both work against the same `requestId`.

`waitMs` is capped at **30 000 ms** server-side; anything higher is silently clamped.

## Polling

Use when your environment can't keep a long-lived connection open but you can call back every few seconds.

```bash
while true; do
  RESP=$(curl -s -H "x-api-key: vk_live_..." \
    "https://links.et/api/verify/$REQUEST_ID")
  STATUS=$(jq -r '.processingStatus' <<< "$RESP")
  if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then
    echo "$RESP" | jq .
    break
  fi
  sleep 2
done
```

### While the request is still queued: `202`

```json
{
	"processingStatus": "queued",
	"requestId": "550e8400-e29b-41d4-a716-446655440000",
	"statusUrl": "/api/verify/...",
	"eventsUrl": "/api/verify/.../events"
}
```

### Once the row resolves: `200` / `502`

Same envelope as the sync flow, with `processingStatus` and `requestId` added:

```json
{
	"ok": true,
	"processingStatus": "completed",
	"requestId": "550e8400-e29b-41d4-a716-446655440000",
	"providerKey": "telebirr",
	"receipt": { "...": "..." },
	"cached": false,
	"httpStatus": 200,
	"error": null
}
```

**Cadence suggestion:** 1.5 to 3 s between polls. Faster than one per second
buys nothing, because the verification runs at its own pace regardless.

A poll reads an existing row, so it counts against your **per-minute request
rate** but never against your plan's verification cap. Only the original
submit can spend an uncached verification, and only if the receipt was not
already cached. See [rate limits](/docs/rate-limits) for the difference.

## Server-Sent Events (SSE)

Use when the client can keep a connection open and wants the result as soon as it's ready (instead of polling on a timer).

```bash
curl -N \
  -H "Accept: text/event-stream" \
  "https://links.et/api/verify/$REQUEST_ID/events?x-api-key=vk_live_..."
```

`EventSource` from the browser:

```js
const es = new EventSource(`/api/verify/${requestId}/events?x-api-key=${apiKey}`);

es.addEventListener('status', (e) => {
	const d = JSON.parse(e.data);
	console.log('status:', d.status);
});

es.addEventListener('completed', (e) => {
	const d = JSON.parse(e.data);
	console.log('done', d.receipt);
	es.close();
});

es.addEventListener('failed', (e) => {
	const d = JSON.parse(e.data);
	console.error('failed', d.error);
	es.close();
});

es.addEventListener('timeout', () => {
	// The server caps the stream at 5 min. Reconnect to pick up where you
	// left off; pending state is replayed on connect.
	es.close();
});
```

### Event types

| Event | When | Payload |
| ----------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `status` | When the row is created (`pending`) and on any intermediate transition | `{ type: 'status', status: 'pending' \| 'processing', ts }` |
| `completed` | Verification finished successfully. Stream closes after. | `{ type: 'completed', ok: true, cached, receipt, httpStatus, ts }` |
| `failed` | Verification finished with an error. Stream closes after. | `{ type: 'failed', error, httpStatus, ts }` |
| `timeout` | The server is closing the stream at the 5-minute lifetime cap. | `{ type: 'timeout', message }` |

The stream also sends an unnamed comment (`: ping`) every 15 s to keep proxies from idling the connection.

### Replay-on-reconnect

If the verification resolves between your submit call and the moment you subscribe to the SSE stream, you don't miss the terminal event; the server replays the last-seen event on connect for ~5 minutes. After that, the in-memory replay is evicted (the row stays in Postgres; the GET polling endpoint still returns the resolution forever).

## Idempotency

Add an `Idempotency-Key` header to `POST /api/verify` to make retries safe. The first call with a given key (per API key) creates a request and returns its id. Any subsequent call with the **same** key replays the current state of that same request; even if the URL differs in the retry, you get the original.

```bash
curl -X POST https://links.et/api/verify \
  -H "x-api-key: vk_live_..." \
  -H "Idempotency-Key: 7e1a4c34-..." \
  -H "content-type: application/json" \
  -d '{"url":"...","waitMs":2000}'
```

A `200` / `202` / `502` will look identical to the first response, including the same `requestId`, for as long as the row exists. Use a UUID (or any stable string up to 256 chars) generated by the client per logical operation. Idempotency rows are kept indefinitely; there's no TTL today.

## When to use which

| Flow | Best for | Trade-off |
| -------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **Sync** _(default)_ | One-off verifications triggered by user interaction; cached receipts | Holds the HTTP request open. Instant on a cache hit, but see below for the worst case |
| **Short wait** | Cached-most-of-the-time receipts where you accept queue fallback when it isn't cached | Up to 30 s of held connection in the worst case |
| **Polling** | Background jobs, queue workers, mobile networks | Slightly stale; uses more HTTP requests |
| **SSE** | Browser UIs that want the result instantly | Needs a long-lived connection; harder to put behind some proxies |

You can mix and match. A common pattern is: submit with `waitMs: 2000`. If `202`, open SSE for the result. If your SSE connection drops, fall back to polling once a second.

## What a busy bank does to a sync call

A cached receipt returns immediately. An uncached one has to reach the bank,
and we are a polite client: each upstream host gets a token bucket per egress
identity, so when a bank is saturated your call **waits for a slot** before the
fetch even starts.

That makes the sync worst case much longer than one fetch timeout:

| Stage | Bound |
| ----------------------------------------- | ------------------------------------------------------------ |
| Waiting for an upstream slot | 30 s, longer on a deliberately slow host, hard ceiling 120 s |
| The fetch itself | 20 s per attempt |
| One retry, on a different egress identity | 20 s |

So a sync call against a struggling bank can hold the connection for well over
a minute. There is one retry, never more, and none at all for a metered link
like Siinqee.

If the queue is full or the wait runs out, the call gives up rather than
hanging, and the error reads like `gave up waiting 34s for a slot on
transactioninfo.ethiotelecom.et`. Today that returns **HTTP 400**, because the
response never got an upstream status code to report. It is a "come back
later", not a complaint about your request, so retry it with backoff the way
you would a 502.

This is the reason `waitMs` exists. Submit with a deadline you are happy to
hold, take the `202`, and pick the result up by polling or SSE instead of
holding a socket open through a bank outage.
