Overview

NADS API Console sits between demand and supply. Clients publish projects to us over their own APIs; you pull those projects from ours, send us respondents, and are paid per complete.

One API surface serves every vendor. There is nothing bespoke to build on our side for you, and nothing bespoke to learn on yours - if you have integrated a supply API before, this is the same trade: fetch the metadata, register your return URLs, receive an entry link.

Base URL for everything below: https://api.responads.com

How a respondent travels

Pull projects into your own console using our API. Your suppliers stay on your side. Each tracking system records the respondent when the browser reaches it.

  1. 1Your panel link — A respondent starts on your side, in your own tool.
  2. 2Our entry link — You redirect them to the entry link we gave you at import, with your own respondent id appended. We record the hit and attribute it to you.
  3. 3Upstream tracking, then the survey — We send our own session ID to the client entry URL. Projects supplied by NADS pass through NADS first, then the client. Each system keeps its own ID mapping.
  4. 4Our status URL — The survey returns through the client and, for NADS projects, NADS before reaching our status URL. We record the outcome and the end IP.
  5. 5Your end link — We hand them back to the URL you registered for that outcome, signed. You record yours.

Your entry code identifies your import. We save your original respondent ID and restore it when returning to you. Each new journey keeps its original routing settings even if you later change your default redirects.

Client project: Your entry → API console → Client → Survey
Return: Survey → Client → API console → Your outcome URL

NADS project: Your entry → API console → NADS → Client → Survey
Return: Survey → Client → NADS → API console → Your outcome URL

Direct supplier: Supplier link → API console → Client → Survey
Return: Survey → Client → API console → Our outcome card

Direct suppliers finish on our outcome card by default. An API admin can explicitly enable supplier redirects on the project attachment. The same four outcomes apply: complete, disqualified, quota full and security terminated.

We record the IP and time at our entry and outcome steps. A return redirect records that we sent the browser onward; it does not prove the next server received it.

Credentials

Apply at the partner portal. Once your company and return domains are approved, sign in and open API access. Your account owner can obtain both credentials there.

  • An API key — se_live_… or se_test_…. Send it as Authorization: Bearer <key> or X-API-Key: <key>. We store only a hash, so it is shown once and cannot be recovered. Generate a replacement and revoke the old key in API access.
  • A signing secret — used to verify that an outcome hand-off came from us. Your account owner can reveal it in API access. Keep it on your server.

A portal login is optional. It reaches exactly the same endpoints; anything you can do by clicking, you can do with a key.

Keys labelled test use the same catalog and traffic routes as live keys. A separate sandbox is not available in this release.

Approved return domains. Before you can register an end link, the host it points at has to be approved on your account. This is deny-by-default: with nothing approved, every import is rejected. Call GET /api/v1/me to see your current allowlist.

Quick start

First save your four default return URLs in Portal → API access, or withPOST /api/v1/redirects. Then import with an empty JSON object to use those defaults, or supply per-project overrides as below. All four URLs must be available.

Four calls, in order.

# 1. Check the key and read your approved return domains
curl -s https://api.responads.com/api/v1/me \
  -H "X-API-Key: se_live_xxx"

# 2. Browse what you can take
curl -s "https://api.responads.com/api/v1/projects?limit=5" \
  -H "X-API-Key: se_live_xxx"

# 3. Dry-run your end links - no state changes
curl -s -X POST https://api.responads.com/api/v1/projects/SE-1A2B3C4D/import-preview \
  -H "X-API-Key: se_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "completeUrl":      "https://panel.acme.com/end?pid={{PID}}&s=1",
    "terminateUrl":     "https://panel.acme.com/end?pid={{PID}}&s=2",
    "quotaFullUrl":     "https://panel.acme.com/end?pid={{PID}}&s=3",
    "securityTermUrl":  "https://panel.acme.com/end?pid={{PID}}&s=4"
  }'

