# Geysera Signal — developer documentation (complete) Every documentation page, in reading order. Generated from the same files the HTML pages render, so this cannot drift from what a person reads. Index: https://app.signal.geysera.com/developers/llms.txt ======================================================================== SECTION: Get started ======================================================================== ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/quickstart ------------------------------------------------------------------------ # Quickstart **Five minutes from nothing to your first identified company.** Signal tells you which companies and people visit your website. This page gets you a working integration; the guides go deeper. --- ## 1. Get a key Sign in, go to **Exports & API**, name a key and choose what it reaches. | Scope | Reaches | |---|---| | `read` | the REST endpoints | | `copilot` | the natural-language endpoint | The key is shown **once**. It is stored hashed — if you lose it, create another and revoke the old one. Scopes are fixed at creation: to change what a key reaches, make a new one. A credential whose powers can grow after it was reviewed is one nobody can reason about. ## 2. Your first call ```bash curl "https://app.signal.geysera.com/signal-api/v1/accounts?page_size=5" \ -H "Authorization: Bearer sk_sig_…" ``` ```json { "accounts": [ … ], "total": 1927, "page": 1, "page_size": 5 } ``` That is the whole authentication story: one header. ## 3. Ask a question instead With a `copilot`-scoped key: ```bash curl https://app.signal.geysera.com/agent-api/signal/copilot/ask \ -H "Authorization: Bearer sk_sig_…" -H "Content-Type: application/json" \ -d '{"question": "which companies visited most last week?", "thread_id": null}' ``` The response carries the answer, the tools that ran, and the figures behind it. It is read-only whatever it is asked. ## 4. Stop polling ```bash # Register an endpoint; we POST the moment someone is identified. ``` Webhooks are how most integrations should work. Polling asks us every few minutes whether anything happened; a webhook tells you when it does. → [Receive identified visitors by webhook](./workflows/receive-identified-visitors-by-webhook.md) --- ## Before you build: four things that will save you a day **1. `total` is not a row count.** It is what your plan permits you to resolve. Rates computed against it change when your billing changes. Use it to page, not to divide. **2. Unknown query parameters are ignored, not rejected.** `?limit=10` returns 200 and the default page size. Assert that the response's `page_size` is what you asked for — one line, catches every parameter typo you will ever write. **3. `401` and `403` mean different things.** 401 = the key is missing, malformed, unknown or revoked. 403 = the key is fine and lacks the *scope*. Re-issuing a key fixes the first and never the second. **4. Identification yield depends on your audience, not your setup.** Two accounts with identical configuration matched ~50% and ~0% of visitors. That is a property of who visits you, not a misconfiguration. Read [why attribution covers less than you think](./case-studies/why-attribution-covers-less-than-you-think.md) before you build a target around a rate. --- ## Discover the surface instead of trusting this page ```bash curl https://app.signal.geysera.com/agent-api/capabilities ``` Public, no key needed, and **generated from the routes' own auth dependencies** — so it cannot describe an endpoint that does not exist or omit one that does. It carries the endpoint list with the scope each needs, the pagination bounds, the rate limit and its scope, the error vocabulary, and the response headers you should act on. Every number in this documentation is derived from that endpoint or asserted against the code by a test. If this page and `/capabilities` ever disagree, `/capabilities` is right — and that is a bug we want to hear about. --- ## Where to go next **Guides — what you are trying to do** - [Receive identified visitors by webhook](./workflows/receive-identified-visitors-by-webhook.md) — the integration most people should build first - [Export and keep in sync](./workflows/export-and-keep-in-sync.md) — paging, rate limits, incremental strategies - [Ask questions in natural language](./workflows/ask-questions-in-natural-language.md) — the copilot API, and using it from another agent **Reference** - [Full API guide](./guide.md) — scopes, conventions, limits - `GET /signal-api/v1/openapi.json` — OpenAPI 3.1 for the REST endpoints **Case studies — what we learned running this** - [Most of your visitors were not people](./case-studies/most-of-your-visitors-were-not-people.md) - [The pipeline that reported success while doing nothing](./case-studies/the-pipeline-that-reported-success-while-doing-nothing.md) - [Why attribution covers less than you think](./case-studies/why-attribution-covers-less-than-you-think.md) --- ## What is not available yet Stated plainly so you do not design around something that does not exist: - **Writes.** No endpoint creates or changes anything. The idempotency and concurrency contracts that would need are not finished, and shipping writes without them would be a promise we could not keep. - **Per-key narrowing below the workspace.** Every key sees the whole workspace. - **More than one webhook event type.** Today: `signal.visitor_identified`. ======================================================================== SECTION: Guides ======================================================================== ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/guide ------------------------------------------------------------------------ # 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](https://www.geysera.com/developers), generated from the running service. This page explains how the product behaves; that one is the lookup. **Contents** 1. [The three surfaces](#1-the-three-surfaces) 2. [Authentication](#2-authentication) 3. [Conventions](#3-conventions-read-this-before-your-first-call) 4. [The public read API](#4-the-public-read-api) 5. [Plan caps, and why your totals may be smaller than reality](#5-plan-caps-and-why-your-totals-may-be-smaller-than-reality) 6. [The copilot: asking questions](#6-the-copilot-asking-questions) 7. [The copilot: making changes](#7-the-copilot-making-changes) 8. [The tool catalogue](#8-the-tool-catalogue) 9. [Webhooks](#9-webhooks) 10. [Capability discovery](#10-capability-discovery) 11. [Errors](#11-errors) 12. [Things that will surprise you](#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 `GET`s. Versioned, documented, safe to build on. | | **Copilot** | `/signal/copilot` | Clerk JWT | Ask in English; read 30 tools worth of data and perform 26 kinds of change. | | **Webhooks** | your endpoint | HMAC signature | Geysera calls you when a visitor is identified. | Pick the read API when you know what you want and need a stable contract. Pick the copilot when you want the platform to work out *how* to answer, or when you want to act. > **Auth asymmetry, stated up front.** The public read API takes an API key. > The copilot takes a Clerk session JWT and is **not** reachable with an > `sk_sig_` key today. A headless integration can therefore read but not act. > If you need programmatic writes, say so — the constraint is the auth wiring, > not the design. --- ## 2. Authentication ### API keys (`sk_sig_…`) Mint one in the dashboard under **Exports & API**, or ask the copilot to (`create_api_key`). Send it as a bearer token: ```bash curl https://app.signal.geysera.com/signal-api/v1/accounts \ -H "Authorization: Bearer sk_sig_your_key_here" ``` - **The plaintext key is shown exactly once**, at creation. Only a SHA-256 hash and a display prefix are stored, so nobody — including support — can recover it. Lose it and you mint a new one. - A key is scoped to **one workspace** and is **read-only**: it reaches the four public `GET`s and nothing else. - Maximum **10 active keys** per workspace. Revocation is immediate and permanent. Missing, malformed, unknown or revoked keys all return `401` with the same body. That is deliberate — a distinct "this key existed once" would confirm which keys are real. ### Clerk JWT The dashboard and the copilot use your session token. Browser clients get this automatically; there is no documented way to mint one from a script. --- ## 3. Conventions — read this before your first call **Base URL.** Read it from `GET /capabilities` (`spec_url`, `servers`) rather than hardcoding it. It is configuration on our side and it will move. **Headers** | Header | Required | Why | |---|---|---| | `Authorization: Bearer sk_sig_…` | yes | Identifies the workspace. | | `X-Correlation-ID` | no | Any string. Generated if absent, echoed on every response and in our logs. Quote it in support requests. | | `X-Idempotency-Key` | no | On mutating calls, makes a retry safe. Send the same key and the original result is returned instead of a second write. | **Pagination.** `page` (1-based) and `page_size`. Responses carry `total`, `page`, `page_size`. `total` is what you may access, **not** the raw row count — see §5. **Rate limit.** 300 requests per minute per workspace. Over it you get `429`. The dashboard itself issues 6+ parallel calls per page load and shares your budget, so leave headroom. **Time.** All timestamps are ISO 8601 UTC. `days` parameters are trailing windows ending today. --- ## 4. The public read API Four endpoints. All `GET`, all paginated where a list is returned. ### `GET /accounts` Organisations that visited, ranked by intent. | Param | Type | Default | |---|---|---| | `page` | integer | 1 | | `page_size` | integer | 50 | | `classification` | `lead`\|`customer`\|`competitor`\|`excluded` | all | ```bash curl "https://app.signal.geysera.com/signal-api/v1/accounts?classification=lead&page_size=10" \ -H "Authorization: Bearer $GEYSERA_KEY" ``` ```json { "accounts": [ { "company_domain": "acme.com", "company_name": "Acme Corp", "intent_score": 82, "visitor_count": 4, "visit_count": 11, "classification": "lead", "first_seen_at": "2026-07-02T09:14:00Z", "last_seen_at": "2026-09-01T16:03:00Z" } ], "total": 137, "page": 1, "page_size": 10 } ``` `intent_score` is 0–100, the same scale the dashboard shows. **Every field, and which can be null.** Nullability is not cosmetic here — the identity fields are the ones most likely to be absent, and a client that assumes otherwise breaks on real data rather than on the example above. | Field | Type | Null? | What it is | |---|---|---|---| | `company_domain` | string | no | The natural key. Join on this, never on the name. `personal` is a real value — see below. | | `company_name` | string | **yes** | Often absent. Fall back to the domain. | | `intent_score` | integer | no | 0–100. | | `visitor_count` | integer | no | Distinct people from this company. | | `visit_count` | integer | no | Total visits across those people. | | `classification` | string | no | `lead`, `customer`, `competitor` or `excluded`. | | `first_seen_at` | ISO 8601 | no | | | `last_seen_at` | ISO 8601 | no | | **`company_domain` can be the string `personal`.** Consumer mailbox domains — gmail, icloud and the rest — all roll up under that one sentinel, because a consumer address carries no company. On a DTC site that single "account" can be the majority of your visitors. Filter it out of anything B2B. ### `GET /visitors` Individual identified people. | Param | Type | Default | |---|---|---| | `page`, `page_size` | integer | 1, 50 | | `min_intent` | integer 0–100 | none | | `classification` | as above | all | Fields include `resolved_email`, `resolved_name`, `resolved_title`, `employer`, and `is_locked`. **When `is_locked` is true, `resolved_email` is `null` and `resolved_name` is masked** — the plan is withholding them. Do not record that person as having no email; see §5. **Every field, and which can be null.** | Field | Type | Null? | What it is | |---|---|---|---| | `company_domain` | string | **yes** | `personal` for consumer mailboxes. | | `company_name` | string | **yes** | | | `intent_score` | integer | no | 0–100. | | `classification` | string | **yes** | | | `resolved_email` | string | **yes** | The identity. Present far more often than the three below. | | `resolved_name` | string | **yes** | Frequently absent. | | `resolved_title` | string | **yes** | Frequently absent. | | `employer` | string | **yes** | Frequently absent. | | `first_visit_at` | ISO 8601 | **yes** | | | `last_visit_at` | ISO 8601 | **yes** | | | `visit_count` | integer | no | | | `is_locked` | boolean | no | `true` means this visitor is beyond your plan's monthly resolution cap: you can see that they exist, not who they are. The identity fields are null. Filter these out of anything a human is meant to action. | Identification gives you an email reliably. Name, title and employer are enrichment on top of that and are absent more often than they are present — write your templates and your CRM sync to survive nulls in all three. ### `GET /attribution` Channel and campaign rollup for a window. Takes `days` (default 30). Served from a nightly rollup, not computed live, so it is fast and may lag by up to a day. It describes **only sessions that carried a campaign context**, which on most workspaces is a minority of traffic. Read the percentages as shares of attributed sessions, not of all visitors. ### `GET /recommendations` Open recommended actions. No parameters. Read-only in v1: status changes carry an optimistic-locking contract that was deliberately not bolted onto a read API. Use the copilot (`set_recommendation_status`) or the dashboard to act on one. --- ## 5. Plan caps, and why your totals may be smaller than reality Every workspace has a monthly **resolution cap**: | Plan | Accounts resolvable per month | |---|---| | free | 150 | | starter | 300 | | pro | 1,000 | | pro_plus | 2,500 | | enterprise | 1,000,000 | New workspaces get 7 days at `pro_plus` limits. Beyond the cap, records are withheld. The three surfaces express that differently, and you need to know which one you are reading: | Surface | Over-cap behaviour | |---|---| | Public API | Records are **absent**. `total` is capped, so it can be lower than the true count. | | Dashboard | Records are **present but masked** — `is_locked: true`, name `•••`. | | Copilot | Masked, **and it tells you how many were withheld** rather than describing the list as complete. | **The practical consequence:** on the public API, `total` is not "how many accounts exist", it is "how many you may see". Do not compute conversion rates against it and do not treat a locked visitor's `null` email as "this person has no email". Both are the plan talking, not the data. --- ## 6. The copilot: asking questions ``` POST /signal/copilot/ask one JSON response POST /signal/copilot/stream the same thing, as SSE ``` `/ask` is complete on its own. The stream exists because humans want progress feedback; a script never needs it. ```bash curl -X POST https://app.signal.geysera.com/agent-api/signal/copilot/ask \ -H "Content-Type: application/json" \ -d '{"question": "Which companies visited pricing last week and have not bought?"}' ``` **Request** | Field | Type | Notes | |---|---|---| | `question` | string, 3–2000 chars | required | | `thread_id` | string | Opaque. Reuse it across turns for follow-ups ("now break that down by month"). Omit for one-shot. | | `turn_id` | string | Your id for this turn. Stored with it in history so a later edit can name it. Send one on every turn — a turn you never identified cannot be replaced later. | | `replaces_turn_id` | string | Edit-and-resend: drops that turn and everything after it. Names the turn rather than counting to it; if it cannot be found, **all** history is dropped. | | `approved_plan` | object | Authorises changes. See §7. | **Response** ```json { "answer": "Four companies viewed pricing…", "refusal": null, "clarification": null, "question_kind": "descriptive", "plan": { "reasoning": "…", "calls": ["search_accounts", "page_funnel"] }, "disclosures": ["…"], "limits": ["…"], "warnings": [], "pending_confirmation": null, "trace": [ { "tool": "…", "sql": "…", "coverage": {…}, "caveats": [], "window": {…}, "data": {…} } ], "thread_id": "…", "message_id": "…" } ``` Exactly one of `answer`, `refusal` or `clarification` is set. - **`refusal`** is the product working, not an error. It means the data cannot support the claim you asked for. Do not retry it. - **`clarification`** is a question back. Answer it on the same `thread_id`. - **`trace`** is the audit trail: every tool that ran, the SQL it executed, its coverage, and the window it covered. **`data` holds the rows** — parse those rather than the prose if you want structure. - **`limits` and `disclosures`** are the constraints the answer was written under. If you are re-publishing the answer, carry them. **Every number in `answer` is guaranteed to appear in `trace`.** A numeric guard truncates the answer rather than let a figure through that no tool produced. So prose and trace cannot disagree. --- ## 7. The copilot: making changes The copilot can change things. Nothing changes without a round trip. **Ask.** If the request would change something, **nothing runs** and you get: ```json { "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`: ```bash curl -X POST .../copilot/ask -H "Content-Type: application/json" -d '{ "question": "push that rule now", "thread_id": "t-42", "approved_plan": { "question_kind": "action", "calls": [ … ] } }' ``` ### Why it is the plan and not a boolean The planner is a sampled model. Approving a flag would authorise whatever it decides on the *second* pass, which need not be what you were shown. Sending the plan back makes the approved actions and the executed actions the same object — and an approved plan is replayed verbatim, with the planner skipped entirely. ### Rules worth knowing - **Approval is good for one request.** It is not stored, not remembered, and does not persist on a thread. The next turn re-gates. - **A mixed plan runs none of it.** A read bundled with a write waits for the same yes. - **`irreversible: true`** means the product cannot undo it: credits spent with a vendor, contacts pushed to an ESP, a secret shown once, a row hard-deleted. - **Show `effect` to your user, not `tool`.** It is written to be consented to. - A malformed `approved_plan` returns `422`, not a silent no-op. ### If you are automating this Be honest with yourself about what the gate is doing for you. In the dashboard it is a human reading a sentence and clicking. In a script, echoing `approve_with` back is three lines, and the gate becomes an **intent check and an audit record** rather than human oversight. If your agent is choosing its own actions, put your own review in front of the irreversible ones — particularly `run_enrichment_pipeline` (spends vendor credits across the workspace), `run_sync_rule` (pushes contacts you cannot recall) and the credential tools. --- ## 8. The tool catalogue 68 tools. You do not call them directly — you ask a question and the planner chooses — but knowing what exists tells you what is answerable. ### Commerce analytics (11) `data_coverage` · `revenue_and_aov` · `aov_decomposition` · `product_performance` · `customer_counts` · `price_history` · `page_funnel` · `page_to_product_attribution` · `membership_conversion_paths` · `visiting_accounts` · `causal_impact` `causal_impact` is the only one that can support a claim about cause — a Bayesian interrupted time series that returns "inconclusive" when the data cannot carry the claim. Everything else measures association. ### The product (22 reads) `search_accounts` · `get_account` · `search_visitors` · `visitor_journey` · `journey_paths` · `attribution_overview` · `open_recommendations` · `workspace_setup` · `intent_pages` · `enrichment_config` · `sync_rules` · `dry_run_sync_rule` · `list_api_keys` · `list_webhooks` · `list_alert_destinations` · `identification_status` · `list_conversations` · `get_conversation` · `list_team` · `subscription` · `esp_connection` · `recall_facts` **`recall_facts`** and **`forget_fact`** are the assistant's memory. It keeps short facts about your workspace and feeds them into the planner on later turns, so a wrong one shapes answers you have not asked yet. `recall_facts` lists them; `forget_fact` stops one being used. Ask "what have you remembered about us" if an answer keeps coming out wrong in the same way. **`dry_run_sync_rule`** is the one to reach for before any push: it reports exactly which contacts a rule WOULD send, and changes nothing. It reads like a write and is not one. Start with **`workspace_setup`** when data looks wrong or missing — it returns store connection, onboarding progress, pixel status, identification health and plan usage in one call, and distinguishes "nothing happened" from "we are not collecting". ### Changes (35) | Tool | Reversible? | |---|---| | `classify_account` | yes | | `set_visitor_contacted` | yes | | `set_recommendation_status` | yes | | `update_enrichment_config` | yes | | `create_sync_rule` | yes — created paused, sends nothing until activated | | `set_sync_rule_state` | pause/activate yes; **no** on delete — hard delete, run history goes too | | `replace_intent_pages` | yes, but it **replaces the whole set**, and scores recompute on the next run, not immediately | | `dry_run_sync_rule` | not a change — previews who would be pushed | | `run_enrichment_pipeline` | **no** — spends vendor credits, workspace-wide | | `run_sync_rule` | **no** — contacts reaching an ESP cannot be recalled | | `create_api_key` | **no** — secret shown once | | `revoke_api_key` | **no** — breaks live integrations immediately | | `create_webhook` | **no** — signing secret shown once | | `set_webhook_state` | **no** on delete — hard delete, history goes too | | `verify_pixel` | yes — re-checks the pixel and stamps the result | | `test_pixel` | yes — sends a synthetic hit; it is recorded like any other event | | `connect_esp` | yes — returns a consent link; nothing is connected until the customer authorizes | | `verify_esp` | yes — re-checks the email platform and stamps the result | | `skip_esp` | yes — connect an ESP later with `connect_esp` | | `complete_intent_step` | yes — reconfigure pages and confirm again | | `activate_workspace` | **no** — nothing in the product returns a workspace to onboarding | | `authorize_store` | yes — returns a link; nothing is connected until the store owner approves it in WordPress | | `claim_domain` | **no** on a change — clears verification, and the pixel check cannot confirm a tag-manager install, so that flag may not come back | | `invite_team_member` | **no** — an email reaches a person and cannot be unsent | | `update_team_member` | yes — set the role back | | `remove_team_member` | **no** — access is revoked at once; re-inviting needs them to accept again | | `cancel_invitation` | yes — invite again | | `start_checkout` | **no** — once the checkout is completed the bill changes | | `open_billing_portal` | yes — a link; changes happen at Stripe | | `update_sync_rule` | yes — retunes filters in place, keeping run history | | `create_alert_destination` | yes — created paused, sends nothing until activated | | `set_alert_destination_state` | yes — activate/pause/delete only | | `test_alert_destination` | **no** — posts a **real** message to the Slack channel; everyone in it sees it | | `rename_conversation` | yes | | `delete_conversation` | **no** — soft-deleted for audit, but nothing in the product restores it | | `forget_fact` | **no** — soft-deleted for audit; nothing in the product puts it back | ### Access and money need the workspace to opt in Six tools grant workspace access or change what is billed: `invite_team_member`, `update_team_member`, `remove_team_member`, `cancel_invitation`, `start_checkout`, `open_billing_portal`. They are **off unless the workspace turns them on** (`guardrails.copilot_privileged_actions_enabled`, default `false`). Nothing in the copilot can set that column — it is enabled deliberately, out of band, by someone who has decided an agent holding a session may act on their behalf. Why these and not the rest: everything else the assistant changes is workspace data, where wrong is recoverable. These decide who can reach the workspace and what it costs, and the confirmation gate does not protect them the way it protects the rest — for a script, approving is echoing the plan back (§7). So three further checks apply, all failing closed: | Check | Team | Billing | |---|---|---| | Workspace opt-in | required | required | | Caller's role, read fresh per call | admin or owner | **owner only** | | Confirmation gate | yes | yes | And some targets are refused whatever your role, because they are the changes that let one compromised session lock everyone else out: - you cannot act on **yourself**; - an **owner** cannot be removed or demoted; - nobody can be promoted **to owner**. Those remain dashboard-only, on purpose. `start_checkout` and `open_billing_portal` return their links under **checkout_url** and **portal_url** in the response's `secrets` field, not in the answer text — a live payment link does not belong in a conversation that is replayed into later prompts and can be exported. ### One plan cannot look things up for itself The calls in a plan run concurrently with static arguments — **no call reads another's output**. A tool cannot be handed an id that a different call in the same plan was meant to fetch. Tools are therefore addressed by the identifier you already have, never by an opaque id: | Tool | Selector | Value | |---|---|---| | `classify_account` | `domain` | `acme.com` | | `set_sync_rule_state`, `run_sync_rule`, `dry_run_sync_rule`, `update_sync_rule` | `rule` | the rule's name from `sync_rules` | | `set_alert_destination_state` | `destination` | the name from `list_alert_destinations` | | `revoke_api_key` | `key` | the name from `list_api_keys` | | `set_webhook_state` | `url` | the endpoint URL from `list_webhooks` — a webhook has no name | That is not a style preference: when `classify_account` took a UUID, "classify acme.com as a competitor" came back as an *answer about* acme.com's current classification, having changed nothing, because the planner could not resolve the id and fell back to a read. `update_sync_rule` takes both `rule` (which one) and `name` (rename it to). **Matching is exact**, ignoring case and surrounding whitespace only. Account domains are unique per workspace and always resolve. Rule, destination and key names are **not** unique — nothing in the schema prevents two rules sharing a name — so if a name matches nothing you get a refusal listing what does exist, and if it matches more than one you get a refusal saying it is ambiguous. Neither case changes anything. There is no fuzzy matching: the approved plan carries the name and re-resolves it at execution, so a loose match could act on a different object than the one whose effect you approved. `set_visitor_contacted` and `set_recommendation_status` take the visitor's email address and the recommendation's subject respectively — the thing a person would actually type, not a row id. If you need two steps, take two turns: run the read, then send a second question using what it returned. --- ## 9. Webhooks Register an endpoint (dashboard, or `create_webhook`) and Geysera posts to it when a visitor is identified. Max 5 per workspace. Delivery carries a Stripe-style signature: ``` X-Signal-Timestamp: 1756742400 X-Signal-Signature: t=1756742400,v1= ``` 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. ```python 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 ``` ```json { "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 ```bash 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. ```bash 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: ```json { "error_code": "RATE_LIMITED", "message": "Too many requests for this workspace.", "correlation_id": "3f2c…", "details": null } ``` | Status | `error_code` | What to do | |---|---|---| | 400 / 422 | `VALIDATION_ERROR` | Fix the request. `details` says which field. | | 401 | — | Key missing, malformed, unknown or revoked. | | 402 | — | Plan cap reached. `details` carries the upgrade path. | | 403 | `FORBIDDEN` | Authenticated, not allowed. | | 404 | `NOT_FOUND` | Also returned for another workspace's resources — we do not confirm they exist. | | 409 | `CONFLICT` | Optimistic-lock failure: someone changed it since you read it. Re-read and retry. | | 429 | `RATE_LIMITED` | Back off. 300/min per workspace. | | 5xx | `INTERNAL` | Retry with backoff. Quote `correlation_id`. | Retry 429 and 5xx with exponential backoff. Do not retry 4xx — send an `X-Idempotency-Key` on writes so a network-level retry is safe. ### When the fix is somewhere in the dashboard Some failures cannot be fixed by changing the request. Pushing to an ESP you have not connected, or using a key that was never granted a scope, needs a person in the dashboard. Those errors carry the address: ```json { "error_code": "CONFLICT", "message": "Connect Klaviyo on Auto-sync Rules first.", "details": { "code": "klaviyo_not_connected", "fix_url": "/rules", "fix_label": "Auto-sync Rules" } } ``` `fix_url` is relative to your dashboard origin (`https://app.signal.geysera.com`). `fix_label` is the wording that appears in the dashboard's own navigation, so telling someone to open "Auto-sync Rules" matches what they will see. This exists because an agent cannot look around. Before these fields, the same errors said "Settings → Integrations" — a tab that has never existed — and the only way to discover that was to follow the instruction and find nothing. --- ## 12. Things that will surprise you Collected because each one has already produced a wrong conclusion. **`total` is not a row count.** It is capped at what your plan may access. Never use it as a denominator. **A masked record is not an empty one.** `is_locked: true` means the plan is withholding the name and email. Recording "no email on record" is wrong, and it is wrong about a real person. **A refusal is a real answer.** The copilot refuses when the data cannot support the claim. Retrying the same question gets the same refusal. **Attribution covers a minority of traffic.** Only sessions carrying a campaign context. On some workspaces that is a few percent. The response says so; read the caveats. **Intent scores can be meaningless.** They rank visitors by which "intent pages" a workspace has configured. If those rules match little of the real traffic, every visitor scores alike and the ranking is close to arbitrary. `intent_pages` reports the coverage — check it before trusting a ranking. **`replace_intent_pages` replaces.** Pages absent from your list are deleted, and any `score_weight` you omit resets to 1. Read the current set, modify it, send it all back. **Empty `page_patterns` matches nobody.** In `update_enrichment_config`, an empty list is not "no restriction" — it switches enrichment off in practice. **`run_enrichment_pipeline` is not one visitor.** It takes no arguments, because it acts on the whole workspace: every eligible abandoner is bought from a data provider, one credit each. It used to take a visitor's email, which bounded nothing — the route checked that visitor's ownership and then ran the same workspace-wide job. There is no single-visitor enrichment. **Bot traffic is filtered, historically imperfectly.** Some older rows have an unset bot flag. If a visitor count looks implausibly large for the number of distinct people, that is why. **The attribution rollup can lag a day.** It is nightly. `attribution_overview` reads the same cached rows the dashboard does, so the two agree — with each other, and with yesterday. --- *Questions, or you need programmatic write access with an API key? Include the `correlation_id` from a recent response — it is the fastest way for us to see exactly what you saw.* ======================================================================== SECTION: Workflows ======================================================================== ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/receive-identified-visitors-by-webhook ------------------------------------------------------------------------ # Receive identified visitors by webhook **The integration most people should build first.** A webhook tells you the moment someone is identified, instead of you asking us every few minutes whether anything happened. Everything below is the contract the service actually implements. Where a number appears, it is read from the code that enforces it. --- ## What you get When a visitor on your site is identified, we POST once per registered endpoint: ```http POST /your/endpoint HTTP/1.1 Content-Type: application/json User-Agent: Geysera-Signal-Webhooks/1 X-Signal-Event: signal.visitor_identified X-Signal-Delivery: 9766ec60-… ← dedup on this X-Signal-Timestamp: 1789171234 X-Signal-Signature: t=1789171234,v1=3f9c… ``` ```json { "id": "9766ec60-…", "type": "signal.visitor_identified", "created": 1789171234, "data": { "visitor_id": "9766ec60-…", "company": { "domain": "acme.com", "domain_canonical": "acme.com", "name": "Acme Corp", "intent_score": 72, "classification": "lead", "firmographics": { } }, "person": { "email": "…", "name": "…", "title": "…" }, "resolution": { "source": "…", "confidence": "…" }, "identified_at": "2026-09-11T22:14:07+00:00" } } ``` ## The delivery contract | | | |---|---| | Success | any `2xx` | | Timeout | **10 seconds** — respond first, work later | | Redirects | **not followed.** A `3xx` is a failure. Register the final URL | | Guarantee | **at-least-once** | | Retries | 10 attempts over ~21h: 2m, 4m, 8m, 16m, 32m, 64m, 128m, 256m, then 6h | | Dedup key | `X-Signal-Delivery` — **stable across retries** | | Endpoint disabled | after 15 consecutive failures | Two consequences worth internalising: **Respond fast, then do the work.** The timeout is 10 seconds and it is measured on our side. If your handler enriches a CRM record before replying, a slow CRM turns into a retry storm. Acknowledge, enqueue, process. **You will occasionally see the same event twice.** At-least-once is a promise that you will not silently *lose* an event; it is not a promise of exactly-once, which nothing delivering over a network can honestly offer. `X-Signal-Delivery` does not change between attempts, so a single unique index on your side makes duplicates harmless. --- ## Verifying the signature Never process an unverified webhook. The URL is guessable; the signature is not. The value is `t=,v1=`, where the hex is `HMAC-SHA256(secret, ".")`. Two rules that cause most implementation bugs: 1. **Sign the raw bytes**, not a re-serialised object. `json.loads` then `json.dumps` will not round-trip to the same bytes and the signature will never match. 2. **Compare in constant time.** `==` on a hex digest leaks timing. ### Python (FastAPI) ```python import hashlib, hmac, os, time from fastapi import FastAPI, HTTPException, Request app = FastAPI() SECRET = os.environ["SIGNAL_WEBHOOK_SECRET"] # whsec_… TOLERANCE_SECONDS = 5 * 60 @app.post("/geysera/webhook") async def receive(request: Request): raw = await request.body() # RAW bytes, before parsing header = request.headers.get("X-Signal-Signature", "") parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) timestamp, provided = parts.get("t", ""), parts.get("v1", "") if not timestamp or not provided: raise HTTPException(400, "malformed signature header") # Reject stale timestamps, or a captured delivery can be replayed forever. if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: raise HTTPException(400, "timestamp outside tolerance") expected = hmac.new( SECRET.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, provided): raise HTTPException(401, "bad signature") event_id = request.headers.get("X-Signal-Delivery") if already_processed(event_id): # your unique index return {"ok": True} # 2xx: do not make us retry enqueue(await request.json()) # return fast, work later return {"ok": True} ``` ### Node (Express) ```js const crypto = require("crypto"); const express = require("express"); const app = express(); // express.raw, NOT express.json — you need the exact bytes we signed. app.post("/geysera/webhook", express.raw({ type: "application/json" }), (req, res) => { const parts = Object.fromEntries( (req.get("X-Signal-Signature") || "").split(",").map(p => p.split("=", 2)) ); const { t, v1 } = parts; if (!t || !v1) return res.status(400).end(); if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).end(); const expected = crypto .createHmac("sha256", process.env.SIGNAL_WEBHOOK_SECRET) .update(`${t}.`).update(req.body) .digest("hex"); const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); if (!ok) return res.status(401).end(); res.json({ ok: true }); // acknowledge first enqueue(JSON.parse(req.body), req.get("X-Signal-Delivery")); }); ``` --- ## Choosing what your handler does The temptation is to write straight into your CRM. Consider what happens when it is down: you return non-2xx, we retry for ~21 hours, and then stop. Your handler's availability becomes our delivery window. Better shape: ``` receive → verify → dedupe → enqueue → 2xx └─ your worker retries against the CRM on your own schedule, forever if needed ``` That decouples our retry budget from your downstream's uptime, which is the whole reason the two systems should not be joined at the handler. --- ## Testing before you go live **Send a test ping.** The API can fire a `signal.test` event at your endpoint on demand — same signature scheme, harmless payload. Use it to confirm your verification code works before real data depends on it. **Test locally with a tunnel** (ngrok or similar). We do not follow redirects, so register the tunnel's final HTTPS URL, not a shortener. **Deliberately fail once.** Return a 500 on purpose and confirm you receive the event again roughly two minutes later, with the same `X-Signal-Delivery`. If you do not, your dedup is keyed on something unstable — the most common integration bug in this list, and one you want to find with a test rather than with a customer's data. --- ## When not to use webhooks If you need a full current picture — a nightly export, a report, a backfill — poll the REST API instead. Webhooks tell you what *changed*; they are a poor way to learn what *is*. See [Export identified accounts](./export-and-keep-in-sync.md). ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/export-and-keep-in-sync ------------------------------------------------------------------------ # Export identified accounts, and keep them in sync **When you need the current picture** — a nightly export, a warehouse table, a report — poll the REST API. Webhooks tell you what *changed*; they are a poor way to learn what *is*. Everything below is the behaviour the service actually implements, including the parts that will surprise you. --- ## The shape of a page ```bash curl "https://app.signal.geysera.com/signal-api/v1/accounts?page=1&page_size=200" \ -H "Authorization: Bearer sk_sig_…" ``` ```json { "accounts": [ … ], "total": 1927, "page": 1, "page_size": 50 } ``` | | | |---|---| | `page` | 1-based | | `page_size` | default **50**, maximum **200** | | above the maximum | **422**, not silently clamped | The 422 is deliberate. A clamp would let a script ask for 1,000, receive 200, and believe it had read everything — the failure would surface weeks later as a number that was quietly too small. ## Three things that will catch you out **1. `total` is not a row count.** It is what *your plan permits you to resolve*. Accounts beyond your cap are absent from this surface entirely rather than returned blurred. **Do not compute rates against it** — the denominator moves when you change plan, and a "conversion rate" built on it will change with your billing rather than your business. **2. Unknown query parameters are ignored, not rejected.** ```bash # You meant page_size. You typed limit. curl ".../accounts?limit=3" # 200 OK — and 50 results ``` A person notices. A script reports the wrong number confidently, forever. If you are wrapping this API, assert the response's `page_size` matches what you asked for; that one line catches every parameter typo you will ever make. **3. Order is not guaranteed to be stable across pages** if the underlying data changes while you page. For an export that must not double-count, prefer a single large page over many small ones, or reconcile on `visitor_id` / account domain when you land the rows. --- ## Paging, with the rate limit respected The limit is **300 requests per 60 seconds, per key** — per key, not per workspace, so one integration cannot exhaust another's budget. Exceeding it returns **429** with a `Retry-After` header carrying the real remaining window. ```python import time, requests BASE = "https://app.signal.geysera.com/signal-api/v1" HEAD = {"Authorization": f"Bearer {KEY}"} def pages(path, page_size=200): page = 1 while True: r = requests.get(f"{BASE}/{path}", params={"page": page, "page_size": page_size}, headers=HEAD, timeout=30) if r.status_code == 429: # Use the header. Guessing means you either hammer or stall. time.sleep(int(r.headers.get("Retry-After", "60"))) continue r.raise_for_status() body = r.json() # The typo check from above — one line, catches every misspelt param. assert body["page_size"] == page_size, body["page_size"] rows = body.get("accounts") or body.get("visitors") or [] if not rows: return yield from rows if page * body["page_size"] >= body["total"]: return page += 1 ``` Back off on the header rather than a fixed sleep. `Retry-After` is the true remaining window, so a caller arriving late in a window waits seconds, not a minute. --- ## Incremental sync There is no `updated_since` filter today. Two workable strategies: **Webhook for new, poll for correction.** Take new identifications from the webhook (see [workflow 1](./receive-identified-visitors-by-webhook.md)) and run a full poll nightly to pick up anything that changed after identification — classification edits, intent-score recomputes. This is the shape most people want, and the nightly job is a reconciliation, not the primary path. **Poll and diff.** If you cannot receive webhooks, page the whole set and diff against your last snapshot on `visitor_id`. At the volumes this product produces — hundreds to low thousands of accounts — a full read is cheap and far simpler than a cursor you have to keep correct. Pick the simpler one until you measure a reason not to. --- ## Errors you should handle Every error carries the same envelope: ```json { "error_code": "RATE_LIMITED", "message": "Rate limit exceeded. Max 300 requests per 60 seconds.", "correlation_id": "6f0a9626-…", "details": null } ``` Branch on `error_code`, not on `message`. The codes are `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL` — and they are published at `/agent-api/capabilities` so you can assert against them rather than hard-coding this list. `401` and `403` mean different things and the difference saves you an hour: - **401** — the key is missing, malformed, unknown or revoked. Re-issue it. - **403** — the key is fine and lacks the *scope* for this endpoint. Re-issuing will not help; `details` names what was required and what your key holds. Send an `X-Correlation-ID` on every request and log it. We echo it, and it is the fastest way for us to find your specific request. --- ## Discovering the surface instead of hard-coding it ```bash curl https://app.signal.geysera.com/agent-api/capabilities ``` Public, no key required, generated from the routes' own auth dependencies — so it cannot describe an endpoint that does not exist or omit one that does. It carries the endpoint list, the scope each needs, the pagination bounds, the rate limit, the error vocabulary and the response headers. If you are building a client, read this at build time rather than transcribing it. Everything in this document is derived from it, and a value you copy by hand is a value nobody will update. ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/ask-questions-in-natural-language ------------------------------------------------------------------------ # Ask questions in natural language Instead of assembling a query, ask. A `copilot`-scoped key reaches an assistant that reads your workspace's data and answers with the figures it used. This is the part of the API most likely to be used *by another agent*, so the contract below is written for one. --- ## One question ```bash curl https://app.signal.geysera.com/agent-api/signal/copilot/ask \ -H "Authorization: Bearer sk_sig_…" \ -H "Content-Type: application/json" \ -d '{"question": "how many accounts visited in the last 30 days?", "thread_id": null}' ``` ```json { "answer": "Between 2026-08-13 and 2026-09-11, the table covers 481 identified accounts…", "refusal": null, "question_kind": "descriptive", "plan": { "reasoning": "…", "calls": ["visiting_accounts"] }, "trace": [ { "tool": "visiting_accounts", "sql": "…", "coverage": …, "has_data": true } ], "thread_id": null, "warnings": [] } ``` **Read `trace` before you trust `answer`.** It names every tool that ran, the SQL behind it, and its coverage. An answer with an empty trace is an answer with nothing behind it. ## The four endpoints | Endpoint | For | |---|---| | `POST /signal/copilot/ask` | one answer, when the whole response is ready | | `POST /signal/copilot/stream` | the same answer as SSE, as it is produced | | `GET /signal/copilot/threads` | your conversations | | `GET /signal/copilot/threads/{id}` | one conversation's turns | `ask` can take 10–30 seconds for a question needing several tools. Use `stream` for anything a person is waiting on. --- ## What it will not do, by construction **It is read-only, whatever it is asked.** A key reaches the assistant's read tools and never the ones that change a workspace. This is not a prompt instruction it might be talked out of — the tool list handed to the planner is filtered by caller type, so an API key cannot even *name* a mutating tool. Verified behaviour, asking it to do something it cannot: ``` Q: "invite alice@example.com to my team as an admin" plan.calls [] pending_confirmation false answer "That is outside what I can do here. I can read this workspace's commerce data…" ``` No call was planned. Changing anything requires a signed-in session. **It refuses causal questions.** Ask "did the price change drive up AOV" and you get a refusal, not a number. A correlation presented as a cause is worse than no answer, and the gate that enforces this is code rather than a prompt — deliberately, because a rule a model can be argued out of is not a rule. **It refuses rather than guesses.** When the data cannot honestly support an answer, `refusal` is populated and `answer` is null. A real example: ```json {"refusal": "There are only 13 days of data after 2026-08-26 and I need at least 14. Too little has happened since the change to separate…"} ``` That is the system working. Treat `refusal` as a first-class outcome, not an error — retrying will not help, and neither will rephrasing. --- ## Follow-up questions need a thread ```python import uuid thread = str(uuid.uuid4()) # you invent it, once, per conversation first = ask("what was revenue in the last 90 days?", thread_id=thread) second = ask("and the 90 days before that?", thread_id=thread) ``` **You choose the `thread_id`; the server does not issue one.** It is an opaque string scoped to your workspace — reuse it across turns and the assistant can see what it just said. The response echoes back whatever you sent, so reading `thread_id` out of a reply you made with `"thread_id": null` gives you `null`, and every follow-up then starts cold. Nothing errors when that happens; the answers just quietly lose their context. Omit it (or send `null`) for a genuinely one-shot question. Those are not recorded in the conversation list, which is deliberate — a scripted caller would otherwise fill the sidebar that exists for people. One caveat worth knowing if you are building on this: the assistant's view of a thread is the **narrated replies**, not the raw tool output. It will not remember an id it printed three turns ago. If you need to act on a specific account, name it — by domain, not by an identifier you saw in a previous response. --- ## Using it from another agent The properties that matter if you are wiring this into your own tool loop: - **Discoverable.** `GET /agent-api/capabilities` lists these endpoints and the scope each needs, generated from the routes themselves. You do not need this document to find them. - **Bounded.** A plan runs at most four tools. Answers do not wander. - **Traceable.** Every figure in the prose is checked against the tool results that produced it before you see it. A number that cannot be traced does not get narrated. - **Honest about coverage.** `trace[].coverage` tells you what fraction of the data the answer describes. Our attribution, for instance, covers a minority of traffic on most accounts — an answer that did not say so would be worse than useless. A reasonable pattern for an agent: call `ask`, check `refusal` first, then `trace` for coverage, and only then use `answer`. If you need the underlying numbers rather than prose, take them from `trace` — they are the same values, already structured. --- ## Rate limits and errors Same as the REST API: **300 requests per 60 seconds per key**, `429` with `Retry-After`. A copilot call is far more expensive than a REST read, so if you are batching questions, serialise them rather than fanning out — you will hit the limit long before the answers arrive. `403` means your key lacks the `copilot` scope. Scopes are fixed when a key is created; make a new key rather than trying to widen this one. ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/alert-your-team-when-a-target-account-appears ------------------------------------------------------------------------ # Alert your team when a target account appears **The job:** you have a list of accounts you care about. When someone from one of them reads your site, you want to know within minutes — in the channel your team already watches, with enough context to act. **Who this is for:** anyone with a named-account list. ABM, enterprise sales, partnerships. **What it costs you:** one webhook endpoint and about thirty lines. --- ## The shape of it Signal pushes every newly identified visitor to your endpoint. You decide whether it matters. Most don't — that filter is the whole job, and it belongs on your side, because only you know your target list. ``` Signal --POST--> your endpoint --filter--> Slack ``` Register the endpoint once, from the dashboard (Exports & API → Webhooks) or by asking the assistant. You get a signing secret exactly once. ## Receiving it ```python import hmac, hashlib, os, json from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["SIGNAL_WEBHOOK_SECRET"].encode() TARGETS = {"acme.com", "globex.com", "initech.com"} seen = set() # use Redis in production @app.post("/signal") def receive(): raw = request.get_data() sent = request.headers.get("X-Signal-Signature", "") expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(sent, expected): abort(401) # Deliveries retry. X-Signal-Delivery is stable for a given event, so it # is the right dedupe key — see "What will go wrong" below. delivery = request.headers["X-Signal-Delivery"] if delivery in seen: return "", 200 seen.add(delivery) v = json.loads(raw) if v.get("company_domain") in TARGETS: notify(v) return "", 200 ``` ## Making the alert worth reading A name and a domain is not enough for a rep to act. Add what they read and how warm they are, both of which you already have: ```python def notify(v): hot = v["intent_score"] >= 70 lines = [ f"*{v['company_name'] or v['company_domain']}* — {v['resolved_name'] or v['resolved_email']}", f"{v.get('resolved_title') or 'role unknown'} · intent {v['intent_score']}" + (" 🔥" if hot else ""), f"{v['visit_count']} visits, first seen {v['first_visit_at'][:10]}", ] post_to_slack("\n".join(lines)) ``` ## Letting an LLM write the message The fields above are a summary, not a briefing. If you want the alert to say *why this person is worth a call*, hand the record to a model and ask: ``` You are helping a salesperson decide whether to reach out right now. Here is a visitor Signal just identified: {visitor_json} In no more than three sentences: who they appear to be, what their behaviour suggests they are evaluating, and one specific opening line that references something real from the data. If the data does not support a confident read, say so instead of inventing one. ``` That last sentence matters more than it looks. Without it a model will write a confident opener from `intent_score: 12` and a single pageview. ## What will go wrong **Duplicate alerts.** Deliveries retry — a failed POST is retried with doubling backoff for about 21 hours. `X-Signal-Delivery` is stable across those attempts; the payload is not a safe dedupe key. Store the delivery id. **A quiet channel, then a flood.** Identification runs in batches, so twenty visitors can arrive in one minute after an hour of nothing. Batch your Slack posts or you will be rate-limited by Slack, not by us. **People who are not buyers.** Consumer mailbox domains roll up under a single `personal` account. If your alert list is B2B, filter `company_domain != "personal"` or you will page your reps about gmail users. --- Next: [score and route inbound leads](./score-and-route-inbound-leads.md) · [receive identified visitors by webhook](./receive-identified-visitors-by-webhook.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/build-a-daily-call-list ------------------------------------------------------------------------ # Build a daily call list **The job:** every morning, a ranked list of who to contact today, in the tool your reps already open. **Who this is for:** any team doing outbound off inbound signal. **What it costs you:** one scheduled script. No webhook, no state. --- ## One call gets you the list ```bash curl -s "https://app.signal.geysera.com/signal-api/v1/visitors?min_intent=60&page_size=100" \ -H "Authorization: Bearer $SIGNAL_API_KEY" ``` ```json { "visitors": [ { "company_domain": "acme.com", "company_name": "Acme Corp", "classification": "lead", "intent_score": 82, "resolved_email": "dana@acme.com", "resolved_name": "Dana Cole", "resolved_title": "VP Engineering", "employer": null, "visit_count": 7, "first_visit_at": "2026-08-02T09:14:00Z", "last_visit_at": "2026-09-15T16:41:00Z", "is_locked": false } ], "total": 214, "page": 1, "page_size": 100 } ``` `total` is what your plan may resolve, not the raw row count. If you are near your cap it will be smaller than reality, and the list is still the best visitors — not an arbitrary slice. ## Ranking it like a human would `intent_score` alone puts a curious researcher above a returning buyer. What reps actually want is *warm and moving*: ```python from datetime import datetime, timezone def rank(v): days_since = (datetime.now(timezone.utc) - datetime.fromisoformat(v["last_visit_at"])).days recency = max(0, 30 - days_since) / 30 # 1.0 today, 0 a month ago depth = min(v["visit_count"], 10) / 10 # saturates; 40 visits is a bot return v["intent_score"] * (0.5 + 0.3 * recency + 0.2 * depth) todays = sorted(visitors, key=rank, reverse=True)[:25] ``` Every term here is a judgement you should change. The point is that the ranking is *yours* and lives in twelve lines you can read. ## Handing it to an LLM ``` Here are today's 25 highest-intent visitors: {visitors_json} Group them into: (1) call today, (2) email today, (3) leave alone. For each in groups 1 and 2, give one sentence on why, citing a specific number from the record. Put anyone in group 3 whose data does not justify contact, and say what is missing. ``` Asking for group 3 explicitly is what stops a model finding a reason for all twenty-five. ## What will go wrong **`is_locked: true`.** Beyond your plan's monthly resolution cap, records come back locked — you can see that a visitor exists but not who. Filter them out of a call list rather than showing a rep a row they cannot action. **Null names and titles.** `resolved_name`, `resolved_title` and `employer` are frequently null. Identification gives you an email reliably; the rest is enrichment and is not guaranteed. Write your templates to survive nulls. **The same person every day.** Nothing here tracks who you already called. Keep your own contacted set, or use the assistant's `set_visitor_contacted` so the state lives with the visitor. --- Next: [write a first-touch email from what they read](./write-a-first-touch-email-from-what-they-read.md) · [export and keep in sync](./export-and-keep-in-sync.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/write-a-first-touch-email-from-what-they-read ------------------------------------------------------------------------ # Write a first-touch email from what they read **The job:** an opener that references something the person actually did, written by a model, checked by a human, sent by your normal tooling. **Who this is for:** anyone whose first-touch reply rate is bad because the first touch is generic. **What it costs you:** two API calls per person and a prompt you will rewrite four times. --- ## Why this works when "personalisation" usually does not Most personalisation is a merge field. This is different: you know which pages they read, in order, and how many times they came back. That is a statement about what they are trying to solve, and it is the only thing worth opening with. ## Getting the behaviour, not just the person The REST API gives you the person. For what they read, ask the assistant — it has the journey tools and will answer in one call: ```bash curl -s https://app.signal.geysera.com/agent-api/signal/copilot/ask \ -H "Authorization: Bearer $SIGNAL_COPILOT_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "What pages did dana@acme.com look at, in order, and how many times did she come back?"}' ``` ```json { "answer": "Dana Cole visited 7 times between 2 August and 15 September...", "question_kind": "descriptive", "plan": { "reasoning": "...", "calls": ["visitor_journey"] }, "disclosures": ["..."], "refusal": null, "trace": [ { "tool": "visitor_journey", "data": { "...": "..." } } ] } ``` Two things to note. `answer` is prose for a human; `trace[].data` is the structured result the prose was written from, and that is what you feed a model if you want it to reason rather than paraphrase. And `refusal` is not an error — it is the assistant declining to answer from data it does not have. Check it. ## The prompt ``` You are drafting the FIRST email to someone who has never spoken to us. What they did on our site: {journey_json} What we know about them: {visitor_json} Write at most five sentences. Rules: - Open by referencing one specific thing they read, by name. - Do not mention that we can see their browsing. Say "I noticed you were looking into X" only if X is a topic, never a URL. - No claim about their company, budget, timeline or intent that is not in the data above. - End with one question that is easy to answer in a sentence. - If the journey is a single pageview, say you have nothing to personalise on and return the word SKIP instead of an email. ``` The SKIP clause is the important one. Roughly a third of identified visitors have one shallow pageview, and an opener built on that is worse than silence — it reads as surveillance without insight. ## Keeping a human in the loop Draft, don't send. Write the drafts somewhere a person clicks approve — your CRM's task queue, a Slack thread, a spreadsheet. Sending unreviewed generated mail to people who have not opted in is how a domain gets burned, and Signal cannot un-send it for you. ## What will go wrong **The model will invent a pain point.** Every time, unless the prompt forbids it and you reject drafts that do it. Put the raw journey in the review UI next to the draft so the reviewer can see the gap. **Pages tell you less than you think.** A pricing-page visit is not intent to buy; it is often a competitor or a candidate. `classification` helps — `competitor` and `excluded` exist for this — but it is not perfect. **Quiet periods look like churn.** The pixel misses landing pageviews for a meaningful share of visitors, so "they only read one page" sometimes means "we only saw one page." --- Next: [build a daily call list](./build-a-daily-call-list.md) · [spot a buying committee](./spot-a-buying-committee.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/spot-a-buying-committee ------------------------------------------------------------------------ # Spot a buying committee **The job:** notice when three people from the same company show up in a week. That is not three leads. That is one deal with a committee, and it should be worked as one. **Who this is for:** B2B teams with deal sizes big enough to have committees — which is most of them, and most of them miss this. --- ## The signal One person reading your pricing page is a maybe. A VP, an engineer and someone from procurement reading it in the same week is an evaluation, and the right move is different: one coordinated approach, not three cold emails that each mention the other two are also looking. ## Finding them ```python import collections, requests from datetime import datetime, timedelta, timezone H = {"Authorization": f"Bearer {KEY}"} BASE = "https://app.signal.geysera.com/signal-api/v1" def recent_visitors(days=7, min_intent=40): page, out = 1, [] cutoff = datetime.now(timezone.utc) - timedelta(days=days) while True: r = requests.get(f"{BASE}/visitors", params={"page": page, "page_size": 200, "min_intent": min_intent}, headers=H).json() out += [v for v in r["visitors"] if datetime.fromisoformat(v["last_visit_at"]) >= cutoff] if page * r["page_size"] >= r["total"]: return out page += 1 by_company = collections.defaultdict(list) for v in recent_visitors(): if v["company_domain"] != "personal" and not v["is_locked"]: by_company[v["company_domain"]].append(v) committees = {d: p for d, p in by_company.items() if len(p) >= 3} ``` `personal` is excluded deliberately: consumer mailbox domains all roll up under that one sentinel, so it will otherwise look like the largest buying committee you have ever seen. ## Deciding whether it is real Three people is a threshold, not a conclusion. What makes it a committee is a spread of *roles*: ``` Here are people from {domain} who visited in the last 7 days: {people_json} Answer three questions: 1. Does this look like a coordinated evaluation, or unrelated individuals? Cite the titles and timing that make you say so. 2. If coordinated, who is most likely the economic buyer and who is the champion? 3. What single question would tell a rep whether this is real? If titles are missing for most of them, say the data cannot support a read. ``` Titles are null more often than you would like. A model asked to identify an economic buyer from three nulls will invent one; the last line is what stops it. ## Acting on it The useful output is not an email, it is a change of plan: one account owner, one thread, one meeting request that names the group. Push the account to your CRM as a single opportunity rather than three leads — see [fill your CRM with who is actually visiting](./fill-your-crm-with-who-is-actually-visiting.md). ## What will go wrong **Agencies and consultancies.** Five people from one domain, none of them buying anything. Classify the account `excluded` once and it stays out. **One person, three devices.** Identification is per-email, so this is rarer than you would expect, but a personal and a work address for the same human will present as two people. **Your own team.** Staff traffic from your own domain will form the tightest committee you have. Exclude it. --- Next: [notice an account going quiet](./notice-an-account-going-quiet.md) · [account-based marketing, end to end](../playbooks/account-based-marketing-end-to-end.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/notice-an-account-going-quiet ------------------------------------------------------------------------ # Notice an account going quiet **The job:** a customer who used to read your site every week stops. That is a churn signal weeks before the renewal conversation, and nobody is watching for it. **Who this is for:** anyone with renewals. It is the cheapest retention signal you have, because it needs no product telemetry. --- ## Why absence is the signal Churn analysis usually starts inside the product: logins, feature use, support tickets. All of that is downstream. A customer whose team stopped reading your docs and changelog has usually disengaged before usage drops — and a disengaged champion who has changed jobs shows up here first. ## Finding the drop ```python customers = get_all("/accounts", classification="customer") def weeks_quiet(a): last = datetime.fromisoformat(a["last_seen_at"]) return (datetime.now(timezone.utc) - last).days / 7 quiet = [ a for a in customers if a["visit_count"] >= 10 # they had a habit to break and weeks_quiet(a) >= 3 # and they have broken it ] ``` `visit_count >= 10` is doing real work. Without it, every customer who visited twice at onboarding and never again is "going quiet", and the list is noise. ## Ranking by what you would lose An account list sorted by silence is not sorted by risk. Sort it by value, and you need your own revenue data for that — Signal knows who visits, not what they pay you. Join on `company_domain` against your billing system: ```python at_risk = sorted( ((a, mrr.get(a["company_domain"], 0)) for a in quiet), key=lambda pair: pair[1], reverse=True, ) ``` ## Asking the assistant what changed Before a CSM calls, it helps to know whether the whole account went quiet or only one person: ```bash curl -s https://app.signal.geysera.com/agent-api/signal/copilot/ask \ -H "Authorization: Bearer $SIGNAL_COPILOT_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "Which people from acme.com visited in the last 90 days, and when did each of them last come?"}' ``` One person going silent while the rest continue is usually a job change, and the play is to find their replacement. The whole account going silent is a different conversation. ## What will go wrong **Seasonality reads as churn.** Nobody visits your site the week of a public holiday. Compare against the same account's own history, not a fixed window, or run this monthly rather than weekly. **Your pixel breaking reads as every account churning at once.** If the list suddenly triples, check that identification is still running before you call anyone — ask the assistant "have we stopped identifying visitors?" It knows. **Silence is not always bad.** A customer in steady-state use has no reason to read your marketing site. Weight this by how much the account used to visit, which is what `visit_count` is for. --- Next: [watch your own data quality](./watch-your-own-data-quality.md) · [a weekly revenue brief your LLM writes](./a-weekly-revenue-brief-your-llm-writes.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/fill-your-crm-with-who-is-actually-visiting ------------------------------------------------------------------------ # Fill your CRM with who is actually visiting **The job:** your CRM has the accounts your reps created. Signal has the accounts that are reading your site. The overlap is smaller than anyone expects, and the gap is your pipeline. **Who this is for:** anyone running HubSpot, Salesforce or a spreadsheet that wishes it were one. --- ## Do the comparison before you build anything ```python signal_domains = {a["company_domain"] for a in get_all("/accounts")} crm_domains = {c["domain"] for c in crm.list_accounts()} print("in both:", len(signal_domains & crm_domains)) print("visiting, not in CRM:", len(signal_domains - crm_domains)) print("in CRM, not visiting:", len(crm_domains - signal_domains)) ``` Run that first. The three numbers tell you which problem you actually have, and they are usually not the one you assumed. A large "visiting, not in CRM" is demand you are not working. A large "in CRM, not visiting" is a pipeline review waiting to happen. ## Creating only what is worth creating Do not sync everything. Most identified visitors are not prospects, and a CRM full of them is worse than one missing them: ```python def worth_creating(a): return ( a["company_domain"] != "personal" and a["classification"] == "lead" and a["intent_score"] >= 50 and a["visitor_count"] >= 2 # more than one human looked ) ``` `visitor_count` versus `visit_count` matters here: one person visiting fifteen times is a researcher; three people visiting twice each is an account. ## Writing back, idempotently ```python for a in filter(worth_creating, accounts): crm.upsert_account( domain=a["company_domain"], name=a["company_name"] or a["company_domain"], properties={ "signal_intent": a["intent_score"], "signal_people": a["visitor_count"], "signal_first_seen": a["first_seen_at"], "signal_last_seen": a["last_seen_at"], }, ) ``` Key on `company_domain`. It is the natural key on both sides and the only field that will not drift — company names change spelling constantly. ## Letting an LLM do the triage When "visiting, not in CRM" is a few hundred rows, a model is better than a threshold at separating real prospects from noise: ``` Here are companies visiting our site that are not in our CRM: {accounts_json} We sell {one sentence about your product} to {your ICP}. Split them into: create now, watch, and ignore. For each in "create now", one sentence citing the specific numbers that justify it. Put anything you cannot tell apart into "watch" rather than guessing — an over-full CRM costs us more than a missed account. ``` ## What will go wrong **Duplicates.** Your CRM probably has `acme.com`, `www.acme.com` and `Acme Corp` as three records already. Normalise before you compare, or you will create a fourth. **Subsidiaries.** `acme.co.uk` and `acme.com` are one customer to a human and two domains here. **`total` is capped.** If you are near your plan's resolution cap, `get_all` returns what you may resolve, not everything that exists. The response says so; do not treat the count as the population. --- Next: [score and route inbound leads](./score-and-route-inbound-leads.md) · [export and keep in sync](./export-and-keep-in-sync.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/find-the-pages-that-create-pipeline ------------------------------------------------------------------------ # Find the pages that create pipeline **The job:** know which pages bring in people who turn into revenue, as distinct from pages that bring in traffic. They are rarely the same pages, and the difference is where your content budget should go. **Who this is for:** content and demand-gen teams who can see pageviews in analytics and cannot see what those pageviews were worth. --- ## The one call ```bash curl -s "https://app.signal.geysera.com/signal-api/v1/attribution?days=90" \ -H "Authorization: Bearer $SIGNAL_API_KEY" ``` ```json { "window_days": 90, "has_data": true, "computed_at": "2026-09-15T06:47:31Z", "totals": { "visitors": 38104, "visitors_total": 41220, "identified": 21068, "identify_rate": 0.55, "high_intent": 3310, "high_intent_rate": 0.157, "intent_threshold": 60, "touch_coverage_pct": 31.2, "median_hours_to_identify": 0.7, "channels_active": 9 }, "sources": [ { "source_medium": "google / organic", "channel": "organic_search", "visitors": 12044, "identified": 7210, "identify_rate": 0.599, "high_intent": 1180, "avg_intent": 41.2 } ], "landing_pages": [ "..." ], "channels": [ "..." ], "campaigns": [ "..." ], "first_vs_last": [ "..." ], "trend": [ "..." ], "revenue": { "...": "..." } } ``` ## Read `touch_coverage_pct` before you read anything else It is the share of visitors whose first touch we can actually see. When it is low — and it often is — every source and landing-page number below it describes that minority, not your traffic. Attribution built on a third of your visitors is still useful for *comparing* sources, and it is not a number to put in a board deck as a total. The single biggest cause is that the pixel misses the landing pageview itself on a meaningful share of sessions, so the visitor's first recorded page is not the page they arrived on. ## The comparison worth making Traffic rank and quality rank are different lists: ```python a = get("/attribution", days=90) by_traffic = sorted(a["sources"], key=lambda s: s["visitors"], reverse=True) by_quality = sorted( (s for s in a["sources"] if s["visitors"] >= 200), # ignore thin rows key=lambda s: s["high_intent"] / s["visitors"], reverse=True, ) ``` The `visitors >= 200` floor is not optional. Without it the top of your quality list is a source with four visitors, two of whom were interested. ## Asking for the analysis ``` Here is 90 days of acquisition data: {attribution_json} touch_coverage_pct is {n}. State clearly what that means for the confidence of everything you are about to say. Then: which three sources bring us visitors who become high intent, and which three bring volume that does not? Use identify_rate and high_intent relative to visitors, not raw counts. Ignore any source with fewer than 200 visitors and say you ignored it. Do not recommend a budget change you cannot support with a number from this payload. ``` ## What will go wrong **`has_data: false`.** Attribution is a cached rollup. On a new workspace, or one whose pixel has been down, it is empty rather than wrong — check the flag rather than reading zeros as a finding. **Self-referral as first touch.** A large share of unattributed visitors have your own domain as their first recorded referrer. That is the pixel missing the entry, not people arriving from nowhere. **`computed_at` is not now.** The rollup runs nightly. If you are looking for the effect of something you shipped this morning, it is not in here yet. --- Next: [turning content into pipeline](../playbooks/turning-content-into-pipeline.md) · [a weekly revenue brief your LLM writes](./a-weekly-revenue-brief-your-llm-writes.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/score-and-route-inbound-leads ------------------------------------------------------------------------ # Score and route inbound leads **The job:** a form fill arrives. Before it reaches a rep, decide whether it is worth a call, and who should get it — using what that person did on your site before they filled the form. **Who this is for:** anyone whose reps complain that inbound is mostly junk. --- ## The idea A form tells you who someone claims to be. Signal tells you what they did for the three weeks before they filled it in. The second is the better predictor and you already have it. ## Enriching a form fill ```python def enrich(email: str) -> dict | None: r = requests.get(f"{BASE}/visitors", params={"q": email, "page_size": 1}, headers=H) r.raise_for_status() hits = r.json()["visitors"] return hits[0] if hits else None ``` A miss is common and informative: someone who filled a form on their first visit has no history, which is itself a weaker signal than someone who read you for a month first. ## A score you can defend ```python def score(form, visitor): if visitor is None: return 20, "no prior visits" pts, why = visitor["intent_score"] // 2, [] if visitor["visit_count"] >= 5: pts += 20; why.append(f"{visitor['visit_count']} visits") if visitor["classification"] == "customer": pts += 30; why.append("existing customer") if visitor["classification"] in ("competitor", "excluded"): return 0, visitor["classification"] if visitor["company_domain"] == "personal": pts -= 20; why.append("consumer mailbox") return min(pts, 100), ", ".join(why) or "no strong signal" ``` Returning the *reason* alongside the number is the part people skip. A rep who can see "existing customer, 9 visits" trusts a 90 in a way they never trust a bare 90. ## Routing ```python points, reason = score(form, enrich(form["email"])) if points >= 70: assign(form, team="enterprise", sla_minutes=15) elif points >= 40: assign(form, team="smb", sla_minutes=240) else: nurture(form, reason=reason) ``` ## Letting a model handle the edges Thresholds handle the middle well and the edges badly. For anything that lands in a band you have flagged as uncertain, ask: ``` A form was submitted: {form_json} Here is what this person did on our site beforehand: {visitor_json} {journey_json} Decide: enterprise, smb, or nurture. Give one sentence of reasoning that cites specific behaviour. If the visitor record is empty, say so and route on the form alone — do not infer behaviour that is not there. ``` ## What will go wrong **The email does not match.** People fill forms with a personal address and browse from a work one, or the reverse. You will miss history you have. Match on company domain as a fallback, and accept the miss rate. **Competitors fill forms.** They do, constantly, and they read your pricing page more carefully than your buyers. `classification: competitor` is the cheap fix; it needs someone to have classified them once. **Scores drift.** The weights above are guesses on day one. Store the score and the reason with the lead, then look at what actually closed in ninety days and change them. A scoring model nobody revisits is astrology with arithmetic. --- Next: [build a daily call list](./build-a-daily-call-list.md) · [alert your team when a target account appears](./alert-your-team-when-a-target-account-appears.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/keep-an-ad-audience-in-sync ------------------------------------------------------------------------ # Keep an ad audience in sync **The job:** stop paying to advertise to people who already converted, and start paying to reach the companies who read you and did not. **Who this is for:** anyone spending on LinkedIn, Meta or Google audiences. --- ## Two audiences, opposite jobs **Suppression** — existing customers, and anyone you have already won. Every impression served to them is wasted budget and mild irritation. **Retargeting** — high-intent visitors you have not converted. These are the people worth paying to reach again. Both come from one endpoint with a different filter. ```python suppress = [a for a in get_all("/accounts", classification="customer")] retarget = [ v for v in get_all("/visitors", min_intent=60) if v["classification"] == "lead" and not v["is_locked"] ] ``` ## Uploading Ad platforms take hashed emails. Hash on your side, never send plaintext: ```python import hashlib def normalise(email: str) -> str: return email.strip().lower() def sha256(email: str) -> str: return hashlib.sha256(normalise(email).encode()).hexdigest() platform.upload_audience( name="signal-high-intent-leads", hashed_emails=[sha256(v["resolved_email"]) for v in retarget if v.get("resolved_email")], ) ``` Normalising before hashing matters — `Dana@Acme.com` and `dana@acme.com` hash differently and the platform will match neither. ## Replace, do not append Audiences go stale in one direction: a lead who became a customer stays in your retargeting list forever unless you remove them. Rebuild the whole audience on each run rather than adding to it. It is one more API call and it removes an entire class of embarrassing spend. ## Consent, which is not optional Signal identifies visitors under the lawful basis you configured, and that is not the same basis as advertising to them. Before you upload anything: - check what your privacy policy told these people; - honour suppression — the enrichment suppression list exists for DSAR requests and an audience upload must respect it; - keep the audience out of any region where you have not established a basis. Identification is US-only by design; your ad targeting should not be wider than your lawful basis. If you cannot answer "what did we tell this person we would do with their address", do not upload it. ## What will go wrong **Match rates look broken.** 20–40% is normal. The platform only matches addresses it already has; a low rate is not a sign your data is wrong. **Minimum audience sizes.** Most platforms refuse audiences under ~300 matched records. A precise, small, high-intent list may be unusable — which is an argument for using it in outbound rather than ads. **`resolved_email` can be null.** Filter before hashing or you will upload the string "None" several hundred times. --- Next: [fill your CRM with who is actually visiting](./fill-your-crm-with-who-is-actually-visiting.md) · [export and keep in sync](./export-and-keep-in-sync.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/a-weekly-revenue-brief-your-llm-writes ------------------------------------------------------------------------ # A weekly revenue brief your LLM writes **The job:** Monday morning, a short written brief on what happened last week and what it means — assembled from your own data, not from a dashboard nobody opens. **Who this is for:** founders and heads of growth who want the number *and* the sentence explaining it. --- ## Gather, then write Two steps, deliberately separate. Gathering is deterministic and should not involve a model. Writing is the part a model is good at. ```python import json, requests BASE = "https://app.signal.geysera.com/signal-api/v1" H = {"Authorization": f"Bearer {READ_KEY}"} brief_inputs = { "attribution": requests.get(f"{BASE}/attribution", params={"days": 7}, headers=H).json(), "attribution_prior": requests.get(f"{BASE}/attribution", params={"days": 14}, headers=H).json(), "recommendations": requests.get(f"{BASE}/recommendations", headers=H).json(), "top_accounts": requests.get(f"{BASE}/accounts", params={"page_size": 25}, headers=H).json(), } ``` There is no "last week versus the week before" endpoint. Two windows and a subtraction is the honest way to get it, and it makes the comparison explicit rather than hidden inside a metric. ## Ask the commerce questions in English The REST API covers acquisition. For revenue, ask: ```python def ask(q): r = requests.post( "https://app.signal.geysera.com/agent-api/signal/copilot/ask", headers={"Authorization": f"Bearer {COPILOT_KEY}"}, json={"question": q}, timeout=60) return r.json() brief_inputs["revenue"] = ask("What was revenue last week compared with the week before?") brief_inputs["products"] = ask("Which products sold most last week?") ``` Each returns `answer` for humans and `trace[].data` for machines, plus `disclosures` — caveats the system attached because they change how the number should be read. Pass the disclosures through. They are the difference between a brief and a misleading brief. ## The prompt ``` Write a Monday brief for the founder of a company. Maximum 250 words. Data: {brief_inputs_json} Rules: - Lead with the single most important change, not a list. - Every number you state must appear in the data above. If you want to state a percentage change, compute it from two numbers that are both there. - Repeat any disclosure that affects how a number should be read. - End with ONE thing to do this week, drawn from the recommendations payload, naming the evidence behind it. - If the week was unremarkable, say that in one sentence. Do not manufacture a narrative. ``` That final rule is what makes the brief trustworthy over time. A brief that finds drama every week teaches the reader to ignore it. ## Scheduling Any cron. The gathering takes seconds; the copilot calls take tens of seconds, so give the job a couple of minutes. ``` 0 7 * * 1 /usr/bin/python3 /opt/briefs/weekly.py | mail -s "Monday brief" you@company.com ``` ## What will go wrong **The copilot refuses.** It does that rather than answer from data it does not have. `refusal` is prose explaining why; put it in the brief verbatim instead of dropping the section, so a missing number is visible rather than silently absent. **Windows that include a gap.** If order sync was broken for two days, a 7-day total is understated and the copilot will say so in `disclosures`. A brief that drops disclosures will report a fall in revenue that did not happen. **Comparisons across a plan-cap boundary.** `total` is capped at what your plan may resolve. If you crossed the cap mid-week, week-over-week visitor counts are not comparable and the difference is billing, not behaviour. --- Next: [find the pages that create pipeline](./find-the-pages-that-create-pipeline.md) · [ask questions in natural language](./ask-questions-in-natural-language.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/workflows/watch-your-own-data-quality ------------------------------------------------------------------------ # Watch your own data quality **The job:** know when Signal has stopped seeing your site, before you build a decision on a number that has quietly become wrong. **Who this is for:** everyone. This is the workflow that protects every other workflow on this site. --- ## The failure that matters The dangerous failure is not an outage. An outage is loud. The dangerous failure is *silence that looks like data*: the pixel stops firing, or the order sync breaks, and every endpoint keeps returning 200 with numbers that are smaller than reality. Dashboards look fine. Conclusions quietly rot. ## The cheapest possible check ```python a = requests.get(f"{BASE}/attribution", params={"days": 7}, headers=H).json() if not a["has_data"]: alert("Signal attribution has no data for the last 7 days") if a["totals"]["visitors"] < expected_floor: alert(f"visitors={a['totals']['visitors']}, expected at least {expected_floor}") if a["totals"]["identify_rate"] < 0.2: alert(f"identify_rate has fallen to {a['totals']['identify_rate']:.0%}") ``` `expected_floor` should come from your own history, not a guess — the median of the last eight weeks is a reasonable start. A fixed threshold is wrong within a quarter. ## Asking the system about itself The assistant can answer the health questions directly, which is often faster than assembling them: ```bash curl -s https://app.signal.geysera.com/agent-api/signal/copilot/ask \ -H "Authorization: Bearer $SIGNAL_COPILOT_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "Is my workspace set up properly, and have we stopped identifying visitors?"}' ``` `workspace_setup` returns store connection, onboarding progress, pixel status, identification health and plan usage in one call, and it distinguishes "nothing happened" from "we are not collecting" — which is exactly the distinction this workflow exists to make. ## What to check, and how often | Check | Cadence | What a failure means | |---|---|---| | `has_data` on a 7-day window | daily | nothing is being collected | | visitors vs your 8-week median | daily | the pixel is partially broken | | `identify_rate` vs its own history | daily | identification is degraded | | `median_hours_to_identify` | weekly | the pipeline is lagging | | revenue question returns a number | daily | order sync has stopped | The last one is the one people miss. Ask *"what was revenue in the last 7 days?"*: a workspace whose store connection has silently expired answers zero, confidently, and everything downstream inherits it. ## Make the alert clearable Before adding any check here, decide what makes it stop. An alert that fires forever is one people mute, and a muted alert is worse than none — it is a check you believe you have. ## What will go wrong **A quiet weekend fires everything.** Compare like with like: this Monday against previous Mondays, not against Friday. **Your own traffic.** Staff and monitoring hit the site too. A floor set from history already includes them; a floor set by intuition usually does not. **Crawlers.** Unflagged bot traffic has inflated visitor counts before — by a lot. If a number moves and nothing else did, check that the visitors are people before you act on it. --- Next: [notice an account going quiet](./notice-an-account-going-quiet.md) · [the pipeline that reported success while doing nothing](../case-studies/the-pipeline-that-reported-success-while-doing-nothing.md) ======================================================================== SECTION: Playbooks ======================================================================== ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/playbooks/account-based-marketing-end-to-end ------------------------------------------------------------------------ # Account-based marketing, end to end A full ABM programme built on Signal and one LLM, from "we have a target list" to "a meeting happened". This is longer than a workflow because ABM is not one job — it is six, and most programmes fail at the seams between them. **Time to build:** a few days. **Time to run:** it runs itself, with one human approval gate that you should not remove. --- ## The six stages, and where programmes actually fail 1. **Define the list** — fails by being too big 2. **Detect arrival** — fails by being too slow 3. **Judge the signal** — fails by treating every visit as intent 4. **Choose the play** — fails by having one play 5. **Execute** — fails by sending generated mail unreviewed 6. **Measure** — fails by measuring activity instead of meetings Stages 1 and 3 are where the value is. Everything else is plumbing. --- ## Stage 1 — Define the list Start from who already reads you, not from a purchased list. You are looking for the shape of accounts that convert, and you have that shape: ```python customers = get_all("/accounts", classification="customer") ``` Ask a model to describe the pattern, then use it as a filter — not as a prediction: ``` Here are our existing customers as Signal sees them: {customers_json} Describe the pattern in at most five bullet points: size signals, behaviour signals, anything about how they arrived. Be explicit about what this data CANNOT tell you — it has no firmographics beyond domain, no revenue, no headcount. Do not infer industry from a domain name. ``` The constraint in the last two sentences is the difference between a useful description and a hallucinated ICP. Keep the target list under 200 accounts. A list of 2,000 is a mailing list wearing a different name, and every stage below degrades with size. --- ## Stage 2 — Detect arrival Webhook, filtered against the list. This is the workflow in [alert your team when a target account appears](../workflows/alert-your-team-when-a-target-account-appears.md); build it exactly as written and come back. The only ABM-specific addition: record the *first* arrival per account, not per person. A committee arriving over ten days is one event. ```python def on_visitor(v): if v["company_domain"] not in TARGETS: return account = state.get(v["company_domain"]) or {"people": [], "opened": now()} account["people"].append(v) state[v["company_domain"]] = account if len(account["people"]) == 1: schedule_evaluation(v["company_domain"], delay_hours=72) ``` The 72-hour delay is deliberate. Deciding after one pageview is how you burn a target account on a generic email. --- ## Stage 3 — Judge the signal After the window, ask whether anything real is happening: ``` Account: {domain} People who visited in the last 72 hours: {people_json} What they read: {journeys_json} Answer: 1. Is this an evaluation, a single curious person, or noise? Cite evidence. 2. If an evaluation, what are they trying to work out? Quote the pages. 3. Confidence: high, medium or low — and what would raise it. If the honest answer is "one person read two pages", say that. A wrong "yes" here costs us a target account; a wrong "no" costs us three days. ``` Make the asymmetry explicit, as in that last line. Without it, a model asked "is this an evaluation?" says yes far too often. --- ## Stage 4 — Choose the play Different signals deserve different responses. At minimum: | Signal | Play | |---|---| | One person, deep read of one topic | Useful content on that topic, no pitch | | Several people, one week | Coordinated outreach, one thread, name the group | | Pricing + docs, repeat visits | Direct meeting request | | Existing customer browsing new area | Expansion conversation, route to CS | | Competitor domain | Nothing. Classify and move on. | The last row is a play. Doing nothing, deliberately, is the correct response to a signal that looks strong and means nothing. --- ## Stage 5 — Execute, with a human gate Generate drafts; do not send them. See [write a first-touch email from what they read](../workflows/write-a-first-touch-email-from-what-they-read.md) for the prompt and the SKIP rule. The gate is not bureaucracy. On your highest-value 200 accounts, the cost of one bad automated email is measured in lost pipeline, and the cost of a human reading a draft is thirty seconds. Keep the gate until you have watched a hundred drafts go through unedited, and probably keep it after that. --- ## Stage 6 — Measure the thing you actually want Not sends, not opens, not "accounts engaged". Meetings. ```python weekly = { "targets_that_visited": len({a for a in state if state[a]["people"]}), "evaluations_judged_real": len([a for a in state.values() if a.get("verdict") == "evaluation"]), "drafts_approved": approved_count(), "meetings_booked": crm.meetings_since(last_monday, source="abm"), } ``` The ratio worth watching is meetings ÷ evaluations-judged-real. If it is low, stage 3 is too generous and you are working noise. If evaluations are near zero while targets are visiting, stage 3 is too strict or your list is wrong. --- ## What will go wrong **The list grows.** Someone will ask to add fifty accounts. Each one dilutes every alert. Cap it and make additions require a removal. **The model gets more confident over time.** It will not — you will get more trusting. Re-read ten judgements a month against the raw data. **Attribution will not close the loop cleanly.** A meeting booked six weeks after a visit rarely traces back through any system. Accept that the last-touch number understates this programme, and judge it on the ratio above instead. **People change jobs.** Your champion at a target account leaves and the account goes quiet. That is [notice an account going quiet](../workflows/notice-an-account-going-quiet.md), and it is part of this programme, not a separate one. ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/playbooks/turning-content-into-pipeline ------------------------------------------------------------------------ # Turning content into pipeline Most content programmes are measured on traffic because traffic is the number that is easy to get. This is how to measure content on the thing you actually want — whether it brings people who buy — and what to do with the answer. **Time to build:** an afternoon. **Time to be confident:** a quarter, because content is slow and you need enough conversions to compare. --- ## Why traffic is the wrong number, concretely Two pages. One brings 8,000 visitors a month and 1% of them become high intent. One brings 400 and 22% do. The first page wins every dashboard and the second page is your business. Ranked by traffic you will write more of the first. The number that separates them is already in one call. --- ## Step 1 — Establish what you can and cannot see Before any analysis, read the coverage: ```python a = get("/attribution", days=90) print(a["totals"]["touch_coverage_pct"], a["totals"]["identify_rate"]) ``` `touch_coverage_pct` is the share of visitors whose first touch is visible. If it is 30%, every statement below describes that 30%. That is fine for *comparing* pages against each other — the bias is broadly similar across them — and it is not fine for reporting totals to anyone. Write the number at the top of whatever you produce. A content report that does not state its coverage is the kind of artefact that gets quoted for two years. --- ## Step 2 — Rank pages by yield, not volume ```python rows = [ { **p, "yield": p["high_intent"] / p["visitors"] if p["visitors"] else 0, } for p in a["landing_pages"] if p["visitors"] >= 200 # below this, yield is noise ] by_yield = sorted(rows, key=lambda r: r["yield"], reverse=True) by_volume = sorted(rows, key=lambda r: r["visitors"], reverse=True) ``` Print both lists side by side. The interesting pages are the ones that move several places between them — high volume and low yield is where your budget is going; low volume and high yield is where it should go. --- ## Step 3 — Ask what the winners have in common ``` Here are our landing pages, ranked by the share of their visitors who reach high intent: {by_yield_json} And ranked by raw traffic: {by_volume_json} Coverage is {touch_coverage_pct}% — say what that means for confidence before anything else. Then: what do the top-yield pages have in common that the high-volume, low-yield pages do not? Ground every claim in the URLs and numbers here. If the only honest answer is "the top pages are all about one product area", say that rather than constructing a content theory. ``` That last instruction stops the most common failure: a model producing a plausible essay about "buyer intent content" from five URLs. --- ## Step 4 — Follow one page all the way through Aggregate yield tells you where to look. To decide what to *write*, take a single high-yield page and ask what happened after it: ```python ask("Which pages do people read after /guides/migration, and which of those " "paths end in a purchase?") ``` The answer is a path, and a path tells you what to write next: the page people go looking for and do not find. --- ## Step 5 — Decide, and write the decision down Three decisions come out of this, and only three: 1. **Write more like this.** Name the page and the yield that justified it. 2. **Fix this.** High volume, low yield, and a clear reason — usually a page that ranks for a query your product does not serve. 3. **Stop.** Low volume, low yield, costing maintenance. Write down the number that justified each decision, with the date and the coverage. In six months someone will ask why you stopped writing about X, and "we decided in September" is not an answer. --- ## Step 6 — Re-measure, honestly ```python before = get("/attribution", days=90) # run before the change # ... ship the content change, wait a full quarter ... after = get("/attribution", days=90) ``` Two traps here, and both are common enough to have bitten this product's own analysis: **A level is not a change.** Yield rising after you shipped does not mean shipping caused it. If you want a causal claim, you need a dated intervention and a proper test — ask the assistant *"the new guides went live on 2026-05-01, did that move high-intent yield?"* and it will run an interrupted time series and give you an interval. An interval containing zero means you do not have an effect, and it will tell you so rather than describing the midpoint as a small win. **Coverage moved too.** If `touch_coverage_pct` changed between the two windows, some of your "improvement" is measurement. Compare coverage first, every time. --- ## What will go wrong **Seasonality eats a quarter.** Content yield in December is not content yield in March. Compare with the same quarter last year if you have it, and say so if you do not. **A page ranks for the wrong query.** The highest-traffic, lowest-yield page is very often ranking for a term adjacent to your product. That is not a content quality problem and rewriting it will not help. **The rollup is nightly.** `computed_at` tells you how fresh the data is. Shipping in the morning and checking at lunchtime tells you nothing. --- Next: [find the pages that create pipeline](../workflows/find-the-pages-that-create-pipeline.md) · [why attribution covers less than you think](../case-studies/why-attribution-covers-less-than-you-think.md) ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/playbooks/let-an-llm-run-your-workspace ------------------------------------------------------------------------ # Let an LLM run your workspace Everything on this site so far treats Signal as a data source: you fetch, you decide, you act. This playbook is the other shape — giving a model a conversational surface and letting it operate the workspace, with the guardrails that make that safe rather than exciting. **Read this before building it.** The interesting part is not what an agent can do. It is what it is prevented from doing, and why. --- ## Two doors, and they are not equivalent | | API key | Signed-in session | |---|---|---| | REST reads | yes, `read` scope | yes | | Ask questions | yes, `copilot` scope | yes | | Change anything | **never** | yes, with confirmation | An `sk_sig_` key reaches the copilot's read tools and no others, whatever it is asked and however it is asked. This is not a prompt instruction — the tool list handed to the planner is filtered by how you authenticated, so a key-authed caller cannot name a mutating tool, let alone invoke one. That means: **an autonomous agent running on a key can analyse and report, and cannot change your workspace.** For most automations that is exactly right, and it is the configuration to reach for first. --- ## Pattern 1 — A read-only analyst (start here) ```python import requests def ask(question: str) -> dict: r = requests.post( "https://app.signal.geysera.com/agent-api/signal/copilot/ask", headers={"Authorization": f"Bearer {COPILOT_KEY}"}, json={"question": question}, timeout=90, ) r.raise_for_status() return r.json() ``` The response is designed for a machine to consume, not just a human to read: ```json { "answer": "Revenue in the last 7 days was ...", "refusal": null, "clarification": null, "question_kind": "descriptive", "plan": { "reasoning": "...", "calls": ["data_coverage", "revenue_and_aov"] }, "trace": [ { "tool": "revenue_and_aov", "data": { "...": "..." } } ], "disclosures": ["..."], "limits": [], "warnings": [], "pending_confirmation": null, "mutated": [], "thread_id": null } ``` Field by field, and each of these exists because of a specific failure: - **`answer`** — prose. Null when the system declined. - **`refusal`** — why it declined, in words. Not an error; do not retry it. Surface it. - **`clarification`** — it needs one fact from you. Ask the user, do not guess. - **`plan.calls`** — which tools ran. Log it; it is how you debug a bad answer. - **`trace[].data`** — the structured results the prose was written from. If you want your own model to reason, feed it this, not `answer`. - **`disclosures`** — caveats that change how the number should be read. **Propagate these.** Dropping them is how a caveated number becomes a confident wrong one downstream. - **`mutated`** — which tools changed something. On a key, always empty. ## Handling refusals properly ```python res = ask("Did the price change drive up AOV?") if res["refusal"]: log.info("declined: %s", res["refusal"]) return None # NOT a retry, NOT an exception if res["clarification"]: return need_input(res["clarification"]) use(res["answer"], res["disclosures"]) ``` A refusal is the system refusing to answer from data that cannot support the claim. Retrying it produces the same refusal and burns your rate limit; treating it as a failure hides a correct answer. --- ## Pattern 2 — A supervised operator To let a model *change* things, you need a signed-in session, and the workspace must have switched on assistant-run changes — off by default, and an owner has to turn it on in the dashboard. The assistant cannot enable it for itself. When a turn would change something, it does not. It returns a plan: ```json { "answer": "", "pending_confirmation": { "actions": [ { "tool": "classify_account", "arguments": { "domain": "acme.com", "classification": "competitor" }, "effect": "Mark acme.com as a competitor...", "irreversible": false } ], "irreversible": false, "approve_with": { "...": "..." } }, "mutated": [] } ``` `mutated: []` and a non-null `pending_confirmation` together mean **nothing has happened yet**. Show `effect` to a human — it is written for them, not for you — and submit `approve_with` only if they agree. Do not build an agent that auto-approves its own plans. The confirmation gate is the entire safety model for writes; an agent that approves itself has removed it and kept the ceremony. --- ## What it can and cannot do, and how to find out Ask it: ```python ask("what can you do?") ``` The answer is generated from the live tool registry rather than a written list, so it cannot drift from the truth. For the machine-readable version: ```bash curl -s https://app.signal.geysera.com/agent-api/capabilities ``` Unauthenticated, and it publishes the auth model, scopes, rate limits, the error vocabulary, pagination bounds and the read-tool catalogue — all derived from the code. Bootstrap from it rather than from anything written down, including this page. --- ## Addressing things by name, and why that is not a style choice Tools take names, not ids: a rule by its name, a webhook by its URL, an account by its domain, a remembered fact by its key. This is load-bearing. A plan's calls run concurrently with static arguments, so no call can read another's output. A tool taking an opaque id would be unreachable from a sentence — the planner cannot list things and use an id from that list in the same turn. Ask for "the rule called Hot Leads", never "rule 7f3a". Where a name is ambiguous, resolution refuses and lists what exists rather than guessing. That refusal is correct behaviour; surface it. --- ## It remembers things, and you should check what The assistant keeps short facts about your workspace and feeds them into later turns, so a wrong one shapes answers you have not asked yet. ```python ask("what have you remembered about us?") ``` On a real workspace this surfaced two domains someone had told it to exclude from reports — invisible until asked, and quietly shaping every answer since. Put this in your monthly review. To remove one: *"forget what you know about X"*. --- ## Rate limits and cost 300 requests per minute per key. A copilot turn is seconds to tens of seconds — it is doing real work, sometimes a Bayesian time series — so the practical limit is wall-clock, not the quota. On 429 the `Retry-After` header is the real remaining window, not a constant; honour it. --- ## The shape of a safe agent 1. Read-only key for anything unattended. 2. Log `plan.calls` and `trace` for every turn. When an answer is wrong, this is the only way to find out why. 3. Propagate `disclosures` everywhere the number goes. 4. Treat `refusal` as an answer. 5. Never auto-approve `pending_confirmation`. 6. Check what it remembers, monthly. 7. Bootstrap from `/capabilities`, not from documentation. --- Next: [ask questions in natural language](../workflows/ask-questions-in-natural-language.md) · [watch your own data quality](../workflows/watch-your-own-data-quality.md) ======================================================================== SECTION: Case studies ======================================================================== ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/case-studies/most-of-your-visitors-were-not-people ------------------------------------------------------------------------ # Most of your visitors were not people **What happened on a pilot account, what it cost, and how to check your own data.** --- ## The number that was wrong A B2B diagnostics company had been running the pixel for months. Their identified-visitor count looked healthy and grew steadily. Then someone asked a question nobody had asked before: *how many distinct people are behind these events?* ``` 224,084 "human" visitors over 90 days 10,496 of them were crawlers (4.7%) 59 distinct crawler user agents, every one self-identifying ``` On the paired staging tenant the proportion was far worse: **69.4% of events were unflagged crawlers.** Roughly seven in ten "visits" were a machine. ## Why the bot filter missed them There was a bot filter. It matched a list of names — `googlebot`, `bingbot`, `semrushbot` and a few dozen others. It missed: - **Every Google crawler that is not Googlebot.** `AdsBot-Google`, `GoogleOther`, `Google-InspectionTool`, `Feedfetcher-Google`, `Google-Read-Aloud`, `Storebot-Google`. Six products, one name in the list. - **Semrush's site auditor.** Its token is `SiteAuditBot`. The list contained `semrushbot`. Same vendor, different string, no match. The filter was not broken. It was a list, and a list is only ever as complete as the last person who edited it. ## The fix that generalises A crawler that puts a URL inside its own user agent is telling you what it is: ``` Mozilla/5.0 (compatible; SiteAuditBot/0.97; +http://www.semrush.com/bot.html) Mozilla/5.0 (compatible; DuckAssistBot/1.0; +https://duckduckgo.com/duckassistbot/) ``` So alongside the name list, one rule: ``` \+https?://\S*(bot|crawl|spider) ``` That single pattern caught SiteAuditBot, DuckAssistBot and the Facebook crawler without anyone having enumerated them. It generalises to crawlers that do not exist yet, which a list cannot. ## What it actually cost — and what it did not This is the part most write-ups get wrong, so it is worth being exact. **Barely affected: identified people.** A crawler has no email to resolve, so it almost never becomes an identified visitor: ``` 19,517 unflagged crawler events, all time 124 of those ever resolved to an identity (0.6%) ``` Against 34,841 identified visitors on that account, 124 is noise. **Badly affected: anything counted per event.** Traffic totals, page-view counts, "most visited pages", funnel step counts — every statistic not scoped to an identified person was inflated, and the inflation was concentrated on the pages crawlers like: pricing, sitemap-linked landing pages, anything in the nav. The first version of our own internal write-up claimed identified-visitor counts were inflated by one in twenty. That was wrong, and we only found out by measuring instead of reasoning. If you take one thing from this: **the blast radius of a data-quality bug is rarely where you first assume.** ## How to check your own data Before believing any behavioural statistic, check that the number of events is plausible against the number of distinct actors: ```bash curl "https://app.signal.geysera.com/signal-api/v1/visitors?page_size=1" \ -H "Authorization: Bearer sk_sig_…" ``` The response's `total` is people. If your event counts are orders of magnitude larger than that and you are not a high-frequency consumer app, something in between is not a person. Two rules worth adopting permanently: 1. **`n ≈ distinct visitors`.** If one "visitor" accounts for hundreds of page-views in a session, look at the user agent before building a funnel on it. 2. **Treat unknown as unknown.** Our own `is_bot` column was NULL — never evaluated — for 28.9% of rows. A query written as `WHERE NOT is_bot` drops every one of those, because `NOT NULL` is not `TRUE`. `WHERE is_bot IS NOT TRUE` is what you want. A three-state column read as two states is a silent filter. ## What we changed - The self-identifying-crawler rule now runs at ingest, so this cannot accumulate again. - History was corrected in a migration that marks the rows it touched, so the change is reversible and auditable. - Crawler-only visitors are **hidden by default and still viewable** — a filter, not a delete. Somebody eventually wants to know how much crawler traffic a page gets, and deleting the evidence to clean up a number is how you end up unable to answer that. ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/case-studies/the-pipeline-that-reported-success-while-doing-nothing ------------------------------------------------------------------------ # The pipeline that reported success while doing nothing **An identification outage that lasted thirteen days, three failed fixes, and what we changed about how we watch things.** --- ## The symptom A customer's dashboard said identification had stopped *yesterday*. The truth was **thirteen days**, and every system involved was reporting healthy. ``` tracking_events 8,191 in 24h, most recent seconds ago ← pixel fine liveintent_resolutions ~1,200/day, most recent minutes ago ← vendor fine signal_account_visitors last created 2026-08-29 13:43 ← nobody new unified_profiles last created 2026-08-29 13:43 ← nobody new ``` Traffic arriving, resolutions arriving, and not one new identified person in thirteen days. ## Three things that were all green **The schedule.** The hourly job was not paused, was firing on time, had nothing stuck, and showed ten recent actions. Looking at the scheduler told you everything was fine. **The `updated_at` column.** Rows were being written every hour, so any monitor watching row freshness saw a live system. Those writes were a *different* job — an hourly intent recompute touching existing rows. `updated_at` moved; `created_at` had not moved in thirteen days. **The workflow status.** In an earlier round of the same outage, the workflow caught its own exception and returned a result object with `status='failed'`. Temporal saw a **COMPLETED** workflow. A caught failure returned as a value is a completion. ## The actual cause Every hourly run failed in two seconds. The error surfaced as a circuit-breaker trip, which was itself a mask — the first attempt raised the real error, the second returned `CircuitOpenError`, and only the second was reported: ``` CheckViolationError: new row for relation "liveintent_ingestion_runs" violates check constraint "liveintent_ingestion_runs_status_check" DETAIL: Failing row contains (…, no_data, 0, 0, discovery found no dated partitions under the configured prefix). ``` The job had a branch for "the vendor delivered nothing", which recorded `status='no_data'`. The CHECK constraint permitted `running | success | partial | failed`. Every one of those writes was rejected, the retries exhausted, the breaker opened, **and the workflow died on its bookkeeping call — before the step that turns resolutions into people.** `SELECT DISTINCT status` returned `{success, failed}`. The value `no_data` had never once landed. ## Each fix planted the next one | | What broke | Duration | |---|---|---| | 1 | Discovery hit an S3 `AccessDenied`; the workflow caught it and returned a value, so the scheduler saw COMPLETED | 3d 7h | | 2 | The fix for #1 recorded the failure with an empty date; the recorder died on `date.fromisoformat('')` and tripped the breaker | 4 days | | 3 | The fix for #2 added the `no_data` branch, and nobody widened the constraint | **13 days** | All three were in the same recorder. The common factor was not carelessness: **the status vocabulary had two homes** — string literals in the job, a CHECK constraint in SQL — and no test read both. ## The design error underneath > **Recording what happened must not be able to prevent what happens.** The write that logged the run was awaited on the critical path, ahead of the step that produced the product's entire value. A logging concern was a hard dependency of the business. It took identification down twice before anyone named it. ## What we changed - **One vocabulary, one home.** The statuses are declared once in code, and a test asserts that set equals the CHECK constraint as written in the migration. Adding a status without a migration now fails a test instead of production. - **The recorder cannot fail the run.** It logs loudly and returns. Safety comes from the *absence* of the row: a monitor watches run freshness with a three-hour budget and goes stale precisely when this breaks. - **The banner measures the output, not the job.** It had read "when did our ingest last run", which is a different fact from "when did we last identify someone" — and a crashed run refreshed it, which is how thirteen days displayed as one. It now reads the last actual identification, on a threshold derived from the measured gap distribution (p50 1.0h, p90 2.0h) rather than chosen. ## What to take from it **Ask what would make the number move.** A monitor that cannot go red is decoration. Before trusting one, work out which specific failure it would catch — and check that a *failure* cannot refresh the thing it measures. Our banner, our `updated_at`, and our workflow status all had that defect in different forms. **Distinguish "the job ran" from "the job did something."** They are different questions with different right answers, and fusing them lets a healthy job speak for a dead pipeline. **A test that reads one side of a contract is not a test of the contract.** The constraint and the code each said something true about themselves. Nothing read both. ------------------------------------------------------------------------ SOURCE: https://app.signal.geysera.com/developers/case-studies/why-attribution-covers-less-than-you-think ------------------------------------------------------------------------ # Why attribution covers less than you think **Three measured ceilings on first-touch attribution, and how to tell whether a number you are reading is a finding or an artefact.** --- Every analytics product shows you a channel breakdown. Almost none tell you what fraction of traffic that breakdown describes. On a pilot account the honest answer was **6.5%**, and on two of three accounts it was **0%** — the chart rendered blank and looked like a bug. Here are the three things that cap it, measured rather than assumed. --- ## 1. The pixel misses the landing page-view **76% of unattributed human visitors have a self-referral as their first recorded event.** The pixel loads asynchronously. On the page a visitor arrives at — the one carrying `?utm_source=…` and the external referrer — the script frequently has not executed by the time they click through. The first event we record is the *second* page, whose referrer is the customer's own domain. We checked the two obvious alternative explanations and both were wrong: - **Identity churn?** No: 1.00 ids per session. Visitors were not fragmenting. - **Session loss?** No. It is simply that the arrival is the hardest page-view to capture, and it is the only one that carries the attribution. **This is a real ceiling, not a bug to fix.** Any first-touch number you read should be understood as "of the visitors whose arrival we caught". ## 2. Attribution only describes visitors who carried campaign data The channel breakdown is built from events with campaign context. Where the traffic is organic, direct, or arrives without parameters, there is nothing to attribute — and the page rendered an empty chart rather than saying so. Silence reads as "no data exists". It should read as "this describes 6.5% of your traffic". We now show the coverage figure next to the chart, because a number without its denominator is not an answer. ## 3. Revenue attribution has a much lower ceiling than visit attribution Connecting a visit to an order requires an identity that appears on both sides. ``` 0.9% of revenue joinable via email order_id works as a second bridge user_id is a useless grain — do not build on it ``` And a subtler trap, which is the one most likely to produce a confident wrong conclusion: **85.6% of buyers were already customers before we ever saw them.** If you compute "identified visitors convert at X%" over all buyers, you are mostly measuring people who had already bought before the pixel existed. The causality runs backwards. Filter to visitors whose first session predates their first order, or the number means nothing. --- ## Identification yield is a property of your audience, not your setup Two accounts, identical configuration, same pipeline: ``` Account A ~50% of visitors matched to a person Account B 0 of 313 visitors matched ``` Nothing was misconfigured on B. Identification depends on whether your visitors appear in the data our providers have, which is a property of *who visits you* — geography, device, whether they are logged into the ecosystems that feed the graph. **Configuration parity does not buy identification parity.** If you are comparing two properties, compare their audiences before concluding one is broken. --- ## How to tell a finding from an artefact Before acting on any number in a dashboard — ours or anyone's — ask four questions: 1. **What is the denominator?** A rate without one is a claim without evidence. Our `total` is capped at what your plan permits, not the raw row count, so rates computed against it are wrong by construction. That is documented, and it is the sort of thing worth checking in any tool. 2. **Is the population what I think?** See [case study 1](./most-of-your-visitors-were-not-people.md) — 69% of one account's "visitors" were crawlers. 3. **Could the causality run backwards?** The buyers-were-already-customers trap, above. 4. **Does zero mean zero, or does it mean not-measured?** Our own `is_bot` column was NULL on 28.9% of rows, and the intent score is 0 for 87.8% of visitors — which is *correct*, because most visitors genuinely show no intent signal. One of those zeros is a measurement; the other is an absence. The product's job is to make those distinguishable. Where we have not yet, the honest thing is to say so on the page rather than render a confident chart.