On this page

Geysera Signal — Developer Guide

How to use Geysera from code: the read API, the webhooks, and the copilot — the endpoint that can both answer questions about a workspace and change it.

Written for two readers who want the same things: an engineer wiring up an integration, and an AI agent operating the platform on someone's behalf. Everything a person can do in the dashboard is reachable here.

For endpoint-by-endpoint detail — paths, parameters, error codes, exact limits — see the API reference, generated from the running service. This page explains how the product behaves; that one is the lookup.

Contents

  1. The three surfaces
  2. Authentication
  3. Conventions
  4. The public read API
  5. Plan caps, and why your totals may be smaller than reality
  6. The copilot: asking questions
  7. The copilot: making changes
  8. The tool catalogue
  9. Webhooks
  10. Capability discovery
  11. Errors
  12. Things that will surprise you

1. The three surfaces

Surface Base Auth What it is
Public read API /signal-api/v1 API key Four stable GETs. Versioned, documented, safe to build on.
Copilot /signal/copilot Clerk JWT Ask in English; read 30 tools worth of data and perform 26 kinds of change.
Webhooks your endpoint HMAC signature Geysera calls you when a visitor is identified.

Pick the read API when you know what you want and need a stable contract. Pick the copilot when you want the platform to work out how to answer, or when you want to act.

Auth asymmetry, stated up front. The public read API takes an API key. The copilot takes a Clerk session JWT and is not reachable with an sk_sig_ key today. A headless integration can therefore read but not act. If you need programmatic writes, say so — the constraint is the auth wiring, not the design.


2. Authentication

API keys (sk_sig_…)

Mint one in the dashboard under Exports & API, or ask the copilot to (create_api_key). Send it as a bearer token:

curl https://app.signal.geysera.com/signal-api/v1/accounts \
  -H "Authorization: Bearer sk_sig_your_key_here"
  • The plaintext key is shown exactly once, at creation. Only a SHA-256 hash and a display prefix are stored, so nobody — including support — can recover it. Lose it and you mint a new one.
  • A key is scoped to one workspace and is read-only: it reaches the four public GETs and nothing else.
  • Maximum 10 active keys per workspace. Revocation is immediate and permanent.

Missing, malformed, unknown or revoked keys all return 401 with the same body. That is deliberate — a distinct "this key existed once" would confirm which keys are real.

Clerk JWT

The dashboard and the copilot use your session token. Browser clients get this automatically; there is no documented way to mint one from a script.


3. Conventions — read this before your first call

Base URL. Read it from GET /capabilities (spec_url, servers) rather than hardcoding it. It is configuration on our side and it will move.

Headers

Header Required Why
Authorization: Bearer sk_sig_… yes Identifies the workspace.
X-Correlation-ID no Any string. Generated if absent, echoed on every response and in our logs. Quote it in support requests.
X-Idempotency-Key no On mutating calls, makes a retry safe. Send the same key and the original result is returned instead of a second write.

Pagination. page (1-based) and page_size. Responses carry total, page, page_size. total is what you may access, not the raw row count — see §5.

Rate limit. 300 requests per minute per workspace. Over it you get 429. The dashboard itself issues 6+ parallel calls per page load and shares your budget, so leave headroom.

Time. All timestamps are ISO 8601 UTC. days parameters are trailing windows ending today.


4. The public read API

Four endpoints. All GET, all paginated where a list is returned.

GET /accounts

Organisations that visited, ranked by intent.

Param Type Default
page integer 1
page_size integer 50
classification lead|customer|competitor|excluded all
curl "https://app.signal.geysera.com/signal-api/v1/accounts?classification=lead&page_size=10" \
  -H "Authorization: Bearer $GEYSERA_KEY"
{
  "accounts": [
    {
      "company_domain": "acme.com",
      "company_name": "Acme Corp",
      "intent_score": 82,
      "visitor_count": 4,
      "visit_count": 11,
      "classification": "lead",
      "first_seen_at": "2026-07-02T09:14:00Z",
      "last_seen_at": "2026-09-01T16:03:00Z"
    }
  ],
  "total": 137,
  "page": 1,
  "page_size": 10
}