# 4. Import for real - the response carries your entry link
curl -s -X POST https://api.responads.com/api/v1/projects/SE-1A2B3C4D/import \
  -H "X-API-Key: se_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "completeUrl": "...", "terminateUrl": "...", "quotaFullUrl": "...", "securityTermUrl": "..." }'

Store the returned entry link in your console. Append a URL-encoded unique respondent ID for every person. Repeated entry with the same ID on the same import reuses its journey; completed journeys cannot restart. Poll GET /api/v1/respondents to reconcile your traffic and verify exit_hmac on every return to your server.

The entry link

The import response returns a link ending on a bare pid=. Append your own respondent id to it and send respondents there.

https://api.responads.com/s/start?gid=UDAwMQ&sid=9F3A21C0B4E7&pid=YOUR_RESPONDENT_ID
  • gid — the project. sid — the code that identifies you on it. Neither is guessable from the other and neither names you.
  • pid — your respondent id. It is what comes back on your end link, so it is how you match an outcome to a panellist.
  • Send pid=AUTO if you genuinely cannot supply one and we will generate it. You will then have to reconcile on our session id instead, which is harder - prefer your own.

A respondent arriving on a paused project, or one where your import has been paused, sees a closed-survey page rather than being passed through. No new session is recorded.

Verifying the hand-off

Your end links are public URLs. Unsigned, anyone who observed one could replay it and be paid for a complete that never happened - and you would have no way to tell. Every hand-off we make therefore carries an exit_hmac. Verifying it is the one step of this integration that is not optional.

To verify: remove the hmac parameters, sort what remains by name, re-encode, and sign origin + path + query with your secret.

import crypto from "node:crypto";

const HMAC_PARAMS = ["hmac", "entry_hmac", "exit_hmac"];

