HelpDevelopers

Connect to the Surveyr API

Pull your organisation's surveys and reports into your own system, one step at a time, with HTTPie, curl or Postman.

Part 1

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.

  1. 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.

  2. 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.

  3. 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_KEY and $SURVEY_ID for real values.

  4. 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), run read -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_ID the same way.
Part 2

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_KEY

The key goes in the x-api-key header on every request. A good answer looks like this:

Response
{
  "organization": {
    "id": "Ab12Cd",
    "name": "Example Observer Mission"
  },
  "authType": "apiKey",
  "rateLimit": {
    "perMinute": 120,
    "maxPageSize": 500
  },
  "version": "v1",
  "serverTime": "2026-11-04T07:12:00.000Z"
}
  • organization is the organisation your key reads from.
  • rateLimit.perMinute is how many requests a minute you may make. rateLimit.maxPageSize is the most records one page can hold.
  • serverTime is 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.

Part 3

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_KEY

You 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
Response (three of its five questions)
{
  "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.

FieldMeaning
idThe question's stable key. Answers are keyed by it.
numberThe 1-based position, for display only.
textThe question, with the *[n of m]* counter removed.
typeOne of optionList, replyButtons, multiSelectList, openEnded, wordCloud, location, whatsAppForm, form.
options[].valueThe exact string stored as the answer.
options[].descriptionThe longer label shown to the observer, when there is one.
multiSelectOn a multiSelectList question: minSelections and maxSelections.
locationOn 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).
Part 4

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_KEY

It 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.

  • count is how many reports to make up: 1 to 50 (3 when you leave it out).
  • The body carries "sample": true, and the observers are called Sample observer 1, Sample observer 2 and so on.
  • format=csv works 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.
  • After the reports come three more records, so you can test versions: the first report's earlier version (latest: false; the first report is its version 2), an unfinished form (status: "incomplete") and a deletion notice.

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.

Part 5

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_KEY

since=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.

Response (one report, two of its five answers)
{
  "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",
      "status": "complete",
      "version": 1,
      "reportId": "6909c2a1b7e4f10012ab34cd",
      "replacesId": null,
      "supersededBy": null,
      "latest": true,
      "deleted": false,
      "deletedAt": null,
      "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://api.cloudinary.com/v1_1/surveyr/video/download?public_id=surveyr%2Fprivate%2FUwtbDE%2Fchatbot%2Fabc%2F123&format=ogg&type=private&expires_at=1793779920&…",
            "type": "audio",
            "expiresAt": "2026-11-04T08:12:00.000Z"
          },
          "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 version of a report (Part 6 explains versions):

FieldMeaning
idThis record's id. Every version has its own. Upsert on it.
surveyId, respondentIdWhich survey, which observer.
respondentname, 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, and on a deletion notice.
submittedAtWhen the report was completed; for an incomplete record, when Surveyr saved the unfinished form.
updatedAtWhen it last changed. since and until filter on this.
statuscomplete, incomplete (an unfinished form) or deleted (a deletion notice).
version1 for a first version; each correction adds one.
reportIdThe id of the report's first version, the same on every version. Group on it.
replacesId, supersededByThe version this one replaces, and the one that replaced it, or null.
latesttrue on the version to use; false on older versions and deletion notices.
deleted, deletedAttrue and the time, on a deletion notice.
languageThe language the observer answered in (eng_Latn, zul_Latn, …).
locationThe observer's shared GPS point, { latitude, longitude }, or null.
locationFlagsGPS checks (Part 7), or null when the survey asked for no location or doesn't check the pin (verifyLocation: false).
answeredCount, questionCountHow many questions were answered, out of how many.
answersAn object keyed by question id. Every question of the survey is present, answered or not.

Each entry in answers has these fields:

FieldMeaning
questionId, number, typeAs in the survey. The type of an open-ended question becomes text, audio, video, image or document, depending on what the observer sent.
answeredtrue when there is an answer.
valueChoice: 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.
labelChoice questions only: the option's description when it has one, else the value.
media{ url, type, expiresAt } for a voice note, video, image or document. See Media below.
transcriptAutomatic transcription of a voice note or video, when available.
location{ latitude, longitude } for a location question.
skippedtrue when a jump rule skipped the question.
iterationsRepeated questions only: one entry per repeat. See Repeated questions below.

An unanswered question looks like this, so your parser never has to guess:

answers.k3Fq9mZa, unanswered
{
  "questionId": "k3Fq9mZa",
  "number": 2,
  "type": "openEnded",
  "answered": false,
  "value": null,
  "label": null,
  "media": null,
  "location": null,
  "transcript": null,
  "skipped": false
}