intent_score is 0–100, the same scale the dashboard shows.

Every field, and which can be null. Nullability is not cosmetic here — the identity fields are the ones most likely to be absent, and a client that assumes otherwise breaks on real data rather than on the example above.

Field Type Null? What it is
company_domain string no The natural key. Join on this, never on the name. personal is a real value — see below.
company_name string yes Often absent. Fall back to the domain.
intent_score integer no 0–100.
visitor_count integer no Distinct people from this company.
visit_count integer no Total visits across those people.
classification string no lead, customer, competitor or excluded.
first_seen_at ISO 8601 no
last_seen_at ISO 8601 no

company_domain can be the string personal. Consumer mailbox domains — gmail, icloud and the rest — all roll up under that one sentinel, because a consumer address carries no company. On a DTC site that single "account" can be the majority of your visitors. Filter it out of anything B2B.

GET /visitors

Individual identified people.

Param Type Default
page, page_size integer 1, 50
min_intent integer 0–100 none
classification as above all

Fields include resolved_email, resolved_name, resolved_title, employer, and is_locked. When is_locked is true, resolved_email is null and resolved_name is masked — the plan is withholding them. Do not record that person as having no email; see §5.

Every field, and which can be null.

Field Type Null? What it is
company_domain string yes personal for consumer mailboxes.
company_name string yes
intent_score integer no 0–100.
classification string yes
resolved_email string yes The identity. Present far more often than the three below.
resolved_name string yes Frequently absent.
resolved_title string yes Frequently absent.
employer string yes Frequently absent.
first_visit_at ISO 8601 yes
last_visit_at ISO 8601 yes
visit_count integer no
is_locked boolean no true means this visitor is beyond your plan's monthly resolution cap: you can see that they exist, not who they are. The identity fields are null. Filter these out of anything a human is meant to action.

Identification gives you an email reliably. Name, title and employer are enrichment on top of that and are absent more often than they are present — write your templates and your CRM sync to survive nulls in all three.

GET /attribution

Channel and campaign rollup for a window. Takes days (default 30).

Served from a nightly rollup, not computed live, so it is fast and may lag by up to a day. It describes only sessions that carried a campaign context, which on most workspaces is a minority of traffic. Read the percentages as shares of attributed sessions, not of all visitors.

GET /recommendations

Open recommended actions. No parameters.

Read-only in v1: status changes carry an optimistic-locking contract that was deliberately not bolted onto a read API. Use the copilot (set_recommendation_status) or the dashboard to act on one.


5. Plan caps, and why your totals may be smaller than reality

Every workspace has a monthly resolution cap:

Plan Accounts resolvable per month
free 150
starter 300
pro 1,000
pro_plus 2,500
enterprise 1,000,000

New workspaces get 7 days at pro_plus limits.

Beyond the cap, records are withheld. The three surfaces express that differently, and you need to know which one you are reading:

Surface Over-cap behaviour
Public API Records are absent. total is capped, so it can be lower than the true count.
Dashboard Records are present but maskedis_locked: true, name •••.
Copilot Masked, and it tells you how many were withheld rather than describing the list as complete.

The practical consequence: on the public API, total is not "how many accounts exist", it is "how many you may see". Do not compute conversion rates against it and do not treat a locked visitor's null email as "this person has no email". Both are the plan talking, not the data.


6. The copilot: asking questions

POST /signal/copilot/ask      one JSON response
POST /signal/copilot/stream   the same thing, as SSE

/ask is complete on its own. The stream exists because humans want progress feedback; a script never needs it.

curl -X POST https://app.signal.geysera.com/agent-api/signal/copilot/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "Which companies visited pricing last week and have not bought?"}'

Request

