Skip to content

Utilize your customer experience data

This page describes how you can fetch data from nps.today and use it to show customer experience data to the users of your own system, or to load it into a data warehouse.

What you'll find here

Your goal Use this
Show responses to agents inside another system's UI NPS Feedback App
Pull responses ad hoc, across campaigns The responses endpoint
Load all data into a data warehouse or BI tool The /v2/bi/ endpoints
React to a new response as it arrives Webhook subscriptions

Parameters, payloads and limits for every endpoint on this page live in the API reference. This page covers which endpoint to use and how to build a reliable load, not the field-by-field specification.

NPS Feedback App

The Feedback App is built as an easy way for systems to visualize customer response data to their users. Not all systems have the functionality to use the Feedback App. In that case, go to Get data with our API.

For the technical documentation on the Feedback App, see Feedback App.

To see how it is used in practice, see our Dixa integration.

Below is an example of the Feedback App:

Feedback App

Get data with our API

By getting data back we mean a third-party system consuming data from nps.today and displaying it to its users. How complex that is depends on the functionality available in the receiving system.

Below is the flow from triggering a survey to retrieving the response back into the system:

Get data flow

For adding responses directly to another system, use GET /campaigns/responses, which returns responses across all campaigns:

https://api.nps.today/campaigns/responses

Use our /v2/bi/ endpoints

Many of our customers retrieve data into their own data warehouse and work with it from there. For that, use a combination of the /v2/bi/ endpoints. They all share the same paging and filtering pattern, so once you have built one, the rest follow.

Endpoint What it contains
GET /v2/bi/responses The survey responses. Your fact table.
GET /v2/bi/campaignmembers The campaign members added to a nps.today, including those who never answered. Holds respondent data and the custom data field.
GET /v2/bi/campaigns Campaign definitions.
GET /v2/bi/employees The responsible employee on a respondent or response.
GET /v2/bi/companies The company dimension.
GET /v2/bi/responsecategories The categorisation applied to responses.
GET /v2/bi/emails and GET /v2/bi/sms Delivery level data per channel.

Parameters and response schemas for all of them are in the BI section of the API reference.

A combination is often needed. The campaign member endpoint, for example, carries the custom data field that is typically used to map metadata onto a respondent when they are added to nps.today from an external system.

Limit on 300,000 records

There is a limit of 300,000 records per request. If the take parameter is bigger than the maximum of 300,000 an error will be thrown.

Max 10,000 records at a time recommended

If you need more than 10,000 records we recommend you to use pagination. See Using skip and take for pagination.

Incremental loads

For a scheduled job you rarely want the full data set every run. All /v2/bi/ endpoints accept modifiedAfter. /v2/bi/responses adds ratedAfter, and /v2/bi/campaignmembers adds firstSurveyExposure. All take an ISO 8601 date-time; see the API reference for the exact behaviour of each.

https://api.nps.today/v2/bi/responses?take=10000&modifiedAfter=2026-01-01T00:00:00Z

The pattern we recommend is a full backfill once, then incremental on modifiedAfter for every run after that. Store the timestamp of your last successful run and use it as the next modifiedAfter value.

Timestamps are UTC

Timestamps in the payload are ISO 8601 in UTC. Convert to local time in your own system rather than assuming a timezone.

Retries

API access is rate limited per API key, and exceeding it returns 429 Too many requests. The current limit is stated at the top of the API reference, and it can be tightened dynamically under load, so treat a 429 as an expected response rather than an exception.

  • Retry on 429 and on 5xx with exponential backoff and jitter, capped at a few attempts.
  • Because one call can return a very large page, sequential paging on a single thread is usually enough and avoids the limit entirely.
  • Fail the job on a non-200 rather than writing an empty page. Silently swallowed errors are the most common cause of "the data looks fine but rows are missing".

Unique identifiers

To combine data from the different /v2/bi/ endpoints, use these identifiers:

  • campaignMemberId combines the campaign member and the response.
  • employeeId combines the campaign member and the employee.
  • campaignId combines the campaign member and the campaign.
  • categoryId combines the response and the category.

