Before you start
You need a key, the address and a tool to call it with. Put the key in a shell variable, and the examples on this page work as they are.
Get an API key from Surveyr
- Ask your Surveyr contact for a key. Surveyr makes it in Super admin → API keys and shows it only once, so store it safely straight away. It starts with
svk_. - Your key is a read-only key for one organisation. It reads that organisation's surveys and responses through this API, and nothing else: it can't change or delete anything, anywhere.
- Keep it server-side. Never put it in a browser, an app or a shared sheet.
If a key leaks, tell Surveyr. We revoke it at once and make you a new one.
- Ask your Surveyr contact for a key. Surveyr makes it in Super admin → API keys and shows it only once, so store it safely straight away. It starts with
Note the base URL
- Every call starts with
https://www.surveyr.africa/api/external/v1. - All times are ISO 8601 in UTC, such as
2026-11-04T07:06:12.000Z. All ids are strings.
Surveyr may give you a test address for trying things out first. Use it in place of
https://www.surveyr.africa; the rest of each call stays the same.- Every call starts with
Pick a tool
- Use HTTPie, curl or Postman. Every example here has an HTTPie tab, a curl tab and a Copy button.
- In Postman, add a header called
x-api-key, with your key as its value, on the Headers tab.
Tip: Postman can import a curl command: choose Import → Raw text and paste it. Then swap
$SURVEYR_API_KEYand$SURVEY_IDfor real values.Put your key in a variable
- The examples read your key from
SURVEYR_API_KEY, so it never lands in a command you copy or share. In a macOS or Linux terminal (or WSL on Windows), runread -rs SURVEYR_API_KEY; export SURVEYR_API_KEY, paste the key and press Enter. Nothing shows as you paste, and the key stays out of your shell history. - In Part 3 you set
SURVEY_IDthe same way.
- The examples read your key from
Your first call
Start with GET /me. It needs no ids, and it tells you which organisation your key belongs to and the limits that apply.
http https://www.surveyr.africa/api/external/v1/me \
x-api-key:$SURVEYR_API_KEYThe key goes in the x-api-key header on every request. A good answer looks like this:
{
"organization": {
"id": "Ab12Cd",
"name": "Example Observer Mission"
},
"authType": "apiKey",
"rateLimit": {
"perMinute": 120,
"maxPageSize": 500
},
"version": "v1",
"serverTime": "2026-11-04T07:12:00.000Z"
}organizationis the organisation your key reads from.rateLimit.perMinuteis how many requests a minute you may make.rateLimit.maxPageSizeis the most records one page can hold.serverTimeis Surveyr's clock.
Got 401? The key is missing or Surveyr doesn't know it. Check the header is called exactly x-api-key and that you copied the whole key.
Find your survey and its questions
List your organisation's surveys, pick yours, then open it to see its questions.
http https://www.surveyr.africa/api/external/v1/surveys \
x-api-key:$SURVEYR_API_KEYYou get data, a list of surveys without their questions, plus count and generatedAt. A survey's id is the surveyId in every call that follows. Put yours in a variable: export SURVEY_ID=6900aa000000000000000001. Then open the survey:
http https://www.surveyr.africa/api/external/v1/surveys/$SURVEY_ID \
x-api-key:$SURVEYR_API_KEY{
"data": {
"id": "6900aa000000000000000001",
"name": "opening_report_2026",
"status": "active",
"type": "survey",
"organizationId": "Ab12Cd",
"start": "2026-11-04T05:00:00.000Z",
"end": "2026-11-04T23:00:00.000Z",
"isLiveEvent": false,
"languages": [
"eng_Latn",
"zul_Latn"
],
"questionCount": 5,
"createdAt": "2026-10-20T09:00:00.000Z",
"updatedAt": "2026-10-25T09:00:00.000Z",
"questions": [
{
"id": "1",
"number": 1,
"text": "Did the voting station open by 07:00?",
"type": "optionList",
"options": [
{
"id": "a",
"value": "Yes",
"description": null
},
{
"id": "b",
"value": "No",
"description": "Opened late"
}
]
},
{
"id": "k3Fq9mZa",
"number": 2,
"text": "Describe the queue when you arrived.",
"type": "openEnded",
"options": []
},
{
"id": "g5Hs1dYu",
"number": 5,
"text": "Share your live location at the station.",
"type": "location",
"options": [],
"location": {
"verifyLocation": true,
"radiusMeters": 300,
"enforcement": "soft_flag",
"target": {
"latitude": -26.2041,
"longitude": 28.0473
}
}
}
]
},
"generatedAt": "2026-11-04T07:12:00.000Z"
}Every question has a stable id. It doesn't change when questions are reordered, renamed or translated, and every response keys its answers by it. In 2024 answers came through in the order they were given, so a report filled in out of sequence couldn't be matched to its questions; keying each answer by its question's id means that can't happen.
| Field | Meaning |
|---|---|
id | The question's stable key. Answers are keyed by it. |
number | The 1-based position, for display only. |
text | The question, with the *[n of m]* counter removed. |
type | One of optionList, replyButtons, multiSelectList, openEnded, wordCloud, location, whatsAppForm, form. |
options[].value | The exact string stored as the answer. |
options[].description | The longer label shown to the observer, when there is one. |
multiSelect | On a multiSelectList question: minSelections and maxSelections. |
location | On a location question: the geofence it checks against (radiusMeters, target), and whether a bad pin blocks the observer (hard_block) or is only flagged (soft_flag). |
The dummy run
Before election day your survey has no reports, so there is nothing real to test against. The dummy run gives you made-up reports in exactly the real shape, so you can build and test your import now, not on the day.
http https://www.surveyr.africa/api/external/v1/surveys/$SURVEY_ID/responses/sample \
count==3 \
x-api-key:$SURVEYR_API_KEYIt takes your real survey, gives each question a made-up answer of its kind, and runs the reports through the same formatter as live data. You get choices, multi-select picks, text, a voice note with a media URL on surveys that take voice notes, and a location inside the geofence. If the survey asks for a location and you ask for two or more reports, the last one has a forwarded pin, so you can test your review filter. Nothing is stored.
countis how many reports to make up: 1 to 50 (3 when you leave it out).- The body carries
"sample": true, and the observers are calledSample observer 1,Sample observer 2and so on. format=csvworks here too.- The answers and ids are the same every time for a given survey, so you can keep them as a test fixture; the times follow the clock.
- The record and observer ids are
sample-…strings, not the 24-character ids of real records, so a made-up report can't be mistaken for a real one.
Tip: Do the dummy run as soon as the survey is built, and run your whole import against it: parse, store, show. A format surprise is cheap now and costly on the day.
Pull reports
GET /surveys/:surveyId/responses gives you the reports for one survey, oldest first, a page at a time. Start with the last half hour:
http https://www.surveyr.africa/api/external/v1/surveys/$SURVEY_ID/responses \
since==30m \
x-api-key:$SURVEYR_API_KEYsince=30m means "from 30 minutes before the server's clock". You can also use 2h or 1d (up to 90d), or a full date such as 2026-11-04T07:00:00Z. A full date needs the Z (or an offset such as +02:00, with the + sent as %2B in a query string); without one it's refused with 400.
{
"survey": {
"id": "6900aa000000000000000001",
"name": "opening_report_2026",
"questionCount": 5
},
"data": [
{
"id": "6909c2a1b7e4f10012ab34cd",
"surveyId": "6900aa000000000000000001",
"respondentId": "66f1a2b3c4d5e6f7a8b9c0d1",
"respondent": {
"id": "66f1a2b3c4d5e6f7a8b9c0d1",
"name": "Thandi M.",
"cellNumber": "27820000001",
"properties": {
"stationNumber": "32850682",
"province": "Gauteng"
}
},
"submittedAt": "2026-11-04T07:06:12.000Z",
"updatedAt": "2026-11-04T07:06:12.000Z",
"language": "eng_Latn",
"source": "chatbot",
"location": {
"latitude": -26.2035,
"longitude": 28.0481
},
"locationFlags": {
"forwarded": false,
"outsideGeofence": false,
"distanceMeters": 102,
"enforcement": "soft_flag",
"flaggedAt": null,
"needsReview": false
},
"answeredCount": 5,
"questionCount": 5,
"answers": {
"1": {
"questionId": "1",
"number": 1,
"type": "optionList",
"answered": true,
"value": "No",
"label": "Opened late",
"media": null,
"location": null,
"transcript": null,
"skipped": false
},
"v2Tn7cRb": {
"questionId": "v2Tn7cRb",
"number": 4,
"type": "audio",
"answered": true,
"value": "The station opened at ten past seven.",
"label": null,
"media": {
"url": "https://res.cloudinary.com/surveyr/video/upload/v1762240000/surveyr/chatbot/abc/123.ogg",
"type": "audio"
},
"location": null,
"transcript": "The station opened at ten past seven.",
"skipped": false
}
}
}
],
"pagination": {
"limit": 100,
"count": 1,
"hasMore": false,
"nextCursor": null
},
"window": {
"since": "2026-11-04T06:42:00.000Z",
"until": null,
"generatedAt": "2026-11-04T07:12:00.000Z"
}
}There is one record per submitted report:
| Field | Meaning |
|---|---|
id | The response id. De-duplicate on it. |
surveyId, respondentId | Which survey, which observer. |
respondent | name, cellNumber (a string, international format, no +) and properties: the observer's profile fields (station number, province, …) as a flat map. null when the observer record is gone. |
submittedAt | When the report was completed. |
updatedAt | When it last changed. since and until filter on this. |
language | The language the observer answered in (eng_Latn, zul_Latn, …). |
location | The observer's shared GPS point, { latitude, longitude }, or null. |
locationFlags | GPS checks (Part 7), or null when the survey asked for no location or doesn't check the pin (verifyLocation: false). |
answeredCount, questionCount | How many questions were answered, out of how many. |
answers | An object keyed by question id. Every question of the survey is present, answered or not. |
Each entry in answers has these fields:
| Field | Meaning |
|---|---|
questionId, number, type | As in the survey. The type of an open-ended question becomes text, audio, video, image or document, depending on what the observer sent. |
answered | true when there is an answer. |
value | Choice: the option's value (an array for multiSelectList). Text: the text. Media: the transcript, when there is one. Form: the submitted fields. null when unanswered or skipped. |
label | Choice questions only: the option's description when it has one, else the value. |
media | { url, type } for a voice note, video, image or document. WhatsApp compresses media before Surveyr receives it; the URL is public and permanent. |
transcript | Automatic transcription of a voice note or video, when available. |
location | { latitude, longitude } for a location question. |
skipped | true when a jump rule skipped the question. |
An unanswered question looks like this, so your parser never has to guess:
{
"questionId": "k3Fq9mZa",
"number": 2,
"type": "openEnded",
"answered": false,
"value": null,
"label": null,
"media": null,
"location": null,
"transcript": null,
"skipped": false
}Paging. When pagination.hasMore is true, call again with cursor set to pagination.nextCursor. The cursor carries the first page's window, so since=30m means the same moment on every page of a pull; a since or until sent with a cursor is ignored, so sending the same query again is fine. The cursor is a position, not an offset, so reports that arrive in the meantime never shift a page. The same cursor is in the X-Next-Cursor header, and X-Has-More says whether there is one.
http https://www.surveyr.africa/api/external/v1/surveys/$SURVEY_ID/responses \
since==2026-11-04T07:00:00Z \
limit==500 \
cursor==$NEXT_CURSOR \
x-api-key:$SURVEYR_API_KEY| Parameter | Default | Meaning |
|---|---|---|
since | none | Only records with updatedAt >= since. An ISO 8601 date-time with Z (or an offset, its + sent as %2B), or a time back from now: 30m, 2h, 1d (up to 90d). |
until | none | Only records with updatedAt < until. Same forms as since. |
limit | 100 | Records per page, up to maxPageSize (500). |
cursor | none | Continue the previous page: pagination.nextCursor. It keeps the first page's since and until; any sent with it are ignored. |
format | json | csv for a CSV page (per survey only). |
CSV. Add format=csv to get the same page as CSV (RFC 4180, UTF-8, CRLF). The fixed columns come first, then one column per question named q_<questionId>, plus q_<questionId>_media after each open-ended and word-cloud question for the media URL. Multi-select values are joined with ; , and a location cell is latitude,longitude. Paging works the same way: read the X-Next-Cursor header.
http https://www.surveyr.africa/api/external/v1/surveys/$SURVEY_ID/responses \
since==1d \
format==csv \
x-api-key:$SURVEYR_API_KEYEvery survey at once. GET /responses gives the reports of every survey of your organisation in one feed, with the same parameters and paging. It is JSON only, because the CSV columns would differ per survey.
http https://www.surveyr.africa/api/external/v1/responses \
since==30m \
x-api-key:$SURVEYR_API_KEYElection day: the polling loop
On the day, pull every 12 minutes (five times an hour), and ask only for what changed since your last pull.
Work out where to start
- Take
window.generatedAtfrom the first page of your previous successful pull. - Subtract 5 minutes and use that as
since. The overlap covers clock skew and late writes. - On your first pull there is no previous one: use the survey's
startinstead.
Use
window.generatedAt, Surveyr's clock, not your own. A full date from it can't skip a record if a poll runs late;since=30mcan.- Take
Pull every page
- Call
GET /surveys/:surveyId/responseswith thatsinceandlimit=500. - While
pagination.hasMoreistrue, call again withcursorset topagination.nextCursor. The cursor keeps the first page'ssince, so sending the samesinceagain is harmless. - Stop when
hasMoreisfalse.
- Call
Upsert each record by
id- Insert each record, or replace the one you already hold with the same
id. - The overlap brings some records twice. An upsert makes that harmless.
A report the observer corrects through the bot arrives as a new record, with a new
idand a laterupdatedAt, and the earlier record is removed on Surveyr's side. Match a correction to the report it replaces byrespondentIdandsurveyId, and keep the newest.- Insert each record, or replace the one you already hold with the same
Save where you got to
- Once every page is stored, save
window.generatedAtof the first page as the time of your last successful pull. - If a pull fails part-way, don't save it. The next pull starts from the same place, and the upserts take care of the repeats.
- Once every page is stored, save
One call of the loop, when the previous pull's window.generatedAt was 2026-11-04T07:12:00.000Z:
http https://www.surveyr.africa/api/external/v1/surveys/$SURVEY_ID/responses \
since==2026-11-04T07:07:00Z \
limit==500 \
x-api-key:$SURVEYR_API_KEYNever pull without since on election day, except to rebuild from scratch.
GPS flags
Observers share a live location at the station. Surveyr compares it with the station's geofence, and notices when a pin was forwarded from another chat instead of shared live.
{
"forwarded": false,
"outsideGeofence": false,
"distanceMeters": 102,
"enforcement": "soft_flag",
"flaggedAt": null,
"needsReview": false
}| Field | Meaning |
|---|---|
forwarded | true when the pin was forwarded from another chat, not shared live. |
outsideGeofence | true when the pin is outside the station's geofence. |
distanceMeters | The distance from the station's target point. null when the survey set no target. |
enforcement | soft_flag: a bad pin is only flagged. hard_block: a bad pin blocks the observer. |
flaggedAt | When the pin was flagged, or null when it wasn't. |
needsReview | true when forwarded or outsideGeofence is. The one field to filter on. |
locationFlags is null when the survey asked for no location, or when it doesn't check the pin (its location question has verifyLocation: false).
Flag rather than block. Surveys can be set to flag rather than block (soft_flag). On such a survey a flagged report is stored and delivered as usual, and needsReview tells you to check it by hand.
Tip: Build your review queue on needsReview, and test it with the dummy run: its last report has a forwarded pin.
Limits and errors
The API is read only, each key has a limit per minute, and every error comes back with a status you can act on.
Read only. Only GET (and HEAD) is accepted. Your key is a read-only key: any other method answers 403 with the error read_only_key. With a valid key that isn't read only, any other method answers 405 with Allow: GET, HEAD and the error method_not_allowed. Nothing you send can change or delete data.
Rate limit. Each key may make 120 requests a minute (rateLimit.perMinute in GET /me). Every answer after the key is accepted carries three headers (a 401, or a 403 from the key check itself, has none):
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute. |
X-RateLimit-Remaining | Requests left in the current minute. |
X-RateLimit-Reset | When the current minute ends, in unix seconds. |
Over the limit, you get 429 and a Retry-After header. Wait that many seconds, then carry on.
{
"error": "rate_limited",
"message": "Too many requests: at most 120 per minute. Retry after 37s.",
"retryAfterSeconds": 37
}Tip: HTTPie shows the response headers by default; with curl, add -i. A poll of 500 records per page, five times an hour, uses a few requests a minute.
Errors. Every error has a message that says what went wrong, and most have an error code too:
| Status | error | When |
|---|---|---|
400 | bad_request | A parameter is malformed; message says which. |
401 | — | No key, or a key Surveyr doesn't know. |
403 | read_only_key | A read-only key used for anything but GET (or HEAD) under /api/external. |
403 | — | The key's organisation doesn't own the survey; or an ops key. |
403 | — (code: ORG_SUSPENDED) | Your organisation is suspended. Talk to your Surveyr contact. |
404 | not_found | No such survey, or no such route. |
405 | method_not_allowed | Anything but GET, with a key that isn't read only: the API is read only. |
429 | rate_limited | Over the per-minute limit. |
500 | server_error | Surveyr couldn't answer. Retry with backoff. |
Questions
Why do I get 401?
The key is missing, or Surveyr doesn't know it. Check the header is called exactly x-api-key, that you copied the whole key (it starts with svk_), and that it hasn't been revoked. Still stuck? Ask Surveyr for a new key.
Why do I get 403?
Usually your key's organisation doesn't own that survey. Check the surveyId against GET /surveys, which lists only your organisation's surveys. If the body says read_only_key, the call wasn't a GET under /api/external: your key only reads this API. If it carries code: ORG_SUSPENDED, your organisation is suspended: talk to your Surveyr contact. An ops key (one with no organisation) is also 403 on every route here: you need an organisation's key.
How often should we poll?
Every 12 minutes (five times an hour) is plenty, with since from your previous pull, as in Part 6. That uses a few requests a minute, far below the limit of 120.
Can we change or delete data through the API?
No. Your key is read only, and so is the API: anything but GET (and HEAD) is refused, with 403 read_only_key for your key.
A report disappeared and came back with a new id. Why?
The observer corrected it through the bot. A correction is saved as a new record, with a new id and a later updatedAt, and the earlier one is removed. Match the two by respondentId and surveyId, and keep the newest.
Will the fields change?
v1 is frozen: fields are only ever added, never renamed or removed. New fields may appear in answers, respondent.properties and the survey description, so ignore what you don't know.
What happens if a survey is rebuilt?
A rebuilt survey is a new survey, with a new surveyId and new question ids. GET /surveys lists both. Question ids never change for the life of a survey.
Need a hand? Email support@surveyr.africa or ask your Surveyr contact.
Surveyr