Field Type Notes
question string, 3–2000 chars required
thread_id string Opaque. Reuse it across turns for follow-ups ("now break that down by month"). Omit for one-shot.
turn_id string Your id for this turn. Stored with it in history so a later edit can name it. Send one on every turn — a turn you never identified cannot be replaced later.
replaces_turn_id string Edit-and-resend: drops that turn and everything after it. Names the turn rather than counting to it; if it cannot be found, all history is dropped.
approved_plan object Authorises changes. See §7.

Response

{
  "answer": "Four companies viewed pricing…",
  "refusal": null,
  "clarification": null,
  "question_kind": "descriptive",
  "plan": { "reasoning": "…", "calls": ["search_accounts", "page_funnel"] },
  "disclosures": ["…"],
  "limits": ["…"],
  "warnings": [],
  "pending_confirmation": null,
  "trace": [ { "tool": "…", "sql": "…", "coverage": {}, "caveats": [], "window": {}, "data": {} } ],
  "thread_id": "…",
  "message_id": "…"
}

Exactly one of answer, refusal or clarification is set.

  • refusal is the product working, not an error. It means the data cannot support the claim you asked for. Do not retry it.
  • clarification is a question back. Answer it on the same thread_id.
  • trace is the audit trail: every tool that ran, the SQL it executed, its coverage, and the window it covered. data holds the rows — parse those rather than the prose if you want structure.
  • limits and disclosures are the constraints the answer was written under. If you are re-publishing the answer, carry them.

Every number in answer is guaranteed to appear in trace. A numeric guard truncates the answer rather than let a figure through that no tool produced. So prose and trace cannot disagree.


7. The copilot: making changes

The copilot can change things. Nothing changes without a round trip.

Ask. If the request would change something, nothing runs and you get:

{
  "answer": null,
  "pending_confirmation": {
    "actions": [
      {
        "tool": "run_sync_rule",
        "arguments": { "rule_id": "b3f1…" },
        "effect": "Push this rule's matching contacts to the connected ESP NOW. Contacts that arrive there cannot be recalled.",
        "irreversible": true
      }
    ],
    "irreversible": true,
    "approve_with": { "question_kind": "action", "calls": [] }
  }
}

Approve. Re-send the same question with approve_with copied verbatim into approved_plan:

curl -X POST .../copilot/ask -H "Content-Type: application/json" -d '{
  "question": "push that rule now",
  "thread_id": "t-42",
  "approved_plan": { "question_kind": "action", "calls": [ … ] }
}'

Why it is the plan and not a boolean

The planner is a sampled model. Approving a flag would authorise whatever it decides on the second pass, which need not be what you were shown. Sending the plan back makes the approved actions and the executed actions the same object — and an approved plan is replayed verbatim, with the planner skipped entirely.

Rules worth knowing

  • Approval is good for one request. It is not stored, not remembered, and does not persist on a thread. The next turn re-gates.
  • A mixed plan runs none of it. A read bundled with a write waits for the same yes.
  • irreversible: true means the product cannot undo it: credits spent with a vendor, contacts pushed to an ESP, a secret shown once, a row hard-deleted.
  • Show effect to your user, not tool. It is written to be consented to.
  • A malformed approved_plan returns 422, not a silent no-op.

If you are automating this

Be honest with yourself about what the gate is doing for you. In the dashboard it is a human reading a sentence and clicking. In a script, echoing approve_with back is three lines, and the gate becomes an intent check and an audit record rather than human oversight. If your agent is choosing its own actions, put your own review in front of the irreversible ones — particularly run_enrichment_pipeline (spends vendor credits across the workspace), run_sync_rule (pushes contacts you cannot recall) and the credential tools.


8. The tool catalogue

68 tools. You do not call them directly — you ask a question and the planner chooses — but knowing what exists tells you what is answerable.

Commerce analytics (11)

data_coverage · revenue_and_aov · aov_decomposition · product_performance · customer_counts · price_history · page_funnel · page_to_product_attribution · membership_conversion_paths · visiting_accounts · causal_impact