function canonicalize(rawUrl) {
  const url = new URL(rawUrl);
  HMAC_PARAMS.forEach((p) => url.searchParams.delete(p));
  const query = [...url.searchParams.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
    .join("&");
  return url.origin + url.pathname + (query ? `?${query}` : "");
}

export function verify(rawUrl, secret) {
  const provided = new URL(rawUrl).searchParams.get("exit_hmac");
  if (!provided) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(canonicalize(rawUrl))
    .digest("hex");
  // Constant-time compare, so the signature cannot be guessed byte by byte.
  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<?php
function nads_api_console_verify(string $rawUrl, string $secret): bool {
    $parts = parse_url($rawUrl);
    parse_str($parts['query'] ?? '', $params);
    $provided = $params['exit_hmac'] ?? null;
    if (!$provided) return false;

    unset($params['hmac'], $params['entry_hmac'], $params['exit_hmac']);
    ksort($params);

    $canonical = $parts['scheme'] . '://' . $parts['host']
        . ($parts['path'] ?? '')
        . ($params ? '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986) : '');

    return hash_equals(hash_hmac('sha256', $canonical, $secret), $provided);
}

Endpoint reference

Every endpoint returns success. Failures also carry a stable code - branch on that, not on the message.

GET
/api/v1/redirects
Read your redirects and our outcome URLs

Your return URLs receive our signed outcomes. Our outcome URLs belong to the upstream return leg; they are not respondent entry links. Each imported project supplies its own entryLink.

Response

{
  "success": true,
  "returnUrls": {
    "completeUrl": "https://panel.example/end?pid={{PID}}&s=1"
  },
  "outcomeUrls": {
    "completeUrl": "https://console.example/surveyStatus?status=1&pid={{PID}}"
  }
}
POST
/api/v1/redirects
Save your four default return URLs

Available to API keys and portal owners. All four URLs must use approved domains and include {{PID}}. New imports use these defaults when fields are omitted. Re-import existing projects to update future entries; respondents already in flight keep their original return route.

ParameterInTypeDescription
completeUrl *bodystringApproved return URL containing {{PID}}.
terminateUrl *bodystringApproved return URL containing {{PID}}.
quotaFullUrl *bodystringApproved return URL containing {{PID}}.
securityTermUrl *bodystringApproved return URL containing {{PID}}.

Response

{
  "success": true,
  "returnUrls": {
    "completeUrl": "https://panel.example/end?pid={{PID}}&s=1"
  }
}
GET
/api/v1/respondents
Your respondent journeys

Only your traffic. Includes your original respondent ID, our session ID, start/end IP, timestamps and outcome. handedOffAt records a redirect issued by us, not acknowledgment from your server. Remote vendor/client IP logs remain in those systems.

ParameterInTypeDescription
importIdquerystringOptional owned import ID.
respondentIdquerystringExact original respondent ID.
pagequeryinteger1-based page.
limitqueryintegerAt most 100, defaults to 25.

Response

{
  "success": true,
  "respondents": [
    {
      "sessionId": "R...",
      "respondentId": "your-person-123",
      "importId": "...",
      "status": "Completed",
      "startIp": "192.0.2.1",
      "endIp": "192.0.2.2",
      "handoffStatus": "returned"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 25,
    "total": 1,
    "totalPages": 1
  }
}
GET
/api/v1/projects/{publicId}/qualifications
Survey screening qualifications

Sanitized targeting data for a listing visible to your account. available:false means this project's source does not expose this data; do not interpret an empty array as unrestricted eligibility. Responses may be cached for five minutes.

ParameterInTypeDescription
publicId *pathstringPublic listing ID.

Response

{
  "success": true,
  "available": true,
  "data": [
    {
      "name": "AGE",
      "type": "Range",
      "questionText": "Your age",
      "required": true,
      "range": "18-65",
      "options": []
    }
  ]
}
GET
/api/v1/projects/{publicId}/quotas
Survey quotas

Sanitized targeting data for a listing visible to your account. available:false means this project's source does not expose this data; do not interpret an empty array as unrestricted eligibility. Responses may be cached for five minutes.

ParameterInTypeDescription
publicId *pathstringPublic listing ID.

Response

{
  "success": true,
  "available": true,
  "data": [
    {
      "name": "All respondents",
      "limit": 100,
      "remaining": 40,
      "status": "Open"
    }
  ]
}
POST
/api/vendor/auth/register
No auth
Apply for a partner account

Creates a pending partner account. An operator reviews the company and return domains before enabling login and API access.

ParameterInTypeDescription
company *bodystringCompany name.
fullName *bodystringAccount owner's full name.
email *bodystringOwner email.
password *bodystringAt least 10 characters, at most 72 UTF-8 bytes.
website *bodystringCompany website URL.
returnDomains *bodystringComma-separated return domains for operator approval.

Response

{
  "success": true,
  "message": "Your application is submitted."
}
POST
/api/vendor/auth/login
No auth
Portal sign-in

Exchanges portal credentials for a session token. Only needed if you drive the API as a person rather than with a key - an API key does everything this token does.

ParameterInTypeDescription
email *bodystringPortal user email.
password *bodystringPortal user password.

Response

{
  "success": true,
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "expiresInSeconds": 604800,
  "vendor": {
    "name": "Acme Supply",
    "status": "Active"
  },
  "user": {
    "fullName": "Dana Reyes",
    "email": "dana@acme.com",
    "role": "owner"
  }
}
GET
/api/v1/me
Account and delivery summary

Confirms the key works and echoes back your approved return domains. Run this first: registering an end link on a host nobody approved is the commonest failure in a new integration, and this shows the allowlist before you hit it.

Response

{
  "success": true,
  "vendor": {
    "name": "Acme Supply",
    "status": "Active",
    "allowedReturnDomains": [
      "panel.acme.com"
    ],
    "contactEmail": "ops@acme.com"
  },
  "authenticatedVia": "apiKey",
  "activeImports": 3,
  "delivery": {
    "inProgress": 12,
    "completes": 480,
    "terminates": 910,
    "quotaFulls": 60,
    "securityTerminates": 4,
    "hits": 1454,
    "incidenceRate": 34.5
  }
}
GET
/api/v1/reference
Status codes, placeholders and signing rules

Everything an integration hard-codes, in one call: which outcome code calls which of your URLs, the placeholder tokens we substitute, and the canonicalization used for exit_hmac.

Response

{
  "success": true,
  "statusCodes": [
    {
      "code": 1,
      "name": "Completed",
      "returnUrl": "completeUrl"
    },
    {
      "code": 2,
      "name": "DisQualified",
      "returnUrl": "terminateUrl"
    },
    {
      "code": 3,
      "name": "QuotaFull",
      "returnUrl": "quotaFullUrl"
    },
    {
      "code": 4,
      "name": "SecurityTerm",
      "returnUrl": "securityTermUrl"
    }
  ]
}
GET
/api/v1/projects
Browse the catalog

Live listings you are allowed to see. Invite-only work you were not offered does not appear, and is not counted in the total.

ParameterInTypeDescription
searchquerystringCase-insensitive match on the title.
countryquerystringMatches the primary country or any target country.
pagequeryinteger1-based. Defaults to 1.
limitqueryintegerDefaults to 25, capped at 100.

Response

{
  "success": true,
  "projects": [
    {
      "id": "SE-1A2B3C4D",
      "title": "Automotive owners study",
      "country": "US",
      "loi": 12,
      "incidenceRate": 35,
      "completesNeeded": 500,
      "cpi": 3.2,
      "currency": "USD",
      "status": "Listed",
      "version": 2
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 25,
    "total": 1,
    "totalPages": 1
  }
}
GET
/api/v1/projects/{id}
One listing

Full detail, plus whether you already hold it. A listing you were not offered answers 404, the same as one that does not exist - we do not confirm the id is real.

ParameterInTypeDescription
id *pathstringThe public listing id, e.g. SE-1A2B3C4D.

Response

{
  "success": true,
  "project": {
    "id": "SE-1A2B3C4D",
    "title": "Automotive owners study",
    "cpi": 3.2
  },
  "imported": {
    "importId": "665f...",
    "entryLink": "https://.../s/start?gid=...&sid=...&pid=",
    "status": "Active",
    "stale": false
  }
}
POST
/api/v1/projects/{id}/import-preview
Dry-run an import

Same validation as the import, no writes. Use it to prove your end links pass the domain allowlist and to see which outcome calls which URL before any respondent is involved.

ParameterInTypeDescription
completeUrlbodystringOverrides the account default. Where a completed respondent is returned.
terminateUrlbodystringOverrides the account default. Where a screened-out respondent is returned.
quotaFullUrlbodystringOverrides the account default. Where a respondent is returned when the cell is full.
securityTermUrlbodystringOverrides the account default. Where a respondent is returned after a failed fraud check.

Response

{
  "success": true,
  "listingId": "SE-1A2B3C4D",
  "version": 2,
  "outcomeMapping": {
    "1": {
      "outcome": "Complete",
      "url": "https://panel.acme.com/end?pid={{PID}}&s=1"
    }
  },
  "alreadyImported": false,
  "entryLink": null
}
POST
/api/v1/projects/{id}/import
Register end links, receive an entry link

Import into your console and receive its tracked entry link. All four URLs must be available, either from account defaults or this request. Send {} to use defaults. Re-importing updates future entries and keeps the same entry link; journeys already in flight keep their original return URLs.

ParameterInTypeDescription
completeUrlbodystringOverrides the account default. Where a completed respondent is returned.
terminateUrlbodystringOverrides the account default. Where a screened-out respondent is returned.
quotaFullUrlbodystringOverrides the account default. Where a respondent is returned when the cell is full.
securityTermUrlbodystringOverrides the account default. Where a respondent is returned after a failed fraud check.

Response

{
  "success": true,
  "project": {
    "id": "SE-1A2B3C4D",
    "title": "Automotive owners study",
    "cpi": 3.2
  },
  "importId": "665f1c0e9a2b4c00123abc45",
  "entryLink": "https://console.nads.com/s/start?gid=UDAwMQ&sid=9F3A21C0B4E7&pid=",
  "entryCode": "9F3A21C0B4E7",
  "listingVersion": 2,
  "cpi": 3.2,
  "currency": "USD"
}
GET
/api/v1/imports
Your imports

Every project you hold, with the entry link and the terms it was taken under. `stale: true` means the listing has been re-published since - your copy of the terms is out of date.

ParameterInTypeDescription
statusquerystringActive, Paused or Revoked.
pagequeryinteger1-based. Defaults to 1.
limitqueryintegerDefaults to 25, capped at 100.

Response

{
  "success": true,
  "imports": [
    {
      "importId": "665f1c0e9a2b4c00123abc45",
      "listingId": "SE-1A2B3C4D",
      "title": "Automotive owners study",
      "entryLink": "https://console.nads.com/s/start?gid=UDAwMQ&sid=9F3A21C0B4E7&pid=",
      "status": "Active",
      "cpi": 3.2,
      "currency": "USD",
      "stale": false
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 25,
    "total": 1,
    "totalPages": 1
  }
}
GET
/api/v1/imports/{importId}/stats
Your delivery on one import

Aggregated from the same respondent rows our own operations pages read, with the same metric definitions - our number and yours can only differ because of traffic still in flight. `unreturnedCompletes` is the reconciliation figure: completes we recorded for which we have not issued a return redirect. Issuing a redirect does not confirm your server received it.

ParameterInTypeDescription
importId *pathstringFrom the import response or /v1/imports.

Response

{
  "success": true,
  "importId": "665f1c0e9a2b4c00123abc45",
  "listingId": "SE-1A2B3C4D",
  "stats": {
    "inProgress": 4,
    "completes": 160,
    "terminates": 300,
    "quotaFulls": 21,
    "securityTerminates": 1,
    "hits": 482,
    "incidenceRate": 34.8,
    "conversionRate": 33.2
  },
  "reconciliation": {
    "completesHandedBack": 160,
    "unreturnedCompletes": 0
  },
  "earnings": {
    "cpi": 3.2,
    "currency": "USD",
    "gross": 512
  }
}

Errors, limits and retries

CodeHTTPMeaning
unauthenticated401No credential was presented.
invalid_key401The API key is unknown or revoked.
suspended403The account is suspended. The key is fine; talk to your account manager.
not_found404No such listing or import, or it is not offered to you.
invalid_request400A required field is missing or malformed.
domain_not_allowed400An end link points at a host that is not on your allowlist.
rate_limited429Too many requests in the current minute. Back off and retry.
server_error500Something failed on our side. Safe to retry.

Rate limits are per vendor, not per IP, so spreading calls across hosts does not raise them. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also carries retryAfterSeconds.

Retries are safe. Importing twice updates in place rather than creating a second hold, and an outcome hand-off fired twice is recorded once - the second is recognised and dropped, so neither side double-counts.

Glossary

TermMeaning
ListingA published project, as you see it. A sanitized copy - it never carries the client's identity or our own cost.
ImportYour hold on one listing: the end links you registered and the entry link we returned.
Entry linkWhere you send respondents. Ends on a bare pid= for your own respondent id.
End linkWhere we send them back, one per outcome.
HitA resolved respondent: complete, terminate, quota full or security terminate. Respondents still in the survey are not hits.
Incidence ratecompletes / (completes + terminates). Quota fulls and security terminates are not in the denominator.
StaleThe listing has been re-published since you imported it, so your copy of the terms is out of date.

Questions about an integration go to your NADS API Console account manager. Machine readable spec: /docs/openapi.json