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

curl "https://app.signal.geysera.com/signal-api/v1/accounts?page=1&page_size=200" \
  -H "Authorization: Bearer sk_sig_…"
{ "accounts": [ … ], "total": 1927, "page": 1, "page_size": 50 }
page1-based
page_sizedefault 50, maximum 200
above the maximum422, 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.

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

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) 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:

{
  "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

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.

Markdown source: /developers/workflows/export-and-keep-in-sync.md