Getting started · for developers
Your first anonymous survey, in about twenty minutes.
Register your HRIS as a client application, rehearse a survey on yourself, check your reporting against a sample survey that is already full of answers, and then run the real one. The API has six endpoints. All of them use POST and JSON. There's no SDK to install — curl is enough.
- API endpoints
- 6
- Auth
- Bearer token
- Employee languages
- 10
- To install
- Nothing
You need an HTTP client, an administrator email address, and a way to email your employees. Nothing needs to run on your own infrastructure.
Before you start: your own test survey cannot produce a result
A group returns results only if it has at least 6 invited participants and at least 6 replies. Below either threshold it is not reported on its own: it is merged into an "other" row instead, and only a survey too small to report anything at all comes back as counts with no scores, no eNPS and no comments. /surveys/enps asks less of you — 4 replies, and it does not look at how many you invited — but four is still more than a test survey has.
So a survey you invite yourself and three colleagues to comes back empty. It looks broken while working exactly as designed. That one rule is why the four parts above are shaped the way they are: part two runs a real survey end to end at a scale where nothing reports, and part three points the reporting calls at a sample survey of 243 invitations and 200 answers that every registered application can read.
Each reporting group has to clear the threshold on its own. If you plan to report per site and per team, every site and every team needs its own 6 invited and 6 replies.
This is an anonymity safeguard, not a limit to work around. A group of three, reported on, is three people identifiable by anyone holding the org chart.
Part one
Get an account and a token
≈ 3 minutesYou do this once in your browser, then prove the credentials work with a single call. Everything after this point is the API.
Register your application
Open /new-caller and give an application name and an administrator email. The application name is the employer name your employees see at the top of the questionnaire. Use your company name, not the name of your integration.
A one-time link then 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 scanners follow links in incoming mail, and one 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
These two routes are browser pages, not API endpoints. The six API endpoints all sit under https://enps-survey.org/api/v1.
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.
The access_token is secret. The client_app_id is not: it goes out on every request. Retain both anyway — the client_app_id is the only way to rotate a lost token.
Lose both and your existing surveys are unreachable, permanently. There is deliberately no lookup by company 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…"
}
Your values 1/2
Paste in the two strings you were just given. Every example below fills in automatically. The values stay in this page: nothing is stored, and nothing is sent anywhere, which is why the panel can safely take an access token. The API base is not a field — it is fixed at https://enps-survey.org/api/v1 and is already written into every command. The third value, the survey_code, does not exist yet: you are given one in part two, and there is a box for it there.
Check that the credentials work
Make this call before anything else. It creates nothing and changes nothing. A successful request returns HTTP 200 and names the application you authenticated as.
Testing the credentials on their own makes every later error easier to diagnose, because every other endpoint can fail for two reasons at once. A 401 deliberately never says whether the id was unknown or the token was wrong, so going straight to creating a survey means debugging your credentials and your request shape together.
This is also the call to make after rotating a token, and a reasonable thing for your own monitoring to poll — sparingly, since it counts against the same hourly budget as everything else.
curl -sX POST "https://enps-survey.org/api/v1/token/check" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{"client_app_id": ""}'
{
"client_app_id": "01J7ZQ8N4KDV6PMB3XW9YC2RTF",
"app_name": "Northwind Trading"
}
app_name is the employer name your employees see on the questionnaire. If it reads like the name of your integration rather than your company, fix it before you send a single invitation link.
Part two
Rehearse on a survey of your own
≈ 8 minutesCreate one survey, invite yourself and two or three colleagues, and answer it. This is the whole write path — creation, invitation links, the questionnaire — at a scale where nothing can go wrong quietly. With the panel in part one filled in, every command below is copy-pasteable as it stands.
Do not expect results from it. Four people is below the reporting threshold, so /surveys/results will return nothing but suppressed rows — and /surveys/enps, whose bar is lower, still needs more answers than a rehearsal collects. That is correct behaviour, and part three is where you check your reporting code instead.
Create the survey
Surveys are create-only. After creation, a survey cannot be renamed, rescheduled, closed early or deleted. There is no edit, close-early or delete endpoint — so give this one a title that will still read as a rehearsal when you find it again in six months.
timezone takes an IANA timezone name, such as America/Toronto. A UTC 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 datetime_start in the past simply opens the survey immediately.
Put the returned survey_code into the box below and the commands that need one fill in — here and in part four, when you paste the real survey's code over it.
curl -sX POST "https://enps-survey.org/api/v1/surveys" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{
"client_app_id": "",
"survey_title": "Integration rehearsal — ignore",
"timezone": "America/Toronto",
"datetime_start": "2027-07-01T09:00:00",
"datetime_end": "2027-07-15T18:00:00"
}'
{
"survey_code": "eQ5H3e7BS62pCojPVM32",
"survey_title": "Integration rehearsal — ignore",
"datetime_start": "2027-07-01T09:00:00-04:00",
"datetime_end": "2027-07-15T18:00:00-04:00",
"timezone": "America/Toronto"
}
Your values 2/2
The last of the three values, and the only one you could not have before now. Paste the survey_code from the response above. Part three is the exception: it reads the shared sample survey, whose code is written into its commands already and is nobody's to change.
Register yourself and a few colleagues
One row per person, carrying only the demographic labels you intend to report on. Each row comes back with an invitation link. Register a couple more than you need: every link works exactly once, so spares are what let you look at the questionnaire twice.
Do not send names, email addresses, employee numbers or any other identifier. The service has nowhere to put them, and unsupported identifying fields are rejected.
Any demographic may be omitted. It then becomes unspecified, which groups like any other value. An unrecognised bracket is rejected rather than quietly defaulted.
Your roster holds ages, not brackets — so send what it holds. age, tenure_years and occupational_rate stand in for the three brackets and are banded here: "age": 25.8 is stored as 21-25, "tenure_years": 3.5 as 2-3, "occupational_rate": 75 as 51-79%. Years band on completed years, so the boundary is the birthday; a rate reads as its label states, so 50.5 is above <=50% and only exactly 100 is 100%. Send numbers, not strings, and send one or the other per field — a measurement beside its own bracket is a 422 validation_failed, as is an age of 310 that was meant to be months.
The rehearsal is also the moment to check the shape of your own roster export. Decide how you want to group the results before you load a real roster — a demographic you did not send cannot be added afterwards, and a survey cannot be reopened to correct it.
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":"toronto", "org_unit":"engineering", "tenure_bracket":"2-3"},
{"location":"boston", "org_unit":"sales", "tenure_bracket":"4-5"},
{"location":"boston", "org_unit":"sales", "tenure_bracket":"4-5"}
]
}'
{
"survey_code": "eQ5H3e7BS62pCojPVM32",
"registered": 4,
"participants": [
{ "participant_survey_code": "…", "submit_url": "…" },
{ "participant_survey_code": "…", "submit_url": "…" }
]
}
Participants come back in the order you sent them. In the real run that ordering is how you match a link to a person — see part four, which is also where the rules about not keeping that mapping live.
Open an invitation link and answer it
Paste one submit_url into a browser. 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.
Answering spends the link. It becomes an "already submitted" page, which is one of the four states worth seeing before an employee sees it first. Open a spare to compare.
The questionnaire picks its language from the participant's browser, and the participant can change it. Append ?lang= to any invitation link to see a specific 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.
The questionnaire works with JavaScript disabled: the follow-up reveal is CSS, not script. Worth knowing before somebody reports it broken from a locked-down desktop.
| 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.
Confirm the answer landed, and see the wall
/surveys/status counts invitations and replies, and it has a much lower threshold than reporting does, so it works on a survey this small. Call it without a grouping1 and the reply you just submitted is there in the totals. That is the end of the write path: everything from creation to a stored answer is now proven.
Now call /surveys/results with the same survey_code. Every row comes back "suppressed": true with no scores, no enps and no comments — including totals. Four people is below both thresholds, and the endpoint is behaving exactly as it is meant to.
/surveys/enps is the same story with a lower number. It needs 4 replies rather than 6, and it ignores the roster entirely — but one answer is still one answer, so it too comes back suppressed, with no enps key on the row and none in totals.
Do not try to get past this by inviting more test accounts. You would need 6 invitations and 6 real answers per group you report on, and you would end up building the sample data that part three already hands you.
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":""}'
{
"survey_code": "eQ5H3e7BS62pCojPVM32",
"grouping": [],
"rows": [ { "group1": null, "group2": null, "other": false, "invited": 4, "replied": 1 } ],
"totals": { "invited": 4, "replied": 1 }
}
{
"survey_code": "eQ5H3e7BS62pCojPVM32",
"grouping": [],
"rows": [ { "group1": null, "other": false, "invited": 4, "replies": 1, "suppressed": true } ],
"totals": { "invited": 4, "replies": 1, "suppressed": true }
}
Part three
Read results, against the sample survey
≈ 10 minutesOne survey on this service is shared. It is invented data — 243 invitations, 200 answers, 190 written comments — and every registered application can read it, using its own credentials and the survey code below. Nothing to set up, and nothing to clean up afterwards.
It exists because the reporting endpoints are the half of this API you cannot exercise on your own test survey. Build your dashboards, your other handling and your comment rendering against this, then point them at your real survey when it closes.
One branch it cannot show you is a suppressed row. The sample is far too large to have one: 200 answers means every threshold is cleared, so "suppressed": true never appears anywhere in it — not in a row, a subtotal or totals. It shows you the other half of the rule instead, the other bucket, which is what the small groups here become. To exercise the suppressed branch, call /surveys/results on the rehearsal survey from part two — four people is below the threshold, and a survey that small is the only thing that produces one.
The sample survey
DEMOSAMPLESURVEY2026 — the same code for every caller. It is read-only and closed: all three reporting endpoints accept it, /participants answers 404 survey_not_found for it, and its window ended long ago, so its figures never move underneath you. Anything you compute from it today you can assert on tomorrow.
It is not a substitute for creating your own survey. Part two proves that your credentials, your roster and your invitation links work. This proves that your reporting handles a full response set — including the parts of one that are missing on purpose.
Start with the whole survey
Omit grouping1 entirely and you get one figure covering everyone. This is the request to make first, because there is exactly one right answer and you can check it against the box beside this text.
Note that the survey code is not one of your values — it is the same literal for every caller, so it is written into the commands in this part rather than filled in from the panel. Your own survey_code box is untouched by this section.
totals covers every answer in the survey, including answers sitting in groups too small to be reported individually. It is the only place those answers are visible, and it is why the totals will not equal the sum of the rows you get back from a grouped query.
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": "DEMOSAMPLESURVEY2026"
}'
{
"survey_code": "DEMOSAMPLESURVEY2026",
"grouping": [],
"rows": [ … ],
"totals": {
"invited": 243, "replies": 200,
"suppressed": false,
"scores": {"0":1,"1":3,"2":4,"3":6,"4":10,"5":12,"6":16,"7":26,"8":34,"9":48,"10":40},
"enps": 18
}
}
200 answers from 243 invitations is a response rate of 82% — high, deliberately, so that most groups have something to report and the ones that do not are the interesting cases rather than all of them.
Group by one dimension, and meet suppression
grouping1 on its own may be any of the six demographics: the three organisational fields location, org_unit and cost_center, and equally the three brackets age_bracket, tenure_bracket and occupational_range. A bracket is only ever available on its own — add a grouping2 and grouping1 narrows to the three organisational fields, so age_bracket crossed with anything is 422 invalid_grouping. This is the query you will use most, because it is the only shape that returns comments.
The sample has 15 org units and returns 7 of them. The other 8 are below a threshold and are merged into a single "other": true row — 27 invited and 21 replies between them. That row is a bucket, not a department.
Always check suppressed before reading any result field. When it is true, the row carries invited and replies and nothing else: the scores, enps and comments keys are absent, not null. totals follows the same rule. You will not meet it here. A group below the threshold is merged into other rather than suppressed, and other keeps absorbing until it clears — so a suppressed row appears only on a survey too small to report anything at all, which is what the rehearsal in part two gives you and what part four asks for more people than feels necessary to avoid.
other is never a handful of people. If the merged groups do not reach the threshold between them, the bucket takes the smallest group that would have been reported along with them, and keeps taking until it clears. Group the sample by location to see it: remote-emea has only 6 invited and 4 replies, so other would have been thinner still — and milan, with 24 replies of its own, is merged in with it and does not appear as a row. The two come back as one other of 40 invited and 28 replies. Never assume a group you registered will have a row.
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": "DEMOSAMPLESURVEY2026",
"grouping1": "org_unit"
}'
{
"rows": [
{
"group1": "engineering", "other": false,
"invited": 58, "replies": 48, "suppressed": false,
"scores": { … },
"enps": 50,
"comments": [
{ "score": 10, "comment": "What keeps me here is simple…" },
{ "score": 4, "comment": "…" }
]
},
…
{
"group1": "other", "other": true,
"invited": 27, "replies": 21, "suppressed": false,
"scores": { … }, "enps": -5, "comments": [ … ]
}
],
"totals": { "invited": 243, "replies": 200, "suppressed": false, "enps": 18 }
}
What is in the sample
Every org unit in the survey, and what the results endpoint does with it. The sizes are chosen so that one call meets every case at once: groups far below the threshold, a group sitting exactly on it, and a group with plenty of invitations but one reply too few.
| org_unit | invited | replies | eNPS | reported |
|---|---|---|---|---|
| data-privacy | 6 | 6 | +33 | yes |
| engineering | 58 | 48 | +50 | yes |
| executive | 2 | 1 | — | merged into other |
| facilities | 4 | 3 | — | merged into other |
| internal-audit | 1 | 1 | — | merged into other |
| investor-relations | 2 | 2 | — | merged into other |
| it-platform | 24 | 20 | +35 | yes |
| legal | 3 | 2 | — | merged into other |
| marketing | 24 | 20 | +35 | yes |
| operations | 34 | 28 | -68 | yes |
| procurement | 5 | 4 | — | merged into other |
| quality-assurance | 7 | 5 | — | merged into other |
| research-development | 40 | 33 | +48 | yes |
| sales | 30 | 24 | 0 | yes |
| sustainability | 3 | 3 | — | merged into other |
| other | 27 | 21 | -5 | yes — the 8 merged groups |
Two cases are worth singling out. data-privacy has exactly 6 invited and exactly 6 replies and is reported — the threshold is inclusive. quality-assurance has more than enough invitations and one reply too few, and is not. Invited and replies are tested separately, and a row fails on either.
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.
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 different problems with different fixes, so read scores. The full 0–10 histogram is in every unsuppressed row.
The sample is built to make that concrete. Its overall enps is +18, which sounds unremarkable until you group it: engineering returns +50 and operations -68 in the same organisation. The survey-wide number is the one figure that tells you least.
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 locate patterns
A poor score for a site is a question, not an answer. Crossing it with tenure or with occupational range often makes it actionable: "new joiners at this site" is something you can act on.
In a crossed query, grouping1 is one of the three organisational fields — location, org_unit or cost_center — and grouping2 is one of the three brackets. Anything else returns 422 invalid_grouping. The response also carries a subtotals array, one row per grouping1 value.
A crossed query never returns comments. Returning free-text comments with cross-tabulated results would create a significant re-identification risk, because each additional dimension narrows the group of possible authors. Suppression still applies, and it applies to every cell: a cell that fails it is not returned as a suppressed row, it disappears into the same single other bucket as all the others. That bucket has to clear the threshold too, so it may take the smallest qualifying cell with it — a cell you expected to see can be missing for that reason alone.
Run it against the sample and that last sentence stops being abstract. The command opposite reports 12 cells and merges 109 of the 200 answers into a single other row — not because anything is wrong, but because 15 org units times seven tenure brackets is a lot of cells to spread 200 answers over. A crossed query is for confirming a pattern you already suspect, not for browsing.
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": "DEMOSAMPLESURVEY2026",
"grouping1": "org_unit",
"grouping2": "tenure_bracket"
}'
subtotals carries one row per grouping1 value — the same numbers a single-dimension query would return, so you can render a total row without a second call. It never carries comments either.
Ask for the score alone, and reach smaller groups
/surveys/enps answers the same question as /surveys/results and returns one thing per group: the eNPS value. No counts, no score breakdown, no comments. Because that is all it hands over, it reports a group on 4 replies and does not look at how many you invited.
The request is identical. Same fields, same permitted groupings, same 422 invalid_grouping for anything else — one piece of code can call either. The envelope is the same too, minus what this endpoint does not report: rows, subtotals on a crossed query, and totals.
Run both against the sample and the difference is the point. By org_unit, /surveys/results reports 7 departments; /surveys/enps reports 9, because two more clear four replies than clear six invited and six replies. By location it reports all 6 and returns no other row at all — the two that /surveys/results has to merge both stand on their own here.
It is not a way around suppression. The merging rule is the same one, at a lower number: small groups still collect into a single other, that row still has to clear the threshold, and it still takes the smallest qualifying group with it when it cannot. A survey with fewer than 4 answers in total returns one suppressed row and no number anywhere.
Reach for it when you want a scorecard across many small teams and do not need the detail. Reach for /surveys/results when you need to know why — the histogram and the comments are the half that tells you.
curl -sX POST "https://enps-survey.org/api/v1/surveys/enps" \
-H "Authorization: Bearer " \
-H 'Content-Type: application/json' \
-d '{
"client_app_id": "",
"survey_code": "DEMOSAMPLESURVEY2026",
"grouping1": "location"
}'
{
"survey_code": "DEMOSAMPLESURVEY2026",
"grouping": [ "location" ],
"rows": [
{ "group1": "basel", "other": false,
"suppressed": false, "enps": 21 },
…
{ "group1": "remote-emea", "other": false,
"suppressed": false, "enps": 50 }
],
"totals": { "suppressed": false, "enps": 18 }
}
remote-emea has 4 replies. /surveys/results merges it away and takes milan with it; here it is a row of its own.
Read the comments — carefully
The comments are where the improvement is. The score tells you there is a problem; only the text tells you what it is.
Each comment arrives as an object carrying the score its author gave it — {"score": 2, "comment": "…"} — because the same sentence means different things from a 2 and from a 9. "The workload is heavy" from a promoter is a warning; from a detractor it is the reason they are one. Read the pair, not the text alone.
They still come back in random order, and the pair is shuffled as a unit, so nothing in the ordering can be lined up against the sequence you sent the invitations in. What the pairing does cost is worth knowing: on a group at the threshold, a comment that identifies its own author now also says what that person scored. Suppression bounds it — no group under 6 invited and 6 replies returns a comment at all — but it does not remove it.
Not every reply has one. scores counts every answer; comments holds only the answers that wrote something. The two lengths are not meant to match — do not derive one from the other, and do not expect a comment for every point in the histogram.
Escape them before rendering, and do not assume a length. They are employee-written text, returned verbatim, and will contain quotes, angle brackets and newlines. The sample carries 190 comments across its 200 answers — around 95%, which is roughly what a well-run survey gets — and they run from a single sentence to several paragraphs. Point your renderer at them before a real one arrives: a table cell that assumed one line is the most common thing to discover late.
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 interface, do not give its users an easy way to do this by accident.
A worked reading, on the sample. Engineering scores +50 and operations -68, in a company whose overall figure is +18. One department is not having the same year as the other.
The operations histogram is not uniformly angry either. It sits in the middle of the detractor band rather than at the bottom of the scale, which is the signature of people who are tired rather than people who have already left in their heads.
The comments say why: work absorbed after colleagues left and were never replaced, and a settled belief that saying so has changed nothing. That is a specific, fixable problem, and no individual was identified at any point in reaching it.
Part four
Run your first real survey
the whole cycleNothing new to learn: the same four calls you have already made, at a size where the answers mean something. What changes is the scale of the roster, the fact that real people are receiving the links, and that you now have to decide who sends them.
Paste the real survey_code over the rehearsal one in the panel in part two, and the commands below fill in with it.
Create it, and get the window right first time
Same call as the rehearsal. The difference is that you cannot correct it afterwards: no rename, no reschedule, no early close, no delete. Check the title — it is not what employees see, but it is what you will be reading in a report next year — and check datetime_end, because a survey that closes too early cannot be reopened and a late roster then means a whole new survey.
Two weeks is a common window. Long enough to catch people returning from leave, short enough that the reminder still has somewhere to go.
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"
}'
Load the roster
One row per employee, carrying the demographics you decided on before you started. Batches cap at 500 participants 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.
Size every group you intend to report on. Before you send this, count your roster by each demographic you plan to group by. Any value with fewer than 6 people will never report — that is the moment to merge two small sites into one label, not after the survey has closed.
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. After datetime_end: 422.
curl -sX POST "https://enps-survey.org/api/v1/participants" \ -H "Authorization: Bearer " \ -H 'Content-Type: application/json' \ -d @roster-batch-1.json
A roster of 2,000 is four requests. There is no bulk endpoint beyond this one and no need for one: the cap exists so a failed request stays small enough to retry.
Send the invitation links
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 API returns participants in the same order you submitted them. Match each submit_url to the corresponding employee, send the mail, then discard the mapping. Storing a code-to-employee table recreates exactly the link this service is designed not to hold, and it would sit in your systems rather than ours.
Two ways to do the send, and the choice matters. Your application does it, addressing each employee from the roster it already holds and keeping nothing — that is the version that scales and the version that stays anonymous by construction. Or the survey administrator does it by hand from an export, which is workable for a small organisation but means a spreadsheet of names against codes exists on somebody's laptop. If it has to be done by hand, delete that file the day the invitations go out.
One code per person. A link forwarded to a colleague is that colleague's answer, filed under the first person's demographics.
to: alice@northwind.example
link: https://enps-survey.org/submit/{participant_survey_code}
Reminders are your job too, and the status endpoint below is what tells you whether one is needed. Send it to everybody: you cannot tell who has already answered, and neither can we.
If your roster records a preferred language, preset it. Append ?lang= to the link you mail — …/submit/{participant_survey_code}?lang=fr — and the questionnaire opens in that language rather than guessing from the browser. The participant can still switch. Supported tags: en es fr de pt-BR it nl ja ko zh-Hans; anything else falls back to the browser instead of failing, so a stale roster value costs nothing.
Monitor participation
This endpoint returns counts only: how many invited, how many replied. Never who. Poll it to decide whether to send a reminder, and to which sites.
Group by location, org_unit or cost_center — either position, any pair. Omit grouping1 for the survey as a whole.
The three brackets are not groupings here. age_bracket, tenure_bracket and occupational_range are accepted by /surveys/results and /surveys/enps, but on this endpoint either position returns 422 invalid_grouping.
Groups with fewer than 2 invited merge into a single row carrying "other": true. Branch on the other boolean, not on the label — a real cost centre may legitimately be named other.
A group whose replies are stuck well below the reporting threshold as the window closes is worth a targeted reminder: it is the difference between that team having a voice in the results and being merged into 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 }
}
Read the results
The same calls you built against the sample in part three, now pointed at your own survey_code. Nothing about the response shape changes, which is the point of having rehearsed on data that was already full.
Results are available while the survey is still open. Resist reading them early — an eNPS over the first thirty replies is mostly a measure of who answers email quickly — but the endpoint does not stop you, and watching a group cross its threshold is a legitimate use.
There is no export endpoint and no per-employee endpoint, now or later. What you can read is what part three showed you: group aggregates, histograms, and free text with no author attached.
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"
}'
Keep the sample survey in your test suite. It is the only fixture on this service whose numbers are guaranteed not to change, which makes it the one thing you can write an assertion against.
Worth understanding before you demo it
How the character meter works
The questionnaire encourages detailed comments with a progress bar below the text box. Different writing systems carry different amounts of information per character, so the meter counts weighted characters rather than raw characters: a Han character or a kana counts about two and a half, a Hangul syllable about two, everything else one.
One target — 150 — for all 10 locales. That is roughly 150 Latin letters, 75 Korean syllables or 60 Japanese characters, or any mixture of the three.
The weighting follows the script, not the 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 comment length, never the score. A detractor who writes at length gets a green bar. The 0–10 score buttons are deliberately never coloured by band: colouring them 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. Read it before you deploy the survey. It explains the part of anonymity that software cannot enforce: participants have to avoid identifying themselves in their free-text comments.
Reference
Accepted values and formats
Slugs match /^[a-z0-9-]{1,40}$/ and are lowercased on write. The three brackets are closed sets: a value that isn't listed is rejected, not defaulted. The full API reference, with every field and every error, is in README.md.
| POST | Returns |
|---|---|
| /token/check | your app_name, if the credentials work |
| /surveys | a survey_code |
| /participants | one submit_url per person |
| /surveys/status | invited and replied counts |
| /surveys/results | scores, eNPS, comments |
| /surveys/enps | eNPS per group, nothing else |
The last three also accept the sample survey, DEMOSAMPLESURVEY2026, from any registered application. Every other endpoint works only on surveys you created.
/surveys/results and /surveys/enps take the same request — same groupings, same errors. They differ in what comes back and in the threshold it comes back under: 6 invited and 6 replies for the first, 4 replies for the second.
| location | slug |
| org_unit | slug |
| cost_center | slug |
| age_bracket or age |
<=20 · 21-25 · 26-30 · 31-35 · 36-40 · 41-45 · 46-50 · 51-60 · 61-70 · >70 |
| tenure_bracket or tenure_years |
<1 · 1 · 2-3 · 4-5 · 6-10 · 11-20 · >20 |
| occupational_range or occupational_rate |
<=50% · 51-79% · 80-99% · 100% |
Send the bracket, or send the measurement and let the service band it: age in years (0–130), tenure_years (0–100), occupational_rate in percent (0–100). Never both for the same field.
| 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
Common issues and expected behaviour
Many apparent problems are the result of anonymity protections or deliberate API constraints. Check these cases before reporting a bug.
-
Everything comes back as one suppressed row
Fewer than 6 invited, or fewer than 6 replies, and a group is not reported on its own. Both halves matter: eight invited with two replies describes those two people. When nothing in the survey clears the bar you get a single
"other"row with counts and no scores. Small pilots return nothing, and that is the design — point your reporting at the sample surveyDEMOSAMPLESURVEY2026instead of enlarging the pilot. -
A group you know qualifies has no row
Look at
"other". If the groups below the threshold do not make a large enough bucket between them, the bucket absorbs the smallest group that would otherwise have been reported — fewest replies first, ties broken on the group value — and keeps absorbing until it clears. A group that passed on its own can disappear for that reason, and nothing in the response says which one did. Never assume a group you registered will have a row; drive your rendering off the values you get back, not the ones you sent. -
/surveys/enpsshows a group/surveys/resultshidesExpected. The two take the same request and apply different thresholds: 6 invited and 6 replies against 4 replies with the roster ignored. A row carrying nothing but a rounded percentage discloses less than one carrying a score breakdown and free text, so it is allowed to cover a smaller group. Do not reconcile the two row-for-row — they are not meant to agree.
-
The sample survey rejects your participants
DEMOSAMPLESURVEY2026is shared and read-only. All three reporting endpoints accept it from any registered application;/participantsanswers404 survey_not_found, because writing into it would change what every other caller sees. Its window is closed too, so its invitation links do not open the questionnaire. -
Reading
scoresthrowsSuppressed rows have no
scores,enpsorcommentskeys at all: they are absent, not null. Testsuppressedbefore reading any of them. The same applies tototals, and to every row from/surveys/enps, where a suppressed row isgroup1and two booleans and nothing else.When it is present,
scoresis a JSON object keyed"0"to"10"— never an array. Beware of confirming that by decoding and re-encoding it: several languages, PHP among them, turn those numeric keys back into a list, so a round-tripped object prints as[1,3,4,…]and looks like an array that was never sent. Read the bytes on the wire if you need to check. -
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" response, because it would confirm that a leaked code is real. Check that you are sending the rightclient_app_id. -
Bearer auth silently never works
A wrong token and a missing one both return
401 invalid_token, with no hint about which. Check that your client really sends theAuthorizationheader: some proxies and some HTTP libraries drop it across a redirect. Isolate the cause with/token/check, which needs nothing but the credentials. -
A group named
othervanishes into the mergeGroups too small to report are merged into a row marked
"other": true. Branch on the boolean, not on the label: the label is just a label, and a real cost centre can share it. -
comments[0]is not a stringEvery entry is an object:
{"score": 9, "comment": "…"}. Readcomments[i].commentfor the text andcomments[i].scorefor the 0–10 the author gave it. Code written against an earlier version of this API reads the element directly and will print[object Object], or concatenate nothing at all. -
A crossed query returns no comments
By design. Returning free text alongside cross-tabulated results would create a significant re-identification risk. You get counts, scores and eNPS, plus a
subtotalsarray.totalsnever carries comments either. Use a single-dimension query for comments. -
The timezone is rejected
IANA names only, validated against a canonical list.
America/Torontois accepted;-04:00is not. Check also thatdatetime_endhas not already passed. -
Adding participants suddenly 422s
You can register participants 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 deleted. 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 are employee-written text, returned verbatim. Escape them 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 existing surveys are unreachable.
/new-caller/rotateneeds theclient_app_id, and there is no lookup by company 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 complete API.
Six API endpoints and one employee questionnaire cover the whole service. Your application decides who to invite, when to invite them, how to deliver the invitation links, and what to do about the aggregated results.
Parts one to three never repeat. Every survey after the first is the same two endpoints — /surveys, then /participants for that survey's roster — followed by your own mail send. This service schedules nothing and sends nothing, so a recurring survey is your application making those calls unattended, on whatever cadence you choose.
The sample survey stays where it is. Its figures are fixed, so it is the one thing on this service you can write a regression test against — worth pointing your reporting at again the next time you change it.