Docs

API reference for agents that collect structured feedback from groups.

HumanSurvey exposes a minimal authenticated API plus an MCP server. Agents create surveys from JSON schema, a group of humans answers at a hosted URL, and the agent retrieves structured results when ready.

Authentication

Creator routes use bearer authentication with keys shaped like hs_sk_.... The raw key is only returned once when you call POST /api/keys. MCP agents can call create_key directly — no human setup required.

curl -X POST https://www.humansurvey.co/api/keys \
  -H "Content-Type: application/json" \
  -d '{
    "name": "event-agent",
    "email": "you@example.com",
    "wallet_address": "eip155:8453:0xabc..."
  }'

All fields are optional. email ties the key to a human owner for future billing. wallet_address accepts CAIP-10 format (e.g. eip155:8453:0x... for Base) and will be used for agent-native payments.

Pass the key on authenticated requests:Authorization: Bearer hs_sk_...

Key management

There is no dashboard or sign-up — HumanSurvey is API-first, and keys are created and managed entirely through the /api/keys endpoints (or the create_key MCP tool). A human generates their first key exactly the way an agent does: an unauthenticated POST /api/keys, as shown above. The raw hs_sk_... value is returned once — store it in a secret manager or an environment variable; it is hashed at rest and cannot be recovered.

# View the current key's metadata — id, name, created_at, last_used_at
curl https://www.humansurvey.co/api/keys \
  -H "Authorization: Bearer hs_sk_..."

# Rotate: mint a replacement, move your integration to it, then revoke the old key
curl -X POST https://www.humansurvey.co/api/keys \
  -H "Content-Type: application/json" \
  -d '{"name": "event-agent (rotated 2026-05)"}'

curl -X DELETE https://www.humansurvey.co/api/keys/<old-key-id> \
  -H "Authorization: Bearer hs_sk_<old-key>"
  • ViewGET /api/keys with the key returns its own metadata (id, name, created_at, last_used_at). The key value itself is never returned. A key only sees itself — there is no account that groups multiple keys.
  • RevokeDELETE /api/keys/{id}with the key, passing that same key's id. After revocation the key is rejected with 401. Do this immediately if a key leaks.
  • Rotate — there is no in-place rotation: mint a new key, move your integration to it, then revoke the old one.

One caveat when rotating: a survey is owned by the key that created it, so a new key cannot read results for surveys created under the old key. Revoking a key does not delete its surveys — hosted /s/{id} URLs keep accepting responses — but creator-API access to those surveys ends with the key. If a key leaks, revoke it, and re-create any surveys you still need to read under the new key.

Markdown Syntax

HumanSurvey has four semantic question types: choice, text, scale, and matrix. The parser turns them into a normalized survey schema.

Choice

**Q1. Would you attend a future event?**

- ☐ Definitely
- ☐ Maybe
- ☐ Unlikely

Text

**Q2. What's one thing we should improve?**

> _______________________________________________

Scale

**Q3. How would you rate the event overall?**

[scale 1-5 min-label="Poor" max-label="Excellent"]

Matrix

| # | Session | Rating |
|---|---------|--------|
| 1 | Keynote      | ☐Excellent ☐Good ☐Fair ☐Poor |
| 2 | Workshops    | ☐Excellent ☐Good ☐Fair ☐Poor |
| 3 | Networking   | ☐Excellent ☐Good ☐Fair ☐Poor |

JSON Schema Input

The API accepts schema as the canonical input format. Send a SurveyInput object — the server validates question types, options, and conditional logic before storing the survey.

{
  "schema": {
    "title": "Post-Event Feedback",
    "description": "Help us improve future events. Takes about 2 minutes.",
    "sections": [
      {
        "title": "Your experience",
        "questions": [
          {
            "type": "scale",
            "label": "How would you rate the event overall?",
            "required": true,
            "min": 1,
            "max": 5,
            "minLabel": "Poor",
            "maxLabel": "Excellent"
          },
          {
            "type": "multi_choice",
            "label": "Which sessions did you attend?",
            "options": [
              { "label": "Keynote" },
              { "label": "Workshops" },
              { "label": "Panels" },
              { "label": "Networking" }
            ]
          },
          {
            "type": "text",
            "label": "What's one thing we should improve?"
          },
          {
            "type": "single_choice",
            "label": "Would you attend a future event?",
            "required": true,
            "options": [
              { "label": "Definitely" },
              { "label": "Maybe" },
              { "label": "Unlikely" }
            ]
          }
        ]
      }
    ]
  }
}

The web demo lets you describe a survey in plain text or Markdown and uses an LLM to translate it into schema — this is a demo convenience, not an API feature. Agents generate JSON schema directly.

API Reference

Machine-readable OpenAPI lives at /api/openapi.json. The cards below summarize the HTTP surface area.

POST /api/keys

Public

Create a new API key and return the raw secret once.

GET /api/keys

Bearer key

List metadata for the current key.

DELETE /api/keys/{id}

