Getting started · for developers
Your first anonymous survey, in about ten minutes.
Register your HRIS as a client application, create a survey, hand out invitation links, watch the replies arrive, and read the results back grouped by site, team or tenure. Four endpoints, all POST, all JSON. No SDK to install — curl is enough.
- Endpoints
- 4
- Auth
- Bearer token
- Employee languages
- 10
- To install
- Nothing
You need an HTTP client, an administrator email address, and a way to email your own staff. Nothing runs on your side. Running your own instance instead? Installation is in README.md, not here.
Before anything else: this needs a real number of people.
Results come back for a group only if it has at least 6 invited and at least 6 replies. Below either threshold the group is marked suppressed and carries counts and nothing else — no scores, no eNPS, no comments.
So a five-person pilot returns nothing at all, and looks broken while working exactly as designed. Test with at least a few dozen participants, spread over the groups you actually intend to slice by. If you plan to report per site and per team, each site and each team has to clear the threshold on its own.
This is the product working rather than a limit to route around. A group of three, reported on, is three people identifiable by anyone holding the org chart.
Your values
Fill these in and every example below updates. They stay in this page — nothing is stored, and nothing is sent anywhere. You get the first two in part one, and the survey code in step 1 of part two. The base address is not one of them: it is fixed at https://enps-survey.org/api/v1 and is already written into every command.
Part one
Get an account and a token
≈ 2 minutesDone once, in a browser. Everything after this is HTTP.
Register your application
Open /new-caller and give a name and an administrator email. The name is the employer label your employees will see at the top of the questionnaire, so use the company name rather than the name of your integration.
A one-time link arrives by email. It opens a confirmation page; pressing the button mints the access token and shows it once.
The extra click is deliberate. Corporate mail gateways follow links in incoming mail, and a scanner would otherwise burn the one-time code before you ever clicked it.
/new-caller # register, then check your email /new-caller/rotate # lost the token — needs client_app_id
Keep both strings
You are given a client_app_id and an access_token. Every API call needs both: the token in the Authorization header, the id in the body.
Save the client_app_id as carefully as the token. It isn't secret — it goes out on every request — but it is the only way to rotate a lost token. Lose both and your surveys are unreachable forever: there is deliberately no lookup by name or by email, because either would let anyone enumerate the employers using this service.
Names are not unique and identify nothing. Identity is the id plus the token.
{
"client_app_id": "01J7ZQ8N4KDV6PMB3XW9YC2RTF",
"app_name": "Northwind Trading",
"access_token": "kP2vR…"
}
Part two
Run a survey
≈ 8 minutesPaste your client_app_id and token into the panel above and the commands below become copy-pasteable.
Create a survey
Create-only: there is no edit, close-early or delete endpoint.
timezone is an IANA name. An offset like -04:00 is rejected. Datetimes are stored as UTC and returned in the survey's own zone. datetime_end must be in the future; a start in the past just opens the survey immediately.
Put the returned code into the panel above.
curl -sX POST "https://enps-survey.org/api/v1/surveys" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{
"client_app_id": "",
"survey_title": "Employee sentiment H2",
"timezone": "America/Toronto",
"datetime_start": "2027-07-01T09:00:00",
"datetime_end": "2027-07-15T18:00:00"
}'
{
"survey_code": "eQ5H3e7BS62pCojPVM32",
"survey_title": "Employee sentiment H2",
"datetime_start": "2027-07-01T09:00:00-04:00",
"datetime_end": "2027-07-15T18:00:00-04:00",
"timezone": "America/Toronto"
}
Register participants, one row per employee
Send only the labels you want to slice by later. No name, no email, no employee number — the service has nowhere to put them, and the fields would be rejected. Each participant comes back with an invitation link.
Batches cap at 500 and are all-or-nothing inside a transaction: one bad row rolls the whole request back and names the index, so a roster can never be half-loaded.
Any demographic may be omitted and becomes unspecified, which then groups like any other value. An unrecognised bracket is rejected rather than quietly defaulted. Decide your slicing before you load the roster — a label you did not send cannot be added afterwards.
curl -sX POST "https://enps-survey.org/api/v1/participants" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{
"client_app_id": "",
"survey_code": "",
"participants": [
{"location":"toronto", "org_unit":"engineering", "tenure_bracket":"2-3"},
{"location":"boston", "org_unit":"sales", "tenure_bracket":"4-5"}
]
}'
{
"survey_code": "eQ5H3e7BS62pCojPVM32",
"registered": 2,
"participants": [
{ "participant_survey_code": "…", "submit_url": "…" },
{ "participant_survey_code": "…", "submit_url": "…" }
]
}
Email the links yourself
This service never contacts your employees. You hold the roster, so you do the matching — and because you do, we never learn which code belongs to whom. That is what the anonymity rests on.
The participants come back in the order you sent them, so pair each submit_url with the person on your side, send it, and do not keep the pairing any longer than the send takes. A stored map of code to employee recreates exactly what this design removes.
Participants can be added at any point up to datetime_end, including before the survey opens — so a new joiner mid-survey is one more call. Afterwards: 422.
to: alice@northwind.example
link: https://enps-survey.org/submit/{participant_survey_code}
Answer one yourself, before your staff do
Register a couple of extra participants for yourself and open one of their links. Pick a score; the follow-up question appears, asking what could improve at 0–8 and what makes the place good at 9–10. The comment is optional; the score is the only required field.
Each link works exactly once. Answering turns it into an "already submitted" page, so keep a spare or two if you want to look again.
The page picks its language from the employee's browser, and they can change it themselves. Append ?lang= to see any locale: en es fr de pt-BR it nl ja ko zh-Hans. Switching language part-way through keeps whatever has been answered so far.
It works with JavaScript disabled, which is worth knowing before somebody reports it broken from a locked-down desktop: the follow-up reveal is CSS, not script.
| Link state | Page |
|---|---|
| Unanswered, survey open | the questionnaire |
| Already answered | "already submitted" |
| Not open yet, or ended | "survey closed" |
| Unknown code | a generic not-found page |
An unknown code and a spent one are deliberately indistinguishable — telling them apart would confirm that a leaked code was real.
Watch participation while it runs
Counts only — how many invited, how many replied. Never who. Poll this to decide whether to send a reminder.
Group by location, org_unit or cost_center — either position, any pair. Omit grouping1 for the survey as a whole.
Groups with fewer than 2 invited merge into an other row. Branch on the other boolean, not on the label — a real cost centre may legitimately be called other.
curl -sX POST "https://enps-survey.org/api/v1/surveys/status" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{"client_app_id":"","survey_code":"","grouping1":"cost_center"}'
{
"grouping": ["cost_center"],
"rows": [
{ "group1": "cc-1001", "group2": null, "other": false, "invited": 17, "replied": 14 },
{ "group1": "other", "group2": null, "other": true, "invited": 1, "replied": 0 }
],
"totals": { "invited": 60, "replied": 40 }
}
Part three
Read the results, and act on them
the point of all thisThe results endpoint is the only way answers ever come back, and it only ever answers about groups. There is no per-employee response to fetch — not with a different parameter, not with a different token. It does not exist in the database to be returned.
Ask for one dimension at a time
grouping1 alone may be any of the six demographics. This is the query you will use most, because it is the only shape that returns comments.
Check suppressed before reading anything else. A suppressed row carries invited and replies and nothing at all besides — the scores, enps and comments keys are absent, not null. totals is subject to the same rule.
Omit grouping1 entirely for one figure covering the whole organisation.
curl -sX POST "https://enps-survey.org/api/v1/surveys/results" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{"client_app_id":"","survey_code":"","grouping1":"org_unit"}'
{
"rows": [
{
"group1": "engineering", "other": false,
"invited": 24, "replies": 17, "suppressed": false,
"scores": {"0":0,"3":1,"…":0,"9":5,"10":3},
"enps": 35,
"comments": ["Good team, clear priorities…", "…"]
},
{
"group1": "other", "other": true,
"invited": 9, "replies": 4, "suppressed": true
}
],
"totals": { "invited": 60, "replies": 40, "suppressed": false, "enps": 25 }
}
Read the histogram, not just the score
eNPS is % promoters − % detractors, as a whole number. 9–10 are promoters, 0–6 are detractors, and 7–8 are passives, who count in the denominator but score nothing.
Which means one number hides a lot. A team split between enthusiasts and the thoroughly fed up scores the same as a team of shrugs. Those are not the same problem and they do not have the same fix, so read scores — the full 0–10 histogram is in every unsuppressed row.
Compare groups against each other and against your own last survey rather than against a published benchmark. The number is most useful as a difference.
| Score | Band | Effect |
|---|---|---|
| 9–10 | Promoter | counts + |
| 7–8 | Passive | counts, scores 0 |
| 0–6 | Detractor | counts − |
Range −100 to +100. Rounded half away from zero, so −2.5 becomes −3.
Cross two dimensions to find where a problem lives
A poor score for a site is a question, not an answer. Crossing it with tenure or with occupational range often turns it into something actionable — "new joiners at this site" is a thing you can act on.
Crossed, grouping1 is location or org_unit and grouping2 is one of the three brackets; anything else is 422 invalid_grouping. You also get a subtotals array, one row per grouping1 value.
A crossed query never returns comments. Not a limitation — a cross-tabulation of free text is a re-identification kit, because each extra dimension narrows the pool of people a comment could have come from. Suppression still applies to every cell, so expect more suppressed rows here than on a single dimension.
curl -sX POST "https://enps-survey.org/api/v1/surveys/results" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{
"client_app_id": "",
"survey_code": "",
"grouping1": "location",
"grouping2": "tenure_bracket"
}'
Read the comments — carefully
The comments are where the improvement actually is. The score tells you there is a problem; only the text tells you what it is.
They come back in random order, unlinked to any score and unlinked to each other, so you cannot tell which comment came from the 2 and which from the 9. That is intentional: pairing a harsh comment with the lowest score in a small team names its author to anyone willing to guess.
Escape them before rendering. They are employee-written text, returned verbatim, and will contain quotes, angle brackets and newlines.
And one caution worth reading properly: running all six single-dimension queries and intersecting the comment lists labels every comment with its author's full demographic profile. Suppression does not prevent this. ARCHITECTURE.md sets out the exposure — if you build a reporting UI, don't hand its users a way to do that by accident.
A worked reading. Engineering scores 35, sales scores −10. The histogram shows sales is not uniformly unhappy: eleven 7s and four 2s.
Crossing sales with tenure puts every low score in the under-one-year bracket. The comments from the org_unit query mention onboarding and unclear targets three times between them.
That is a specific, fixable thing — and no individual was identified at any point in getting to it.
Worth understanding before you demo it
The character meter measures the answer, not the interface.
Comments are worth more when they are longer, so the questionnaire nudges for detail with a bar under the text box. One target — 150 — for all 10 locales, but each character counts for what its script carries: a Han character or a kana about two and a half, a Hangul syllable about two, everything else one. So the target is roughly 150 Latin letters, 75 Korean syllables or 60 Japanese characters, or any mixture of the three.
Weighted by script rather than by locale because the two need not agree. Someone reading the Japanese page and answering in English would otherwise fill the bar in a sentence.
Weighted units, one colour per encouragement message. The target at 150 fills 85%; the bar only completes at 200.
Reaching the target changes the message without the bar implying there is no reason to keep writing — "you have written enough" and "the bar is full" are deliberately different moments.
The colours describe length, never score. A detractor who writes at length gets a green bar. The 0–10 row on the questionnaire is never coloured by band — colouring it would tell an employee which numbers are the bad ones before they answer, and bias the data you are collecting.
The questionnaire also links to a page explaining the anonymity, with a worked example of a comment that identifies its author and one that does not. Worth reading once yourself: it is the part of the guarantee no software can enforce.
Reference
The vocabularies you'll get wrong once
Slugs match /^[a-z0-9-]{1,40}$/ and are lowercased on write. The three brackets are closed sets — send a value that isn't listed and the request is rejected, not defaulted. The full API reference, with every field and every error, is in README.md.
| POST | Returns |
|---|---|
| /surveys | a survey_code |
| /participants | one submit_url per person |
| /surveys/status | invited and replied counts |
| /surveys/results | scores, eNPS, comments |
| location | slug |
| org_unit | slug |
| cost_center | slug |
| age_bracket | <=20 · 21-25 · 26-30 · 31-35 · 36-40 · 41-45 · 46-50 · 51-60 · 61-70 · >70 |
| tenure_bracket | <1 · 1 · 2-3 · 4-5 · 6-10 · 11-20 · >20 |
| occupational_range | <=50% · 51-79% · 80-99% · 100% |
| Status | Codes |
|---|---|
| 400 | malformed_json, invalid_request |
| 401 | invalid_token |
| 403 | app_disabled |
| 404 | survey_not_found, not_found |
| 405 | method_not_allowed |
| 422 | validation_failed, invalid_grouping |
| 429 | rate_limited |
| 500 | server_error |
| Limit | Value |
|---|---|
| API, per application | 1000 / hour |
| Failed auth, per IP | 60 / hour |
| Registration and rotation, per IP | 5 / hour |
| Registration, per admin email | 5 / hour |
| Token redemption, per IP | 30 / hour |
Before you file a bug
Eleven things that look broken and aren't.
Most of these are anonymity rules doing their job, and the rest are the sharp edges of a service that deliberately can't remember anything about anyone.
-
Every group comes back suppressed
Fewer than 6 invited, or fewer than 6 replies, and the row collapses to counts. Both halves matter — eight invited with two replies describes those two people. Small pilots return nothing; that is the design.
-
Reading
scoresthrowsSuppressed rows have no
scores,enpsorcommentskeys at all — they're absent, not null. Testsuppressedfirst. The same applies tototals. -
Your own survey returns 404
A survey belonging to another application answers
survey_not_found, identically to an imaginary code. There is no "not yours" — it would confirm that a leaked code is real. Check you're sending the rightclient_app_id. -
Bearer auth silently never works
Check your client really sends
Authorization. Some proxies and some HTTP libraries drop it across a redirect. A wrong token and a missing one are both401 invalid_token, never a hint about which. -
A group named
othervanishes into the mergeGroups too small to report are merged into a row marked
"other": true. Branch on the boolean; the label is just a label and a real cost centre can share it. -
A crossed query returns no comments
By design — a cross-tabulation of free text is a re-identification kit. You get counts, scores and eNPS, plus a
subtotalsarray.totalsnever carries comments either. -
The timezone is rejected
IANA names only, validated against a canonical list.
America/Torontoyes,-04:00no. Anddatetime_endmust not already have passed. -
Adding participants suddenly 422s
You can register people right up to
datetime_endand not one second after. There is no way to reopen a survey, so a late roster means a new survey. -
There is no edit, and no delete
A survey cannot be renamed, rescheduled, closed early or removed. Create-only is deliberate: an editable survey would let an employer quietly redefine what a set of answers meant after reading them.
-
Comments arrive with markup in them
They're employee-written text, returned verbatim. Escape before rendering. They also arrive in random order, deliberately unlinked to the scores in the same group.
-
You lost the token and the id
Then the surveys are gone.
/new-caller/rotateneeds theclient_app_id, and there is no lookup by name or email because either would let anyone enumerate the employers using this service. Store the id somewhere you would not lose a password.
That's the whole surface.
Four endpoints and one questionnaire. Everything else is your own code deciding who gets asked, and what you do about the answer.