Unique identifiers

Custom data

Custom fields come back in a custom property as JSON. What it contains depends on what the source system sent when the respondent was added. See Data fields for how fields are defined, and Custom data in Power BI for working with it in a dashboard.

Parse custom as a string first

Depending on how the data was delivered, custom can arrive as JSON encoded inside a string rather than as a nested object. Read it as a string and parse it, instead of assuming it expands directly into an object. This is a common cause of failing transformations in Power BI and in warehouse pipelines.

Advanced Survey Module answers

Surveys built with the Advanced Survey Module can hold far more than a rating and a comment: single and multi select, per-question comments, and matrix questions. Those answers are returned by both GET /campaigns/responses and GET /v2/bi/responses, in two shapes with the same content:

  • surveyAnswers is an array, in the order the questions appear in the survey.
  • surveyAnswersSimple is an object keyed by the question name, for looking a question up directly.

Pick the one that fits your model and ignore the other. The array is usually easier if you are flattening to one row per answer; the keyed object is easier if you want a specific question.

Every entry carries name (the question key, and what you should join on), title (the question text as the respondent saw it), value (the machine value, null when unanswered), displayValue (the rendered string) and isNode. When isNode is true, the content sits in a data array underneath: one child for an attached comment, one child per chosen option on a select question, and for a matrix question one node per row containing one node per column.

Treat name as a constant

name can be edited in the campaign builder, but changing it is treated as a different question. Responses collected before the change keep the old key and everything after uses the new one, so the same question ends up split across two columns in your reporting and in any data warehouse built on this data. There is no way to merge them afterwards without a manual mapping.

We strongly recommend deciding the naming up front and then treating name as a constant: fixed for the life of the campaign, and the same structure across campaigns that ask the same questions. If the wording of a question needs to change, edit title and leave name alone.

Don't assume a naming convention

The standard questions use fixed keys such as rating, disappointing_experience, passive_experience and satisfied_experience. Everything else is named per campaign, so keys can look like question_7 in one campaign and Question1 in another. Treat name as an opaque key rather than something you can parse or sort on.

{
  "Question2": {
    "name": "Question2",
    "title": "How would you rate our service?",
    "value": 10,
    "displayValue": "10",
    "isNode": true,
    "data": [
      {
        "name": 0,
        "isComment": true, // (1)!
        "title": "Comment",
        "value": "-Comment",
        "displayValue": "My comment",
        "isNode": false
      }
    ]
  }
}
  1. A comment attached to a question arrives as a child with isComment: true. On a matrix question, the column title spells out both the row and the column, so you can flatten it without reconstructing the grid.

Modelling this in a data warehouse

Land the answers as a map or a JSON string and flatten them in a separate step, rather than mapping them to fixed columns. Unanswered questions are still present with value: null, so the key set is predictable within a campaign, but it differs between campaigns and changes if someone edits a name. A long table of one row per answer, keyed by response id and question name, absorbs both without a schema change, where a wide table needs a new column every time. The structure nests at most three levels deep.

Get responses with a webhook

To retrieve data from an nps.today account you can create a webhook subscription.

Here is an example on how a "create webhook subscription" could look like:

curl --location 'https://api.nps.today/webhooks/subscriptions' \
--data '{
    "action": "Create",
    "model": "Response",
    "url": "https://webhook.site/example-webhook-url",
    "filter": {
        "include": {
            "campaignId": 1234
        },
        "exclude": {
            "id": 1232345125
        }
    }
}'

"Create subscriptions" uses the following endpoint:

https://api.nps.today/webhooks/subscriptions
  • action: this is the database operation
  • model: this is the database entity
  • url: a POST request is sent to this URL
  • filter: the purpose of filter is to include or exclude certain webhook calls when a database operation has occurred
  • include: decides what criteria you want to INCLUDE in your webhook subscription
  • exclude: decides what criteria you want to EXCLUDE in your webhook subscription

