API Reference
Integrate ImpactCheck with your platform to programmatically manage facilitators, generate survey links, and retrieve results.
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.
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 two modes. The mode is set when the creator builds the survey and locks once the first response is collected.
Anonymous
No respondent identity captured at all — 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 require a Named survey (403 on Anonymous); lookup and invitation-create return 200 with a warning and attach no respondent. Webhooks never fire.
Named
Respondent name + email captured at invite time (via the API) or at submission time (via the form). Required when integrating with a coaching platform / Matchwell / custom client portal. Aggregate reporting is the default; individual rows are admin-only on the consultancy's side. 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.
| Scope | Can read | Cannot 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, 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 |
|---|---|
| 200 | Success — resource already exists |
| 201 | Created — new resource was created |
| 401 | Unauthorized — missing or invalid API key |
| 404 | Not found — survey, facilitator, or respondent doesn't exist (or belongs to another organization) |
| 422 | Validation error — missing required fields (respondent list/status on an Anonymous survey returns 403, not 422) |
Example error response:
{
"error": "email is required"
}
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.
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"
}
]
}
Example Request
curl https://impactcheck.net/api/v1/surveys \ -H "Authorization: Bearer YOUR_API_KEY"
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 |
|---|---|---|---|---|
| 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": "jane@coaching.com"
},
"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": "jane@coaching.com",
"first_name": "Jane",
"last_name": "Smith"
}'
Quickstart
Generate your API key
Go to Account Settings in the ImpactCheck dashboard and click "Generate API Key".
Create a survey
Create and publish a survey in the dashboard. Note its ID from the URL (e.g., /surveys/15).
Look up a facilitator
Call the Facilitator Lookup endpoint with the coach's email. You'll get back a unique survey URL.
Send the link to participants
Use the survey_url from the response. Responses submitted via this link are automatically attributed to the facilitator.
How Facilitator Links Work
Each facilitator assigned to a survey gets a unique, opaque token appended to the survey URL:
https://impactcheck.net/k7m2p9x4q1?f=a8f3x2m9
To tag a response with a group and/or session, append &g= and &s=:
https://impactcheck.net/k7m2p9x4q1?f=a8f3x2m9&g=Cohort+3&s=Day+1
- ✓ Tokens are random and unguessable — participants can't switch between facilitators
- ✓ Anonymous surveys collect no respondent identity. Named surveys capture name + email — see Respondents below.
- ✓ If a participant uses a link without a token (or with an invalid one), their response is still recorded but marked as "unattributed"
- ✓ Results can be filtered by facilitator, group, and session in the dashboard
-
✓
Group identifiers (
&g=) are for cohorts, locations, or teams. Session identifiers (&s=) are for specific events within a group. Both are free-form strings. On Anonymous surveys, values that look like an email address are dropped to preserve anonymity, and any group/session value appearing on fewer than 3 responses is suppressed in exports — use low-cardinality cohort labels, not per-person identifiers.
Typical integration: Your platform calls the lookup endpoint after each session, gets the facilitator's survey URL, appends group and session identifiers, and emails the link to the participant. The response is attributed to the correct facilitator, group, and session.
Respondent Lookup
Named onlyEndpoint
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
| Parameter | Type | Description |
|---|---|---|
| survey_id | integer | Must belong to your organization. On an Anonymous survey the call no-ops gracefully (see note below) rather than erroring. |
Body Parameters
| Parameter | Type | Description |
|---|---|---|
| string | Required. Respondent's email address. Case-insensitive. | |
| first_name | string | Required. Respondent's first name. Updated on subsequent calls if changed. |
| last_name | string | Optional, defaults to empty string. Respondent's last name. |
Response
{
"anonymity": "named",
"respondent": { "id": 12, "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com" },
"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.
200 with "anonymity": "anonymous", respondent: null, and a warnings entry — never a 422. anonymity is always present so you can learn the mode from the response.
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": "alice@example.com", "first_name": "Alice", "last_name": "Smith" }'
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
| Parameter | Type | Description |
|---|---|---|
| respondent | object | Optional. {email, first_name, last_name?}. Identifies the respondent on Named surveys. On Anonymous surveys it's dropped (with a warning) rather than rejected — see the note below. last_name is optional. |
| facilitator | object | Optional. {email, first_name, last_name?}. Find-or-create. |
| group_identifier | string | Optional. Free-form cohort/programme tag. Pass-through to the response. |
| session_identifier | string | Optional. 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": "alice@example.com", "token": "kx9p2m..." },
"facilitator": { "id": 7, "first_name": "Sam", "last_name": "Coach", "email": "coach@example.com", "token": "sf123456" },
"warnings": []
}
The composed URL only includes query params for inputs that were populated — no empty ?g=&s= clutter.
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.
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": "alice@example.com", "first_name": "Alice", "last_name": "Smith" },
"facilitator": { "email": "coach@example.com", "first_name": "Sam", "last_name": "Coach" },
"group_identifier": "Cohort A",
"session_identifier": "Session 3"
}'
Filter Dimensions
aggregate scopeEndpoint
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). Designed so you can render a complete filter UI in a single API call.
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": "alice@x.com",
"invitations": [
{ "token": "kx9p2m..", "completed_at": "2026-05-04T10:00:00Z" },
{ "token": "qz4n7r..", "completed_at": null }
]
}
]
}
Results (aggregate)
aggregate scope ETag-cachedEndpoint
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)
| Parameter | Description |
|---|---|
| facilitator | Token from filter_dimensions (the opaque token, not a numeric id), or unattributed. |
| group | Group identifier, or none. |
| session | Session identifier, or none. |
| respondent | Respondent token. Admin scope only. |
Response
HTTP/1.1 200 OK
ETag: W/"v1:30:1715000000:abc123"
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": {...}, "response_count": 30, "distribution": {"1": 0, "2": 1, ...}, "mean": 4.2 }
],
"responses_over_time": [{ "date": "2026-04-30", "count": 6 }, ...],
"filters": { "facilitator": "abc12345" }
}
min_display_responses − 1 responses, suppressed is true and question_results / responses_over_time come back empty — total_responses is still reported. Treat a suppressed payload as "not enough responses to display." Zero responses returns suppressed: false with empty results.
Individual Responses
admin scope Named onlyEndpoint
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 surveys.
Query Parameters
| Parameter | Description |
|---|---|
| page | Page number (1-indexed). Default 1. |
| per_page | Page size. Default 50, max 200. |
| facilitator / group / session / respondent | Same filters as the aggregate endpoint. |
JSON Response
{
"data": [
{
"id": 1234,
"submitted_at": "2026-05-04T10:00:00Z",
"respondent": { "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com" },
"facilitator": { "first_name": "Sam", "last_name": "Coach", "email": "coach@example.com" },
"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 }
}
Respondents (list)
admin scope Named onlyEndpoint
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": "alice@x.com",
"invitations": [
{ "token": "kx9p2m..", "completed_at": "2026-05-04T10:00:00Z" },
{ "token": "qz4n7r..", "completed_at": null }
]
},
{
"id": 13, "first_name": "Bob", "last_name": "Jones", "email": "bob@x.com",
"invitations": [
{ "token": "by7n3z..", "completed_at": null }
]
}
]
}
Respondent Status (bulk)
aggregate scope Named onlyEndpoint
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 }
]
}
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.
Respondent Erasure
admin scopeEndpoint
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": "alice@example.com" }
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.
Multi-survey Aggregate
aggregate scopeEndpoint
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 retrospective mode must all match; otherwise the request is rejected (422).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.
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.
Response Webhook
Named only
Set webhook_url on a Named survey (in the creator UI), and ImpactCheck will POST a signed event to that URL each time a response lands. 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> 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": "alice@example.com", "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": "coach@example.com" }
}
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.
- Every attempt (succeeded or failed) is logged in your survey's webhook activity log.
Verifying signatures
- Split the
X-ImpactCheck-Signatureheader on,, parset=<timestamp>andv1=<hex>. - Reject events older than 5 minutes (
|now - t| > 300) to prevent replay. - Compute
HMAC-SHA256(secret, "<t>.<raw_body>")and constant-time-compare againstv1.