API

Send Downright survey invitations from your own systems. Create a free account to get an API key.

Introduction

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.

Authentication

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"
POST /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.

Body
FieldTypeDescription
contact_emailstring required Who to survey.
contact_namestring optional Only set the first time we see this address; an existing name is never overwritten.
localestring optional The contact's own language. Stored on them and used for every survey they get from then on.
deliver_atstring 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.

Example
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"
  }'
Response
{
  "message": "Survey sent successfully",
  "survey_id": "SURVEY_ID"
}
Example: send it on Monday morning instead
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"
  }'
Response 202
{
  "message": "Survey scheduled",
  "survey_id": "SURVEY_ID",
  "deliver_at": "2026-09-07T09:00:00Z"
}
GET /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.

Example
curl "https://www.usedownright.com/api/v1/surveys" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "surveys": [
    {
      "id": "SURVEY_ID",
      "name": "Post-purchase survey",
      "survey_type": "NPS",
      "status": "active"
    }
  ]
}
POST /api/v1/surveys

Creates a survey, so an integration can set itself up rather than waiting for somebody to open the dashboard.

Body
FieldDescription
survey_typerequiredOne of the questions listed at the bottom of this page.
channeloptionalemail (default) to send invitations, or link for a URL anyone can answer.
nameoptionalWhat to call it in your reports. Worth setting: it is how you tell placements apart.
localeoptionalDefaults to your account language.
Response
{
  "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.

GET /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.

Query
FieldDescription
sinceoptionalISO 8601. Only answers after this time, so catching up costs one small call rather than a full re-read.
limitoptionalUp to 200.
Example
curl "https://www.usedownright.com/api/v1/surveys/SURVEY_ID/responses?since=2026-08-01T00:00:00Z" \\
  -H "Authorization: Bearer KEY"

Getting notified when somebody answers

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.

Payload
{
  "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.

The two events, and which one you want

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.

EventFires whenComment 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.
Which to listen for
  • Alerting a human about an unhappy customer: listen for 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.
  • Counting, scoring, dashboards: listen for response.created. The score is final at that point and waiting adds nothing.
  • Writing to a CRM: handle both and update the same record. response.id is stable across the two, so the second event should overwrite the row the first created rather than adding one.
Not every answer produces both. Somebody who scores and closes the tab produces only 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.

Verifying a call really came from us

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.

HeaderWhat it is
X-Downright-EventThe event name.
X-Downright-Event-IdUnique per event. Use it to ignore repeats: a retry after a timeout carries the same id.
X-Downright-TimestampUnix seconds, and part of what is signed.
X-Downright-SignatureHMAC-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)
Retries, and what we expect back
  • Answer 2xx and we consider it delivered. Anything else is a failure.
  • We retry after 1 minute, 5 minutes, 30 minutes and 2 hours, then stop.
  • We wait 5 seconds for a reply. Do the slow part after answering, not before.
  • Because we retry, the same event can arrive twice. Deduplicate on the event id.
  • Settings → Webhook shows recent deliveries with their status codes, so “did it fire?” has an answer.
  • Only https endpoints on public addresses are accepted.

Responses & errors

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.

StatusMeaningBody
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" }

Languages

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
Ready to try it?

A free account gives you an API key and 25 responses a month.

Get started