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.
https://api.responads.comPull 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.
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 cardDirect 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.
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.
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 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.
GET /api/v1/me to see your current allowlist.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 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_IDgid — 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.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.
All four are required at import. If you register only a complete URL, your terminates land on our status page instead of yours - which looks, from your side, like traffic that vanished.
| Code | Outcome | Calls | Meaning |
|---|---|---|---|
| 1 | Complete | completeUrl | The respondent finished the survey. |
| 2 | Terminate | terminateUrl | The respondent was screened out. |
| 3 | Quota full | quotaFullUrl | Their cell was already full. |
| 4 | Security terminate | securityTermUrl | They failed a fraud or duplication check. |
Only {{...}} tokens are substituted. Anything else is left exactly as written, so a typo shows up as a visibly wrong URL during testing rather than as a plausible-looking guess in production. A link with no placeholder at all gets ?pid=… appended.
| Token | Substituted with |
|---|---|
| {{PID}} | Your respondent id, exactly as you appended it to the entry link. |
| {{RID}} | Alias of {{PID}}. |
| {{RESPONDENT_ID}} | Alias of {{PID}}. |
| {{SESSION_ID}} | Our id for the session. |
| {{PROJECT_ID}} | Our project id. |
| {{STATUS}} | The numeric outcome code. |
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);
}Every endpoint returns success. Failures also carry a stable code - branch on that, not on the message.
/api/v1/redirectsYour 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}}"
}
}/api/v1/redirectsAvailable 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| completeUrl * | body | string | Approved return URL containing {{PID}}. |
| terminateUrl * | body | string | Approved return URL containing {{PID}}. |
| quotaFullUrl * | body | string | Approved return URL containing {{PID}}. |
| securityTermUrl * | body | string | Approved return URL containing {{PID}}. |
Response
{
"success": true,
"returnUrls": {
"completeUrl": "https://panel.example/end?pid={{PID}}&s=1"
}
}/api/v1/respondentsOnly 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| importId | query | string | Optional owned import ID. |
| respondentId | query | string | Exact original respondent ID. |
| page | query | integer | 1-based page. |
| limit | query | integer | At 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
}
}/api/v1/projects/{publicId}/qualificationsSanitized 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| publicId * | path | string | Public listing ID. |
Response
{
"success": true,
"available": true,
"data": [
{
"name": "AGE",
"type": "Range",
"questionText": "Your age",
"required": true,
"range": "18-65",
"options": []
}
]
}/api/v1/projects/{publicId}/quotasSanitized 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| publicId * | path | string | Public listing ID. |
Response
{
"success": true,
"available": true,
"data": [
{
"name": "All respondents",
"limit": 100,
"remaining": 40,
"status": "Open"
}
]
}/api/vendor/auth/registerCreates a pending partner account. An operator reviews the company and return domains before enabling login and API access.
| Parameter | In | Type | Description |
|---|---|---|---|
| company * | body | string | Company name. |
| fullName * | body | string | Account owner's full name. |
| email * | body | string | Owner email. |
| password * | body | string | At least 10 characters, at most 72 UTF-8 bytes. |
| website * | body | string | Company website URL. |
| returnDomains * | body | string | Comma-separated return domains for operator approval. |
Response
{
"success": true,
"message": "Your application is submitted."
}/api/vendor/auth/loginExchanges 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| email * | body | string | Portal user email. |
| password * | body | string | Portal 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"
}
}/api/v1/meConfirms 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
}
}/api/v1/referenceEverything 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"
}
]
}/api/v1/projectsLive listings you are allowed to see. Invite-only work you were not offered does not appear, and is not counted in the total.
| Parameter | In | Type | Description |
|---|---|---|---|
| search | query | string | Case-insensitive match on the title. |
| country | query | string | Matches the primary country or any target country. |
| page | query | integer | 1-based. Defaults to 1. |
| limit | query | integer | Defaults 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
}
}/api/v1/projects/{id}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.
| Parameter | In | Type | Description |
|---|---|---|---|
| id * | path | string | The 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
}
}/api/v1/projects/{id}/import-previewSame 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| completeUrl | body | string | Overrides the account default. Where a completed respondent is returned. |
| terminateUrl | body | string | Overrides the account default. Where a screened-out respondent is returned. |
| quotaFullUrl | body | string | Overrides the account default. Where a respondent is returned when the cell is full. |
| securityTermUrl | body | string | Overrides 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
}/api/v1/projects/{id}/importImport 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| completeUrl | body | string | Overrides the account default. Where a completed respondent is returned. |
| terminateUrl | body | string | Overrides the account default. Where a screened-out respondent is returned. |
| quotaFullUrl | body | string | Overrides the account default. Where a respondent is returned when the cell is full. |
| securityTermUrl | body | string | Overrides 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"
}/api/v1/importsEvery 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| status | query | string | Active, Paused or Revoked. |
| page | query | integer | 1-based. Defaults to 1. |
| limit | query | integer | Defaults 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
}
}/api/v1/imports/{importId}/statsAggregated 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.
| Parameter | In | Type | Description |
|---|---|---|---|
| importId * | path | string | From 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
}
}| Code | HTTP | Meaning |
|---|---|---|
| unauthenticated | 401 | No credential was presented. |
| invalid_key | 401 | The API key is unknown or revoked. |
| suspended | 403 | The account is suspended. The key is fine; talk to your account manager. |
| not_found | 404 | No such listing or import, or it is not offered to you. |
| invalid_request | 400 | A required field is missing or malformed. |
| domain_not_allowed | 400 | An end link points at a host that is not on your allowlist. |
| rate_limited | 429 | Too many requests in the current minute. Back off and retry. |
| server_error | 500 | Something 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.
| Term | Meaning |
|---|---|
| Listing | A published project, as you see it. A sanitized copy - it never carries the client's identity or our own cost. |
| Import | Your hold on one listing: the end links you registered and the entry link we returned. |
| Entry link | Where you send respondents. Ends on a bare pid= for your own respondent id. |
| End link | Where we send them back, one per outcome. |
| Hit | A resolved respondent: complete, terminate, quota full or security terminate. Respondents still in the survey are not hits. |
| Incidence rate | completes / (completes + terminates). Quota fulls and security terminates are not in the denominator. |
| Stale | The 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