FlexiSurvey exposes a versioned REST API and signed webhooks so you can integrate survey data into your own systems without waiting on anyone. This guide covers everything an implementer needs: authentication, the response envelope, pagination, incremental pulls, rate limits, idempotent submission, and webhook signature verification.
Base URL and versioning
All endpoints live under a versioned URI prefix:
https://api.flexisurvey.net/api/v1
Versioning is URI-based. v1 is the current and only supported version. Additive changes (new fields, new endpoints) can appear at any time; breaking changes only arrive as a new URI version. Responses echo the resolved version in the X-API-Version header, and deprecated call shapes carry an X-API-Deprecation-Warning header well before removal.
The complete machine-readable specification is public:
- Interactive reference:
https://api.flexisurvey.net/api/docs - Raw OpenAPI 3 document:
https://api.flexisurvey.net/api/docs-json
The same OpenAPI document is committed to the FlexiSurvey repository and checked against the runtime on every build, so the published spec cannot drift from behaviour.
Authentication
There are two credential types. Use API keys for machine integrations.
| Credential | Header | Intended for |
|---|---|---|
API key (fsk_…) |
X-API-Key: fsk_… |
Server-to-server integrations, BI pulls, ingestion |
| User JWT | Authorization: Bearer … (cookie-based in the dashboard) |
The web dashboard and mobile apps |
Mint API keys in the dashboard under Settings → API Keys (requires a Team plan or above). Each key has a role that bounds what it can do:
| Key role | Capabilities | Rate limit |
|---|---|---|
READ_ONLY |
Read surveys, responses, analytics | 1,000 requests/hour |
INGEST |
Submit responses and respondent data | 5,000 requests/hour |
FULL |
Read + write across the granted scope | 2,000 requests/hour |
Limits are per key and can be adjusted per key by an administrator. Keys are stored hashed; the plaintext is shown exactly once at creation.
What an API key currently reaches
Today an API key authenticates the BI feed (/api/v1/bi/*): the flat response feed, the indicator time series, and the schema endpoints that let a connector build its table definitions. Those are the routes this guide's examples use.
The rest of the REST API is dashboard-authenticated, so a request to it with only X-API-Key returns 401. If you need programmatic access to an endpoint outside /bi/*, tell us which one: extending key auth to further routes is a scoping decision rather than a technical obstacle. Webhooks (below) are the supported way to receive everything else as it happens, and they need no key at all.
A first request in fifteen seconds:
curl -H "X-API-Key: fsk_your_key_here" \
"https://api.flexisurvey.net/api/v1/bi/indicators/schema"
import requests
API = "https://api.flexisurvey.net/api/v1"
headers = {"X-API-Key": "fsk_your_key_here"}
schema = requests.get(f"{API}/bi/indicators/schema", headers=headers).json()["data"]
Response envelope
Every JSON endpoint wraps its payload in one envelope:
{
"success": true,
"data": { "…the actual payload…": "…" },
"meta": { "timestamp": "2026-07-22T12:00:00.000Z" }
}
Errors use HTTP status codes plus a machine-readable message key that is stable across releases (the human text is localized):
{
"success": false,
"message": "Survey not found",
"statusCode": 404
}
File-download endpoints (CSV/XLSX/ZIP exports) stream the file directly with no envelope.
Pagination
List endpoints return { data: [...], total, page, limit } (some legacy routes use skip/take query parameters — the OpenAPI reference is authoritative per endpoint). Page until data.length < limit or you have total rows.
Incremental pulls (deltas)
For warehouse and BI pipelines, do not re-download everything nightly. The responses list supports keyset-paginated delta reads:
GET /api/v1/surveys/{surveyId}/responses?updatedSince=2026-07-01T00:00:00Z
The reply is { data, nextCursor, hasMore }, ordered by (updatedAt, id). Persist nextCursor and resume with it:
GET /api/v1/surveys/{surveyId}/responses?cursor={nextCursor}
Cursor pages are strictly non-overlapping under concurrent writes, and review-state changes (approve/reject/return) bump updatedAt, so state transitions arrive as deltas too. The CSV/JSON export routes accept the same updatedSince parameter, plus includeReview=1 to add Review Status and QA Flags columns.
import requests
API = "https://api.flexisurvey.net/api/v1"
headers = {"X-API-Key": "fsk_your_key_here"}
survey_id = "…"
params = {"updatedSince": "2026-07-01T00:00:00Z"}
while True:
page = requests.get(
f"{API}/surveys/{survey_id}/responses",
headers=headers, params=params,
).json()["data"]
upsert_rows(page["data"]) # your warehouse upsert
if not page["hasMore"]:
break
params = {"cursor": page["nextCursor"]}
Idempotent submission
When submitting responses from your own capture layer, send a stable client-generated UUID as clientResponseId. Re-submitting the same clientResponseId for a survey returns the existing response instead of inserting a duplicate — safe retries for flaky networks and offline queues.
Rate limits
Every key role has an hourly budget (table above), enforced on the BI feed. Every response carries your position in the current window, so you never have to guess:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Your key's budget for the window |
X-RateLimit-Remaining |
Requests left in it |
X-RateLimit-Reset |
Unix time when the window rolls over |
X-RateLimit-Window |
Window length in seconds |
When you exceed the budget the API returns 429 Too Many Requests with a Retry-After header giving the seconds until the window resets. Back off for that long rather than retrying immediately. Export endpoints additionally carry a per-user limit (10 exports/hour) and a 10,000-row cap per file — capped downloads say so loudly via X-Export-Truncated: true headers, a _PARTIAL filename marker, and an in-file notice.
Webhooks
Webhooks push events to your endpoint instead of you polling. Configure them in the dashboard (or via POST /api/v1/webhooks), choosing the events you want. The authoritative event catalog — names, descriptions, payload shapes — is self-describing at:
GET /api/v1/webhooks/meta/events
Event families: survey.* (created/updated/published/closed/deleted), response.* (started/submitted/updated/deleted, plus the review-pipeline events approved/rejected/returned/flagged/quarantined), user.*, team.member.*, analytics.*, export.*, quota.*, and system.*.
Delivery contract
Each delivery is an HTTP POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
X-FlexiSurvey-Signature |
sha256=<hex HMAC> of "{timestamp}.{rawBody}" using your webhook secret |
X-FlexiSurvey-Timestamp |
Milliseconds since epoch when the delivery was signed |
X-FlexiSurvey-Event |
The event name, e.g. response.submitted |
X-FlexiSurvey-Webhook-ID |
Your webhook configuration id |
X-FlexiSurvey-Delivery-ID |
Unique per delivery attempt — use it for de-duplication |
Respond with any 2xx within 30 seconds. Anything else is retried with exponential backoff. Payloads are capped at 5 MB.
Verifying signatures
Always verify before trusting a delivery. Reject deliveries whose timestamp is older than 5 minutes (replay protection), then compare HMACs with a constant-time comparison.
Node.js:
const crypto = require('crypto');
function verifyFlexiSurveySignature(rawBody, headers, secret) {
const timestamp = headers['x-flexisurvey-timestamp'];
const signatureHeader = headers['x-flexisurvey-signature'] || '';
const [algorithm, received] = signatureHeader.split('=');
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) {
return false; // stale — possible replay
}
const expected = crypto
.createHmac(algorithm, secret) // 'sha256'
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the RAW body — verifying a re-serialized JSON.parse'd
// body will fail on key order and whitespace.
// app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); } }));
Python (Flask shown; any framework works the same way):
import hashlib, hmac, time
def verify_flexisurvey_signature(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-FlexiSurvey-Timestamp", "")
algorithm, _, received = headers.get("X-FlexiSurvey-Signature", "").partition("=")
if abs(time.time() * 1000 - float(timestamp or 0)) > 5 * 60 * 1000:
return False # stale — possible replay
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, getattr(hashlib, algorithm)).hexdigest()
return hmac.compare_digest(expected, received)
Retries and recovery
Failed deliveries are retried automatically with exponential backoff and jitter. From the webhook detail page you can inspect delivery history and manually retry or bulk-retry failures. Use X-FlexiSurvey-Delivery-ID to de-duplicate on your side, and treat webhook delivery as at-least-once: pair webhooks (push) with the incremental endpoints above (pull) for reconciliation.
Power BI and other BI tools
The endpoints above are general-purpose and return nested JSON. BI tools want the opposite: flat, wide rows with stably-named, typed columns. The /bi feed provides exactly that, so you do not have to unwrap and pivot in Power Query.
The feed is available on Team and above, riding the same API entitlement as everything else in this guide. There is no separate flag: if you have API access, you have the feed.
What the feed guarantees
Column names never change. Columns are derived from each question's immutable id (q_<id>), not its text. Rewording a question changes only its label, so a renamed question cannot silently break a published report. Adding a question appends a column and never reorders existing ones.
PII is always masked. API keys carry no personal-data entitlement, so respondent identifiers and any question flagged as containing PII arrive as ***. Every row carries pii_masked so this is visible rather than surprising. If you need unmasked data, export it through the authenticated dashboard instead.
Repeat-group questions are excluded, not silently flattened. A question inside a repeatable group has one answer per instance, so it cannot occupy a single cell without either losing data or producing columns that change shape as data arrives. /schema lists those questions under excluded with the reason. Use the long export for repeat-group data.
Discover the columns
GET /api/v1/bi/surveys/{surveyId}/responses/schema
X-API-Key: <your key>
Returns columns (name, type, label) plus any excluded questions. Build your table definition from name and type; show label to report authors, but never bind to it.
Connect from Power BI Desktop
Home → Get Data → Blank Query → Advanced Editor, then paste. Replace the survey id and store the key with Manage Parameters rather than inlining it.
let
BaseUrl = "https://api.flexisurvey.net/api/v1",
SurveyId = "00000000-0000-0000-0000-000000000000",
ApiKey = "<your key>",
// Every response is wrapped as { success, data, meta }; unwrap once here so
// no downstream step has to know about the envelope.
GetPage = (cursor as nullable text) =>
let
Query = if cursor = null then [] else [cursor = cursor],
Response = Json.Document(
Web.Contents(
BaseUrl,
[
RelativePath = "bi/surveys/" & SurveyId & "/responses",
Query = Query,
Headers = [ #"X-API-Key" = ApiKey ]
]
)
)
in
Response[data],
// Page until the feed says there is nothing left. Do NOT request one huge
// page: `take` is capped at 500 and larger values are rejected outright.
Gather = List.Generate(
() => [ Page = GetPage(null) ],
each [Page] <> null,
each [ Page = if [Page][hasMore] then GetPage([Page][nextCursor]) else null ],
each [Page][data]
),
Rows = List.Combine(Gather),
Table = Table.FromRecords(Rows)
in
Table
Incremental refresh
Power BI's incremental refresh passes RangeStart and RangeEnd and expects the source to filter on them. Map RangeStart onto updatedSince:
Query = [ updatedSince = DateTimeZone.ToText(RangeStart, "yyyy-MM-ddTHH:mm:ssZ") ]
Both parameters must be declared as DateTime in Manage Parameters before Power BI will offer the incremental-refresh policy. On the second run only changed rows come back, which you can confirm from the row count in the refresh history.
Combine updatedSince with cursor: updatedSince selects the window, the cursor pages within it.
Indicator time series
For MEAL reporting the indicator feed usually matters more than the response feed, because donor reporting is indicator-led rather than response-led. Same auth, same paging:
GET /api/v1/bi/indicators/{indicatorId}/results?updatedSince=...
GET /api/v1/bi/indicators/schema
One row per period per disaggregation slice, with value, numerator, denominator, target_value and achieved_against_target already converted to numbers, so a chart plots without post-processing. Incremental reads key on computed_at (an indicator result has no updatedAt; computed_at is what moves when it is recomputed).
Disaggregations arrive as separate rows identified by cluster_id, not as columns, because their keys vary per indicator and column-per-key would make the table shape depend on the data.
Getting help
The interactive reference at /api/docs supports authenticated try-it-out calls. For anything the spec does not answer, contact support with your request id (X-Request-Id response header) — it links your call to our logs.