Send Downright survey invitations from your own systems. Create a free account to get an API key.
The Downright API sends survey invitations from your own systems. It sends into a survey you have already created, so the question, the language and the reporting all come from that survey and one integration reports as a single line.
Base URL
https://www.usedownright.com/api/v1
Every request and response is JSON. There is no versioning beyond the
/v1 in the path.
Pass your API key as a bearer token on every request.
You will find your key in your dashboard, under API, once you have an account.
curl "https://www.usedownright.com/api/v1/surveys" \ -H "Authorization: Bearer YOUR_API_KEY"
/api/v1/surveys/:survey_id/invitations
Emails one person their own one-click survey. The same throttling, unsubscribe and quota rules apply as when you send from the dashboard, so a contact who has opted out or was surveyed recently is skipped rather than mailed again.
| Field | Type | Description | |
|---|---|---|---|
contact_email | string | required | Who to survey. |
contact_name | string | optional | Only set the first time we see this address; an existing name is never overwritten. |
locale | string | optional | The contact's own language. Stored on them and used for every survey they get from then on. |
deliver_at | string | optional | ISO 8601. Sends then instead of now. A time in the past, or one we cannot read, sends immediately rather than never. |
Scheduling returns 202 rather than 200. The unsubscribe, throttling and quota rules are checked again at the moment of sending rather than when you scheduled, so somebody who unsubscribes on Tuesday will not receive on Wednesday something you queued on Monday.
Send an offset or a Z with the timestamp. Without one we
read it as UTC, which is rarely the hour you meant. The same option
exists in the app, under More options when you create an email
survey — there it applies to the whole list at once, while here it
is per invitation.
curl -X POST "https://www.usedownright.com/api/v1/surveys/SURVEY_ID/invitations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_email": "jane@example.com",
"contact_name": "Jane Doe",
"locale": "it"
}'
{
"message": "Survey sent successfully",
"survey_id": "SURVEY_ID"
}
curl -X POST "https://www.usedownright.com/api/v1/surveys/SURVEY_ID/invitations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_email": "jane@example.com",
"deliver_at": "2026-09-07T09:00:00Z"
}'
{
"message": "Survey scheduled",
"survey_id": "SURVEY_ID",
"deliver_at": "2026-09-07T09:00:00Z"
}
/api/v1/surveys
Lists the email surveys you can send into, newest first. Use it to discover an id rather than hard-coding one. Link surveys are not listed: they collect answers from a URL and have nothing to send.
curl "https://www.usedownright.com/api/v1/surveys" \ -H "Authorization: Bearer YOUR_API_KEY"
{
"surveys": [
{
"id": "SURVEY_ID",
"name": "Post-purchase survey",
"survey_type": "NPS",
"status": "active"
}
]
}
/api/v1/surveys
Creates a survey, so an integration can set itself up rather than waiting for somebody to open the dashboard.
| Field | Description | |
|---|---|---|
survey_type | required | One of the questions listed at the bottom of this page. |
channel | optional | email (default) to send invitations, or link for a URL anyone can answer. |
name | optional | What to call it in your reports. Worth setting: it is how you tell placements apart. |
locale | optional | Defaults to your account language. |
{
"id": "SURVEY_ID",
"name": "Receipt QR code",
"survey_type": "CSAT",
"channel": "link",
"status": "active",
"url": "https://www.usedownright.com/s/SLUG"
}
url appears only for a link survey; an email survey has
nothing public to point at.
/api/v1/surveys/:survey_id/responses
Reads answers back, newest first. Webhooks are how you react to an answer; this is how you make sure you have all of them. Any integration that runs long enough misses a webhook eventually, and this is what makes that survivable.
| Field | Description | |
|---|---|---|
since | optional | ISO 8601. Only answers after this time, so catching up costs one small call rather than a full re-read. |
limit | optional | Up to 200. |
curl "https://www.usedownright.com/api/v1/surveys/SURVEY_ID/responses?since=2026-08-01T00:00:00Z" \\ -H "Authorization: Bearer KEY"
Set an endpoint under Settings → Webhook and we POST to it as answers arrive. One URL per account; the payload names the survey, so a receiver that wants to treat the receipt QR code differently from the post-support survey can branch on it.
This is what most people use instead of an integration: point it at a Zapier or Make catch hook and you can raise a Slack message, write a row to a sheet, or open a ticket, without us having built any of those.
{
"event": "response.created",
"survey": { "id": "SURVEY_ID", "name": "Receipt QR code",
"question": "NPS", "channel": "link" },
"response": { "id": "RESPONSE_ID", "score": 3,
"comment": null, "answered_at": "2026-08-27T09:14:00Z" },
"contact": null,
"account": { "name": "Acme Coffee" },
"sent_at": "2026-08-27T09:14:01Z"
}
contact is absent entirely on an answer from a link survey,
rather than present and empty: nobody was identified, as opposed to
somebody whose details we failed to include.
Answering happens in two steps. Somebody picks a score, and the comment box appears afterwards. They may write something seconds later, minutes later, or never. So there are two events, and choosing the wrong one is the most common way to be disappointed by this webhook.
| Event | Fires when | Comment present? |
|---|---|---|
response.created |
The moment a score is picked. | Almost never — it has not been written yet. |
response.updated |
When the comment is submitted, or a score is changed. | Usually. This is the one that carries it. |
response.updated. A Slack message saying somebody scored
3, without the reason, gives whoever reads it nothing to act on —
and the reason is exactly what the second event carries.
response.created. The score is final at that point and
waiting adds nothing.
response.id is stable across the two, so the second event
should overwrite the row the first created rather than adding one.
response.created. Wait for an update
that never comes and you will silently lose those answers, which are
usually the majority. If you must have the comment, wait for the update
with a timeout and fall back to the score alone.
Every call carries these headers. Check the signature before trusting the body: your endpoint is a public URL, and without this anybody who finds it can tell you a customer is furious.
| Header | What it is |
|---|---|
X-Downright-Event | The event name. |
X-Downright-Event-Id | Unique per event. Use it to ignore repeats: a retry after a timeout carries the same id. |
X-Downright-Timestamp | Unix seconds, and part of what is signed. |
X-Downright-Signature | HMAC-SHA256 of timestamp + "." + body, keyed with your signing secret. |
# Ruby
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['DOWNRIGHT_SECRET'],
"#{timestamp_header}.#{raw_body}")
ActiveSupport::SecurityUtils.secure_compare(expected, signature_header)
https endpoints on public addresses are accepted.Two of these are deliberately not errors: a contact who was surveyed recently, or who is being skipped by your throttling settings, returns 200 with a message. Nothing went wrong, and an integration should not treat it as a failure.
| Status | Meaning | Body |
|---|---|---|
| 200 | Invitation sent | { "message": "Survey sent successfully" } |
| 200 | Skipped: surveyed too recently | { "message": "Contact skipped due to throttling" } |
| 400 | The body was not valid JSON | { "error": "Invalid JSON format in request body" } |
| 401 | Missing or unknown API key | { "error": "Invalid API key" } |
| 403 | Monthly response quota spent | { "error": "Account survey quota exceeded" } |
| 404 | No such survey on your account | { "error": "Unknown survey" } |
| 410 | The survey is closed | { "error": "Survey is closed" } |
| 422 | Missing contact_email, or the contact unsubscribed |
{ "error": "Contact has unsubscribed" } |
Language resolves in three steps: the contact's own, then the survey's,
then your account default. Send locale when you know what
language the person reads and leave it out when you do not. A language
outside this list is ignored rather than rejected, so an invitation is
still delivered.
en
de
fr
es
it
nl
pt
pt-BR
pl
sv
da
nb
fi