API Reference

Integrate ImpactCheck with your platform to programmatically manage facilitators, generate survey links, and retrieve results.

The API is currently in v1. All endpoints are stable and backwards-compatible within this version.

Authentication

All API requests require a bearer token in the Authorization header.

Authorization: Bearer YOUR_API_KEY

Generate your API key from Account Settings in the ImpactCheck dashboard. Keep it secret — treat it like a password.

Regenerating your API key invalidates the previous one immediately.

Base URL

https://impactcheck.net/api/v1

All endpoints return JSON. Send request bodies as JSON with Content-Type: application/json.

Survey Modes

Each survey is one of three modes. The mode is set when the creator builds the survey and locks once the first response is collected, with one exception: an owner or admin can change an Anonymous survey to Confidential (responses collected while it was Anonymous stay untracked).

Anonymous

No respondent identity is stored with responses: no name, email, IP, tracking cookies, or fingerprints. Use for candor-sensitive workshops, classroom check-ins, or anywhere the consultancy explicitly promised anonymity. Respondent list/status reads return 403; lookup and invitation-create return 200 with a warning and attach no respondent. Webhooks never fire.

Confidential

Respondents are tracked like Named (invitations, reminders, per-person completion status, erasure) but every report looks like Anonymous: no answer is ever returned next to a person. Respondent dates are date-only (YYYY-MM-DD), open-text answers come in a fixed shuffled order, and the completion webhook is respondent.completed, which carries who completed, never their answers. Individual responses (GET …/responses) return 403.

Named

Respondent name + email captured at invite time (via the API) or at submission time (via the form). The only mode where the consultancy's admins can see each person's answers alongside their name (individual rows, the individual responses API). Choose it only when you need to know who said what: invitations, reminders, status and webhooks also work on Confidential. Webhooks fire on each submission.

The endpoint reference below indicates which mode each endpoint applies to.

API Key Scopes

Each API key carries a scope that determines what it can read. Create keys from Settings → API Keys in your dashboard. Default for new keys is aggregate.

ScopeCan readCannot read
aggregate List surveys, filter dimensions, aggregate results, multi-survey aggregate, respondent completion status. Individual responses, respondent identity in filter dimensions.
admin Everything aggregate can read, plus individual responses on Named surveys (JSON or CSV), respondent details (Named and Confidential), and the invitation / facilitator / respondent lookup write endpoints. —

Reads that return individual respondent identity on an admin-scope key are recorded in an internal audit trail.

Rate Limiting

Each API key gets a per-minute request budget. Default is 120 requests / minute; high-volume integrations can request a higher limit per key in the dashboard.

On overflow, we return 429 Too Many Requests with a Retry-After header (seconds).

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
Retry-After: 60

Conditional requests still count toward your rate-limit budget, but ETag caching avoids transferring and recomputing the payload.

Requests with a missing, malformed, or revoked key are separately rate-limited per IP and receive 429 when that limit is exceeded.

Caching (ETags)

The Results endpoint (GET /surveys/:id/results) returns a weak ETag derived from the survey's responses and its current question/metadata state, so renaming a survey or question also invalidates it. Send If-None-Match on subsequent requests; we'll return 304 Not Modified if nothing has changed.

curl -H "Authorization: Bearer YOUR_API_KEY" \
     -H 'If-None-Match: W/"v1:80:1715000000:abc123"' \
     https://impactcheck.net/api/v1/surveys/15/results

# HTTP/1.1 304 Not Modified  (no body; still counts toward rate limit budget)

Also sets Cache-Control: private, max-age=30 so an HTTP cache or CDN can serve the same response for 30 seconds. The ETag changes whenever a new response lands.

Error Handling

The API uses standard HTTP status codes. Error responses include a JSON body with an error field.

Status Meaning
200Success — resource already exists
201Created — new resource was created
401Unauthorized — missing or invalid API key
404Not found — survey, facilitator, or respondent doesn't exist (or belongs to another organization)
422Validation error — missing required fields (respondent list/status on an Anonymous survey, and individual responses on an Anonymous or Confidential survey, return 403, not 422)

Example error response:

{
  "error": "email is required"
}
GET

List Surveys

Endpoint

