# HumanSurvey > Self-reported attribution for channels with no referrer. One question — "how did you hear > about us" — rendered inside a host's own signup or payment flow, answered at > creator/show/event granularity, and read back by an agent as a row stream or as a rollup. Machine reference. Every endpoint, field and status below was checked against the shipping code. Where a capability does not exist, this file says so rather than omitting it. ## Removed — do not call, do not generate These were documented in earlier revisions of this file and no longer exist. Calling them is a 404 or a 401, not a deprecation. - `POST/GET /api/surveys`, `GET/PATCH /api/surveys/{id}`, `POST/GET /api/surveys/{id}/responses`, `POST /api/demo/parse` - The Markdown survey syntax and the LLM that translated it - The question types `single_choice`, `multi_choice`, `text`, `scale`, `matrix` - `showIf` and its `eq` / `neq` / `contains` / `answered` operators - `create_survey` inputs of any shape, including the `{ markdown }` form this file still documented after it was removed in 0.3.0 - The `max_responses` / `expires_at` / `notify_at_responses` / `webhook_url` create fields and the `survey_closed` and `threshold_reached` webhook events - The `open` / `closed` / `expired` / `full` lifecycle, and `close_survey` - `is_final` and `completion_reason` on any read — see Cursor reads - The `"optionId::typed text"` fill-in encoding; free text is now a first-class `raw` answer - Anonymous `POST /api/keys`; key creation requires a credential - The noun `survey` in the creator API. A placement is a **form**; a question is a **node**; an option is a **candidate** The OpenAPI document at `/api/openapi.json` (also served at `/openapi.json`) has been regenerated for the surface below and no longer describes any of the above. ## Model - **Account** owns data. **Keys** are credentials pointing at an account. Two layers, no project layer. - **Form** = one placement. A customer typically runs two: one in signup, one at payment. Divide a channel's share of the paying population by its share of the signup population. Above 1 it converts better than your average, below 1 worse. Multiply that ratio by your overall signup-to-paid rate to get the channel's own rate. **The ratio is an index against your own average, not a rate** — writing `p_c` for the channel's share of payers, `s_c` for its share of signups, `P` for total payers and `S` for total signups: `p_c / s_c = (payers_c / P) / (signups_c / S) = (payers_c / signups_c) x (S / P) = conversion(c) / conversion(overall)`. Two channels converting at 5% and 20% under an overall rate of 14% give 0.357 and 1.429 — neither is a conversion rate, and each returns its channel's true rate multiplied by 0.14 - **Config** = a graph of ask **nodes**, each holding **candidates**. Stored as an immutable numbered snapshot; responses are joined against the version they were rendered with. - **Response** = one respondent's pass. Written progressively: POST the first pick, PATCH the follow-up. Visible to readers only once complete or swept. - **Catalog** = product-owned platform vocabulary (slug, label, mark, aliases). **Candidates** are caller-owned. The product renders a candidate set and returns the id that was chosen; it does not resolve identities. - An attribution form is a **perpetual stream**. There is no terminal state and no field claims one. Status is `active` or `paused`; pausing is reversible. ## Core flow ``` POST /api/auth/code { email } # six-digit code, 202 POST /api/auth/verify { email, code, grant: "api_key" } # → hs_sk_... GET /api/attribution/catalog # public: slugs to configure with POST /api/attribution/forms { name, allowed_origins } PUT /api/attribution/forms/{id} { nodes } # → immutable config version host embeds /s/{id}?embed=1&external_id= POST /api/attribution/events { form_id, events: [...] } GET /api/attribution/rollup?form_id=...&from=&to= GET /api/attribution/forms/{id}/responses?since_seq=... GET /api/attribution/forms/{id}/unresolved POST /api/attribution/forms/{id}/remaps PUT /api/attribution/forms/{id} { nodes } # monthly retune ``` ## Authentication Creator endpoints require `Authorization: Bearer hs_sk_...`. The header must start with `Bearer hs_sk_` or the answer is 401 before any lookup. Public endpoints (no key): - `POST /api/auth/code`, `POST /api/auth/verify` - `GET /api/attribution/catalog` - `POST /api/attribution/forms/{id}/responses`, `PATCH /api/attribution/forms/{id}/responses` - `/s/{id}` ### `POST /api/auth/code` - Public. Request: `{ "email": "you@example.com" }` - Response 202: `{ "sent": true, "expires_in_seconds": 600 }` — identical whether or not the address has an account, so this cannot be used to test for one - 400 on an implausible email; 429 when throttled (per address and per client IP) ### `POST /api/auth/verify` - Public. Request: `{ "email", "code", "grant"?, "name"?, "agent_client"? }` - `code` must be exactly six digits. `grant` is `"api_key"` or `"session"` (default `"session"`; any other value is treated as `"session"`) - `grant: "api_key"` → 201 `{ "id", "key" }`. The only time the key is readable; stored hashed - `grant: "session"` → 200 `{ "signed_in": true }` plus an httpOnly `hs_session` cookie, 30-day TTL. Consumed by /signin, which sets it, and accepted by the three /api/keys routes so that /account can list and issue keys for a visitor who does not have one yet. Every other route takes a bearer key only. `grant: "api_key"` is the only useful grant today - 400 `{ "error": "That code is not valid" }` for both a wrong code and no outstanding code — deliberately indistinguishable. 400 on expiry, 429 after too many wrong attempts ### `POST /api/keys` - **Auth required.** Anonymous key creation is gone: a key is issued only against a verified email code or an existing key - Request: `{ "name"?, "agent_client"? }`. There are no `email` or `wallet_address` fields - Response 201: `{ "id", "key", "name", "created_at" }` ### `GET /api/keys` - Auth required. Returns **every key on the account**, newest first — a key that can see only itself cannot be rotated - Each entry: `{ "id", "name", "agent_client", "created_at", "last_used_at", "revoked_at", "current" }`. `current` flags the key in the Authorization header. Key values are never returned ### `DELETE /api/keys/{id}` - Auth required. Revokes **any** key on the account, including one you are not holding — that is the case that matters, killing a leaked key from somewhere safe - 204 on success. 404 for unknown id, another account's key, and already-revoked alike - Soft delete; the row is kept as an audit trail. Forms and responses belong to the account, so rotation orphans nothing ## Form config ### `POST /api/attribution/forms` - Auth required. Request: `{ "name", "allowed_origins"?, "theme"?, "per_response_webhook_url"? }` - `name` is required, ≤ 120 characters - Response 201: `{ "id", "form_url": "https://www.humansurvey.co/s/{id}", "warnings": [...] }` - `warnings` always includes `this form has no config yet; PUT /api/attribution/forms/{id} with {nodes} before embedding it`, and includes the empty-allowlist warning when `allowed_origins` is absent or empty - 400 `{ "error", "errors": [...] }` on invalid settings — every problem at once, not the first ### `GET /api/attribution/forms` - Auth required. Array, newest first. Each: `{ "id", "name", "status", "current_version", "response_count", "allowed_origins", "created_at", "form_url" }` ### `GET /api/attribution/forms/{id}` - Auth required. `{ "id", "name", "status", "current_version", "allowed_origins", "theme", "per_response_webhook_url", "response_count", "created_at", "form_url", "config": { "version", "nodes", "root_node_id", "config_hash", "created_at" } | null }` - `config` is null when the form has never been configured - 404 for both "no such form" and "not your form", on this and every route taking a form id. Splitting them would let a key holder enumerate the id space ### `PUT /api/attribution/forms/{id}` — configure - Auth required. Request: `{ "nodes": [...], "root_node_id"? }` - Validates, snapshots, and points the form at the snapshot - Response 200: `{ "id", "version", "created", "warnings" }`. 200 on both paths — a 201/200 split would tempt a caller to read the dedupe as a failure - `created: false` means an identical config already existed and its version was reused. `current_version` still moves: re-posting an old config is a request for it to be live again, only the minting is skipped. Dedupe is on a content hash of the **hydrated** config, so a catalog label or mark changing in a deploy mints a new version on the next configure - Do not defeat the dedupe by adding noise. The position-effect sample is scoped to one config version, so a fresh version every month fragments it silently - 400 `{ "error": "Invalid attribution config", "errors": [...] }` #### Node ```json { "id": "channel", "prompt": "Where did you first hear about us?", "candidates": [ ... ], "allow_free_text": true, "order": "rotate" } ``` - `id` required, ≤ 128 chars, unique across nodes. `prompt` required, ≤ 120 chars - `candidates` required, non-empty, ≤ 500 entries. At most 12 nodes per form - `allow_free_text` default `true`. When false, a `raw` answer on that node is a 400 - `order` is `"rotate"` (default) or `"fixed"`. `rotate` permutes the orderable segment per respondent, seeded by `render_id`, so every option spends equal expected time at every position and the raw share is unbiased by construction. `fixed` uses the array order verbatim and accepts the position bias #### Candidate ```json { "id": "oecuid_8f21", "label": "Jade", "handle": "@jade.work0", "icon_url": "https://cdn.example.com/avatars/jade.jpg", "aliases": ["the one who does the office skits"], "tile_color": "#0A66C2", "pinned": "end", "dont_remember": true, "expands": "creator", "catalog_slug": "tiktok" } ``` - `id` required, ≤ 128 chars, unique within the node, **caller-defined**. The product validates ids rather than minting them. Use a key that survives a rename; a handle used as an id splits that creator's history the day they rename - `label` required, ≤ 120 chars. `handle`, `icon_url`, `tile_color` optional and held to the same 120-char cap — **an avatar URL longer than 120 characters is rejected**, so shorten it or proxy it. `expands` and `catalog_slug` share that cap too, which means an id longer than 120 characters cannot be referenced even though ids themselves allow 128 - `aliases` ≤ 24 entries, lowercased and deduped, matched by search and **never displayed**. They exist because people remember descriptions, not handles. Merged with the catalog's rather than overriding them - `pinned: "end"` — the only accepted value. Excluded from ordering and rendered last, in `fixed` mode too, because pinning is a property of the candidate and not of the mode. At most one per node - `dont_remember: true` — the only accepted value. Records the pick as a non-answer rather than as a channel, and **requires** `pinned: "end"`. At most one per node. Semantics live in the snapshot, not in the client: a headless integrator sending `{ "candidate_id": "dunno" }` gets `kind: "dont_remember"`, same as the browser picker - `expands` — the id of the node this pick reveals, in place. Must resolve, must not be the node's own id, must not create a cycle - `catalog_slug` — copies `label`, `icon_url`, `tile_color` and `aliases` from the platform catalog at configure time. Anything you send yourself wins; a blank string means "use the catalog's". `tile_color` is the exception in practice: the catalog supplies one only for an entry with no mark, and all 39 have marks, so it copies as null for every platform today — send your own or expect none. An unknown slug is a 400. Copied, never joined at read time, so a product-side logo swap cannot rewrite what an old rollup says was rendered - `expands_by_default` from the catalog is **advisory and never applied** — expansion policy is the caller's monthly decision #### Graph rules - Exactly one node must have no incoming `expands` edge. That node is the root, derived rather than declared; sending `root_node_id` is checked against the derived root - Expansion cycles are rejected. Nodes unreachable from the root are rejected - Errors arrive as a full list, with paths like `nodes[0].candidates[3].id is required and must be a non-empty string` ### `PATCH /api/attribution/forms/{id}` — settings - Auth required. Accepts any subset of `name`, `status`, `allowed_origins`, `theme`, `per_response_webhook_url`. At least one is required - **Rejects `nodes` and `root_node_id` with 400** naming `PUT`, rather than accepting them and silently dropping the candidate list - `status`: `"active"` or `"paused"`. Nothing else exists — no open/closed/expired/full - `allowed_origins`: ≤ 20 absolute http/https origins, compared against the host page's origin (scheme + host + port). A path, query or fragment is rejected at configure time, because the value is compared against an `Origin` and would silently match nothing. **An empty list is enforced as allow-all**, and every write warns about it: an unlisted origin embedding the form spends the account's response quota - `theme`: exactly four tokens, unknown keys are a 400 — `accent` (hex `#rgb`/`#rrggbb`/ `#rrggbbaa`), `radius` (integer 0–48 px), `font` (font-family list, restricted charset), `dark_mode` (`"light"` / `"dark"` / `"auto"`). Replaces the stored theme wholesale, so `{}` resets it. Not a theme editor — a bounded set, because a form that looks foreign inside someone's checkout costs completion rate - `per_response_webhook_url`: http/https URL, ≤ 2048 chars, or `null` to clear. **Validated and stored; nothing delivers to it yet.** Do not build on it — use the cursor read - Response 200: the form summary plus `form_url` and `warnings` ## Respondent write path Public on both verbs, because it is called from inside a form embedded in someone else's payment flow. What stands in for auth is per verb: POST is gated on the origin allowlist, PATCH on the one-time token POST mints. ### `POST /api/attribution/forms/{id}/responses` ```json { "render_id": "V1StGXR8_Z5j", "config_version": 7, "node_id": "channel", "answer": { "candidate_id": "tiktok" }, "selected_via_search": false, "external_id": "usr_8812", "host_origin": "https://app.example.com", "metadata": { "plan": "pro" } } ``` - Response 201: `{ "response_id", "patch_token", "next_node"? }`. `next_node` is the full node object to render; its absence means the response is already complete - `render_id` required, ≤ 64 chars, minted by the client **before first paint** — it is the rotation seed, so it cannot be the response id, which is minted server-side inside this call - **Send no position data.** `positions` and `position` in the body are read by nothing and are also not rejected (grace period for clients built against the old contract). The server derives the impressions map *and* the chosen index from `(render_id, config_version, node)` by running the same pure permutation the client rendered with. Neither number is forgeable, and no honest submission can be rejected for disagreeing - `selected_via_search` optional boolean, default false, and stays client-supplied because the server sees the list but not the keystrokes. It can only **suppress** a recorded position, never invent one: someone who types "jad" and takes the only match did not choose row 0 over eleven alternatives. A client that lies withholds its own data point and nothing else - `node_id` must be the root node. Any other node is a 400 — a POST is the first selection by definition, and accepting another would create a response carrying no channel answer - `config_version` optional positive int4. Omitted means the form's current version, which is only right for a client predating the field. A version belonging to another form is a 400 - `external_id` optional, ≤ 256 chars, opaque. The join key in both directions. Deliberately **not unique** — a retake is allowed — and the rollup counts the first response per `(form_id, external_id)`. **Respondent-asserted and never verified:** it identifies, it does not authenticate. Not backfillable, so capture it from day one - `host_origin` optional, ≤ 256 chars, the embedding page's origin. Checked instead of the `Origin` header because the iframe is served from our origin, so every embed is same-origin by construction. **Billing hygiene, not a security boundary** — anything in the browser can assert it. The `Origin` header is the fallback for a direct cross-origin caller - `metadata` optional. Sanitized: string and number values only, ≤ 20 keys, keys ≤ 64 chars, values ≤ 512 chars; `embed`, `external_id` and `host_origin` are dropped - All respondent strings reject C0 control characters, DEL, and unpaired UTF-16 surrogates (tab, newline and carriage return are allowed in free text) #### `answer` Exactly one of four keys. Two, or none, is a 400 naming the count. `false` counts as absent, so spreading a full shape is safe. ```json { "candidate_id": "tiktok" } { "raw": "the office skits girl" } { "dont_remember": true } { "skipped": true } ``` - `candidate_id` must be a candidate of that node in that config version, or 400. A candidate carrying `dont_remember` is recorded as `kind: "dont_remember"` and opens no follow-up - `raw` ≤ 500 characters, **rejected rather than truncated** when longer — it is the remap key, and a clipped key resolves to a different bucket than the respondent typed. Stored verbatim, never trimmed; normalization is `lower(btrim(raw))`, a generated column in Postgres - Skipping is allowed on purpose: making the question required converts non-rememberers into random pickers, which lowers data quality while appearing to raise completion #### Statuses - 400 `{ "error": "Invalid response payload", "errors": [...] }` - 403 `This origin is not allowed to submit to this form` - 404 `Form not found` - 409 `This form is not accepting responses` — paused. **409 and not 410**, because pausing is reversible and 410 tells a client the resource is gone for good - 409 `This form has not been configured yet` ### `PATCH /api/attribution/forms/{id}/responses` ```json { "response_id": "xyz789abcd01", "patch_token": "…", "node_id": "creator", "answer": { "candidate_id": "oecuid_8f21" }, "selected_via_search": false } ``` - Response 200: `{ "response_id", "completed", "next_node"? }` - `patch_token` is required and is good until the response completes, not for exactly one call, so an expansion chain deeper than two levels works. Stored hashed, compared in constant time - `node_id` must equal the response's `awaiting_node_id` - The render id comes off the stored row, never the body — taking it from a later request would let a caller choose the permutation their pick is scored against after seeing the list - Deliberately **not** gated on the origin allowlist or on form status: both decide whether a response should be *created*, and re-checking here would convert a real answer into an abandonment whenever an origin list or a pause landed mid-response - 403 `Unknown response id or patch token` — one answer for both, because response ids are 12 nanoid characters handed back to a browser and separating the cases is an existence oracle - 409 `This response is already complete`, or `This response is awaiting node ""` ### Visibility gate A response becomes visible to the reads below only when it is **complete**, or when the abandonment sweep closes it out. The cursor token is stamped at that moment, not at insert, so every row is emitted exactly once and is final when emitted — no consumer has to upsert. The sweep runs lazily on authenticated reads of that form (cursor read, identity read, rollup), rate-limited to once a minute per form and batched. The current threshold is 30 minutes and is a placeholder. A swept response has `completion: "abandoned"`, is billed and counted exactly like a finished one — its channel answer is real data — and keeps its `awaiting_node_id`. ## Cursor reads `GET /api/attribution/forms/{id}/responses` — auth required. Two reads on one verb, dispatched on the query string. Sending both `since_seq` and `external_id` is a 400: two different orderings and two different meanings of "first row", and a cursor from one is not a cursor into the other. ### `?since_seq=&limit=<1..500>` The agent's delta read. `since_seq` is **exclusive**; absent means from the beginning of the stream, so a first call needs no cursor. `limit` defaults to 100 and an out-of-range value is **rejected, not clamped** — an agent that asked for 5000 and silently got 500 believes it drained the stream. ```json { "responses": [ ... ], "count": 100, "next_cursor": "48211", "has_more": true, "open_responses": true, "next_check_hint_seconds": 0 } ``` - **`is_final` was REMOVED and is not coming back.** It could never legitimately be true: its condition included `status !== 'active'`, i.e. a paused form — but pausing is reversible, so an agent that stopped on it stopped reading a stream that resumes the next day. An attribution form is perpetual by design; it has no terminal state, so no field may claim one. `completion_reason` is gone with it - `has_more` — a row past this page exists **right now**. A fact, not a `rows.length === limit` guess: the page is read with `LIMIT limit + 1` and the extra row dropped, so a page ending exactly on the boundary does not claim a page that is not there - `open_responses` — a response is in flight: it answered its first question and has not yet resolved or been swept. Derived from the **same snapshot** as the page, in one statement, because computing it afterwards reports "drained" about a stream that grew in between — and an agent stops polling on that, so the row is not delayed, it is lost - `next_check_hint_seconds` — advisory, derived from the two above: `0` when `has_more`, `120` when something is open, `3600` when drained. Server-side so the cadence is tunable in one place; agents may check sooner - `next_cursor` — this page's own last cursor, never the stream's maximum (which would skip every row between). Echoes the caller's cursor on an empty page rather than resetting to null, which would restart the stream from the beginning - The cursor is `completed_seq`, not insert order and not `created_at` ### `?external_id=&limit=<1..500>` One identity, so a customer can join our answer into their own user table one row at a time. ```json { "external_id": "usr_8812", "responses": [ { "canonical": true, ... }, { "canonical": false, ... } ], "count": 2, "canonical_response_id": "xyz789abcd01", "has_retakes": true, "truncated": false } ``` - Ordered by `created_at` (tie-broken by `completed_seq`), **not** by the cursor token: an abandoned response is stamped complete by the sweep up to the threshold after it was created, so ordering on the cursor would call a retake canonical and book that identity's revenue against the channel they answered second - The first row is `canonical` — the one the rollup books revenue against - `has_retakes` surfaces the extras rather than hiding them; `truncated` is true when the identity has more responses than `limit` returned - Only completed responses are visible here too, so an empty result can mean "still answering", not "never answered" ### Response row ```json { "id": "xyz789abcd01", "external_id": "usr_8812", "config_version": 7, "completion": "finished", "completed_at": "2026-07-14T09:12:00.000Z", "cursor": "48211", "awaiting_node_id": null, "answers": [ { "node_id": "channel", "kind": "candidate", "raw": null, "candidate_id": "tiktok", "resolved_candidate_id": "tiktok", "resolved_via": "answer", "resolved_label": "TikTok", "position": 3, "selected_via_search": false }, { "node_id": "creator", "kind": "raw", "raw": "the office skits girl", "candidate_id": null, "resolved_candidate_id": "oecuid_8f21", "resolved_via": "remap", "resolved_label": "Jade", "position": null, "selected_via_search": false } ], "positions": { "channel": { "tiktok": 3, "chatgpt": 0, "reddit": 1 } }, "metadata": { "plan": "pro" }, "created_at": "2026-07-14T09:11:12.000Z" } ``` - `completion` is `"finished"` or `"abandoned"`. `awaiting_node_id` non-null is the row-level candidate-coverage read-out: the follow-up this respondent never came back for - `cursor` is this row's `completed_seq`, as a **string** because it is an int8 — `Number()` rounds past 2^53. Treat it as an opaque token - `kind` is `"candidate"`, `"raw"`, `"dont_remember"` or `"skipped"` - `raw` is verbatim, exactly as typed. `candidate_id` is the pick before any remap - `resolved_candidate_id` = `COALESCE(live_remap.candidate_id, candidate_id)`, computed at read time against the live remap table. `resolved_via` is `"answer"`, `"remap"`, or null when nothing resolved. **The unresolved values ship next to the resolved one on purpose:** a caller who only sees the resolved answer cannot audit a mapping it disagrees with - `resolved_label` comes from the most recent config version containing that id on that node, never from the live catalog. Null is normal for a remap target — the target is deliberately not a foreign key, so a candidate can be dropped while history still needs the mapping - `position` is the rendered index in the initial unfiltered list, or null when no position exists: a `raw`/`dont_remember`/`skipped` answer, a pinned candidate, a candidate past the 12 visible rows, or a pick flagged `selected_via_search` - `positions` is `{node_id: {candidate_id: index}}` for the initial unfiltered render. Pinned rows and rows past the visible cap are excluded entirely — an impression is a claim that the respondent could have chosen that row ## Free text and remapping Free text is never normalized at write time and never discarded. Because candidate ids are caller-defined, there is no way to end up with a `TikTok` bucket beside a `tiktok` one. **A mapping is not an edit.** Nothing about a response or an answer row changes; the rollup and the row reads resolve against the live remap table on every read, joined on `(form_id, node_id, raw_normalized) WHERE revoked_at IS NULL`. So one row fixes two months of history at once with no backfill, and revoking it moves them back. ### `GET /api/attribution/forms/{id}/unresolved` - Auth required. Query: `node_id`, `from`, `to`, `include_mapped` (`1`/`0`/`true`/`false`), `limit`, `offset`. An unrecognized boolean is a 400, not a silent false - The window filters the response's `completed_at` and `to` is exclusive, matching the rollup, so the same pair reconciles across both endpoints ```json // ILLUSTRATIVE — invented figures, shown for shape { "form_id": "abc123efgh45", "window": { "from": null, "to": null }, "totals": { "raw_responses": 63, "mapped_responses": 12, "unmapped_responses": 51, "texts": 44, "unmapped_texts": 38 }, "returned": 38, "truncated": false, "entries": [ { "node_id": "creator", "raw_normalized": "the office skits girl", "occurrences": 12, "variants": ["The office skits girl", "the office skits girl"], "variant_count": 2, "first_seen": "…", "last_seen": "…", "mapped": false, "remap_id": null, "mapped_candidate_id": null, "mapped_candidate_label": null } ], "notes": [ ... ] } ``` - Grouped by `(node_id, raw_normalized)`, ordered by `occurrences` descending — work the twelve-occurrence entry before the singleton - Entries a live remap already covers are excluded unless `include_mapped=1` - `variants` is capped at 5 distinct spellings; `variant_count` is the truth ### `POST /api/attribution/forms/{id}/remaps` - Auth required. Request: `{ "node_id", "candidate_id", "raw" | "raw_normalized", "note"? }` - Send **exactly one** of `raw` (a verbatim sample straight out of the unresolved list) or `raw_normalized`; both take the identical path, `lower(btrim(...))` evaluated in Postgres, which is the same function that generated the stored key. Sending both, or neither, is a 400 - Not trimmed client-side on purpose: JavaScript `trim()` strips tabs, newlines and Unicode spaces where `btrim` strips spaces only, so trimming first would produce a key that matches nothing for any text pasted with a trailing newline — a mapping that resolves zero rows, reported as success - Response 201: `{ "remap": { "id", "node_id", "raw_normalized", "candidate_id", "note", "created_at", "revoked_at" }, "resolved_responses", "candidate_label", "candidate_label_version", "warnings" }` - `resolved_responses` is the exact number of completed responses that just moved, so "I mapped it and nothing changed" is visible immediately rather than in next month's numbers - `candidate_id` is **not** validated against the current config — the candidate may have been dropped while history still needs the mapping — but an id present in no version of the form comes back in `warnings`, because the likelier cause is a typo - 409 `{ "error", "existing": { ... } }` when a live remap of the same string already exists — two live remaps of one string double-count in the read-time join, so this is a real conflict and the body names the row to revoke ### `GET /api/attribution/forms/{id}/remaps` - Auth required. Query: `include_revoked`, `limit`, `offset` - `{ "form_id", "returned", "truncated", "remaps": [ ... ], "notes": [ ... ] }`, each remap carrying `candidate_label`, `candidate_label_version` and `resolved_responses` ### `DELETE /api/attribution/forms/{id}/remaps/{remapId}` - Auth required. **Soft** — `revoked_at` is stamped and the row stays, because the row is the record that a number was once reported differently - Response 200: `{ "remap", "revoked", "resolved_responses", "notes" }` - Idempotent: revoking an already-revoked mapping is 200 with `revoked: false` and the original timestamp, not a 409. Moving `revoked_at` forward would rewrite when the mapping stopped applying - Scoped by `form_id` as well as id — a remap id alone never revokes another form's mapping - 404 covers "no such form", "not your form" and "no such remap" alike ## Conversion events ### `POST /api/attribution/events` Auth required. The inbound half of the `external_id` join: the host pushes what happened to a person, we already know which channel that person named, and the rollup becomes channel × revenue instead of channel × heads. Caller-pushed on purpose — there is no Stripe or AppsFlyer integration. Accepts a single event object, a bare array, or an envelope. An envelope `form_id` is pushed onto every element that omits one. Batch cap 500; a larger batch is a 400 telling you to page. ```json // ILLUSTRATIVE — invented figures, shown for shape { "form_id": "abc123efgh45", "events": [ { "external_id": "usr_8812", "event": "paid", "value_cents": 4900, "currency": "USD", "occurred_at": "2026-07-14T09:12:00Z", "idempotency_key": "stripe_in_1P9x" }, { "external_id": "usr_9130", "event": "signup", "occurred_at": "2026-07-14T10:01:00Z" } ] } ``` - `form_id`, `external_id`, `event`, `occurred_at` required. `event` is one of `signup`, `activated`, `paid`, `churned` - `value_cents` optional, a safe integer of minor units within ±(2^53 − 1), negatives allowed, and it **requires `currency`** — money with no unit cannot be summed. `currency` is 2–12 alphabetic characters. `occurred_at` must be an ISO 8601 timestamp string - `idempotency_key` optional, ≤ 200 chars, unique per `(form_id, idempotency_key)` - `external_id` is held to the same rules as the write path, **including the same trimming** — the join key has to be byte-identical to the one the embed stored, or the event lands and joins to nobody ```json { "accepted": 1, "duplicates": 1, "rejected": 0, "results": [ { "index": 0, "status": "created", "id": "kQ8fL2mN7pRt", "form_id": "abc123efgh45", "external_id": "usr_8812", "event": "paid", "idempotency_key": "stripe_in_1P9x" }, { "index": 1, "status": "duplicate", "id": "kQ8fL2mN7pRt", "form_id": "abc123efgh45", "external_id": "usr_8812", "event": "paid", "idempotency_key": "stripe_in_1P9x", "existing": { ... }, "warnings": ["value_cents differs: stored 4900, sent 5900"], "duplicate_of_index": 0 } ], "join_check": { "checked": 2, "matched": 1, "unmatched": 1, "examples": ["usr_9130"] }, "notes": [ ... ] } ``` - **One bad row does not fail the batch.** Every element is validated independently and reported at its own index as `created`, `duplicate` or `rejected`; a rejected element is `{ "index", "status": "rejected", "errors": [...] }` - A replay is a success that says so, **and warns when the replayed payload disagrees with what is stored**. Without that a caller reusing one key for two different amounts believes the second landed, and every revenue number downstream is quietly the first - `duplicate_of_index` names the earlier element when one request reuses a key twice - `join_check` counts how many `external_id`s match a response on the named form, with up to five unmatched examples. An event for someone who never answered is legitimate and expected — it usually arrives before the answer — which is why this is a count and not a rejection. It is also where an id-format mismatch becomes visible at push time instead of as a zero in the rollup - Status: **201** when at least one event was created; **200** when nothing was new but something was a clean replay; **400** when nothing was written; **404** when nothing was written and every rejection named a form this key cannot see Facts the endpoint returns in `notes`, restated because each one changes a number: - `value_cents` is summed as revenue **only** for `event: "paid"`. A value on `signup`, `activated` or `churned` is stored and never counted, so a refund has to be pushed as a negative-value `paid` event to move the total - An event with no `idempotency_key` cannot be deduplicated, so a retried request stores it twice and doubles that person's revenue - `occurred_at` is **not** what the rollup windows on. The rollup windows on the response's `completed_at` and then sums all `paid` events for those responses whatever their date: a payment in March belongs to the channel recorded in January - The rollup sums cents across whatever currencies it finds and warns when there is more than one. It does not convert ## Rollup ### `GET /api/attribution/rollup` Auth required. Query: `form_id` (**required**), `by`, `metric`, `from`, `to`. - `form_id` is required and there is **no union across forms**: candidate populations differ per form, so a union would divide one form's selections by another form's respondents. One-form-per-key was never a constraint — a customer runs one form in signup and another at checkout - `by` is `"candidate"` (default, one row per node × candidate) or `"node"` (candidates rolled together; `candidate_id`, `label` and `label_from_node_id` are null) - `metric` is `"responses"` (default) or `"revenue"`. **It chooses the sort, not which columns ship** — every column is present either way - `from` / `to` filter the response's `completed_at`, half-open `[from, to)`. `from == to` is a 400 rather than a payload full of zeroes that reads as "this channel stopped working". A value carrying no timezone — `2026-07-01` or `2026-07-01T00:00:00` — is read as **UTC** ```json // ILLUSTRATIVE — invented figures, shown for shape { "form_id": "abc123efgh45", "by": "candidate", "metric": "revenue", "window": { "from": "2026-07-01T00:00:00.000Z", "to": "2026-08-01T00:00:00.000Z", "basis": "response.completed_at", "bounds": "[from, to)" }, "denominator": { "completed_responses": 1330, "per_node": { "channel": 1330, "creator": 374 } }, "rows": [ { "node_id": "channel", "candidate_id": "tiktok", "label": "TikTok", "label_from_node_id": null, "responses": 412, "share": 0.31, "share_corrected": null, "revenue_cents": 1840000, "paying_responses": 96, "resolved_by_remap": 7 } ], "unresolved": { "raw": 63, "dont_remember": 128, "skipped": 91, "per_node": { "channel": { "raw": 41, "dont_remember": 128, "skipped": 91 } } }, "followup_unresolved": [ { "node_id": "channel", "candidate_id": "tiktok", "follow_node_id": "creator", "picks": 412, "unresolved": 91, "rate": 0.22 } ], "followup_abandoned": [ { "node_id": "channel", "candidate_id": "tiktok", "follow_node_id": "creator", "picks": 412, "abandoned": 38, "rate": 0.09 } ], "revenue": { "total_cents": 3910000, "paying_responses": 204, "event": "paid", "currencies": ["USD"], "basis": "first response per (form_id, external_id); all their paid events, regardless of occurred_at" }, "position_effect": null, "calibration": null, "notes": [ ... ] } ``` - Only completed responses count. A response swept as `abandoned` **is** complete and does count — its channel answer is real, which is also the rule billing uses - **The denominator ships in the payload.** `share = responses / denominator.per_node[node_id]`, where that denominator is every completed response that answered that node. Resolved shares therefore sum to less than 1 and the remainder is the `unresolved` block for the same node. A number whose denominator a reader cannot locate is worse than no number - A response that picked a channel and never answered the follow-up is counted for the channel node and is absent from the follow-up node's denominator entirely. It appears in `followup_abandoned` - `followup_unresolved` and `followup_abandoned` share a denominator (picks that opened a follow-up) and count different things; **neither is derivable from the other**. `followup_unresolved` is candidate coverage: a follow-up counts as resolved only when it resolves to a candidate id, so never-returned, `dont_remember`, `skipped` and free text with no live remap are all unresolved — and free text a remap *has* resolved counts as resolved, so a mapping visibly moves the number. `followup_abandoned` counts only the picks with no answer row for the follow-up node - Both follow-up read-outs are **arrays keyed by explicit fields**, never a map keyed `node:candidate` — both ids are caller-defined, so any separator is a character a caller may already be using inside an id - Revenue is booked **once per response**, against the first response per `(form_id, external_id)`. A retake counts in `responses` and books no revenue, so revenue-weighted and head-weighted shares can legitimately disagree - Per-row `revenue_cents` and `paying_responses` appear **only on rows of the root node** and are `null` elsewhere: a response's money belongs to the response, so repeating it on every node answered would multiply the total by the number of questions. Null rather than 0, because 0 would read as "this candidate produced no revenue" - `revenue.total_cents` can exceed the sum of `rows[].revenue_cents`: a response whose channel answer is unresolved still paid, and has no row to book it on - `share_corrected`, `position_effect` and `calibration` are **null in v1**, returned as explicit nulls so their absence is visible rather than mysterious. Under the default `rotate` order the raw share is already unbiased, and the estimator needs volume before it can return anything but null - Labels come from the config snapshots, never the live catalog. `label_from_node_id` is set when a label had to be read off another node of this form, which only happens for a remap target. `label` is null only when no version of the form contains the id at all - Read `notes[]`. It carries these caveats plus a multi-currency warning when the window mixes currencies, in which case the totals are unusable as-is ## Catalog ### `GET /api/attribution/catalog` **Public, no key** — configuration is agent-driven and an agent cannot name a `catalog_slug` it has never seen. `Cache-Control: public, max-age=300, s-maxage=3600`; it is a checked-in module and only changes on deploy, and a stale read can misinform discovery but can never alter what a stored response says was rendered. ```json { "platforms": [ { "slug": "tiktok", "label": "TikTok", "class": "creator", "brand_color": "#000000", "icon_url": "/logos/tiktok.svg", "monogram": "TI", "aliases": ["douyin", "short video"], "expands_by_default": true } ], "default_channel_slugs": ["google", "chatgpt", "linkedin", "x", "tiktok", "youtube", "instagram", "reddit", "friend", "coworker-internal", "press", "event"] } ``` - `class` is one of `creator`, `ai_assistant`, `search`, `podcast`, `community`, `messaging`, `word_of_mouth`, `offline`, `other` - `icon_url` is a `/logos/{slug}.svg` path for **every one of the 39 entries**. An earlier revision of this file said it was null for LinkedIn, ChatGPT and Slack following trademark requests. That described the simple-icons set; marks now come from svgl, which carries all three, so **no entry returns null today**. The field stays nullable in the type so an entry added ahead of its artwork degrades instead of rendering blank — but a caller that branches on null against the current catalog writes a branch that never runs - `monogram` ships on every entry regardless: two characters off the label, for that fallback and for the caller-defined candidates (a creator with no avatar) that still need one - `expands_by_default` is **advisory**. `PUT` never applies it — which channels earn a follow-up is a monthly decision about where the money went, and a static flag cannot know - `default_channel_slugs` errs long on purpose. A missing channel does not cost one data point; its traffic lands in a neighbouring bucket and books a false entry there ## Respondent page and embed `/s/{id}` is public, `noindex`, never cached, and the only page a respondent sees. It renders "This form isn't accepting responses right now" when the form is paused **or** has never been configured — publicly indistinguishable on purpose, since a respondent cannot act on the difference. - `?embed=1` renders on a transparent full-width container with no header, footer or page gradient - Reserved query params, consumed by the page and never stored as tags: `embed`, `external_id`, `host_origin`. Every other param is captured as response `metadata` - Metadata sanitization: string and number values only, ≤ 20 keys, keys ≤ 64 chars, values ≤ 512 chars, repeated params keep their last value, `metadata: {}` when none were sent ### postMessage events Five types, each posted to `window.parent` with `source: 'humansurvey'` and `formId`. Fired only when `?embed=1`. `targetOrigin` is `*`, because the host's origin is not knowable from inside the frame — every payload is content the host already has. - `{ source: 'humansurvey', type: 'mounting', formId }` — fired the instant the iframe HTML is parsed, by an inline script, before React hydrates. Use it to swap a blank spinner for a skeleton during the cold load - `{ source: 'humansurvey', type: 'loaded', formId }` — hydrated and interactive - `{ source: 'humansurvey', type: 'resize', formId, height }` — content height changed; size the iframe so there is no inner scrollbar. Deduped, and a zero height is never reported: a collapsed frame stops producing resize callbacks and never recovers - `{ source: 'humansurvey', type: 'submitted', formId, responseId, answers }` — the first answer is durable. **Not the end of the flow** — the follow-up may still be on screen, and a host that hides the iframe here cuts the respondent off mid-question - `{ source: 'humansurvey', type: 'completed', formId, responseId, answers }` — the follow-up landed, or the respondent finished in one step. Route on this one `answers` is keyed by **node id** and carries only the node just answered — the same answer object the write path accepts, not a whole-form map: ```json { "channel": { "candidate_id": "tiktok" } } { "creator": { "raw": "the office skits girl" } } { "channel": { "dont_remember": true } } { "channel": { "skipped": true } } ``` There is no `::` fill-in encoding any more, and no `redirect` / `callback` / `message+html` mode — the host JS decides what happens on `completed`. ```html ``` ## Distribution boundary HumanSurvey returns a `/s/{id}` URL and an iframe that renders it. It does **not** distribute to the audience — no email blast, no auto-posting to Slack/Discord, no SMS — and it never contacts a respondent. Getting the question in front of people is the host's job (embed it in a flow they already own) or the agent's job (if it has a messaging tool connected). This is intentional: reaching the audience requires access to the audience, which belongs to the user, not to this service. Transports the host or user controls — the URL, the iframe — are fine. ## Limits | Thing | Limit | |---|---| | nodes per form | 12 | | candidates per node | 500 stored, 12 rendered (search carries the rest) | | aliases per candidate | 24 | | label / prompt / handle / `icon_url` / `expands` / `catalog_slug` | 120 characters | | any id | 128 characters | | `render_id` | 64 characters | | `external_id` | 256 characters | | free-text answer (`raw`) | 500 characters, rejected not truncated | | `allowed_origins` | 20 entries | | `theme.radius` | 0–48 px | | response page `limit` | 1–500, default 100 | | events per batch | 500 | | `idempotency_key` | 200 characters | | metadata | 20 keys, keys ≤ 64, values ≤ 512 | ## MCP Two facts that point in opposite directions. Both are true as written. **In the repo — on the 1.x line, speaks this API.** `packages/mcp-server` carries ten tools, verified end to end against the endpoints above: | Tool | Wraps | Direction | |---|---|---| | `login` | `POST /api/auth/code`, then `POST /api/auth/verify` | bootstrap — mails the six-digit code on the first call, stores the key at `~/.humansurvey/credentials` on the second, and never prints it | | `get_catalog` | `GET /api/attribution/catalog` | read — the slugs a config may name | | `list_forms` | `GET /api/attribution/forms` | read — a form cannot be configured by an agent that cannot find its id | | `get_form` | `GET /api/attribution/forms/{id}` | read — the form plus its current config snapshot | | `create_form` | `POST /api/attribution/forms` | write — a placement, name and origins only | | `configure_form` | `PUT /api/attribution/forms/{id}` | write — nodes, candidates, order, expansion policy | | `get_attribution` | `GET /api/attribution/rollup` | read — shares with their denominator, coverage read-outs | | `list_unresolved` | `GET /api/attribution/forms/{id}/unresolved` | read — free text awaiting a mapping | | `remap` | `POST /api/attribution/forms/{id}/remaps` | write — resolve free text to a candidate, retroactively | | `revoke_remap` | `DELETE /api/attribution/forms/{id}/remaps/{remapId}` | write — stop a mapping applying; soft, the row is kept | - The pre-pivot tools `create_key`, `create_survey`, `get_results`, `list_surveys` and `close_survey` are **removed** with the endpoints they called. `close_survey` has no successor: a perpetual stream has nothing to close - No tool for `PATCH /api/attribution/forms/{id}` (settings), the cursor read, the identity read, `POST /api/attribution/events` or `DELETE .../remaps/{id}`. Those are REST-only today - Every tool is a thin wrapper. Anything the tools can do, the HTTP surface above can do **On npm — `humansurvey-mcp` is published at 1.x.** `npx -y humansurvey-mcp` installs a server whose ten tools match the endpoints documented here. Versions below 1.0.0 are the pre-pivot build: every one of their tools calls a deleted `/api/surveys` route and fails against the current deployment. Those versions are deprecated on npm, but a pinned version or a stale lockfile still resolves one — pin `^1` if you pin at all. Driving the REST endpoints directly remains equivalent; every tool is a thin wrapper over them. The MCP registry is a **separate** publish from npm and can lag behind it. Neither state is inferable from this file. One page is kept current on both, and everything else points there: the "Can I use the MCP server yet?" answer at https://www.humansurvey.co/faq (markdown twin: `/faq.md`). ## Situations Four walkthroughs, one per channel class with no referrer. Each carries a `PUT` config verified against the shipping API, the follow-up question that makes the answer specific, and the placement. Read the matching one before writing a candidate list: option wording decides whether a channel gets picked at all, and a channel left off does not lose one data point — its people pick a neighbouring row and book a false entry there. - AI assistants (ChatGPT, Claude, Perplexity, Gemini — no referrer, usually no click): https://www.humansurvey.co/use-cases/ai-assistants — markdown: `/use-cases/ai-assistants.md` - Communities and word of mouth (Reddit → subreddit, a named Slack group): https://www.humansurvey.co/use-cases/community-feedback — markdown: `/use-cases/community-feedback.md` - Launch day (Product Hunt, HN, X → the account whose post was seen): https://www.humansurvey.co/use-cases/product-launch — markdown: `/use-cases/product-launch.md` - Podcasts and events (no click, signup weeks later, ids carry the edition): https://www.humansurvey.co/use-cases/events — markdown: `/use-cases/events.md` - Index: https://www.humansurvey.co/use-cases — markdown: `/use-cases.md` ## Discoverability Every content page listed below carries a markdown twin at the same path plus `.md`, served through `alternates.types["text/markdown"]`. The twin is the same substance without the chrome, so fetch it rather than parsing the HTML. The homepage (https://www.humansurvey.co) is the one page with no twin — its centrepiece is a working picker rather than text. `/about.md` and this file cover what it says in prose. - About: https://www.humansurvey.co/about (markdown: `/about.md`) — what this is, what it refuses to do, its limitations, licence (MIT) and repository, where pricing stands, and how it compares to a DIY text field and to multi-touch attribution platforms. Start here for anything that is not an endpoint - Site docs: https://www.humansurvey.co/docs (markdown: `/docs.md`) — the same surface as this file, written for someone integrating rather than for a machine enumerating fields - FAQ: https://www.humansurvey.co/faq (markdown: `/faq.md`) — MCP publish status lives here - Use cases: https://www.humansurvey.co/use-cases (markdown: `/use-cases.md`), and the four walkthroughs listed under Situations above - Changelog: https://www.humansurvey.co/changelog (markdown: `/changelog.md`) — every breaking change of the 2026-07-30 pivot, and what has been removed since an older entry described it - llms.txt: https://www.humansurvey.co/llms.txt — the short overview, and the map of every page and twin on the site - OpenAPI: https://www.humansurvey.co/api/openapi.json (alias: `/openapi.json`) - Human-facing account surface, neither of which an agent needs: https://www.humansurvey.co/signin (six-digit code in a browser) and https://www.humansurvey.co/account (list, mint and revoke keys — nothing else)