Above subscription example listens to created responses on a campaign with the ID "1234" and excludes responses with responseID "1232345125".

After creating the webhook subscription, these conditions will trigger webhook calls from that point on. Every time these conditions are fulfilled, a POST request will be sent to the URL given. In above example the URL is "https://webhook.site/example-webhook-url".

For more information on how to create a webhook subscription click here.

For more information on other webhook endpoints click here.

Data by campaign

In your nps.today campaign builder you can use webhooks to listen to specific campaigns.

If your system does not provide you with an API you can retrieve responses with a webhook in nps.today. Please read the this guide.

Using skip and take for pagination

AI created content

This section was mainly created by an AI, and edidted by nps.today.

To pull a large amount of data, page through it rather than requesting everything at once. take is the number of records to return and is required on the /v2/bi/ endpoints; skip is the number to skip first and is optional. Start at skip=0 and increase skip by take on each following request:

https://api.nps.today/v2/bi/responses?skip=0&take=10000
https://api.nps.today/v2/bi/responses?skip=10000&take=10000

The records are not returned as a bare array. They come in a wrapper that also tells you where you are in the result set:

{
  "total": 259,   // (1)!
  "skip": 0,
  "take": 1,
  "results": [ ]   // (2)!
}
  1. The total number of records matching your query, not the number in this page.
  2. The records themselves.

Keep going until you have collected total records, or until a request returns fewer records than the take you asked for. Checking both is worth it: total can shift underneath you if new responses arrive while the job is running.

Example: fetch everything in blocks

Doing this by hand quickly becomes tedious, so it is normally written once as a small function that your job calls whenever it needs the data. The example below is that function.

What you give it: the API base URL and your authentication headers. What you get back: one list containing every response, already assembled from all the pages.

You do not need to know how many records exist, or how many requests it will take. The function works that out as it goes. It also deals with the two things that most often break an unattended job: being told to slow down, and a request that genuinely fails. The first means wait and try the same block again. The second means stop, rather than quietly handing back half the data as if it were complete.

In plain terms, the loop does this:

  1. Ask for the first block. Records 1 to 10,000.
  2. Handle a busy API. If nps.today replies that you are asking too quickly (429), or something goes wrong on our side (a 5xx), wait a couple of seconds and ask for the same block again.
  3. Stop on a real error. Any other failure ends the job and reports what went wrong.
  4. Collect and move on. Add this block's records to the pile, move the starting point 10,000 forward, and ask for the next block.
  5. Know when to stop. When a block comes back with fewer records than asked for, or the pile has reached the total the API reported, everything has been collected.

The example is JavaScript, but nothing about it is specific to that language. The same five steps translate directly to Python, C# or whatever your pipeline is written in.

async function fetchAllResponses(apiBase, headers) {
    const take = 10000;   // (1)!
    let skip = 0;
    const all = [];

    while (true) {
        const res = await fetch(`${apiBase}/v2/bi/responses?skip=${skip}&take=${take}`, { headers });

        if (res.status === 429 || res.status >= 500) {   // (2)!
            await new Promise(r => setTimeout(r, 2000 + Math.random() * 1000));
            continue;
        }
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);

        const page = await res.json();
        all.push(...page.results);

        if (page.results.length < take || all.length >= page.total) break;
        skip += take;
    }

    return all;
}
  1. The block size. Change this one value to page in larger or smaller blocks.
  2. This example retries forever. In production, use exponential backoff with a cap on the number of attempts, so a prolonged outage fails the job instead of hanging it.

Verify your setup

Before you schedule the job, confirm the basics with a few small requests:

  1. Call GET /v2/bi/responses with ?take=1 and check that you get 200 OK with one record.
  2. Call it again with skip=1&take=1 and confirm you get a different record.
  3. Add modifiedAfter with a recent timestamp and confirm the result set shrinks.

If all three behave as expected, your paging and incremental logic is sound.


Need help? Contact us at [email protected].