GET /api/v1/surveys

Returns all active surveys for the authenticated user. Use the id field from the response as the survey_id parameter in other endpoints. Each results_url is the absolute URL of the creator results page, including the account slug; opening it requires a signed-in user with access to that account.

Sample surveys are excluded from all API endpoints. A sample survey ID returns the same 404 response as an unknown ID.

Response

{
  "surveys": [
    {
      "id": 15,
      "name": "Leadership Accelerator 2025",
      "programme_name": "Leadership Accelerator",
      "survey_type": "programme_evaluation",
      "template_name": "Post-Programme",
      "retrospective": false,
      "slug": "k7m2p9x4q1",
      "response_count": 25,
      "published_at": "2026-02-14T10:30:00Z",
      "survey_url": "https://impactcheck.net/k7m2p9x4q1",
      "results_url": "https://impactcheck.net/apex-leadership/surveys/15/results"
    }
  ]
}

Example Request

curl https://impactcheck.net/api/v1/surveys \
  -H "Authorization: Bearer YOUR_API_KEY"
POST

Facilitator Lookup

Endpoint

POST /api/v1/surveys/:survey_id/facilitators/lookup

Look up a facilitator by email. Creates the facilitator and/or assigns them to the survey if they don't exist yet. Returns the facilitator's unique survey link token. This endpoint is idempotent — calling it multiple times with the same email returns the same result.

URL Parameters

Parameter Type Description
survey_id integer The ID of the survey to assign the facilitator to. Must belong to the authenticated user.

Request Body

Field Type Required Default Description
email string required — Facilitator's email address. Used as the unique lookup key within your account.
first_name string required* — Facilitator's first name. Required when creating a new facilitator. Updates the value if it has changed.
last_name string optional "" Facilitator's last name. Optional. Updates the value if supplied and changed.
create_facilitator boolean optional true If the facilitator doesn't exist, create them. Set to false to return 404 instead.
assign_to_survey boolean optional true If the facilitator isn't assigned to this survey, assign them. Set to false to return 404 instead.

Response

Returns 201 if anything was newly created or assigned, 200 if everything already existed.

{
  "facilitator": {
    "id": 42,
    "first_name": "Jane",
    "last_name":  "Smith",
    "email": "[email protected]"
  },
  "survey_facilitator": {
    "token": "a8f3x2m9"
  },
  "survey_url": "https://impactcheck.net/k7m2p9x4q1?f=a8f3x2m9",
  "created": false,
  "assigned": true
}
Field Description
facilitator The facilitator object (id, first_name, last_name, email)
survey_facilitator.token The opaque token for this facilitator's survey link
survey_url The full URL to send to participants — includes the ?f= token. Append &g=Cohort+3 to tag a group and &s=Day+1 to tag a session.
created true if the facilitator was newly created
assigned true if the facilitator was newly assigned to this survey

Example Request

curl -X POST https://impactcheck.net/api/v1/surveys/15/facilitators/lookup \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email":      "[email protected]",
    "first_name": "Jane",
    "last_name":  "Smith"
  }'

Quickstart

1

Generate your API key

Go to Account Settings in the ImpactCheck dashboard and click "Generate API Key".

2

Create a survey

Create and publish a survey in the dashboard. Note its ID from the URL (e.g., /<account>/surveys/15).

3

Look up a facilitator

Call the Facilitator Lookup endpoint with the coach's email. You'll get back a unique survey URL.

4

Send the link to participants

Use the survey_url from the response. Responses submitted via this link are automatically attributed to the facilitator.

POST

Respondent Lookup

Named or Confidential

Endpoint

POST /api/v1/surveys/:survey_id/respondents/lookup

Look up a respondent by email. Creates the respondent record (organization-scoped, reusable across surveys) and assigns them to the survey if they aren't already. Returns the survey_respondent.token — the per-invite hash that identifies this person on this survey. Idempotent — same (survey, email) returns the same token. Use this when you want to manage URL composition yourself; otherwise prefer the Invitation endpoint below.

URL Parameters

ParameterTypeDescription
survey_id integer Must belong to your organization. On an Anonymous survey the call no-ops gracefully (see note below) rather than erroring. On Named and Confidential surveys it attaches the respondent.

