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 }
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.
# 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;
detailsnames 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.