Bearer key

Revoke the current API key.

POST /api/surveys

Bearer key

Create a survey from JSON schema.

GET /api/surveys

Bearer key

List surveys owned by the current key.

GET /api/surveys/{id}

Public

Return survey metadata, schema, and lifecycle fields.

PATCH /api/surveys/{id}

Bearer key

Update status (close or reopen), max_responses, or expires_at. Manual close also clears expires_at; reopen resets the close-webhook fire-once gate so the next close fires fresh.

POST /api/surveys/{id}/responses

Public

Submit a response payload.

GET /api/surveys/{id}/responses

Bearer key

Return aggregated question results and raw submissions.

Create survey

curl -X POST https://www.humansurvey.co/api/surveys \
  -H "Authorization: Bearer hs_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "schema": {
      "title": "Post-Event Feedback",
      "sections": [{
        "questions": [
          {
            "type": "scale",
            "label": "How would you rate the event overall?",
            "required": true,
            "min": 1,
            "max": 5,
            "minLabel": "Poor",
            "maxLabel": "Excellent"
          },
          {
            "type": "text",
            "label": "What\'s one thing we should improve?"
          }
        ]
      }]
    },
    "expires_at": "2026-12-31T23:59:59.000Z"
  }'

Get results

curl https://www.humansurvey.co/api/surveys/svy_123/responses \
  -H "Authorization: Bearer hs_sk_..." 

Close survey

curl -X PATCH https://www.humansurvey.co/api/surveys/svy_123 \
  -H "Authorization: Bearer hs_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"status":"closed"}'

Embed

Drop a HumanSurvey form into any third-party page (landing page, onboarding step, in-app form) by appending ?embed=1 to the survey URL. The iframe renders without the site chrome and emits postMessage events to the host on mount, load, content resize, and submission — the host owns whatever happens after submission.

<iframe id="hs-survey"
        src="https://www.humansurvey.co/s/abc123?embed=1"
        style="width:100%; border:0;"></iframe>
<script>
  window.addEventListener('message', e => {
    if (e.origin !== 'https://www.humansurvey.co') return
    if (e.data?.source !== 'humansurvey') return
    const f = document.getElementById('hs-survey')
    if (e.data.type === 'resize')    f.style.height = e.data.height + 'px'
    if (e.data.type === 'submitted') {
      // your code: redirect, hide the iframe, fire analytics, etc.
      console.log('lead:', e.data.responseId, e.data.answers)
    }
  })
</script>

Four event types, all with source: 'humansurvey':

  • { type: 'mounting', surveyId } — fired the instant the iframe HTML is parsed, before the form bundle downloads or hydrates. Use it to swap your blank spinner for a skeleton while the embed cold-loads.
  • { type: 'loaded', surveyId } — fired once the form has hydrated and is interactive.
  • { type: 'resize', surveyId, height } — fired whenever content height changes; use to auto-size the iframe so there is no inner scrollbar.
  • { type: 'submitted', surveyId, responseId, answers } — fired after the response is accepted. Route the user, hide the form, render a custom thank-you — your call. See Answer payload below for the shape of answers.

Answer payload

The submitted event's answers is an object keyed by question id (q_0, q_1, … — the ids from the survey schema), not an array. Only questions the respondent actually answered appear; skipped optional questions and questions hidden by conditional logic are omitted. Each value's type follows the question type:

// e.data.answers — an object keyed by question id (q_0, q_1, ...)
{
  "q_0": "We needed it to fail loudly",      // text   -> string
  "q_1": 4,                                  // scale  -> number, within min..max
  "q_2": "opt_1",                            // single_choice -> option id
  "q_3": ["opt_0", "opt_2"],                 // multi_choice  -> array of option ids
  "q_4": ["row_0:opt_1", "row_1:opt_0"],     // matrix -> "rowId:optionId" per answered row
  "q_5": "opt_3::Found via a friend"         // choice option with a fill-in text field
}
  • text — a string.
  • scale — a number within the question's minmax.
  • single_choice — the selected option id as a string.
  • multi_choice — an array of selected option id strings.
  • matrix — an array of "rowId:optionId" strings, one entry per answered row.

For a choice option that has a fill-in text field, the value carries the typed text after a :: separator — "optionId::the typed text". Split on the first :: to recover the option id. This is the same answers object persisted on the response and returned by get_results / GET /api/surveys/{id}/responses.

Response tagging

Append your own query params to the survey URL — embedded or standalone — to tag where a response came from (which page, product, tier). Any param that isn't reserved is captured and stored with the submission as a metadata object.

<!-- ?source and ?tier are captured as response metadata -->
<iframe src="https://www.humansurvey.co/s/abc123?embed=1&source=pricing&tier=lite"
        style="width:100%; border:0;"></iframe>

// later — GET /api/surveys/abc123/responses
{
  "raw": [
    {
      "id": "xyz789abcd01",
      "answers": { "q_0": 5 },
      "metadata": { "source": "pricing", "tier": "lite" },
      "created_at": "2026-04-07T14:00:00.000Z"
    }
  ]
}