Repeated questions. A question with loop: { sourceQuestionId, shuffle } in the survey is asked once for each option the respondent picked in the multi-select question sourceQuestionId (its text names that option with {{loop.currentAnswer}}). Its answer has type: "loop", value a readable summary ("Soul Fries: KFC; SoulFire: Steers") and iterations: one entry per repeat, in the order asked, each with sourceOptionId, sourceValue, sourceLabel, position, answered, value, label, explanation and media. Join on sourceOptionId (the source option's id), not on position: the order can be shuffled per respondent. Options marked exclusive ("None of them") are never repeated for.

answers.q_brand, a repeated question
{
  "questionId": "q_brand",
  "number": 17,
  "type": "loop",
  "answered": true,
  "value": "Soul Fries: KFC; SoulFire: Steers",
  "label": null,
  "media": null,
  "location": null,
  "transcript": null,
  "skipped": false,
  "iterations": [
    {
      "sourceOptionId": "e2",
      "sourceValue": "Soul Fries",
      "sourceLabel": "Soul Fries",
      "position": 1,
      "answered": true,
      "value": "KFC",
      "label": "KFC",
      "explanation": null,
      "media": null
    },
    {
      "sourceOptionId": "e1",
      "sourceValue": "SoulFire",
      "sourceLabel": "SoulFire",
      "position": 2,
      "answered": true,
      "value": "Steers",
      "label": "Steers",
      "explanation": null,
      "media": null
    }
  ]
}

Media. WhatsApp shrinks photos and videos on the observer's phone before sending them, so Surveyr receives the reduced copy, not the original; voice notes are not affected in any way that matters. For organisations whose media is private (SAFE VOTE's is), the files can't be opened without a link Surveyr issues: media.url is such a link, made fresh on every pull, and it stops working at media.expiresAt, an hour after the pull. Download the files you need into your own storage during the pull. For other organisations the link is permanent and expiresAt is null.

Order and paging. Records come oldest first, or newest first with order=newest; ties on updatedAt are broken by id. When pagination.hasMore is true, call again with cursor set to pagination.nextCursor. The cursor is only a bookmark for the next page: a 12-minute poll normally fits in one page, so you rarely need it, but keep the loop for busy moments and full rebuilds. The cursor carries the first page's window and order, so since=30m means the same moment on every page of a pull; a since, until or order 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
ParameterDefaultMeaning
sincenoneOnly 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).
untilnoneOnly records with updatedAt < until. Same forms as since.
limit100Records per page, up to maxPageSize (500).
cursornoneContinue the previous page: pagination.nextCursor. It keeps the first page's since, until and order; any sent with it are ignored.
orderoldestoldest or newest: the order records come in, by updatedAt then id.
formatjsoncsv 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, then the version columns status, version, report_id, replaces_id, superseded_by, latest, deleted_at, and last, for each repeated question, one column per option of its source question named q_<questionId>__<sourceOptionId> with the answer given for that option. 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_KEY

Every 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_KEY
Part 6

Election 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.

  1. Work out where to start

    • Take window.generatedAt from 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 start instead.

    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=30m can.

  2. Pull every page

    • Call GET /surveys/:surveyId/responses with that since and limit=500.
    • While pagination.hasMore is true, call again with cursor set to pagination.nextCursor. The cursor keeps the first page's since, so sending the same since again is harmless.
    • Stop when hasMore is false.
  3. 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.

    Corrections, unfinished forms and deletions arrive through this same loop, each as its own record (below). Nothing is removed without a record saying so.

  4. Save where you got to

    • Once every page is stored, save window.generatedAt of 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.

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_KEY

Never pull without since on election day, except to rebuild from scratch.

Versions, unfinished forms and deletions. Surveyr keeps every version of a report and sends each as its own record with its own id. An observer can send the same form many times (an hourly report, several incidents); each submission is a separate report with its own reportId.

What happenedWhat the feed sends
The observer finished a formA complete record, version 1, latest: true.
The observer corrected it through the botA new complete record, version 2, replacesId = the first one, latest: true; and the first one again with latest: false and supersededBy = the new one. Its answers and GPS checks are unchanged.
The observer stopped halfway and the chat went quiet for 30 minutes, or pressed ExitAn incomplete record with the answers so far. If they come back to the same chat and stop again, the same record again with more answers. Starting the form again later is a new report; the unfinished one stays as it was.
They then finished that formA complete record with replacesId = the incomplete one, which comes again with latest: false.
Surveyr deleted a record (a test record, or an observer's POPIA request to delete their data)A deletion notice: status: "deleted", deleted: true, deletedAt, the same id, empty answers, respondent: null. That observer's earlier versions and unfinished forms for the survey come as deletion notices too. Delete your copies.

Tip: For the current picture, keep the records where latest is true; to count finished reports, keep those that are also status: "complete" (an abandoned unfinished form stays latest because nothing replaced it). To audit a report, take every record with its reportId, in version order. Nobody at Surveyr can edit an observer's answers; the only change after a report is saved is an automatic voice-note transcript being redone.

Part 7

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.

locationFlags
{
  "forwarded": false,
  "outsideGeofence": false,
  "distanceMeters": 102,
  "enforcement": "soft_flag",
  "flaggedAt": null,
  "needsReview": false
}
FieldMeaning
forwardedtrue when the pin was forwarded from another chat, not shared live.
outsideGeofencetrue when the pin is outside the station's geofence.
distanceMetersThe distance from the station's target point. null when the survey set no target.
enforcementsoft_flag: a bad pin is only flagged. hard_block: a bad pin blocks the observer.
flaggedAtWhen the pin was flagged, or null when it wasn't.
needsReviewtrue 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):

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute.
X-RateLimit-RemainingRequests left in the current minute.
X-RateLimit-ResetWhen 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.

HTTP 429 · Retry-After: 37
{
  "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:

StatuserrorWhen
400bad_requestA parameter is malformed; message says which.
401—No key, or a key Surveyr doesn't know.
403read_only_keyA 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.
404not_foundNo such survey, or no such route.
405method_not_allowedAnything but GET, with a key that isn't read only: the API is read only.
429rate_limitedOver the per-minute limit.
500server_errorSurveyr 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.