Routers and Redirects in JavaScript
This guide is for anyone running a survey on their own JavaScript stack — a router, a redirect page, a plain HTML page, a Node backend — rather than on a survey platform like Decipher or Alchemer. It covers the whole integration end to end: the one thing that has to happen in the browser, and everything that happens after it.
📌 Overview
Your router page does one small job: it loads the Quality Tools and mints a requestId. That
is the only part that has to run in a browser. Where everything after it runs — your server, or the
page itself — is your call; we recommend your server, and say why below.
Each respondent produces two sends to DQC, and you build both:
| Send | When it fires | What DQC gets |
|---|---|---|
| Mid-flow | Once the requestId exists, before you redirect the respondent onward | A partial record — we have the row |
| On completion | When the respondent returns to your router, however the survey ended | The final record — outcome, timings, failures |
The partial send is what stops an abandoned respondent from vanishing. If someone never comes back, the partial record is still there and becomes an Abandon disposition rather than nothing at all.
This page is the Quality Tools half plus the hand-off. For when to send each record see Sending Data to DQC; for the payload contract, every field and the response codes, see POST /data.
📌 How the two sends fit together
Three browser pages, and we are only present on the first. The survey itself runs no DQC code.
Figure: the flow we have in mind. Both POST /data calls may run from the browser, or from your server — see below.
Reading the arrows:
- The respondent arrives from the panel, on page 1 — your router or redirect page.
- Your page imports the toolbox as an ES module.
- You call
getIdentityRequestId(surveyId). - You get a
requestIdback, and nothing else. - The partial record goes to
POST /data. This is what stops an abandoned respondent from vanishing — the row exists from here on. - You redirect to page 2, the survey.
- Nothing of ours runs on page 2. We were loaded on page 1 and we are not loaded again.
- The respondent finishes — completed, screened out, or quota full — and lands on page 3, your return page.
- The final record goes to
POST /data, samecustomerTransactionID, now carrying the real outcomestatus,endDate, andsubstatusorfailures.
Every call drawn as page → DQC can equally be page → your server → DQC, and we do not mind
which. The only call that must happen in the browser is getIdentityRequestId — everything
after it is yours to place.
Server-side is what we recommend, for two reasons: a respondent cannot edit a record they never
touch, and GET /tools/request/:requestId has to stay server-side regardless, because it returns
participant history from across the co-op.
Arrow 5 does not have to finish before arrow 6. Put a DQC round trip in front of your redirect and
every respondent pays for our latency. The only reason to fetch before redirecting is if your
router routes on quality — screening out a low deviceScore before choosing a survey. If you do
that, give it a hard timeout and redirect anyway when it expires.
Where each outcome comes from
Three of the four need a send. Abandon does not — it is the resting state of a partial nobody came back to finish.
Prerequisites
- ✅ Your DQC API key. If you do not have one, follow these steps.
- ✅ Somewhere to make outbound HTTPS requests from. A server, serverless function or router backend is what we recommend, and it is required for the quality lookup in the helper. The record sends themselves can run from the browser if you prefer.
- ✅ A stable id per respondent that you can produce before the survey and look up afterwards.
- ✅ A
surveyId— any string that identifies this survey to you.
Step 1 — Add the script to your page
No build step and no framework. Drop two preconnect hints and one script tag into the router page you already have:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your router page</title>
<!-- Preconnect to the DQC servers so the toolbox loads faster -->
<link
rel="preconnect"
href="https://api.dqco-op.com"
crossorigin="anonymous"
/>
<link
rel="preconnect"
href="https://fpmetrics.dqco-op.com"
crossorigin="anonymous"
/>
</head>
<body>
<!-- your router page, whatever it already contains -->
<script type="module" src="./dqc.js"></script>
</body>
</html>
The preconnects open connections to the DQC servers early, which takes latency out of the toolbox load.
file://Opening the file directly gives you a file:// page, where the browser blocks both the module
script and the cross-origin import. Any static server will do - npx serve . or
python3 -m http.server. See Blocked Requests.
Step 2 — The browser script
This is the only DQC code that runs in a browser. It mints a requestId and returns it — that is
the entire job. What you do with the id is the next section:
const DQC_API_KEY = 'YOUR_DQC_API_KEY_HERE'; // Replace with your actual API key
const DQC_ENDPOINT = 'https://api.dqco-op.com/tools/toolbox';
// Identifies the survey to you - one value per survey, the same for every
// respondent. Keep exactly ONE of these three: a literal, a query parameter,
// or a variable your page already sets. Uncommenting a second without deleting
// the first redeclares SURVEY_ID, and the whole file stops parsing.
// Drop all three and the toolbox falls back to the page URL.
const SURVEY_ID = 'my-router-test';
// const SURVEY_ID = new URLSearchParams(window.location.search).get("survey");
// const SURVEY_ID = window.MY_SURVEY_ID;
// Start downloading the toolbox the moment this file loads, and hold on to the
// one promise. Loading the module and minting an id are two separate waits -
// this gets the first one out of the way while your page is still setting up,
// so by the time you call dqcStart the module is usually already here.
const toolboxPromise = import(`${DQC_ENDPOINT}/${DQC_API_KEY}`);
// If that download fails, dqcStart still sees it below. This only stops the
// browser reporting it as an unhandled rejection before anyone awaits it.
toolboxPromise.catch(() => {});
/**
* Mints a requestId and returns it. That is all it does - where the id goes
* next is yours. Call this ONCE per respondent.
*
* Never throws. If the toolbox cannot be reached you get "Could not process",
* a value we recognise: send it on unchanged rather than branching on it.
*/
export async function dqcStart(surveyId) {
try {
const { DQCToolBox } = await toolboxPromise;
// Note the destructure: the toolbox returns an object, not a string.
const { requestId } = await DQCToolBox.getIdentityRequestId(surveyId);
return requestId;
} catch (err) {
console.error('DQC toolbox failed:', err);
return 'Could not process';
}
}
SURVEY_ID — one per survey, the same for everyone in itAny string works, but use the same value for every respondent in that survey — it is what groups them together. Omit it entirely and the toolbox falls back to the page URL (hostname + pathname), which puts every survey sharing a router URL into one bucket, so a real id is better.
Do not make it unique per respondent. Duplicate detection runs per participant per
surveyId, so an id that changes every time leaves isDuplicate permanently false and the
check quietly stops working.
DQC_API_KEY — read it from config, but do not treat it as secretIt is written inline here only so the file runs the moment you paste it. In your own project read
it from configuration instead — a .env file, import.meta.env, your framework's runtime config,
whatever you already use — the same way the server helper reads
process.env.DQC_API_KEY.
That keeps it out of source control and lets it differ per environment. It does not make it
secret: the toolbox URL hands it to the browser by design, and it is the same key that
authenticates POST /data. Treat it as public, and keep the ingestion and lookup calls on your
server.
customerTransactionID is yours, and it has to be uniqueThis is the only thing joining your two sends together, and your server must receive it and put it on both records. Use whatever your system already calls a transaction, session or entry id.
Two ways it goes wrong. Missing, and the partial and the final can never be matched. Reused across
respondents, and they collapse into each other: POST /data upserts on customerTransactionID, so
a repeated value overwrites the earlier respondent instead of adding a new one.
The import() above is a plain dynamic import of a remote URL. Webpack will try to bundle it, so
add its escape hatch: import(/* webpackIgnore: true */ url). Vite and esbuild leave it alone.
Using dqcStart
The id comes back as a plain string, so getting it to your server is ordinary code you already know how to write. Two places to call it.
In dqc.js, right under the function — when the router page has no flow of its own. The file
is a module, so await works at the top level:
const requestId = await dqcStart(SURVEY_ID);
// now send it to your server, however you already talk to it
Or from your own file, if you already have something that runs when a respondent arrives:
import { dqcStart } from './dqc.js';
const requestId = await dqcStart(SURVEY_ID);
// now send it to your server, however you already talk to it
That is the whole integration on the browser side. How the id travels is up to you — a fetch to
an endpoint you already have, a hidden form field, whatever your router already does. Send it when
it suits you, as long as it reaches your server.
getIdentityRequestId is not idempotent. Each call creates a brand new requestId, and a
respondent who ends up with two has a split history — which breaks duplicate detection for them.
Call it once, and be deliberate about where from. The usual ways this goes wrong are a redirect path that runs it more than once, and a framework re-render — a React effect firing twice will mint two ids without anything looking broken.
Minting the id starts a write on our side, and a redirect that fires immediately can cancel it in
flight. If that happens, the id is real but has no data behind it, and your server's lookup in
the lookup comes back Could not process.
Await the requestId and let whatever you hand it to answer before you navigate — the order in
the diagram above. Do not fire the redirect in the same tick as the toolbox call.
window.dqcStartAnything on window can be overwritten by any other script on the page, including one you did not
put there. import gives you the same reuse without the global.
When the requestId is not an id
Five values come back in place of a real id when the tools could not score the respondent:
| Value | Meaning |
|---|---|
Request-Blocked | The request was blocked before it was scored |
Wrapper Tampering | The toolbox was interfered with |
Could not process | Scoring did not complete |
Submission too quick, data not processed | The respondent moved on before scoring finished |
Deactivated-Key | Your API key has been revoked |
You do not have to do anything special with them:
- Pass them to
GET /tools/requestunchanged. The endpoint recognises them and answers with the matching payload. No branching on your side. - Send them to
POST /dataas-is too. Do not blank them out or substitute a fake id. They are recognised on our side and they change how the response is dispositioned.
Deactivated-Key keeps your flow running on purposeA revoked key does not break the page. The toolbox still loads, still exports the same two methods,
and getIdentityRequestId still resolves — it just resolves to Deactivated-Key instead of an id.
Your redirect, your hand-off and the rest of your own logic run exactly as they do on a good key,
and the respondent never sees a difference. That is the whole point: a key revoked mid-field should
not strand people in a live survey.
The part that does change is ingestion. POST /data rejects a revoked key with 403, so records
stop landing while the journey carries on. Treat it as an operational problem on your side, not a
respondent one — see Deactivated Key.
Step 3 — Send data to DQC
Two sends per respondent, both through the same file. What follows are moments in this one step, not steps of their own — you build the file once and call it twice.
What you can send us
The code below sends a deliberately small record — enough to work, not everything you could send. The full list of fields lives on two other pages, and you should read them before you finalise your payload:
- Sending Data to DQC — what each send means, the four outcomes, and the rules for the two records.
- POST /data — every field, the dispositions, seller and buyer, the failures mapping, and the response codes.
A few worth knowing about now, because they are easy to leave out and hard to add later:
| Field | Why you may want it |
|---|---|
surveyState | live, testing, staging, dev — keeps your test traffic out of your real numbers. See below. |
substatus | Your own reason code behind the status. This is where your termination reasons go. |
failures | Your in-survey quality checks, in your own names. |
cpi, projectType, groups | Things you almost certainly already have, and that make the dashboard more useful. |
Send your own field names and your own values. We write the mapping rules that translate them — that is what phase 2 is for, and it is normal for it to take a few rounds of questions.
The helper file
One file does the work: it exchanges the requestId for the quality payload and sends both
records.
These are three plain HTTPS calls — you can run them from the browser if that suits your setup better. Why we recommend a server is at the top of the page; no need to make the case twice.
customerTransactionID is yours to chooseNothing here generates it and DQC never mints one for you — you pass it in. Take it from wherever you already identify this respondent: the id on the router redirect URL, a row in your database, a UUID you create at entry. Any value works, on one condition: you can produce the same one at every stage, from the partial record through to the final one. That is what makes the two sends a single transaction instead of two unrelated rows.
// Node 18+ (needs global fetch), ESM. In a CommonJS project either add
// "type": "module" to package.json or save this as dqc-server.mjs.
// Not Node? The three calls below are plain HTTPS - port them anywhere.
const DQC_API_KEY = process.env.DQC_API_KEY; // from your own secret store
const DQC_BASE = "https://api.dqco-op.com";
// DQC's own marker for "we could not score this respondent". Reused below as
// the default whenever a lookup does not give us real values.
const COULD_NOT_PROCESS = "Could not process";
/**
* The exact values DQC returns when it could not score someone. Your server
* answers with the same shape when the lookup itself fails, so there is one
* payload shape downstream and nothing has to tell the two cases apart.
*
* The marker sits on participantId on purpose - that is the field our
* termination rules check. The scores stay 0, and 0 passes every threshold,
* so the marker is what stops a real respondent being terminated as fraud.
*/
function couldNotProcess() {
const noData = "No data";
return {
participantId: COULD_NOT_PROCESS,
surveyId: COULD_NOT_PROCESS,
country: COULD_NOT_PROCESS,
subdivision: COULD_NOT_PROCESS,
deviceFailures: [COULD_NOT_PROCESS],
deviceScore: 0,
dataTrustScore: 0,
persona: "NONE",
isDuplicate: false,
averageDeviceScore: 0,
lowestDeviceScore: 0,
totalSurveys: 0,
completionRate: noData,
duplicationRate: noData,
failureRate: noData,
qualificationRate: noData,
manualISQRate: noData,
automatedISQRate: noData,
osqRate: noData,
lastSurveyTaken: noData,
brandFamiliarity: 0,
openEnd: 0,
speeding: 0,
honeyPot: 0,
straightlining: 0,
distinctSupplierCount: 0,
suppliers: [],
};
}
/**
* Exchanges a requestId for the quality payload.
*
* Never returns null and never throws. If DQC cannot be reached you get the
* "Could not process" payload back - the same shape DQC sends when it could
* not score someone. Send whatever comes back through unchanged.
*/
export async function fetchQualityPayload(requestId) {
try {
const response = await fetch(
`${DQC_BASE}/tools/request/${encodeURIComponent(requestId)}`,
{
method: "GET",
headers: {
// A scheme word plus a space. "Bearer" and "ApiKey" work too.
Authorization: `apikey ${DQC_API_KEY}`,
Accept: "application/json",
},
}
);
if (!response.ok) {
console.error("DQC lookup failed:", response.status);
return couldNotProcess();
}
return await response.json();
} catch (err) {
console.error("DQC lookup failed:", err);
return couldNotProcess();
}
}
/**
* Sends one record. Returns true if DQC stored it. Never throws.
*
* Both failure paths log the payload. A rejected record is gone unless you
* kept it, and the log line is the cheapest place to keep it - you can replay
* from your logs without having stored anything else.
*
* It is respondent data, though. No API key (that rides in the header, never
* the body), but a participantId, a country and the quality scores are in
* there - so give these logs whatever retention and access rules the rest of
* your respondent data already has.
*/
export async function sendToDQC(payload) {
try {
const response = await fetch(`${DQC_BASE}/data`, {
method: "POST",
headers: {
Authorization: `apikey ${DQC_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(
"DQC rejected the record:",
response.status,
await response.text(),
JSON.stringify(payload)
);
return false;
}
return true;
} catch (err) {
console.error("DQC send failed:", err, JSON.stringify(payload));
return false;
}
}
/**
* The first record. Send it as soon as you have an id for the respondent.
*
* STORE THE RECORD THIS RETURNS against your own id for this respondent.
* sendFinal has to send all of it again - see the note there - and nothing
* else in your system has it.
*/
export async function sendPartial({ requestId, customerTransactionID, session }) {
const quality = await fetchQualityPayload(requestId);
// Mid-flow you do not know when they finish, but a record with no endDate is
// dropped during mapping - silently, after POST /data has already said 200.
// A placeholder 100 ms after the start keeps it valid. The final send
// replaces it, and must carry a strictly later endDate than this one.
const startDate = new Date().toISOString();
const endDate = new Date(Date.parse(startDate) + 100).toISOString();
const record = {
// Everything the lookup gave us, passed straight through. Spreading beats
// picking fields by hand: the endpoint grows, and a field you forgot to
// copy is a field we never see. Extra keys are stored as-is and cost you
// nothing.
...quality,
// Yours. Listed after the spread so they always win.
customerTransactionID: customerTransactionID,
projectName: session.projectName, // dynamic - from your DB or query params
surveyName: session.surveyName, // dynamic - from your DB or query params
sellerName: session.sellerName, // dynamic - the panel this respondent came from
buyerName: "Your Company", // static - this one is you
status: "Partial",
startDate: startDate,
endDate: endDate,
requestId: requestId,
};
const ok = await sendToDQC(record);
// Store this whole record. sendFinal takes it back and resends it with the
// outcome filled in.
return { ok: ok, record: record };
}
/**
* The final record, when the respondent reaches a terminal state.
*
* stored - the record sendPartial returned, looked up by your own
* customerTransactionID.
* outcome - your result: status, substatus, failures.
*
* Send the stored fields AGAIN. An omitted field is not "leave it as it was":
* most columns are written on every send, so leaving one out overwrites what
* we already had with nothing. If you can only keep one thing, keep the
* requestId - it is how we tie the row back to the rest on our side.
*/
export async function sendFinal({ customerTransactionID, stored, outcome }) {
// The partial already reserved startDate + 100 ms. Ours has to beat that, or
// a respondent who bounces in under 100 ms sends a final that looks OLDER
// than its own partial - and the two can no longer be ordered.
const partialEnd = Date.parse(stored.startDate) + 100;
const endDate = new Date(Math.max(Date.now(), partialEnd + 100)).toISOString();
const record = {
// Everything the partial sent, sent again unchanged.
...stored,
customerTransactionID: customerTransactionID,
status: outcome.status, // your own outcome code
substatus: outcome.substatus, // your own reason code, if any
failures: outcome.failures, // e.g. { trapQuestion: 1, honeyPot: 0 }
endDate: endDate, // the real one now
// Add anything else you already track. We store the record as you send it
// and map your names later, so third-party scores, your own quality flags,
// panel ids, an isTest flag - all fine, and none of them have to be ours.
};
const ok = await sendToDQC(record);
return { ok: ok, record: record };
}
Call fetchQualityPayload once, right after the hand-off. The endpoint waits internally for the
data to land and then answers. It returns 200 either way: if the data never arrived you get the
Could not process payload back, not a 404.
3.1 — Send the partial record
Your endpoint receives the requestId, and everything else in the record is already yours — your
id for this respondent, the project and survey names, the seller. Put those together and call
sendPartial. The respondent never sends their own scores, so they cannot edit them on the way
through.
import { sendPartial } from './dqc-server.js';
app.post('/api/dqc/start', (req, res) => {
const { requestId } = req.body;
// Answer first. Everything below is off the respondent's critical path.
res.status(200).json({ ok: true });
// customerTransactionID and session are yours - a session store, a cookie,
// your database, the URL this route was reached on. We do not care where
// they come from, only that they reach sendPartial.
sendPartial({ requestId, customerTransactionID, session })
// Keep result.record against your customerTransactionID - the final send
// below hands it straight back.
.then((result) => storePartial(customerTransactionID, result.record))
// The response already went out, so nothing can handle a rejection here -
// unhandled, it takes the process down.
.catch((err) => console.error('DQC partial failed:', err));
});
sendPartial returnsThe final send needs that whole record back — the requestId, the participantId, the scores and
the same startDate. They exist only inside the partial send. If you do not persist them, the
final record arrives without them, and the fields it omits are overwritten rather than kept.
3.2 — Send the final record
When the respondent comes back, look the partial record up by your customerTransactionID and
send it again with the outcome filled in:
import { sendFinal } from './dqc-server.js';
app.post('/api/dqc/complete', (req, res) => {
res.status(200).json({ ok: true });
// Both of these are yours.
// stored - the record sendPartial returned, read back from your database,
// session store, cache, wherever you put it. It carries the
// requestId, participantId, the scores and the startDate.
// outcome - your result: status, substatus, failures.
loadPartial(customerTransactionID)
.then((stored) => sendFinal({ customerTransactionID, stored, outcome }))
.catch((err) => console.error('DQC final failed:', err));
});
You send your own status and substatus values. DQC writes the rules that turn them into a
disposition — see
Sending Data to DQC.
Omitting a field does not preserve it: most columns are written on every send, so a field you
leave out overwrites the stored value with nothing. The requestId matters most; with it we can
rebuild the rest.
Required fields get no grace at all: miss a seller, a survey name or a valid date and the whole
record is rejected, leaving the stored row on its Partial status with nothing in your logs. See
the two-record rules.
Verifying the integration
The flow below is running in your browser right now. Page 1 already happened when this page
loaded — the toolbox was fetched and a real requestId was minted and looked up, exactly as it
would be for a respondent landing on your router. Pick an outcome to see the second record.
Everything here is a live call, both POST /data sends included. They go to our demo
environment, never production, and every record carries a docs-demo-…
customerTransactionID so these rows are easy to tell apart from yours.
Nothing runs until you press a button, and each run mints a new requestId.
Set the two ids, then run it. In your router there is no button — this all fires when the page loads, before the respondent touches anything. It is behind a click here only because minting an id costs a real fingerprint call, and doing that for every reader who opens this page would be wasteful.
- ○Toolbox module loadedreal call
- ○requestId mintedreal call
- ○Handed to your servernot sent
- ○Quality payload looked upreal call
- ○Partial record sent to POST /datareal call
Four ways out of a survey. Three of them send a second record; one does not.
Then work through this before you launch:
- A real
surveyIdreturns a 20-characterrequestId. - One respondent produces exactly one call to
getIdentityRequestId- watch for re-renders and retries minting a second id. - The
GET /tools/requestlookup returns a payload with a realparticipantId. - One respondent produces exactly two
POST /datacalls, both with the samecustomerTransactionID. - An abandoned respondent leaves exactly one record behind, with
status: "Partial". - Killing the network mid-flow still lets the respondent redirect.
📌 Notes and gotchas
- One
requestIdper respondent. Every call mints a new one, and two ids split the respondent's history. Re-renders and retried redirects are the usual culprits. - The lookup must stay on your server;
POST /datashould. Different strengths.GET /tools/requestreturns co-op-wide participant history and must never reach a respondent's browser.POST /datafrom the browser is supported — but a respondent can edit a record their own browser sends, which is the reason to move it server-side, not the key: it is one key for both surfaces and the toolbox URL already ships it to the browser. getIdentityRequestIdturns off in-browser tampering detection. That is the trade for the speed. Duplicate detection still runs, on our side.- Send the special values through. A blanked field tells us nothing;
Could not processtells us exactly what happened. terminationReasonis Decipher-specific. Put your own reason codes insubstatusand your quality checks infailures.
Next steps
- POST /data — the endpoint, every field, response codes and the retry policy.
- Dispositions — what each end result means.
- Company Names and Codes — look up the exact buyer and seller records.
✅ Summary
- The browser does one thing: load the toolbox and call
getIdentityRequestId(surveyId). Destructure{ requestId }— it returns an object. - Call it once per respondent. Every call mints a new
requestId. - Hand the
requestIdto wherever you send records from, and let it answer before you redirect. Your owncustomerTransactionIDnever travels in that hand-off — whatever receives it already knows who is calling. - Your server fetches
GET /tools/request/{requestId}. Keep that call server-side — the response carries co-op-wide participant history. - Send two records to
POST /data: a partial once the id exists, a final when the respondent returns. Both carry the samecustomerTransactionID. Store what the partial sent and send it again — a field the final record omits is overwritten, not preserved. - Five special values arrive in place of an id. Pass them through unchanged - to the lookup and
to
POST /data. No special-casing on your side.