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. The free plan's monthly allowance
  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.
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: read reaches the four public GETs, and a key with the copilot scope additionally reaches /signal-api/v1/copilot/ask. A Clerk session JWT works too, which is what the dashboard uses.

What a read or copilot key 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: audiences creates, edits, reviews and exports (it spends nothing), and enrich starts 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 audiences and enrich scopes; with read or copilot alone it changes nothing.
  • A key also carries scopes, chosen at creation and fixed thereafter: read reaches the four public GETs; copilot additionally reaches /signal-api/v1/copilot/ask; audiences reaches /signal-api/v1/audiences; enrich is needed to start a paid run or change daily lookups. A read-only key calling /ask gets 403, and since scopes cannot be edited, that means minting a new key rather than upgrading one. GET /capabilities states 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.

  • refusal is usually the product working, not an error. Read refusal_kind before deciding whether to retry — the three are not alike:

    refusal_kind means retry?
    data the data cannot support the claim you asked for no — it will refuse again
    guard a safety check stopped the answer, e.g. a figure it could not source in trace yes — a different sampling often passes
    unavailable an upstream service was down yes, with backoff

    This page used to say "do not retry it" of all refusals. That is right for data and wrong for the other two — one of which already told you to try again in its own prose. A guard refusal in particular means the tools answered and the write-up was stopped, so the rows you wanted are in trace[].data even when answer is null.

    When a guard stop happens part-way, answer holds the sentences that were verified before it and refusal explains where it stopped — the one case where both are set.

  • 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.


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.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": "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.

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.