POST /api/keys
Public
Create a new API key and return the raw secret once.
Docs
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.
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_...
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>"GET /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.DELETE /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.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.
HumanSurvey has four semantic question types: choice, text, scale, and matrix. The parser turns them into a normalized survey schema.
**Q1. Would you attend a future event?**
- ☐ Definitely
- ☐ Maybe
- ☐ Unlikely**Q2. What's one thing we should improve?**
> _______________________________________________**Q3. How would you rate the event overall?**
[scale 1-5 min-label="Poor" max-label="Excellent"]| # | Session | Rating |
|---|---------|--------|
| 1 | Keynote | ☐Excellent ☐Good ☐Fair ☐Poor |
| 2 | Workshops | ☐Excellent ☐Good ☐Fair ☐Poor |
| 3 | Networking | ☐Excellent ☐Good ☐Fair ☐Poor |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.
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.
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"
}'curl https://www.humansurvey.co/api/surveys/svy_123/responses \
-H "Authorization: Bearer hs_sk_..." curl -X PATCH https://www.humansurvey.co/api/surveys/svy_123 \
-H "Authorization: Bearer hs_sk_..." \
-H "Content-Type: application/json" \
-d '{"status":"closed"}'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.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 min…max.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.
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: {}.
embed — consumed by the renderer, never stored as metadata. Every other param is captured.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.
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)
})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.
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" })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 inequalitycontains for multi-select membershipanswered to check whether the earlier question has any answer