Skip to content

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.

  1. In wrkflow, open Settings, API and webhooks and create a key. Pick only the scopes it needs, and an expiry if you can.
  2. Copy the key when it is shown. It is shown once, and wrkflow keeps only a hash of it.
  3. 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.

ScopeWhat it allows
surveys:readList surveys, read a survey and its published questions, and the survey.published polling trigger.
responses:readRead responses and uploaded files, and the response.created polling trigger.
contacts:readList contacts.
contacts:writeAdd and update contacts.
hooks:writeSubscribe, list and unsubscribe event hooks.
  • Rate limit. 600 requests a minute for each key. Beyond that the API answers 429 with the code rate_limited and a Retry-After header 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 codeMeaning
400 bad_requestThe request is malformed. The message says what to send instead.
401 unauthorizedNo key, a key in the wrong format, or one that is invalid, revoked or expired.
403 plan_requiredThe organization’s plan does not include this part of the API.
403 forbiddenThe key does not have the scope the endpoint needs.
403 ip_not_allowedThe request came from an address the organization does not allow.
404 not_foundNo survey, file or subscription with that id in this organization, or no such endpoint.
409 limit_reachedA key can hold 100 hook subscriptions.
422 too_few_responsesAn anonymous survey has fewer than 5 responses, so none are released yet.
429 rate_limitedMore than 600 requests in a minute for this key.
500 server_errorSomething failed on our side. Nothing was changed by a write; try again.
503 unavailablewrkflow 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

GET/surveyssurveys:read

The 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
}
GET/surveys/{id}surveys:read

One 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

GET/surveys/{id}/responsesresponses:read

The survey’s responses, oldest first, a page at a time. Answers are keyed by question id; read the survey’s definition for the questions.

QueryMeaning
limitResponses per page: 1 to 500, 100 by default.
cursorThe next_cursor of the previous page.
sinceOnly 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…"
}
FieldMeaning
version_idThe published version the person answered, so you can read the matching questions after later edits.
source, deviceHow the response arrived (link, QR, embed, invitation) and on what kind of device. Either can be null.
languageThe language code the person answered in (for example sl). Responses recorded before languages were kept read as the survey’s own language.
attributesThe participant’s details when they were invited, such as department. Empty for anonymous surveys.
hidden_fieldsValues the survey read from its link, with personal data masked. Empty for anonymous surveys.
answersOne entry per answered question. See below for what is withheld.
GET/surveys/{id}/files/{fileId}responses:read

A 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

GET/contactscontacts:read

Contacts, oldest first. Add email to look one person up.

QueryMeaning
emailReturn only the contact with this address (compared in lower case).
limit1 to 500, 100 by default.
cursorThe next_cursor of the previous page.

Each contact has id, email, full_name, attributes, created_at and updated_at.

POST/contactscontacts:write

Add 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_existing is 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.
  • skipped counts 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.

EventWhen
response.createdSomeone submits a response. Needs responses:read to poll.
survey.publishedA survey version goes live. Needs surveys:read to poll. It carries no answers.
POST/hookshooks:write

Subscribe 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.
GET/hookshooks:write

The subscriptions made with this key, newest first.

DELETE/hooks/{id}hooks:write

Remove a subscription made with this key. Answers { id, deleted: true }, or 404 for one that is not yours.

GET/hooks/samplesany key

An example item for each event, so a tool can build its field mapping before any real response exists. Not real data.

GET/triggers/{event}responses:read or surveys:read

Poll 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 2xx within 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-Delivery id and body, and says which attempt it is in X-Wrkflow-Attempt; it is signed again each time. Use the delivery id to ignore one you have already handled.
  • Answer 410 Gone to 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.