How it works
Every delivery is a full snapshot of one respondent's interview so far:
every answer they have given, where they are, and how it ended if it has ended. A
respondent produces several snapshots over an interview, all under the same
response_id. You keep the newest one.
- When the survey opens, the respondent's browser asks us for a short-lived token for its session. We sign it with a secret that only you and we hold.
- As the respondent moves through the survey, the browser posts snapshots to your endpoint with that token attached. When the interview ends, it posts a final snapshot with the outcome.
- Your endpoint verifies the token, checks it belongs to the session in the body, and keeps the snapshot if it is newer than the one you hold.
We are not in the delivery request. If your endpoint is down, the respondent's browser keeps the snapshot and retries, and it tries again the next time that respondent opens the survey.
Setup
This is done once per survey, when it is published.
- Run an HTTPS endpoint that accepts
POSTwith a JSON body. You enter its URL when you publish. - Choose an audience (optional). The token's
audclaim is your webhook URL unless you set a different audience string. Your endpoint checks it. - Store the secret. Publishing generates a secret and shows it once. Keep it where your endpoint can read it. Use it exactly as shown, as a text string; it is not hex-decoded. If it is lost, regenerate it from the survey's delivery settings. The old secret stops working at once, so update your endpoint straight away.
- Answer the CORS preflight. The request comes from a browser, so your
endpoint must answer an
OPTIONSpreflight allowingPOSTand theContent-TypeandAuthorizationheaders, from the origin your survey links are served from. - Choose how often partial snapshots are sent: on every page advance (the default), every N seconds (5 to 3600), or not at all (final snapshots only). A 20-page survey taken by 1,000 respondents is roughly 20,000 requests on page advance, against 1,000 with partials off.
The request
POST <your webhook URL>
Content-Type: application/json
Authorization: Bearer <jwt>
{
"schema_version": "1.0",
"response_id": "3f6c2a9e-8d41-4b7a-9c5e-1a2b3c4d5e6f",
"session_id": "3f6c2a9e-8d41-4b7a-9c5e-1a2b3c4d5e6f",
"sequence": 7,
"survey": { "slug": "brand-tracker", "title": "Brand Tracker", "version": "9b2f4c0e71d8a3b5c6d7e8f901234567" },
"publication_id": "42",
"disposition": "partial",
"last_page": "page_usage",
"started_at": "2026-09-24T14:02:11.000Z",
"updated_at": "2026-09-24T14:05:40.000Z",
"completed_at": null,
"meta": { "pid": "abc123", "src": "fieldline" },
"answers": {
"q_gender": {
"questionType": "single_select",
"answered": true,
"answeredAt": "2026-09-24T14:02:30.000Z",
"selectedValues": ["Woman"],
"selectedCodes": ["2"]
}
}
}
| Field | Meaning |
|---|---|
schema_version | Always "1.0" for this contract. See Versioning. |
response_id | The key you upsert on. Stable across every snapshot and retry for one respondent's interview. Equal to session_id in v1.0; it is a separate field so the two can differ later without a breaking change. |
session_id | The respondent's session. A UUID. Matches the token's sub claim. |
sequence | Positive integer, strictly increasing per response_id across distinct snapshots. It may skip numbers, so compare values and never assume they are consecutive. A resend of the same snapshot repeats its sequence. |
survey | slug and title of the survey, and version, a hash of the survey definition that changes whenever the questionnaire does. |
publication_id | The published link this response came through, as a string. Matches the token's pub claim. |
disposition | partial, complete, terminate (screened out) or overquota (sent away because the respondent's quota group was already full). |
last_page | The name of the page the respondent was on, as written in the survey definition. For a repeating block, the block's name. Shows where a respondent stopped. |
started_at | When the interview started. ISO 8601, UTC. |
updated_at | When this snapshot was taken. A resend carries the original value. |
completed_at | Set only when disposition is not partial; otherwise null. |
meta | The query parameters from the link the respondent opened (panel IDs, source codes), passed through unchanged. null if the link had none. |
answers | Every answer given so far, keyed by question name. See The answers object. |
The token
A JWT signed with HS256 using your secret. It lasts 15 minutes; the browser gets a new one before it expires. One token covers one session of one published survey.
| Claim | Value |
|---|---|
iss | "srp.solutions" |
aud | Your audience string if you set one, otherwise your webhook URL. |
sub | The session id. Must equal session_id in the body. |
pub | The publication id, as a string. Must equal publication_id in the body. |
jti | A unique id per token, for your logs. Not single-use: one token signs every delivery made in its 15 minutes, including retries. Don't reject a repeated jti. |
iat, exp | Unix seconds. exp is iat + 900. |
Verifying a delivery
- Verify the token. Check the HS256 signature with your secret, then
exp,issandaud. Accept HS256 only; reject any otheralg, includingnone. - Check that
sub == session_idandpub == publication_id. The token is a bearer token for one session. Without this check, a token for one session could be attached to a made-up body for another. - Upsert on
response_id. Apply the snapshot only if itssequenceis higher than the highest you have applied. Otherwise drop it.
The sequence rule is your replay protection. Snapshots can arrive out of order, since a retry can land after a newer page advance. An old snapshot replayed does nothing because it is stale; the latest one replayed does nothing because applying it twice changes nothing.
With a database, step 3 is one statement:
-- Apply a snapshot only if it is newer than the one you hold.
-- Postgres; SQLite 3.24+ accepts the same statement.
INSERT INTO survey_responses (response_id, sequence, disposition, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (response_id) DO UPDATE
SET sequence = excluded.sequence,
disposition = excluded.disposition,
payload = excluded.payload
WHERE excluded.sequence > survey_responses.sequence;
How to respond
| Your response | What the browser does |
|---|---|
| 2xx | Marks the snapshot delivered. |
| 401 | Gets a new token and resends once. A second 401 is treated as permanent for that snapshot. |
| Other 4xx | Permanent for that snapshot; it is not retried. The next snapshot is still sent. |
| 5xx or network error | Retries after 5, 10, 20, 40 and 80 seconds, then waits for the next page advance, the next snapshot, or the respondent's next visit. |
A stale snapshot you dropped must still get a 2xx. If you answer it with an error, the browser keeps retrying it.
Respond quickly and do heavier processing after you've replied. The body of your response is ignored.
Receiver examples
Complete, minimal receivers that do all of the above: answer the preflight, verify the
token, check sub and pub, apply only newer snapshots, and return
the right status. Each keeps responses in memory; replace that with your database.
They read three environment variables: SRP_WEBHOOK_SECRET,
SRP_AUDIENCE and SRP_SURVEY_ORIGIN.
# config.ru — run with: rackup -p 4567
require "json"
require "jwt"
SECRET = ENV.fetch("SRP_WEBHOOK_SECRET") # shown once when you publish
AUDIENCE = ENV.fetch("SRP_AUDIENCE") # your webhook URL, unless you set one
SURVEY_ORIGIN = ENV.fetch("SRP_SURVEY_ORIGIN") # where your survey links are served from
RESPONSES = {} # response_id => payload — use your database
class Receiver
def call(env)
return [204, cors, []] if env["REQUEST_METHOD"] == "OPTIONS"
return [405, cors, []] unless env["REQUEST_METHOD"] == "POST"
body = JSON.parse(env["rack.input"].read) rescue nil
return [400, cors, ["bad json"]] unless body.is_a?(Hash)
# 1. Signature, algorithm, exp, iss and aud
token = env["HTTP_AUTHORIZATION"].to_s.delete_prefix("Bearer ")
claims, = JWT.decode(token, SECRET, true,
algorithm: "HS256",
iss: "srp.solutions", verify_iss: true,
aud: AUDIENCE, verify_aud: true)
# 2. The token belongs to this session of this publication
unless claims["sub"] == body["session_id"] && claims["pub"] == body["publication_id"]
return [401, cors, ["token does not match body"]]
end
# 3. Upsert on response_id; apply only a higher sequence
held = RESPONSES[body["response_id"]]
RESPONSES[body["response_id"]] = body if held.nil? || body["sequence"] > held["sequence"]
[200, cors, []] # 2xx even when the snapshot was stale and dropped
rescue JWT::DecodeError
[401, cors, ["invalid token"]]
end
private
def cors
{ "access-control-allow-origin" => SURVEY_ORIGIN,
"access-control-allow-methods" => "POST, OPTIONS",
"access-control-allow-headers" => "Content-Type, Authorization" }
end
end
run Receiver.new
# receiver.py — pip install pyjwt; python receiver.py
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
import jwt
SECRET = os.environ["SRP_WEBHOOK_SECRET"] # shown once when you publish
AUDIENCE = os.environ["SRP_AUDIENCE"] # your webhook URL, unless you set one
SURVEY_ORIGIN = os.environ["SRP_SURVEY_ORIGIN"] # where your survey links are served from
RESPONSES = {} # response_id -> payload — use your database
class Receiver(BaseHTTPRequestHandler):
def reply(self, status, text=""):
self.send_response(status)
self.send_header("Access-Control-Allow-Origin", SURVEY_ORIGIN)
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.end_headers()
self.wfile.write(text.encode())
def do_OPTIONS(self):
self.reply(204)
def do_POST(self):
try:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
except (TypeError, ValueError):
return self.reply(400, "bad json")
# 1. Signature, algorithm, exp, iss and aud
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
try:
claims = jwt.decode(token, SECRET, algorithms=["HS256"],
audience=AUDIENCE, issuer="srp.solutions")
except jwt.InvalidTokenError:
return self.reply(401, "invalid token")
# 2. The token belongs to this session of this publication
if claims["sub"] != body.get("session_id") or claims["pub"] != body.get("publication_id"):
return self.reply(401, "token does not match body")
# 3. Upsert on response_id; apply only a higher sequence
held = RESPONSES.get(body["response_id"])
if held is None or body["sequence"] > held["sequence"]:
RESPONSES[body["response_id"]] = body
self.reply(200) # 2xx even when the snapshot was stale and dropped
HTTPServer(("", 4567), Receiver).serve_forever()
// receiver.mjs — no dependencies; node receiver.mjs
import http from 'node:http';
import crypto from 'node:crypto';
const SECRET = process.env.SRP_WEBHOOK_SECRET; // shown once when you publish
const AUDIENCE = process.env.SRP_AUDIENCE; // your webhook URL, unless you set one
const SURVEY_ORIGIN = process.env.SRP_SURVEY_ORIGIN; // where your survey links are served from
const responses = new Map(); // response_id -> payload — use your database
const b64url = (s) => Buffer.from(s, 'base64url');
// Returns the claims, or null if the token is not valid for us.
function verifyToken(token) {
const [header, claims, signature] = (token || '').split('.');
if (!signature) return null;
const expected = crypto.createHmac('sha256', SECRET).update(`${header}.${claims}`).digest();
const given = b64url(signature);
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null;
if (JSON.parse(b64url(header)).alg !== 'HS256') return null;
const c = JSON.parse(b64url(claims));
if (!(c.exp > Date.now() / 1000) || c.iss !== 'srp.solutions' || c.aud !== AUDIENCE) return null;
return c;
}
http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', SURVEY_ORIGIN);
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') return res.writeHead(204).end();
if (req.method !== 'POST') return res.writeHead(405).end();
let raw = '';
req.on('data', (chunk) => (raw += chunk));
req.on('end', () => {
let body;
try { body = JSON.parse(raw); } catch { return res.writeHead(400).end('bad json'); }
// 1. Signature, algorithm, exp, iss and aud
const claims = verifyToken((req.headers.authorization || '').replace(/^Bearer /, ''));
if (!claims) return res.writeHead(401).end('invalid token');
// 2. The token belongs to this session of this publication
if (claims.sub !== body.session_id || claims.pub !== body.publication_id) {
return res.writeHead(401).end('token does not match body');
}
// 3. Upsert on response_id; apply only a higher sequence
const held = responses.get(body.response_id);
if (!held || body.sequence > held.sequence) responses.set(body.response_id, body);
res.writeHead(200).end(); // 2xx even when the snapshot was stale and dropped
});
}).listen(4567);
When snapshots are sent
- Page advance (the default): each time the respondent moves to a new page. The first page shown never triggers a send; the first page advance does, even if nothing was answered.
- Interval (if chosen): every N seconds, if anything changed.
- End of the interview: a final snapshot with
dispositionset tocompleteorterminate. This is sent even when partials are off. No partial snapshots follow it. - Leaving the page: when the respondent closes or navigates away, the browser tries to send the latest unsent snapshot.
- Returning: when a respondent reopens the survey, any snapshot that wasn't delivered is sent again.
Only the latest snapshot is ever waiting to be sent, and there is at most one request in flight per respondent. A newer snapshot replaces an older unsent one rather than queueing behind it. That is safe because each snapshot contains everything the earlier ones did.
Drop-off and quota
There is no abandonment event
A respondent who closes the tab sends nothing that says so. An abandoned interview is
one whose snapshots stopped and which never received a final complete,
terminate or overquota. Choose your own inactivity threshold,
then use last_page to see where respondents stopped.
Partials never count toward quota
Quota cells are counted only when an interview ends with complete. Partial
snapshots and screen-outs arriving at your endpoint don't fill cells.
The answers object
answers is keyed by the question's name in the survey definition. Only
questions the respondent has answered appear. Every entry has:
| Key | Meaning |
|---|---|
questionType | The question type, from the table below. |
answered | true, except a conjoint choice with no profile chosen. |
answeredAt | When the answer was last changed. ISO 8601, UTC. |
selectedValues | The answer. Its shape depends on the type. |
whyText | Present if the question has a "why?" follow-up box and it was filled in. |
otherText | Present if the respondent chose "Other" and typed a value. |
For choice questions, selectedValues holds the labels the respondent saw and
selectedCodes holds the stored values: the code where the survey assigns
one, otherwise the label again. Analyse on selectedCodes; labels can change
between survey versions.
| questionType | Answer shape |
|---|---|
single_selectdropdownbutton_rating |
selectedValues: [label]; selectedCodes: [code]. otherText on single select and dropdown. |
multi_selectbutton_checkbox |
selectedValues: [label, …]; selectedCodes: [code, …]. otherText on multi select. |
open_endedphone_numberautosuggestthis_or_thatemotion_selector |
selectedValues: [text]. For this-or-that, the chosen label; for emotion selector, the emotion's id. |
date_picker | selectedValues: ["YYYY-MM-DD"] |
numbernpsrating |
selectedValues: [value]. NPS is 0–10; rating is the number of stars. |
slider |
Single slider: selectedValues: [value]. Multi-row slider: matrixSelections: {row: value} and selectedValues: the row names. |
single_select_matrixsingle_select_bipolar |
matrixSelections: {row: column}. selectedValues: rows answered; selectedColumns: distinct columns chosen. |
multi_select_matrix |
matrixSelections: {row: [column, …]}. selectedValues and selectedColumns as above. |
open_ended_matrixsingle_select_bipolar_matrix |
matrixSelections: {row: {column: value}}. selectedValues: rows answered; selectedColumns: columns with a value. |
ranking | selectedValues: item texts in ranked order, first is top. |
constant_sum | selectedValues: {row: amount}. Optional otherText and noneSelected. |
heat_maptimed_heat_mapsticky_note |
selectedValues: [{x, y, category, timestamp}, …]. x and y are percentages of the image's width and height. |
text_highlighter | selectedValues: [{start, end, text, category}, …] |
max_diff | selectedValues: one {best, worst} per set, in order. |
card_sort | selectedValues: {item: category} |
card_rating | selectedValues: {item: rating} |
conjoint_choice | selectedValues: the chosen profile's name as a string (not an array), or null with answered: false. |
fill_in_the_blank | selectedValues: the blanks' values, in order. |
age | selectedValues: {month, day, year} |
us_address | selectedValues: {street, city, state, zip} |
international_address | selectedValues: {street, city, state, postal, country} |
media_upload | selectedValues: file names; fileMetadata: [{name, size, type}, …]. The files themselves are not delivered. |
What we keep
We never store answers for a survey on direct delivery. The page has no way to send them to us.
We do keep the minimum needed to run fielding:
- One session record per respondent: session id, which survey and version,
the link's query parameters (
meta), when it started and ended, and how it ended. This is what gives you fielding progress, and lets both sides reconcile counts: if you received 412 completes and we counted 415, the difference is three deliveries that didn't arrive. - Quota claims: which quota cell a session fell into, never the answers. To check quota, the browser sends us only the answers to the questions your quota cells depend on, and we use them to place the session in a cell.
Be aware that cell membership implies the quota variables: knowing a session is in a "women 35–44, Northeast" cell tells you those facts about that respondent.
Limits
The signature proves origin, not honesty
A valid token proves a delivery came through your published survey for that session. It doesn't prove the respondent is genuine or careful. Anyone who can open the survey link can get a token and submit poor answers. That is true of all web-based collection. Keep using your panel's fraud and quality checks.
Leaving the page is best-effort
Browsers limit what a page can send as it closes. The final send on tab close reliably arrives in Chrome, sometimes in Firefox, and hasn't yet been tested in Safari. The snapshot isn't lost: it is sent when the respondent next opens the survey. A respondent who never returns may leave you with a snapshot one page behind where they stopped.
Links without query parameters
The session id is derived from the link's query parameters, so a returning respondent resumes the same session. A link with no parameters gets a new session on every visit, so a snapshot left unsent when the tab closed is never resent. Panel links always carry parameters.
Reopening a finished survey
A respondent who reopens a link they already finished doesn't start again. They are
sent back to their panel with the outcome they first finished with, or told they have
already completed the survey, and nothing more is sent for that
response_id. This relies on the link's query parameters, so it doesn't
apply to links without them (see above).
We still recommend keeping the first final snapshot. Once you hold a
complete, terminate or overquota for a
response_id, don't replace it. A normal interview sends nothing after its
final snapshot, so this costs nothing and protects your data if a retake ever gets
through. We record the first outcome the same way. In the SQL above, add
AND survey_responses.disposition = 'partial' to the WHERE.
Versioning
Every body carries schema_version. This page describes 1.0.
- Additive changes such as a new field, a new question type or a new
dispositionvalue keep the major version. Ignore fields you don't recognise and accept unknown dispositions. - Breaking changes such as removing or renaming a field, changing a field's meaning, or changing the token's claims bump the major version and are announced before any survey sends them.