Appearance
Responses API
Read, export, and delete survey responses (authenticated), and submit responses from a respondent's browser (public, slug-based, no API key).
Base URL: https://app.krafter.dev/api/v1
The response API has two distinct surfaces:
- Authenticated — your team reading and managing collected responses (
GET /surveys/:id/responses, etc.). Requiressurveys:read/surveys:write. - Public — the respondent submitting answers from a browser or your own form (
POST /surveys/:slug/responses, etc.). Slug-based, no auth, CORS-enabled, stricter rate limit.
The Response object
| Field | Type | Description |
|---|---|---|
id | string | UUID |
survey_id | string | UUID of the survey |
respondent_token | string | null | Per-respondent token for invitation flows. Set on start_response when the respondent opens an invite link |
respondent_email | string | null | Email address if known (invitation flow or email-typed answer copied through) |
answers | object | {question_id: answer_value} — see Answer shapes |
question_versions | object | {question_id: version} snapshot of question versions at submit time. Lets the dashboard render historical responses against the question shape they saw |
ai_conversation | object[] | AI-mode only. Ordered transcript of {role, question, answer} turns. Empty for manual surveys |
ai_cost_cents | integer | Token cost in cents. 0 for manual surveys |
metadata | object | Free-form. Embed widget writes {ip, user_agent, referrer, country}; you can pass your own keys via start_response and save_progress |
status | string | in_progress, completed, or abandoned |
started_at | string | ISO 8601, set on start_response |
completed_at | string | null | ISO 8601, set on complete_response |
created_at | string | ISO 8601, same as started_at for normal flow |
Authenticated endpoints
List Responses
GET /surveys/:survey_id/responsesRequired scope: surveys:read
Query parameters
| Param | Type | Description |
|---|---|---|
page | integer | 1-based page. Default 1 |
per_page | integer | Items per page. Default 20, max 100 |
status | string | Filter by in_progress, completed, or abandoned |
Example Request
bash
curl "https://app.krafter.dev/api/v1/surveys/SURVEY_ID/responses?status=completed" \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": [
{
"id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"survey_id": "b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"respondent_token": null,
"respondent_email": "alice@example.com",
"answers": {
"q1c2d3e4-...": "A landing page form",
"q2c2d3e4-...": "Search",
"q3c2d3e4-...": 9
},
"question_versions": {
"q1c2d3e4-...": 1,
"q2c2d3e4-...": 1,
"q3c2d3e4-...": 1
},
"ai_conversation": [],
"ai_cost_cents": 0,
"metadata": {
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"country": "US"
},
"status": "completed",
"started_at": "2026-05-10T10:05:00Z",
"completed_at": "2026-05-10T10:06:30Z",
"created_at": "2026-05-10T10:05:00Z"
}
],
"pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
}Get Response
GET /surveys/:survey_id/responses/:response_idRequired scope: surveys:read
Returns a single response. Returns 404 if the response belongs to a different survey or team — even if you know the id.
Delete Response
DELETE /surveys/:survey_id/responses/:response_idRequired scope: surveys:write
Permanently deletes one response. The survey's response_count is decremented if the response was completed.
204 No ContentBulk delete
There is no bulk-delete endpoint. To wipe all responses for a survey, fetch ids via List Responses and delete them one by one, or delete and recreate the survey.
Export Responses
POST /surveys/:survey_id/responses/exportRequired scope: surveys:read
Streams every response for a survey. Two formats:
format | Output |
|---|---|
"csv" (recommended) | text/csv chunked stream, one row per response. Headers: id, respondent_email, respondent_token, status, started_at, completed_at, created_at, answer:<question_id>, .... List answers are joined with ;; map answers are JSON-encoded; cells starting with =/+/-/@/tab/CR are prefixed with ' to defuse spreadsheet formula injection |
"json" (default) | First 10,000 responses returned in a single JSON array. No streaming — use CSV for larger surveys |
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/surveys/SURVEY_ID/responses/export \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"format": "csv"}' \
-o responses.csvThe CSV is downloaded with Content-Disposition: attachment; filename="<slug>-responses.csv".
Public endpoints
The public response flow is slug-based, unauthenticated, and CORS-enabled — designed to be called directly from a respondent's browser (or from the embed widget). Three calls form a single response:
POST /api/v1/surveys/:slug/responses— start a responsePATCH /api/v1/surveys/:slug/responses/:response_id— save answers (any number of times)POST /api/v1/surveys/:slug/responses/:response_id/complete— finalise
Rate limit (public)
The public response endpoints share the public rate-limit bucket:
start_response,complete_response,ai_next_question— stricter per-IP bucket (write/AI traffic)save_progress— standardpublicbucket
See Rate limits for current numbers. Survey must be active — calls against draft or closed surveys return 404 Not Found (the slug is not exposed).
Start Response
POST /api/v1/surveys/:slug/responsesAuth: none.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
respondent_token | string | No | Set this when the respondent opened a per-recipient invitation link. Lets the response be linked back to the respondent record |
respondent_email | string | No | Email if known up-front |
metadata | object | No | Custom metadata (UTM, referrer, anything you want preserved on the response). Merged with widget-provided fields |
bash
curl -X POST https://app.krafter.dev/api/v1/surveys/product-feedback/responses \
-H "Content-Type: application/json" \
-d '{}'json
// 201 Created
{
"data": {
"id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"survey_id": "b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"respondent_token": null,
"respondent_email": null,
"answers": {},
"question_versions": {},
"ai_conversation": [],
"ai_cost_cents": 0,
"metadata": {},
"status": "in_progress",
"started_at": "2026-05-10T10:05:00Z",
"completed_at": null,
"created_at": "2026-05-10T10:05:00Z"
}
}The returned id is the only handle you need for save-progress and complete. Treat it as opaque — it is not signed, but a respondent who loses it cannot resume their response.
Save Progress
PATCH /api/v1/surveys/:slug/responses/:response_idAuth: none.
Allows the embed widget (or your own front-end) to persist partial progress as the respondent answers. Multiple PATCHes are allowed — each call overwrites answers, question_versions, ai_conversation, and metadata with whatever you send. Send the full state each time.
Request Body
| Field | Type | Description |
|---|---|---|
answers | object | {question_id: value} map |
question_versions | object | {question_id: version} — copy version from each question at the moment the respondent saw it |
ai_conversation | object[] | AI-mode only. Append-only transcript |
metadata | object | Free-form |
bash
curl -X PATCH https://app.krafter.dev/api/v1/surveys/product-feedback/responses/$RESPONSE_ID \
-H "Content-Type: application/json" \
-d '{
"answers": {
"q1c2d3e4-...": "A landing page form",
"q2c2d3e4-...": "Search"
},
"question_versions": {
"q1c2d3e4-...": 1,
"q2c2d3e4-...": 1
}
}'Save-progress does not change status — the response stays in_progress until you call complete.
Complete Response
POST /api/v1/surveys/:slug/responses/:response_id/completeAuth: none.
Finalises the response. Sets status: "completed", sets completed_at, increments the survey's response_count, and triggers any response.completed webhooks.
bash
curl -X POST https://app.krafter.dev/api/v1/surveys/product-feedback/responses/$RESPONSE_ID/complete \
-H "Content-Type: application/json"json
{
"data": {
"id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"status": "completed",
"completed_at": "2026-05-10T10:06:30Z",
"...": "..."
}
}A second complete against the same response is a no-op — it returns the response unchanged.
AI Next Question
POST /api/v1/surveys/:slug/nextAuth: none.
For surveys with mode: "ai". Asks the AI engine to generate the next question based on the running conversation. The embed widget calls this between save-progress calls; you can call it directly if you're rendering the survey yourself.
Request Body
| Field | Type | Description |
|---|---|---|
conversation | object[] | The running [{role, question, answer}, ...] transcript |
context | object | Optional context the AI should consider (target persona, locale, etc.) |
bash
curl -X POST https://app.krafter.dev/api/v1/surveys/product-feedback/next \
-H "Content-Type: application/json" \
-d '{
"conversation": [
{"role": "ai", "question": "What did you build with Krafter?"},
{"role": "user", "answer": "A signup form"}
],
"context": {"locale": "en-US"}
}'json
{
"data": {
"question": "How long did the form take to set up?",
"type": "text",
"done": false
}
}When the AI engine decides the conversation is complete, the response carries "done": true and the front-end should call /complete.
Errors
503 Service Unavailable— AI engine not configured (no API key, model unreachable). The embed widget falls back to ending the conversation.502 Bad Gateway— the upstream model returned an error. Retry with the sameconversation.
CORS preflight
OPTIONS /api/v1/surveys/*Returns 204 No Content with permissive CORS headers so browsers can POST/PATCH from any origin. No body, no auth.
Errors
404 Not Found— survey slug doesn't exist, isn'tactive, or the response belongs to a different survey422 Unprocessable Entity—answerskeys don't match a question id, or values fail per-type validation429 Too Many Requests— rate-limit bucket exhausted. Retry afterRetry-Afterheader503/502— AI engine errors (only on/next)