causal_impact is the only one that can support a claim about cause — a Bayesian interrupted time series that returns "inconclusive" when the data cannot carry the claim. Everything else measures association.

The product (22 reads)

search_accounts · get_account · search_visitors · visitor_journey · journey_paths · attribution_overview · open_recommendations · workspace_setup · intent_pages · enrichment_config · sync_rules · dry_run_sync_rule · list_api_keys · list_webhooks · list_alert_destinations · identification_status · list_conversations · get_conversation · list_team · subscription · esp_connection · recall_facts

recall_facts and forget_fact are the assistant's memory. It keeps short facts about your workspace and feeds them into the planner on later turns, so a wrong one shapes answers you have not asked yet. recall_facts lists them; forget_fact stops one being used. Ask "what have you remembered about us" if an answer keeps coming out wrong in the same way.

dry_run_sync_rule is the one to reach for before any push: it reports exactly which contacts a rule WOULD send, and changes nothing. It reads like a write and is not one.

Start with workspace_setup when data looks wrong or missing — it returns store connection, onboarding progress, pixel status, identification health and plan usage in one call, and distinguishes "nothing happened" from "we are not collecting".

Changes (35)

Tool Reversible?
classify_account yes
set_visitor_contacted yes
set_recommendation_status yes
update_enrichment_config yes
create_sync_rule yes — created paused, sends nothing until activated
set_sync_rule_state pause/activate yes; no on delete — hard delete, run history goes too
replace_intent_pages yes, but it replaces the whole set, and scores recompute on the next run, not immediately
dry_run_sync_rule not a change — previews who would be pushed
run_enrichment_pipeline no — spends vendor credits, workspace-wide
run_sync_rule no — contacts reaching an ESP cannot be recalled
create_api_key no — secret shown once
revoke_api_key no — breaks live integrations immediately
create_webhook no — signing secret shown once
set_webhook_state no on delete — hard delete, history goes too
verify_pixel yes — re-checks the pixel and stamps the result
test_pixel yes — sends a synthetic hit; it is recorded like any other event
connect_esp yes — returns a consent link; nothing is connected until the customer authorizes
verify_esp yes — re-checks the email platform and stamps the result
skip_esp yes — connect an ESP later with connect_esp
complete_intent_step yes — reconfigure pages and confirm again
activate_workspace no — nothing in the product returns a workspace to onboarding
authorize_store yes — returns a link; nothing is connected until the store owner approves it in WordPress
claim_domain no on a change — clears verification, and the pixel check cannot confirm a tag-manager install, so that flag may not come back
invite_team_member no — an email reaches a person and cannot be unsent
update_team_member yes — set the role back
remove_team_member no — access is revoked at once; re-inviting needs them to accept again
cancel_invitation yes — invite again
start_checkout no — once the checkout is completed the bill changes
open_billing_portal yes — a link; changes happen at Stripe
update_sync_rule yes — retunes filters in place, keeping run history
create_alert_destination yes — created paused, sends nothing until activated
set_alert_destination_state yes — activate/pause/delete only
test_alert_destination no — posts a real message to the Slack channel; everyone in it sees it
rename_conversation yes
delete_conversation no — soft-deleted for audit, but nothing in the product restores it
forget_fact no — soft-deleted for audit; nothing in the product puts it back

Access and money need the workspace to opt in

Six tools grant workspace access or change what is billed: invite_team_member, update_team_member, remove_team_member, cancel_invitation, start_checkout, open_billing_portal.

They are off unless the workspace turns them on (guardrails.copilot_privileged_actions_enabled, default false). Nothing in the copilot can set that column — it is enabled deliberately, out of band, by someone who has decided an agent holding a session may act on their behalf.

Why these and not the rest: everything else the assistant changes is workspace data, where wrong is recoverable. These decide who can reach the workspace and what it costs, and the confirmation gate does not protect them the way it protects the rest — for a script, approving is echoing the plan back (§7). So three further checks apply, all failing closed:

Check Team Billing
Workspace opt-in required required
Caller's role, read fresh per call admin or owner owner only
Confirmation gate yes yes

And some targets are refused whatever your role, because they are the changes that let one compromised session lock everyone else out:

  • you cannot act on yourself;
  • an owner cannot be removed or demoted;
  • nobody can be promoted to owner.

Those remain dashboard-only, on purpose.

start_checkout and open_billing_portal return their links under checkout_url and portal_url in the response's secrets field, not in the answer text — a live payment link does not belong in a conversation that is replayed into later prompts and can be exported.

One plan cannot look things up for itself

The calls in a plan run concurrently with static arguments — no call reads another's output. A tool cannot be handed an id that a different call in the same plan was meant to fetch.

Tools are therefore addressed by the identifier you already have, never by an opaque id:

Tool Selector Value
classify_account domain acme.com
set_sync_rule_state, run_sync_rule, dry_run_sync_rule, update_sync_rule rule the rule's name from sync_rules
set_alert_destination_state destination the name from list_alert_destinations
revoke_api_key key the name from list_api_keys
set_webhook_state url the endpoint URL from list_webhooks — a webhook has no name

That is not a style preference: when classify_account took a UUID, "classify acme.com as a competitor" came back as an answer about acme.com's current classification, having changed nothing, because the planner could not resolve the id and fell back to a read.

update_sync_rule takes both rule (which one) and name (rename it to).

Matching is exact, ignoring case and surrounding whitespace only. Account domains are unique per workspace and always resolve. Rule, destination and key names are not unique — nothing in the schema prevents two rules sharing a name — so if a name matches nothing you get a refusal listing what does exist, and if it matches more than one you get a refusal saying it is ambiguous. Neither case changes anything. There is no fuzzy matching: the approved plan carries the name and re-resolves it at execution, so a loose match could act on a different object than the one whose effect you approved.

set_visitor_contacted and set_recommendation_status take the visitor's email address and the recommendation's subject respectively — the thing a person would actually type, not a row id.

If you need two steps, take two turns: run the read, then send a second question using what it returned.


9. Webhooks

Register an endpoint (dashboard, or create_webhook) and Geysera posts to it when a visitor is identified. Max 5 per workspace.

Delivery carries a Stripe-style signature:

X-Signal-Timestamp: 1756742400
X-Signal-Signature: t=1756742400,v1=<hex hmac-sha256>

The signed payload is "{timestamp}.{raw_body}", keyed by your whsec_… secret. Verify against the raw body, before any JSON parsing — re-serialising changes the bytes and the signature will not match.

import hashlib, hmac, time

