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
- The three surfaces
- Authentication
- Conventions
- The public read API
- Plan caps, and why your totals may be smaller than reality
- The copilot: asking questions
- The copilot: making changes
- The tool catalogue
- Webhooks
- Capability discovery
- Errors
- 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 masked — is_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.
refusalis the product working, not an error. It means the data cannot support the claim you asked for. Do not retry it.clarificationis a question back. Answer it on the samethread_id.traceis the audit trail: every tool that ran, the SQL it executed, its coverage, and the window it covered.dataholds the rows — parse those rather than the prose if you want structure.limitsanddisclosuresare 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: truemeans the product cannot undo it: credits spent with a vendor, contacts pushed to an ESP, a secret shown once, a row hard-deleted.- Show
effectto your user, nottool. It is written to be consented to. - A malformed
approved_planreturns422, 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.testarrives 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
/adminand/observability) is not published. Serving it unauthenticated would hand out a map of the attack surface./capabilitiesdescribes 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.
Markdown source: /developers/guide.md