Body Parameters

ParameterTypeDescription
emailstringRequired. Respondent's email address. Case-insensitive.
first_namestringRequired. Respondent's first name. Updated on subsequent calls if changed.
last_namestringOptional, defaults to empty string. Respondent's last name.

Response

{
  "anonymity":         "named",
  "respondent":        { "id": 12, "first_name": "Alice", "last_name": "Smith", "email": "[email protected]" },
  "survey_respondent": { "token": "kx9p2m...", "completed_at": null },
  "created":  true,
  "assigned": true,
  "warnings": []
}

Returns 200 on the second+ call (already exists, no creation) and 201 on first creation.

Anonymous surveys degrade gracefully. You don't need to know the mode before calling. On an Anonymous survey this no-ops (no respondent attached, no token) and returns 200 with "anonymity": "anonymous", respondent: null, and a warnings entry — never a 422. anonymity is always present (anonymous, confidential or named) so you can learn the mode from the response. On a Confidential survey, completed_at is a date (YYYY-MM-DD), not a timestamp.

Example Request

curl -X POST https://impactcheck.net/api/v1/surveys/15/respondents/lookup \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "first_name": "Alice", "last_name": "Smith" }'
POST

Invitation URL (meta)

Endpoint

POST /api/v1/surveys/:survey_id/invitations

Convenience endpoint that does find-or-create for both respondent and facilitator and returns a fully composed survey URL. Each input is independent — send only what you have. Use this when you want a single API call to produce a ready-to-send link.

Body Parameters

ParameterTypeDescription
respondentobjectOptional. {email, first_name, last_name?}. Identifies the respondent on Named and Confidential surveys. On Anonymous surveys it's dropped (with a warning) rather than rejected — see the note below. last_name is optional.
facilitatorobjectOptional. {email, first_name, last_name?}. Find-or-create.
group_identifierstringOptional. Free-form cohort/programme tag. Pass-through to the response.
session_identifierstringOptional. Free-form session/event tag. Pass-through to the response.

Response

{
  "survey_url": "https://impactcheck.net/abc1234567?r=kx9p2m...&f=sf123456&g=Cohort+A&s=Session+3",
  "anonymity":  "named",
  "respondent":  { "id": 12, "first_name": "Alice", "last_name": "Smith", "email": "[email protected]", "token": "kx9p2m..." },
  "facilitator": { "id": 7,  "first_name": "Sam",   "last_name": "Coach", "email": "[email protected]",  "token": "sf123456" },
  "warnings": []
}

The composed URL only includes query params for inputs that were populated — no empty ?g=&s= clutter.

Send a uniform payload — anonymity is handled for you. You don't need to know a survey's mode before calling. On an Anonymous survey, a supplied respondent is dropped (never attached) and the response comes back 201 with "anonymity": "anonymous", respondent: null, the ?f=&g=&s= link intact, and a warnings entry explaining the drop. anonymity is always present and warnings is always an array (empty when nothing was dropped). Facilitator, group, and session work on Anonymous surveys — only per-person respondent identity is omitted. Confidential surveys attach the respondent exactly as Named surveys do; what differs is that no report ever shows their answers.

Example Request

curl -X POST https://impactcheck.net/api/v1/surveys/15/invitations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "respondent":         { "email": "[email protected]", "first_name": "Alice", "last_name": "Smith" },
    "facilitator":        { "email": "[email protected]", "first_name": "Sam",   "last_name": "Coach" },
    "group_identifier":   "Cohort A",
    "session_identifier": "Session 3"
  }'
GET

Filter Dimensions

aggregate scope

Endpoint

GET /api/v1/surveys/:survey_id/filter_dimensions

Returns the set of filter values consumers can use when fetching results — facilitators, groups, sessions, plus respondents on admin-scope keys (Named surveys only; never on Confidential). Designed so you can render a complete filter UI in a single API call.

To protect respondent anonymity, any facilitator, group, or session with fewer than 3 responses is omitted from this list (and its count is never revealed). A bucket appears once it reaches 3.

Response

