API reference
Read surveys and responses, keep contacts in step with your HR system, and receive an event when someone answers. The API returns JSON, uses an organization API key and follows the same privacy rules as the app.
Version 1 · base address https://new.wrkflow.si/api/v1 · see what changed in the changelog
Start here
The API is available on the Team plan and above. Below that, every call answers 403 with the code plan_required.
- In wrkflow, open Settings, API and webhooks and create a key. Pick only the scopes it needs, and an expiry if you can.
- Copy the key when it is shown. It is shown once, and wrkflow keeps only a hash of it.
- Send it as a bearer token with every request.
curl https://new.wrkflow.si/api/v1/me \
-H "Authorization: Bearer wrk_YOUR_KEY"{
"organization": { "id": "…", "name": "Northwind Traders", "slug": "northwind", "plan": "team" },
"key": { "id": "…", "name": "Reporting", "prefix": "wrk_a1b2", "scopes": ["surveys:read", "responses:read"], "expires_at": null }
}GET /me works with any valid key, so it is the call to test a connection with.
Keys, scopes and limits
A key looks like wrk_ followed by 32 letters and digits. It belongs to one organization and only ever sees that organization. A key that is revoked or past its expiry stops working at once.
| Scope | What it allows |
|---|---|
surveys:read | List surveys, read a survey and its published questions, and the survey.published polling trigger. |
responses:read | Read responses and uploaded files, and the response.created polling trigger. |
contacts:read | List contacts. |
contacts:write | Add and update contacts. |
hooks:write | Subscribe, list and unsubscribe event hooks. |
- Rate limit. 600 requests a minute for each key. Beyond that the API answers
429with the coderate_limitedand aRetry-Afterheader in seconds; wait that long and retry. - IP allowlist. If the organization limits access to certain addresses, the same list applies to API keys (
403,ip_not_allowed). - No caching. Every response carries
Cache-Control: no-store. - Audit log. Reading a survey’s responses or a file is recorded in the organization’s audit log with the key’s name: the first read of each survey by a key each day (UTC), so a sync that polls every minute writes one entry a day.
Errors and paging
Errors share one shape: { "error": { "code": "not_found", "message": "No survey with that id." } }. Match on the code; the message is for people.
| Status and code | Meaning |
|---|---|
400 bad_request | The request is malformed. The message says what to send instead. |
401 unauthorized | No key, a key in the wrong format, or one that is invalid, revoked or expired. |
403 plan_required | The organization’s plan does not include this part of the API. |
403 forbidden | The key does not have the scope the endpoint needs. |
403 ip_not_allowed | The request came from an address the organization does not allow. |
404 not_found | No survey, file or subscription with that id in this organization, or no such endpoint. |
409 limit_reached | A key can hold 100 hook subscriptions. |
422 too_few_responses | An anonymous survey has fewer than 5 responses, so none are released yet. |
429 rate_limited | More than 600 requests in a minute for this key. |
500 server_error | Something failed on our side. Nothing was changed by a write; try again. |
503 unavailable | wrkflow is briefly unavailable. Your key is fine (this is never a 401): retry after the Retry-After header’s seconds. |
Lists that can be long (surveys, responses, contacts) page with an opaque cursor. Read next_cursor from a page and send it back; it is null on the last page. Responses and contacts read oldest first, so a sync can keep the last cursor and continue from it later; surveys read newest first.
let cursor = null;
do {
const url = new URL("https://new.wrkflow.si/api/v1/surveys/SURVEY_ID/responses");
url.searchParams.set("limit", "500");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: "Bearer wrk_YOUR_KEY" } });
const page = await res.json();
for (const response of page.data) handle(response);
cursor = page.next_cursor;
} while (cursor);Surveys
/surveyssurveys:readThe organization’s surveys, newest first, 500 a page (limit 1 to 500; cursor for the next page). public_url is null until the survey has been published.
{
"data": [
{
"id": "01a10e00-0000-7000-8000-0000000000b1",
"title": "Onboarding check-in (day 30)",
"status": "live",
"opens_at": null,
"closes_at": "2026-12-01T00:00:00+00:00",
"response_count": 212,
"published": true,
"public_url": "https://new.wrkflow.si/s/k3v9q2",
"created_at": "2026-10-01T08:00:00+00:00",
"updated_at": "2026-10-06T08:00:00+00:00"
}
],
"next_cursor": null
}/surveys/{id}surveys:readOne survey and its published questions. The definition is what respondents see; its question ids are the keys of the answers you read back.
{
"data": {
"id": "01a10e00-0000-7000-8000-0000000000b1",
"title": "Onboarding check-in (day 30)",
"status": "live",
"response_count": 212,
"anonymous": false,
"published_version": {
"version": 1,
"published_at": "2026-10-06T08:00:00+00:00",
"definition": { "title": "…", "questions": [{ "id": "enps", "type": "nps", "title": "…" }] }
}
}
}published_version is null for a survey that has not been published. anonymous tells you which privacy rules the responses follow.
Responses
/surveys/{id}/responsesresponses:readThe survey’s responses, oldest first, a page at a time. Answers are keyed by question id; read the survey’s definition for the questions.
| Query | Meaning |
|---|---|
limit | Responses per page: 1 to 500, 100 by default. |
cursor | The next_cursor of the previous page. |
since | Only responses submitted at or after this ISO 8601 time. Anything else answers 400. |
curl "https://new.wrkflow.si/api/v1/surveys/SURVEY_ID/responses?since=2026-10-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer wrk_YOUR_KEY"{
"data": [
{
"id": "01a10e00-0000-7000-8000-0000000000a1",
"version_id": "01a10e00-0000-7000-8000-0000000000c1",
"submitted_at": "2026-10-06T09:14:03.120+00:00",
"duration_seconds": 95,
"source": "invitation",
"device": "mobile",
"language": "sl",
"attributes": { "department": "Engineering", "location": "Berlin" },
"hidden_fields": {},
"answers": {
"enps": { "value": 9 },
"support": { "selected": "yes" },
"better": { "text": "More time with my buddy. Reach me at j••••@…" },
"health": { "withheld": "sensitive" }
}
}
],
"next_cursor": "WyIyMDI2LTEwLTA2VDA5OjE0OjAzLjEyMCswMDowMCIsIjAxYTEw…"
}| Field | Meaning |
|---|---|
version_id | The published version the person answered, so you can read the matching questions after later edits. |
source, device | How the response arrived (link, QR, embed, invitation) and on what kind of device. Either can be null. |
language | The language code the person answered in (for example sl). Responses recorded before languages were kept read as the survey’s own language. |
attributes | The participant’s details when they were invited, such as department. Empty for anonymous surveys. |
hidden_fields | Values the survey read from its link, with personal data masked. Empty for anonymous surveys. |
answers | One entry per answered question. See below for what is withheld. |
/surveys/{id}/files/{fileId}responses:readA file a respondent uploaded: its name, type and size, and a download address that works for 60 seconds. The file ids are in the answers of file upload questions.
{
"data": {
"id": "…",
"response_id": "…",
"name": "evidence.pdf",
"type": "application/pdf",
"size": 184320,
"url": "https://… (works for 60 seconds)",
"expires_at": "2026-10-06T09:15:03.120Z"
}
}What the API never returns
An API key has no person behind it, so it is held to the strictest reading rules. These cannot be switched off from the key.
- Sensitive questions. Answers to a question marked sensitive are never returned. The answer reads
{ "withheld": "sensitive" }in every response. - Personal data in written answers (email addresses, phone numbers and similar) is masked.
- Anonymous surveys. Nothing is released until the survey has 5 responses. After that there are no attributes, hidden fields or language, and uploaded files lose their names. Identity is never included in any survey.
The same rules apply to exports, shared reports, the Google Sheets feed and webhooks.
Contacts
/contactscontacts:readContacts, oldest first. Add email to look one person up.
| Query | Meaning |
|---|---|
email | Return only the contact with this address (compared in lower case). |
limit | 1 to 500, 100 by default. |
cursor | The next_cursor of the previous page. |
Each contact has id, email, full_name, attributes, created_at and updated_at.
/contactscontacts:writeAdd or update up to 1,000 contacts by email in one call. All of it happens together or not at all.
curl -X POST https://new.wrkflow.si/api/v1/contacts \
-H "Authorization: Bearer wrk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "email": "ana@example.com", "full_name": "Ana Novak", "attributes": { "department": "Support" } }
],
"update_existing": true
}'{ "data": { "inserted": 1, "updated": 0, "skipped": 0 } }update_existingis true unless you send false; false leaves people who already exist as they are.- Attribute keys must already exist in the organization. Unknown keys are ignored, and a blank value never erases a detail you already have.
skippedcounts rows with an address that is not valid.
Events and hooks
Two events exist. A delivery carries the same item whether you receive it as a webhook, through a hook subscription or by polling.
| Event | When |
|---|---|
response.created | Someone submits a response. Needs responses:read to poll. |
survey.published | A survey version goes live. Needs surveys:read to poll. It carries no answers. |
/hookshooks:writeSubscribe a URL to an event, for every survey or for one. Built for automation tools, which subscribe when someone turns a workflow on and unsubscribe when they turn it off.
curl -X POST https://new.wrkflow.si/api/v1/hooks \
-H "Authorization: Bearer wrk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "event": "response.created", "target_url": "https://example.com/hooks/wrkflow", "survey_id": "SURVEY_ID" }'{
"id": "…",
"event": "response.created",
"target_url": "https://example.com/hooks/wrkflow",
"survey_id": "SURVEY_ID",
"active": true,
"created_at": "2026-10-06T09:00:00+00:00"
}- Needs the Team plan or above. The target must be an HTTPS address on a public host. A key can hold 100 subscriptions.
- The response does not include a signing secret. Subscriptions made here are meant for tools that trust the address they gave; to verify signatures yourself, add a webhook under Settings, API and webhooks, which shows its secret once.
/hookshooks:writeThe subscriptions made with this key, newest first.
/hooks/{id}hooks:writeRemove a subscription made with this key. Answers { id, deleted: true }, or 404 for one that is not yours.
/hooks/samplesany keyAn example item for each event, so a tool can build its field mapping before any real response exists. Not real data.
/triggers/{event}responses:read or surveys:readPoll for recent items instead of receiving them. Newest first as a plain array under data, up to limit (1 to 100, 25 by default). Each item has a unique id, so a poller can skip what it has seen. response.created also takes survey_id.
Webhooks and signatures
Under Settings, API and webhooks you can add a webhook for the events you choose. Each delivery is a POST of JSON with three headers, and a body that wraps the item in data.
POST /hooks/wrkflow HTTP/1.1
Content-Type: application/json
User-Agent: wrkflow-webhooks/1.0
X-Wrkflow-Event: response.created
X-Wrkflow-Delivery: 5d0c…
X-Wrkflow-Attempt: 1
X-Wrkflow-Signature: t=1791277200,v1=9f2c…
{
"id": "5d0c…",
"event": "response.created",
"created_at": "2026-10-06T09:14:04.020+00:00",
"organization_id": "…",
"data": {
"id": "01a10e00-0000-7000-8000-0000000000a1",
"survey": { "id": "01a10e00-0000-7000-8000-0000000000b1", "title": "Onboarding check-in (day 30)" },
"response": {
"id": "01a10e00-0000-7000-8000-0000000000a1",
"submitted_at": "2026-10-06T09:14:03.120Z",
"answers": { "enps": { "value": 9 } },
"attributes": { "department": "Engineering" },
"source": "invitation",
"language": "sl",
"version_id": "01a10e00-0000-7000-8000-0000000000c1",
"anonymous": false
}
}
}A survey.published delivery has this in data:
{
"id": "01a10e00-0000-7000-8000-0000000000b1:1",
"survey": { "id": "01a10e00-0000-7000-8000-0000000000b1", "title": "Onboarding check-in (day 30)", "public_id": "k3v9q2", "url": "https://new.wrkflow.si/s/k3v9q2" },
"version": 1,
"published_at": "2026-10-06T08:00:00.000Z"
}X-Wrkflow-Signature holds a timestamp and an HMAC-SHA256 of <timestamp>.<raw body> made with the webhook’s signing secret. Check it against the raw bytes you received, before parsing, and reject anything older than a few minutes:
import { createHmac, timingSafeEqual } from "node:crypto";
// header: X-Wrkflow-Signature: t=<unix seconds>,v1=<hex>
export function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = Buffer.from(createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"));
const given = Buffer.from(parts.v1 ?? "");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && given.length === expected.length && timingSafeEqual(given, expected);
}- Answer with a
2xxwithin ten seconds. Anything else (another status, a timeout, no answer) is tried again after 1 minute, 5 and 30 minutes, 2, 5 and 10 hours and 10 hours again: eight attempts over about a day. Then the delivery is marked failed, and an admin can send it again from the webhook’s log in Settings for 30 days. - Every attempt of a delivery has the same
X-Wrkflow-Deliveryid and body, and says which attempt it is inX-Wrkflow-Attempt; it is signed again each time. Use the delivery id to ignore one you have already handled. - Answer
410 Goneto stop: that delivery is not retried, and a subscription made through/hooks(Zapier, Make) is removed. - Each delivery’s status, HTTP code, attempts and next try are in the webhook’s log under Settings, API and webhooks. Pausing a webhook drops the deliveries waiting for a retry.
- Deliveries for an anonymous survey are held until it has 5 responses and are sent only for days before today (UTC), in no particular order, so timing gives nothing away that an export would not.
- A submission never waits for a webhook and never fails because of one.
These hooks and triggers are what a Zapier or Make integration is built on. wrkflow does not publish an app in either marketplace; the help center explains how to build your own.