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.


Before you start

  • You need an API key (sk_sig_…) — mint one under Exports & API in the dashboard. The plaintext is shown exactly once; store it as SIGNAL_API_KEY.
  • You need the pixel live on your site, or there are no visitors to read. The quickstart covers install and verification.
  • You need a connected email platform, if you want contacts pushed rather than just listed. Rules can be written and previewed without one.

If you just want a spreadsheet

Not every export needs a script. Exports & API → Export CSV / Export JSON downloads the current account list straight from the dashboard — company, intent score, visitor counts, classification and timestamps.

Two things to know before you trust the file:

  • It can be partial, and it tells you when it is. Past a certain size the download stops early and warns you that the file is incomplete, with the count it managed against the count available. It does not quietly hand you a truncated file — but it is a toast, and toasts are easy to dismiss. If you are loading this into a spreadsheet and trusting the totals, read it. For a guaranteed-complete extract, email support.
  • It is a snapshot, not a feed. It is right at the moment you click and stale immediately afterwards. Everything below this section exists because most people who start with the download eventually want the thing that stays current.

The assistant cannot do this one for you — it can return the rows into a conversation, but it has no way to hand your browser a file. Ask it for the data and you get the data; ask it for a spreadsheet and the answer is this button, or the API below.


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.

<!-- ignored-on-purpose: this block demonstrates a parameter the API does NOT have. A guard checks that workflows only tell callers to send real parameters; this marker exempts a deliberate counter-example. -->
# 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