{
  "facilitators": [
    { "token": "abc12345", "first_name": "Sam", "last_name": "Coach", "response_count": 18 }
  ],
  "groups":   [{ "value": "Cohort A", "response_count": 14 }],
  "sessions": [{ "value": "Session 1", "response_count": 30 }],
  "total_responses": 30,
  "has_unattributed": true,
  "respondents": [    // admin scope + Named survey only
    {
      "id": 12, "first_name": "Alice", "last_name": "Smith", "email": "[email protected]",
      "invitations": [
        { "token": "kx9p2m..", "completed_at": "2026-05-04T10:00:00Z" },
        { "token": "qz4n7r..", "completed_at": null }
      ]
    }
  ]
}
GET

Results (aggregate)

aggregate scope ETag-cached

Endpoint

GET /api/v1/surveys/:survey_id/results

Per-question rollup — same data the IC dashboard uses. Returns a distribution per scale question, mean, retrospective deltas where applicable, response counts, and a date-bucketed trend if the survey has been live more than 7 days.

Query Parameters (all optional, all combine)

ParameterDescription
facilitatorToken from filter_dimensions (the opaque token, not a numeric id), or unattributed.
groupA single group identifier, or none for responses with no group. An omitted group or JSON null scalar leaves groups unfiltered; a supplied empty or whitespace-only string returns 422. To pool several groups, use group[]=Cohort+A&group[]=Cohort+B. Null list entries are dropped before validation, including the 100-entry limit. Lists are trimmed, blank entries removed, deduplicated, and sorted; filters.group echoes the normalized list. In a list, "none" is a literal group name. Group identifiers match stored responses case- and accent-insensitively. A hash, non-string scalar or non-string non-null list element, list that becomes empty, or more than 100 entries after null removal (before trimming or deduplication) returns 422. Equivalent normalized lists share an ETag regardless of order; different normalized selections have different ETags even when case- or accent-insensitive matching returns the same responses.
sessionSession identifier, or none.
respondentRespondent token. Admin scope, Named surveys only (ignored on Anonymous and Confidential).

Response

HTTP/1.1 200 OK
ETag: W/"v4:30:1715000000:abc123def456:123456abcdef"
Cache-Control: private, max-age=30

{
  "survey":          { "id": 15, "name": "...", "anonymity": "named", "response_count": 30, ... },
  "total_responses": 30,
  "suppressed": false,
  "min_display_responses": 3,
  "question_results": [
    { "question": { "id": 23, "question_type": "scale", "retrospective": true, ... },
      "response_count": 30, "distribution": {"1": 0, "2": 1, ...}, "mean": 4.2,
      "distribution_before": {"1": 4, "2": 9, ...}, "mean_before": 2.1, "mean_shift": 2.1 }
  ],
  "responses_over_time": [{ "date": "2026-04-30", "count": 6 }, ...],
  "filters": { "facilitator": "abc12345", "group": ["Cohort A", "Cohort B"] }
}
Retrospective questions. retrospective is set per question, not per survey, so one survey can mix both kinds. A retrospective question asks respondents to rate themselves before and after the program in one sitting: it carries distribution_before, mean_before and mean_shift alongside the usual fields, and its individual answers carry value_before. Read the question's retrospective flag rather than inferring mode from the presence of those fields, which are absent on suppressed results and all-zero on a question nobody has answered yet.
Anonymity threshold. When the matched response set (after filters) has between 1 and min_display_responses − 1 responses, suppressed is true and question_results / responses_over_time come back empty — total_responses is still reported. For a group list, this threshold applies to the pooled set: three or more responses are shown even if each individual group has fewer than three. Treat a suppressed payload as "not enough responses to display." Zero responses returns suppressed: false with empty results.
GET

Individual Responses

admin scope Named only

Endpoint

GET /api/v1/surveys/:survey_id/responses
GET /api/v1/surveys/:survey_id/responses.csv

Paginated individual response rows with respondent identity. Default 50 per page, max 200. Append .csv (or send Accept: text/csv) for the same data formatted as CSV — column-for-column matching the dashboard's CSV export. Returns 403 on Anonymous and Confidential surveys.

Query Parameters