def verify(secret: str, header: str, timestamp: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(
        secret.encode(), f"{timestamp}.{raw_body.decode()}".encode(), hashlib.sha256
    ).hexdigest()
    # Constant-time: a fast-failing comparison leaks the signature a byte at a time.
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        return False
    # Reject stale timestamps, or a captured delivery can be replayed forever.
    return abs(time.time() - int(timestamp)) < 300
{
  "type": "signal.visitor_identified",
  "created": 1756742400,
  "data": { "…": "…" }
}

Notes:

  • The signing secret is shown once, at registration. Rotating issues a new one and invalidates the old.
  • Endpoints receive every event type — there is no per-endpoint subscription filter. Branch on type.
  • Return 2xx quickly. Deliveries retry.
  • signal.test arrives from the "send test ping" action.

10. Capability discovery

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

Unauthenticated, and enough to bootstrap without reading this document: the public route list, the auth model, rate limits, pagination and error conventions, and spec_url pointing at the OpenAPI 3.1 spec.

curl https://app.signal.geysera.com/signal-api/v1/openapi.json

The spec is derived from the live application, not hand-maintained, so it cannot drift from what the endpoints do.

Two deliberate omissions, so you do not go looking:

  • The internal schema (~326 paths, including /admin and /observability) is not published. Serving it unauthenticated would hand out a map of the attack surface. /capabilities describes the public surface only, by allowlist, so a new internal route is invisible here until someone deliberately publishes it.
  • There is no checked-in spec. One used to sit in public/openapi.json, describing nine paths of which six had been deleted, served publicly at /openapi.json — it is gone, and a test stops another appearing. The spec you want is generated from the live routes at /signal-api/v1/openapi.json.

11. Errors

Every error has the same shape:

{
  "error_code": "RATE_LIMITED",
  "message": "Too many requests for this workspace.",
  "correlation_id": "3f2c…",
  "details": null
}
Status error_code What to do
400 / 422 VALIDATION_ERROR Fix the request. details says which field.
401 Key missing, malformed, unknown or revoked.
402 Plan cap reached. details carries the upgrade path.
403 FORBIDDEN Authenticated, not allowed.
404 NOT_FOUND Also returned for another workspace's resources — we do not confirm they exist.
409 CONFLICT Optimistic-lock failure: someone changed it since you read it. Re-read and retry.
429 RATE_LIMITED Back off. 300/min per workspace.
5xx INTERNAL Retry with backoff. Quote correlation_id.

Retry 429 and 5xx with exponential backoff. Do not retry 4xx — send an X-Idempotency-Key on writes so a network-level retry is safe.

When the fix is somewhere in the dashboard

Some failures cannot be fixed by changing the request. Pushing to an ESP you have not connected, or using a key that was never granted a scope, needs a person in the dashboard. Those errors carry the address:

{
  "error_code": "CONFLICT",
  "message": "Connect Klaviyo on Auto-sync Rules first.",
  "details": {
    "code": "klaviyo_not_connected",
    "fix_url": "/rules",
    "fix_label": "Auto-sync Rules"
  }
}

fix_url is relative to your dashboard origin (https://app.signal.geysera.com). fix_label is the wording that appears in the dashboard's own navigation, so telling someone to open "Auto-sync Rules" matches what they will see.

This exists because an agent cannot look around. Before these fields, the same errors said "Settings → Integrations" — a tab that has never existed — and the only way to discover that was to follow the instruction and find nothing.


12. Things that will surprise you

Collected because each one has already produced a wrong conclusion.

total is not a row count. It is capped at what your plan may access. Never use it as a denominator.

A masked record is not an empty one. is_locked: true means the plan is withholding the name and email. Recording "no email on record" is wrong, and it is wrong about a real person.

A refusal is a real answer. The copilot refuses when the data cannot support the claim. Retrying the same question gets the same refusal.

Attribution covers a minority of traffic. Only sessions carrying a campaign context. On some workspaces that is a few percent. The response says so; read the caveats.

Intent scores can be meaningless. They rank visitors by which "intent pages" a workspace has configured. If those rules match little of the real traffic, every visitor scores alike and the ranking is close to arbitrary. intent_pages reports the coverage — check it before trusting a ranking.

replace_intent_pages replaces. Pages absent from your list are deleted, and any score_weight you omit resets to 1. Read the current set, modify it, send it all back.

Empty page_patterns matches nobody. In update_enrichment_config, an empty list is not "no restriction" — it switches enrichment off in practice.

run_enrichment_pipeline is not one visitor. It takes no arguments, because it acts on the whole workspace: every eligible abandoner is bought from a data provider, one credit each. It used to take a visitor's email, which bounded nothing — the route checked that visitor's ownership and then ran the same workspace-wide job. There is no single-visitor enrichment.

Bot traffic is filtered, historically imperfectly. Some older rows have an unset bot flag. If a visitor count looks implausibly large for the number of distinct people, that is why.

The attribution rollup can lag a day. It is nightly. attribution_overview reads the same cached rows the dashboard does, so the two agree — with each other, and with yesterday.


Questions, or you need programmatic write access with an API key? Include the correlation_id from a recent response — it is the fastest way for us to see exactly what you saw.

This page is maintained next to the service it describes and rendered here. Read it as markdown if you are pointing an agent at it.