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.
A first request in fifteen seconds:
curl -H "X-API-Key: fsk_your_key_here" \
"https://api.flexisurvey.net/api/v1/surveys?page=1&limit=10"
import requests
API = "https://api.flexisurvey.net/api/v1"
headers = {"X-API-Key": "fsk_your_key_here"}
surveys = requests.get(f"{API}/surveys", 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). When you exceed it the API returns 429 Too Many Requests; back off and retry after the window resets. 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.
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.