# Export identified accounts, and keep them in sync

**When you need the current picture** — a nightly export, a warehouse table, a
report — poll the REST API. Webhooks tell you what *changed*; they are a poor
way to learn what *is*.

Everything below is the behaviour the service actually implements, including
the parts that will surprise you.

---

## The shape of a page

```bash
curl "https://app.signal.geysera.com/signal-api/v1/accounts?page=1&page_size=200" \
  -H "Authorization: Bearer sk_sig_…"
```

```json
{ "accounts": [ … ], "total": 1927, "page": 1, "page_size": 50 }
```

| | |
|---|---|
| `page` | 1-based |
| `page_size` | default **50**, maximum **200** |
| above the maximum | **422**, not silently clamped |

The 422 is deliberate. A clamp would let a script ask for 1,000, receive 200,
and believe it had read everything — the failure would surface weeks later as a
number that was quietly too small.

## Three things that will catch you out

**1. `total` is not a row count.** It is what *your plan permits you to
resolve*. Accounts beyond your cap are absent from this surface entirely rather
than returned blurred. **Do not compute rates against it** — the denominator
moves when you change plan, and a "conversion rate" built on it will change
with your billing rather than your business.

**2. Unknown query parameters are ignored, not rejected.**

```bash
# You meant page_size. You typed limit.
curl ".../accounts?limit=3"      # 200 OK — and 50 results
```

A person notices. A script reports the wrong number confidently, forever. If
you are wrapping this API, assert the response's `page_size` matches what you
asked for; that one line catches every parameter typo you will ever make.

**3. Order is not guaranteed to be stable across pages** if the underlying data
changes while you page. For an export that must not double-count, prefer a
single large page over many small ones, or reconcile on `visitor_id` /
account domain when you land the rows.

---

## Paging, with the rate limit respected

The limit is **300 requests per 60 seconds, per key** — per key, not per
workspace, so one integration cannot exhaust another's budget. Exceeding it
returns **429** with a `Retry-After` header carrying the real remaining window.

```python
import time, requests

BASE = "https://app.signal.geysera.com/signal-api/v1"
HEAD = {"Authorization": f"Bearer {KEY}"}

def pages(path, page_size=200):
    page = 1
    while True:
        r = requests.get(f"{BASE}/{path}",
                         params={"page": page, "page_size": page_size},
                         headers=HEAD, timeout=30)

        if r.status_code == 429:
            # Use the header. Guessing means you either hammer or stall.
            time.sleep(int(r.headers.get("Retry-After", "60")))
            continue

        r.raise_for_status()
        body = r.json()

        # The typo check from above — one line, catches every misspelt param.
        assert body["page_size"] == page_size, body["page_size"]

        rows = body.get("accounts") or body.get("visitors") or []
        if not rows:
            return
        yield from rows

        if page * body["page_size"] >= body["total"]:
            return
        page += 1
```

Back off on the header rather than a fixed sleep. `Retry-After` is the true
remaining window, so a caller arriving late in a window waits seconds, not a
minute.

---

## Incremental sync

There is no `updated_since` filter today. Two workable strategies:

**Webhook for new, poll for correction.** Take new identifications from the
webhook (see [workflow 1](./receive-identified-visitors-by-webhook.md)) and
run a full poll nightly to pick up anything that changed after identification —
classification edits, intent-score recomputes. This is the shape most people
want, and the nightly job is a reconciliation, not the primary path.

**Poll and diff.** If you cannot receive webhooks, page the whole set and diff
against your last snapshot on `visitor_id`. At the volumes this product
produces — hundreds to low thousands of accounts — a full read is cheap and far
simpler than a cursor you have to keep correct.

Pick the simpler one until you measure a reason not to.

---

## Errors you should handle

Every error carries the same envelope:

```json
{
  "error_code": "RATE_LIMITED",
  "message": "Rate limit exceeded. Max 300 requests per 60 seconds.",
  "correlation_id": "6f0a9626-…",
  "details": null
}
```

Branch on `error_code`, not on `message`. The codes are
`VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`,
`RATE_LIMITED`, `INTERNAL` — and they are published at `/agent-api/capabilities`
so you can assert against them rather than hard-coding this list.

`401` and `403` mean different things and the difference saves you an hour:

- **401** — the key is missing, malformed, unknown or revoked. Re-issue it.
- **403** — the key is fine and lacks the *scope* for this endpoint. Re-issuing
  will not help; `details` names what was required and what your key holds.

Send an `X-Correlation-ID` on every request and log it. We echo it, and it is
the fastest way for us to find your specific request.

---

## Discovering the surface instead of hard-coding it

```bash
curl https://app.signal.geysera.com/agent-api/capabilities
```

Public, no key required, generated from the routes' own auth dependencies — so
it cannot describe an endpoint that does not exist or omit one that does. It
carries the endpoint list, the scope each needs, the pagination bounds, the
rate limit, the error vocabulary and the response headers.

If you are building a client, read this at build time rather than transcribing
it. Everything in this document is derived from it, and a value you copy by
hand is a value nobody will update.
