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
- The free plan's monthly allowance
- 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. |
| Audiences API | /signal-api/v1/audiences | API key (audiences; enrich to spend) or Clerk JWT | Define professional audiences, review who matches, enrich them and export the accepted people. |
| Copilot | /signal-api/v1/copilot | API key (copilot scope) or Clerk JWT | Ask in English; read 54 tools worth of data and perform 55 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, stated up front. Both surfaces take an API key:
readreaches the four publicGETs, and a key with thecopilotscope additionally reaches/signal-api/v1/copilot/ask. A Clerk session JWT works too, which is what the dashboard uses.What a
readorcopilotkey cannot do is change anything — no key reaches a mutating copilot tool, whatever it asks for, and the tools that would change your workspace are absent from a key caller's plan entirely rather than refused at the end. Through the copilot a script can read and ask, but not act.The one write surface is audiences, and only under scopes that say so:
audiencescreates, edits, reviews and exports (it spends nothing), andenrichstarts paid enrichment. Separate on purpose — a key that can export a list can never spend money.
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. It writes only under the
audiencesandenrichscopes; withreadorcopilotalone it changes nothing. - A key also carries scopes, chosen at creation and fixed thereafter:
readreaches the four publicGETs;copilotadditionally reaches/signal-api/v1/copilot/ask;audiencesreaches/signal-api/v1/audiences;enrichis needed to start a paid run or change daily lookups. Aread-only key calling/askgets403, and since scopes cannot be edited, that means minting a new key rather than upgrading one.GET /capabilitiesstates the required scope per endpoint. - Maximum 10 active keys per workspace. Revocation is immediate and permanent.
Missing, malformed, unknown and revoked keys all return 401 with
error_code: UNAUTHORIZED. The message separates a key you did not send from
one we will not accept, because that is the difference you can act on.
An unknown key and a revoked key are deliberately identical — both answer
Invalid or revoked API key. A distinct "this key existed once" would confirm
which keys are real. Branch on error_code, never on the sentence.
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
curl -s https://app.signal.geysera.com/capabilities
(spec_url, servers) rather than hardcoding it. It is configuration on our
side and it will move.
That URL carries no /signal-api/v1, and that is not a typo.
/capabilities is pre-auth discovery — no key, no scope — so it sits outside
the versioned, key-authed surface it describes. Behind that prefix you would
have to know the prefix in order to look it up, which is the one thing you came
here to find out. Concretely:
https://app.signal.geysera.com/capabilities | 200 |
https://app.signal.geysera.com/agent-api/capabilities | 200 — the same document, byte for byte (§10 uses this one) |
Composing it onto the versioned prefix instead — /signal-api/v1 +
/capabilities — returns 404 NOT_FOUND. Every other path in this guide is
relative to a prefix from §2; this one is the exception, so it is spelled out in
full wherever it appears.
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 the real count, records masked by the free
plan included — see §5.
Rate limit. 300 requests per minute per key. Over it you get 429.
Per KEY, not per workspace — so a second integration gets its own budget rather than eating yours, and separate keys per job is the cheap way to
isolate a noisy one. The dashboard buckets separately and does not share
this budget at all.
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|excluded | everyone except excluded |
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 or excluded. Excluded accounts are left out of lists unless you ask for classification=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 | Notes |
|---|---|---|---|
page, page_size | integer | 1, 50 | |
min_intent | integer 0–100 | none | filters this visitor's score |
classification | as above | all | filters the account's classification |
segment | provider|dtc|internal|unclassified | all | filters what your store says they are |
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, this person's own. Not the account's — see the note below. |
account_classification | string | yes | How Signal classifies the account (the company domain), not this person. Every visitor on the same domain shares it, so on personal that is every consumer mailbox at once. |
status | string | yes | This person's status: lead, customer or excluded — their own if your team set one, otherwise their company's (never a mailbox provider's). The value the dashboard shows. ?classification= filters on it; excluded people are left out unless you ask for classification=excluded. |
orders_count | integer | no | Paid orders by this person, net of refunds. |
has_ordered | boolean | no | true when this person has bought. The buyer/prospect split. |
store_segment | string | no | What your store says this person is: provider (trade — wholesale, partner, and their applicant/lead roles), dtc (a consumer account), internal (staff), or unclassified when your store's customer list holds no entry matching this address. A buyer can be unclassified — read has_ordered for whether they bought. |
store_roles | string[] | no | The raw roles or tags your store holds for them, e.g. ["provider"]. WordPress roles on WooCommerce, customer tags on Shopify. Store-defined, so open-ended. |
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 the free plan is withholding this person's contact details: they were identified after the first 150 people of their month (the free plan shows the first 150 identified each month in full). The identity fields are null and the name is masked. Never true on a paid or trialling plan. Filter these out of anything a human is meant to action. See §5. |
intent_score is this visitor's, and account_classification is not. They
sit next to each other and answer different questions, so it is worth being
blunt about which is which.
An account is a domain. Consumer mailboxes collapse into one account called
personal, and on a DTC store that is most of your list — on one workspace it
was 70,429 of 76,647 visitors. Anything account-level therefore describes that
whole crowd, not the person in front of you: they all carry the same
account_classification, and until 2026-09-29 they all carried the same
intent_score too, because the endpoint returned the account's. It does not
any more.
So: rank on intent_score, and split buyers from prospects on has_ordered
— not on account_classification. On that same workspace, 92% of visitors came
back customer while 0.55% of them had ever bought.
store_segment is your own data, not our inference. Your store already
records what each customer is — a WordPress role on WooCommerce, tags on
Shopify — and we read it rather than guessing.
It takes four values: provider for trade of any kind, dtc for a consumer
account, internal for staff, and unclassified when your store's customer
list holds no entry matching that address.
unclassified is about the customer list, not about the person. It does not
mean they have never bought: a checkout's email need not match a customer-list
entry, so a visitor with orders can still be unclassified. Read has_ordered
for whether they bought, and orders_count for how much. Your own role and tag names are not among them — a
provider_applicant or wholesale role both arrive as provider, and
?segment=wholesale is a 422. ?segment=provider is the one call that gives
you the trade list:
curl "https://app.signal.geysera.com/signal-api/v1/visitors?segment=provider&page_size=50" \
-H "Authorization: Bearer $SIGNAL_API_KEY"
Most visitors come back unclassified, and that is the honest answer: they are
web traffic your store has never seen, not customers it forgot.
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.
Audiences
An audience is one kind of professional you sell to — the job titles and employers that mark them and the pages on your site they read. Everything the Audience Classification page does is here, on the same code:
| Call | Scope | What it does |
|---|---|---|
GET /audiences | audiences | Every audience, with its definition, and the store segments a definition may name. |
POST /audiences | audiences | Create one from a name (the key is derived from it); 409 if it exists. Honours X-Idempotency-Key. |
GET /audiences/summaries | audiences | Every audience's headline counts in one call — identified, profiled, likely, accepted, and whether its outputs are on. Use this to list audiences; it is far cheaper than calling /overview for each, which also computes account watch, calibration and recent runs. |
GET /audiences/{key} | audiences | One definition. |
PUT /audiences/{key} | audiences | Replace the definition; the response says what it now matches, and warns when it matches nobody. A language model always reads the candidates no term matched — professional fields only, never pages — and marks a fitting one likely; such a row has matched_on: ["model_read"] and the model's reason in model_read.reason. |
DELETE /audiences/{key} | audiences | Delete it. Decisions and runs are kept, and creating the same name brings it back; its daily lookups stop and its sync rules pause. |
GET /audiences/{key}/overview | audiences | The pipeline: identified → profiled → classified → reviewed, each with its own denominator; the calibration and whether it is out of date; whether exports are on and, if not, why. |
GET /audiences/{key}/prospects | audiences | People who look like the audience: likely (a professional field matches) and candidate (read the pages, nothing else known). sort and direction order the whole tier, not one page. since (ISO time) lists only people who entered their tier since then — the daily list of new prospects; each row's classified_at says when, and classification_history_starts_at says when tracking began (people already there then are never new). |
POST /audiences/{key}/reviews | audiences | Accept or reject one person, by email. The latest decision stands. |
POST /audiences/{key}/reviews/clear | audiences | Withdraw a decision made by mistake. |
GET /audiences/{key}/reviews | audiences | The decided people — the export. ?decision=accepted&format=csv returns the whole accepted list as a spreadsheet. Each row has business_email (their own address when it is at a work domain; never bought) and last_order_at. |
POST /audiences/{key}/runs/estimate | audiences | Who a run would look up and the most it could cost, in credits. Spends nothing. |
POST /audiences/{key}/runs | enrich | Start a paid run up to max_credits. Honours X-Idempotency-Key, so a retry after a timeout cannot spend twice. |
GET /audiences/{key}/runs | audiences | Recent runs: status, credits spent, what was found. |
POST /audiences/{key}/runs/{run_id}/cancel | audiences | Stop a run before its next paid lookup. Stopping never costs. |
PUT /audiences/{key}/automation | enrich | Daily lookups of new readers, within a required monthly budget. |
GET /audiences/{key}/account-watch | audiences | Confirmed members worth a call over the last days: late (reorder overdue against their own rhythm) and browsing_without_buying (identified on the site, no order since). Only members the site identified can appear in the second list. |
GET /audiences/{key}/digest | audiences | The daily email of new prospects: who is subscribed, and what was sent each of the last seven days — or why not (not calibrated, workspace email off, nobody new). |
PUT /audiences/{key}/digest | signed in only | Subscribe yourself ({"subscribed": true}) or stop. An API key gets 403: the email goes to a team member's own address, never one typed in. |
GET /audiences/{key}/acceptance | audiences | The spec's acceptance tests on your data: each check's bar, what it measured over how many, and pass, fail or undecided. Spends nothing. |
POST /audiences/{key}/acceptance/made-up-addresses | enrich | Looks up 20 invented addresses live; none should return a profile. Costs credits only on a wrong hit. |
# Create, estimate, enrich (needs `enrich`), then export the accepted people.
curl -X POST https://app.signal.geysera.com/signal-api/v1/audiences \
-H "Authorization: Bearer sk_sig_..." -H "Content-Type: application/json" \
-d '{"name": "Chiropractors", "title_terms": ["chiropract", "DC"], "page_patterns": ["%/providers%"], "confirmed_roles": ["provider"]}'
curl -X POST .../signal-api/v1/audiences/chiropractors/runs/estimate -d '{"mode": "prospects"}' ...
curl -X POST .../signal-api/v1/audiences/chiropractors/runs -H "X-Idempotency-Key: run-2026-10-13" -d '{"mode": "prospects", "max_credits": 500}' ...
curl ".../signal-api/v1/audiences/chiropractors/reviews?decision=accepted&format=csv" ...
Exports and syncs switch on only after a calibration of the current
definition (mode: "calibrate") — the overview's outputs.reason says what is
missing. A run never contacts anyone; only people your team accepts are
exported, and only people subscribed in your email platform, or whose business
address we hold outside the EU, UK and Canada, are ever sent to one — never
anyone unsubscribed or suppressed there, and only while ESP sync is on. A sync rule for an audience labels every contact it sends with the
audience's name: the geysera_signal_audience property in Klaviyo and HubSpot,
an audience:<name> tag in Mailchimp, so a flow can target the audience
without relying on the list's name.
5. The free plan's monthly allowance
The free plan shows the first 150 people identified each month in full. Anyone identified after that, in the same month, is still there, but masked. A month's 150 stay revealed after the month ends. Paid and trialling plans see everyone.
Each plan includes a number of resolutions (people identified) a month; your usage is on the Billing page. Only the free plan masks beyond its number:
| Plan | Monthly resolutions included |
|---|---|
| free | 150 |
| starter | 300 |
| pro | 1,000 |
| pro_plus | 2,500 |
| enterprise | 1,000,000 |
New workspaces get 7 days at pro_plus.
Every surface applies this one rule, so they always agree:
| Surface | Beyond the allowance |
|---|---|
Public API — /visitors | Present but masked: is_locked: true, resolved_email/resolved_name/resolved_title/employer null. |
Public API — /accounts | Present. A company known only through masked people is masked too: company_domain •••.com, company_name •••, is_locked: true. |
| Dashboard, exports | The same: present, masked, name shown as •••. |
| Copilot | Masked, and it says how many rather than describing the list as complete. |
Nothing is dropped by plan, so total is the real count, masked records
included. Do not treat a masked visitor's null email as "this person has no
email": that is the plan talking, not the data.
6. The copilot: asking questions
POST /copilot/ask one JSON response
POST /copilot/stream the same thing, as SSE
GET /copilot/threads your conversations
GET /copilot/threads/{thread_id} one conversation, with its messages
/ask is complete on its own. The stream exists because humans want progress
feedback; a script never needs it.
thread_id accepts either identifier. You may choose your own and send it
with the question — that is the thread_key — or use the server-generated id
that GET /copilot/threads returns. Both name the same conversation, and an
id that matches neither returns 404.
curl -X POST https://app.signal.geysera.com/signal-api/v1/copilot/ask \
-H "Authorization: Bearer $SIGNAL_API_KEY" \
-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 — with one
documented exception, described under refusal below.
-
refusalis usually the product working, not an error. Readrefusal_kindbefore deciding whether to retry — the three are not alike:refusal_kindmeans retry? datathe data cannot support the claim you asked for no — it will refuse again guarda safety check stopped the answer, e.g. a figure it could not source in traceyes — a different sampling often passes unavailablean upstream service was down yes, with backoff This page used to say "do not retry it" of all refusals. That is right for
dataand wrong for the other two — one of which already told you to try again in its own prose. Aguardrefusal in particular means the tools answered and the write-up was stopped, so the rows you wanted are intrace[].dataeven whenansweris null.When a
guardstop happens part-way,answerholds the sentences that were verified before it andrefusalexplains where it stopped — the one case where both are set. -
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.
GET /capabilities lists 55 of them, and that is deliberate: it lists the ones
your key can reach. The other 55 change the workspace, and a key cannot
invoke one whatever it asks for, so advertising them to a key would be
advertising a capability that always refuses. The same response carries
copilot_tool_reach, which says how many are omitted and what reaches them — so
an agent reading only the JSON learns the write surface exists without being
told it can use it. This page is the full catalogue; that field is the machine
answer to "what can I do here".
8. The tool catalogue
110 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 (17)
data_coverage · revenue_and_aov · aov_decomposition · product_performance
· product_catalogue · customer_counts · customer_accounts · list_orders ·
discount_codes ·
order_sources · lapsing_accounts · price_history · page_funnel ·
page_to_product_attribution · membership_conversion_paths ·
visiting_accounts · causal_impact
product_performance and product_catalogue answer different questions and
are easy to confuse. The first ranks what sold in a window, built from order
line items, so a product that did not sell cannot appear in it. The second is
the catalogue — every product the store holds, with its SKU, price,
categories and stock status, whether or not it has ever sold. "What is out of
stock" is a question about exactly the products that did not sell, so it is only
answerable from the second.
A product's stock status is null when the store does not track inventory for it. That is not "out of stock", and the catalogue says which it is.
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.
list_orders returns the orders themselves rather than a total over them, and
it is the only order tool that includes refunded, cancelled and failed orders —
each with its status, because a list that hid them reads as orders going
missing. For that reason it reports no revenue total: adding those rows together
would count refunds as income. Ask revenue_and_aov for money. Orders the store
has since deleted are excluded, and the store's own order number is not
available — the sync does not carry it. The payment method now is: ask
order_sources.
order_sources answers how orders came about, in three breakdowns over one
window: the channel the store attributed each order to, whether it was a
subscription renewal, and how it was paid for. The attribution is the store's
own record from checkout, not ours — on the pilot it covers 84% of orders
against our pixel's 0% over the same window, which is why the two are reported
separately and must never be added together. Each breakdown returns its own
observed count because the three differ; take a share against that rather
than the window total.
customer_accounts lists the store's own customers with the role the store
gives them — provider, wholesale — and their lifetime order count, revenue
and AOV. Two things to hold onto: it reads the order record, so a customer
appears whether or not the pixel ever identified them (most trade customers
never visit identifiably), and the date window selects which customers
appear rather than what their totals cover. Use customer_counts for totals
over a period instead.
discount_codes reports per-code orders, customers, revenue, discount given and
AOV. One thing to know before reading it: an order that used two codes is
counted under each, so adding the per-code revenue column together exceeds
the window total. The response returns the revenue on coded orders as a single
reconciling figure, and the number of orders that used more than one code, so
the gap is visible rather than surprising.
lapsing_accounts lists the store's own buyers in a segment (providers by
default) who have stopped ordering: an account is late once the time since its
last order exceeds the longest gap it has ever had between orders.
The product (38 reads)
search_accounts · get_account · search_visitors · visitor_journey ·
journey_paths · attribution_overview · open_recommendations ·
workspace_setup · intent_pages · enrichment_config · preview_enrichment_rule · credit_usage · esp_sync · sync_rules · sync_rule_targets ·
dry_run_sync_rule · list_api_keys · list_webhooks ·
list_alert_destinations · identification_status · visitor_enrichment ·
deletion_status ·
learned_plans ·
list_conversations ·
get_conversation · list_team · subscription · esp_connection ·
recall_facts · list_audiences · audience_overview · audience_prospects ·
audience_accepted · audience_rejected · audience_runs · audience_run_estimate · audience_digest · audience_account_watch
audience_prospects lists people browsing your site who look like a
professional audience you do not yet sell to — health practitioners for a
supplement brand — split into likely (title or employer matches) and
candidate (read your trade pages, nothing else known), or only the people who
reached their tier in the last few days ("who is new this week"). Your existing trade
accounts are excluded; they belong to lapsing_accounts. Evidence is limited to
professional fields and the audience's own pages. A language model reads each
person's professional profile and accepts or rejects those who clearly fit or
clearly do not, with its reason, so audience_prospects lists only those still
to review. review_prospect records your team's accept/reject, which the model
never overrules, and set_audience defines the audience — including people
confirmed by a purchase, named as you would say it ("bought Pure NMN Powder").
start_audience_run looks those people up within a credit limit —
calibrate first, which measures the audience against your known members and an
equal sample of non-members, shared by every audience on the same store roles so a
second one reuses its lookups — and audience_runs reports what a run found
and spent; audience_run_estimate says what one would cost before it starts,
counting only the people not yet looked up, and cancel_audience_run stops
one before its next paid lookup. list_audiences names every audience you have and what may confirm a member,
audience_accepted returns the people accepted — by the model, with its reason,
or by your team — the export list, with business email and last order, and
audience_rejected the people rejected, the same way. audience_overview is the whole pipeline in
one read, the numbers the Audience Classification page shows: who read the
pages, who has a profile, who matches, what the team decided, and whether
exports are on — and if not, why. set_audience_digest emails the person asking
the audience's new prospects every morning (only themselves — it cannot add
anyone), and audience_digest says who gets it and what was sent, or why not.
audience_account_watch names the confirmed members worth a call: late on
their reorder, or back on the site with no order since.
create_sync_rule with audience keeps
the people your team accepted, and the audience's confirmed members (the
store roles it names), in one list in your email platform; only people
subscribed there, or whose business address we hold outside the EU, UK and
Canada, are sent, and it needs a calibration of the current definition. Each contact it sends carries the audience's name (see §4).
recall_facts, teach_fact 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; teach_fact adds one deliberately rather than waiting for the assistant to infer it; forget_fact stops one being used. Ask "what have you remembered
about us" if an answer keeps coming out wrong in the same way. The same
list is in the dashboard under Settings → Privacy, with a delete beside each
fact — useful when you are debugging an integration and want to see what the
planner is being told without spending a turn asking it.
Two properties worth building on. Facts are scoped to the workspace, not to the person who taught them, so a fact one team member adds steers every other member's answers and yours. And a delete takes effect on the next turn: the fact table is the system of record and the vector index is checked against it on every recall, so nothing keeps steering after it is removed.
prove_domain_ownership and check_domain_ownership are the route to
use when the pixel check cannot confirm your site. That check scans the page
source, so a snippet injected by a tag manager or a single-page app router is
invisible to it even though it is working. Claim with DNS TXT or a meta tag
instead, place the record, then check. They are two calls because a human has
to go and place the record in between — nothing can chain them.
learned_plans shows what the assistant has picked up: for a recurring
question, which tools it reuses and how many times that was confirmed. Worth
checking if the same question keeps coming back answered the same wrong way —
and forget_learned_plan drops one. Retirement is permanent on purpose: a
revived plan that is still wrong costs a wrong answer every time, while
dropping it costs only consistency, since the question is still answered, just
planned fresh.
withdraw_domain_claim undoes a claim made against a typo, or for a site
you no longer run. It soft-deletes, so the history stays — and claiming again
afterwards starts from scratch with a new token.
visitor_enrichment answers "what did we actually get for this person, and
is it still current" — by email address, not by id. Reach for it after running
the enrichment pipeline, which spends credits across the workspace: without it
the assistant can spend money and not report what it bought.
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 (55)
| Tool | Reversible? |
|---|---|
prove_domain_ownership | yes — issues a token, changes no tracking |
withdraw_domain_claim | yes — soft delete; the row is kept for audit |
forget_learned_plan | no — retirement is permanent by design |
check_domain_ownership | yes — stamps verified when the record is found |
classify_account | yes |
review_prospect | yes — the latest decision stands |
set_audience | yes — replaces the definition; nobody is contacted |
start_audience_run | no — enrichment credits spent cannot be refunded; audience_run_estimate first spends nothing |
delete_audience | yes — decisions and runs are kept; creating the same name brings it back |
cancel_audience_run | yes in effect — it only stops further lookups; what was found is kept, and a new run picks up whoever is left |
set_audience_digest | yes — subscribes or unsubscribes only the person asking; nothing is sent until the next morning |
set_visitor_contacted | yes |
set_visitor_status | yes — null gives them their company's status again; excluded also takes them off email lists Geysera created |
set_recommendation_status | yes |
update_enrichment_config | yes |
create_enrichment_rule | yes — created switched off; it buys nobody until it is turned on |
set_enrichment_rule_state | on/off yes, but turning on spends credits from the next run; delete removes it from enrichment and sync |
set_enrichment_rule_budget | yes — set or clear a rule's monthly credit budget |
update_alert_destination | yes — edits in place, keeping state and history |
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 |
enrich_visitor | no — a credit is spent with a third party |
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 or Teams channel; everyone in it sees it. Teams only accepts it, so the person checks it arrived |
disconnect_email_platform | yes in effect — reconnecting with a key restores it; rules sending there skip meanwhile, nothing in the platform's account changes |
confirm_alert_test | yes — records that the person saw a Teams test message, which unlocks turning it on; not needed for Slack |
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 |
forget_person | no — the appended fields are deleted and the suppression cannot be lifted |
teach_fact | yes — forget_fact removes it |
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, "mark
acme.com as excluded" 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, set_visitor_status 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": "Rate limit exceeded. Max 300 requests per 60 seconds.",
"correlation_id": "3f2c…",
"details": null
}
| Status | error_code | What to do |
|---|---|---|
| 400 / 422 | VALIDATION_ERROR | Fix the request. details says which field. |
| 401 | UNAUTHORIZED | Key missing, malformed, unknown or revoked. |
| 402 | PAYMENT_REQUIRED | 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. Also an unrecognised path. |
| 405 | METHOD_NOT_ALLOWED | The path exists; the verb does not. Every endpoint here is a GET except /copilot/ask and /copilot/stream, which are POST, and the audience writes listed under Audiences: POST /audiences, PUT and DELETE /audiences/{key}, POST /audiences/{key}/reviews, /audiences/{key}/reviews/clear, /audiences/{key}/runs, /audiences/{key}/runs/estimate, /audiences/{key}/runs/{run_id}/cancel, PUT /audiences/{key}/automation, PUT /audiences/{key}/digest, and POST /audiences/{key}/acceptance/made-up-addresses. |
| 409 | CONFLICT | Optimistic-lock failure: someone changed it since you read it. Re-read and retry. |
| 429 | RATE_LIMITED | Back off. 300/min per key. The response carries a Retry-After header in seconds — honour it rather than guessing. |
| 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.
INTERNAL means a 5xx and nothing else. If you ever see it on a 4xx, that is a
bug on our side and worth reporting with the correlation_id — it used to be
the fallback for any status this table does not list, which told callers to
retry a request that could never succeed.
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 counts masked records too. On the free plan some of them are
is_locked; filter those out before you count people a human can contact.
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.
update_enrichment_config does not choose who is enriched. It turns
enrichment on or off and sets the daily and lifetime caps. Who is enriched is
the workspace's enrichment rules, and with no rule on, nobody is;
enrichment_config lists them.
run_enrichment_pipeline is not one visitor. It takes no arguments, because
it acts on the whole workspace: everyone the enabled rules choose is bought from
a data provider, one credit each. To buy one person's details deliberately, use
enrich_visitor with their email.
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