ParameterDescription
pagePage number (1-indexed). Default 1.
per_pagePage size. Default 50, max 200.
facilitator / group / session / respondentSame filters as the aggregate endpoint, including group lists, normalization, the 100-entry limit, and 422 validation errors. These rules apply to both JSON and CSV responses.

JSON Response

{
  "data": [
    {
      "id": 1234,
      "submitted_at": "2026-05-04T10:00:00Z",
      "respondent":  { "first_name": "Alice", "last_name": "Smith", "email": "[email protected]" },
      "facilitator": { "first_name": "Sam",   "last_name": "Coach", "email": "[email protected]" },
      "group_identifier": "Cohort A",
      "session_identifier": "Session 1",
      "answers": [
        { "question_id": 23, "question_type": "scale", "value": "4", "value_before": "2" }
      ]
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 30, "next_page": null }
}
GET

Respondents (list)

admin scope Named or Confidential

Endpoint

GET /api/v1/surveys/:survey_id/respondents

Completion-status list — one row per person, with each invitation they've received nested under invitations. A respondent can be invited multiple times (e.g. after session 1 and again after session 5), so each invitation has its own token and completed_at.

Response

{
  "data": [
    {
      "id": 12, "first_name": "Alice", "last_name": "Smith", "email": "[email protected]",
      "invitations": [
        { "token": "kx9p2m..", "completed_at": "2026-05-04T10:00:00Z" },
        { "token": "qz4n7r..", "completed_at": null }
      ]
    },
    {
      "id": 13, "first_name": "Bob", "last_name": "Jones", "email": "[email protected]",
      "invitations": [
        { "token": "by7n3z..", "completed_at": null }
      ]
    }
  ]
}

On a Confidential survey, completed_at is a date ("2026-05-04"), never a timestamp.

GET

Respondent Status (bulk)

aggregate scope Named or Confidential

Endpoint

GET /api/v1/surveys/:survey_id/respondents/status?tokens[]=t1&tokens[]=t2

Bulk completion-check for tokens you already hold. Designed for the "did this batch of invitees finish?" use case without requiring the admin scope (no PII in the response). Up to 200 tokens per request.

Response

{
  "data": [
    { "token": "kx9p2m..", "found": true,  "completed": true,  "completed_at": "2026-05-04T10:00:00Z" },
    { "token": "by7n3z..", "found": true,  "completed": false, "completed_at": null },
    { "token": "missing.", "found": false, "completed": false, "completed_at": null }
  ]
}

On a Confidential survey, completed_at is a date ("2026-05-04"), never a timestamp.

Each token in the request is echoed in the response in the same order, so you can map results 1:1 to your input list. Duplicate tokens are each echoed; blank tokens are returned with found: false. Only a completely empty tokens[] is rejected (422). Unknown tokens return found: false rather than 404.

POST

Respondent Erasure

admin scope

Endpoint

POST /api/v1/respondents/erase

GDPR right-to-erasure. Anonymises the respondent matching email across your whole organisation: name and email are removed permanently, but their submitted responses remain so aggregate results stay intact. Org-level (not survey-scoped). Erasing the same email a second time returns 404 (the person is no longer identifiable in your organisation).

Request body

{ "email": "[email protected]" }

Response

{
  "erased": true,
  "already_erased": false,
  "respondent_id": 42,
  "invitation_count": 3
}

Returns 404 when no respondent in your organisation matches the email, 422 when email is missing. This endpoint works even when your plan has lapsed — erasure is a legal obligation, so it's never gated.

Erasure cannot be undone. If the same person is later re-registered via Respondent Lookup, they get a fresh respondent record with no link to their erased history.
GET

Multi-survey Aggregate

aggregate scope

Endpoint

GET /api/v1/surveys/aggregate?ids[]=1&ids[]=2&strategy=union

Cross-survey roll-up for "compare cohort 1 vs cohort 2" reporting. Two strategies:

  • union (default) — pool all responses across the listed surveys into one combined result. Surveys must share question structure — position, type, resolved scale points, choice options, and each question's retrospective setting must all match; otherwise the request is rejected (422). Option text must match across pooled surveys, so an option reworded on one survey must be reworded identically on the others.
  • compare — return parallel result objects, one per survey, for side-by-side rendering.

Response (compare)

{
  "strategy": "compare",
  "min_display_responses": 3,
  "surveys": [
    { "survey": { "id": 9,  "name": "Cohort A", "response_count": 30 }, "suppressed": false, "question_results": [...], "total_responses": 30 },
    { "survey": { "id": 10, "name": "Cohort B", "response_count": 2  }, "suppressed": true,  "question_results": [],    "total_responses": 2  }
  ]
}

Returns 422 if surveys diverge structurally and you ask for union, or if you pass more than 100 survey IDs in one request. 404 if any of the listed survey IDs belongs to another organisation.

The same anonymity threshold applies. In compare, each survey carries its own suppressed flag. In union, the pooled set is suppressed ("suppressed": true, empty question_results) when the combined total is between 1 and min_display_responses − 1, or when any pooled survey on its own has between 1 and min_display_responses − 1 responses (so a small survey can't be read by pooling it with others).
EVENT

Webhooks

Named or Confidential

Set webhook_url on a Named or Confidential survey (in the creator UI), and ImpactCheck will POST a signed event to that URL: response.created each time a response lands on a Named survey, or respondent.completed each time an invited respondent completes a Confidential survey. The signature scheme is Stripe-compatible — existing webhook libraries (Ruby, Node, Python, Go, etc.) decode it without modification.

Headers

Content-Type: application/json
X-ImpactCheck-Signature: t=<unix_timestamp>,v1=<HMAC-SHA256 hex>
Idempotency-Key: evt_<response_id>   (Named)  |  evt_rc_<id>   (Confidential)
User-Agent: ImpactCheck-Webhook/1.0

The HMAC is computed over "<timestamp>.<raw_body>" using your survey's signing secret (shown once in the creator UI, prefixed whsec_). The Idempotency-Key (also the body's id) is stable across retries — dedupe on it.

Body

{
  "id": "evt_1234",
  "event": "response.created",
  "survey":      { "id": 9, "slug": "abc1234567", "name": "Programme Eval" },
  "respondent":  { "token": "kx9p2m...", "email_address": "[email protected]", "first_name": "Alice", "last_name": "Smith" },
  "response":    {
    "id": 1234,
    "submitted_at": "2026-05-07T14:30:00Z",
    "group_identifier":   "Cohort A",
    "session_identifier": "Session 3"
  },
  "facilitator": { "token": "sf123456", "first_name": "Sam", "last_name": "Coach", "email_address": "[email protected]" }
}

Body: respondent.completed (Confidential)

{
  "id": "evt_rc_5678",
  "event": "respondent.completed",
  "survey":       { "id": 9, "slug": "abc1234567", "name": "Programme Eval" },
  "respondent":   { "token": "kx9p2m...", "email_address": "[email protected]", "first_name": "Alice", "last_name": "Smith" },
  "completed_on": "2026-05-07",
  "facilitator":  { "token": "sf123456", "first_name": "Sam", "last_name": "Coach", "email_address": "[email protected]" },
  "group_identifier":   "Cohort A",
  "session_identifier": "Session 3"
}

It says who completed and on which day. It never carries a response id, a time, or any answer. Only respondents who came through their personal (?r=) link fire it.

Reliability

  • Asynchronous — submission flow never waits on consumer availability.
  • Retry on non-2xx response or network error: 3 retries at 1m, 5m, 25m. Retries reuse the same id / Idempotency-Key, so dedupe on it to handle the rare double-delivery.
  • Anonymous surveys never fire webhooks.
  • Each attempt, including a retry, sends the respondent's current name and email: if they have been erased (or can no longer be found) since the response, the retry carries the erased placeholders (or no name and email). So the body can differ between attempts that share an Idempotency-Key: don't reject a reused key because its body changed.
  • The signature timestamp (t=) is when we sent the event, not when the respondent answered.
  • Every attempt (succeeded or failed) is logged in your survey's webhook activity log.

Verifying signatures

  1. Split the X-ImpactCheck-Signature header on ,, parse t=<timestamp> and v1=<hex>.
  2. Reject events older than 5 minutes (|now - t| > 300) to prevent replay.
  3. Compute HMAC-SHA256(secret, "<t>.<raw_body>") and constant-time-compare against v1.