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.

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",
      "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:

FieldMeaning
idThe response id. De-duplicate 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.
submittedAtWhen the report was completed.
updatedAtWhen it last changed. since and until filter on this.
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 } for a voice note, video, image or document. WhatsApp compresses media before Surveyr receives it; the URL is public and permanent.
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.

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
}

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
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 and until; any sent with it are ignored.
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. 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.

    A report the observer corrects through the bot arrives as a new record, with a new id and a later updatedAt, and the earlier record is removed on Surveyr's side. Match a correction to the report it replaces by respondentId and surveyId, and keep the newest.

  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.

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.