The tags surface on every response in raw[] from GET /api/surveys/{id}/responses, and get_results prints a per-tag breakdown — so you can segment responses by source without a separate analytics event. Responses with no custom params carry metadata: {}.

  • Reserved param: embed — consumed by the renderer, never stored as metadata. Every other param is captured.
  • Sanitized on capture: string keys and values only, at most 20 keys, keys truncated to 64 characters and values to 512. Repeated params keep their last value.

Async results

Surveys collect over hours or days. Agents shouldn't stay alive polling, and shouldn't re-read old data on every check. Two primitives let an agent exit after creating a survey and rejoin only when there's new signal.

Webhook events

Set webhook_url at create time. The same URL receives two event shapes — branch on the event field. Use event_id to dedupe; delivery is at-least-once per event type.

  • { event: 'survey_closed', closed_reason: 'manual' | 'max_responses' | 'expired', ... } — fires once on closure. expired fires lazily within seconds of any next interaction with the survey (no cron).
  • { event: 'threshold_reached', status: 'open', threshold, response_count, ... } — fires once when response_count first crosses notify_at_responses. Survey stays open. Use this to wake the agent on "enough signal" without waiting for closure.
// Receive both event types on the same URL — branch on payload.event
app.post('/hooks/humansurvey', (req, res) => {
  const { event, event_id, survey_id } = req.body
  if (alreadyProcessed(event_id)) return res.sendStatus(200) // dedupe

  if (event === 'threshold_reached') {
    // survey is still open; act on enough signal
    wakeAgent({ survey_id, reason: 'threshold' })
  } else if (event === 'survey_closed') {
    // terminal: closed_reason ∈ { manual | max_responses | expired }
    wakeAgent({ survey_id, reason: 'closed', cause: req.body.closed_reason })
  }
  res.sendStatus(200)
})

Cursor reads (pull fallback)

Webhooks can be lost. GET /api/surveys/{id}/responses accepts a since_response_id query param so a re-entering agent fetches only the deltas. The response carries is_final, completion_reason, next_check_hint_seconds, and next_cursor. Aggregates always reflect the full survey; the cursor only filters raw.

// On re-entry, fetch only new responses since last cursor
const r = await fetch(`${api}/api/surveys/${id}/responses?since_response_id=${cursor}`,
  { headers: { Authorization: `Bearer ${key}` } }).then(r => r.json())

if (r.is_final) {
  // completion_reason ∈ { closed | max_responses | expired }
  act(r)                  // done; do not check again
} else {
  saveCursor(r.next_cursor)                              // r.raw is only the deltas
  scheduleNextCheck(r.next_check_hint_seconds)            // server's advisory cadence
}

next_check_hint_seconds is advisory — server-computed from recent response rate and time-to-expiry, capped to give roughly four checks before expiry. Agents may check sooner if the task requires.

MCP Tools

Claude Code and other MCP clients can call HumanSurvey directly throughhumansurvey-mcp.

{
  "mcpServers": {
    "survey": {
      "command": "npx",
      "args": ["-y", "humansurvey-mcp"],
      "env": { "HUMANSURVEY_API_KEY": "hs_sk_your_key_here" }
    }
  }
}

create_key

Create an API key. Call this first if HUMANSURVEY_API_KEY is not set — agents can self-provision without human setup.

create_survey

Create a survey from JSON schema and return the respondent URL and survey ID.

get_results

Fetch aggregated results for a survey by survey_id.

list_surveys

List surveys owned by the configured API key.

close_survey

Close an open survey so it stops collecting responses.

create_survey({
  schema: {
    title: "Post-Event Feedback",
    sections: [{
      questions: [
        { type: "scale", label: "How would you rate the event overall?",
          required: true, min: 1, max: 5, minLabel: "Poor", maxLabel: "Excellent" },
        { type: "multi_choice", label: "Which sessions did you attend?",
          options: [{ label: "Keynote" }, { label: "Workshops" }, { label: "Networking" }] },
        { type: "text", label: "What's one thing we should improve?" },
        { type: "single_choice", label: "Would you attend a future event?",
          required: true, options: [{ label: "Definitely" }, { label: "Maybe" }, { label: "Unlikely" }] }
      ]
    }]
  }
})

// share /s/{id} with attendees, check back later
get_results({ survey_id: "svy_123" })

// once you have enough responses
close_survey({ survey_id: "svy_123" })

Conditional Logic

Use show if: blocks in Markdown or showIf in JSON schema to reveal follow-up questions only when the condition matches.

**Q1. Did you participate in the networking session?**

- ☐ Yes
- ☐ No

**Q2. What would have made networking more valuable?**

> show if: Q1 = "Yes"

> _______________________________________________

Supported operators map to semantic checks:

  • = or eq for equality
  • != or neq for inequality
  • contains for multi-select membership
  • answered to check whether the earlier question has any answer