Responses go to your endpoint.
Here is exactly what arrives.

When a survey is published with direct delivery, each respondent's browser posts their answers straight to a webhook you run. Our servers never receive the answers. This page is the contract for that webhook: the request, the signed token, how to verify it, and how to respond.

schema_version 1.0 transport HTTPS POST, JSON auth HS256 JWT, 15-minute TTL

On this page
  1. How it works
  2. Setup
  3. The request
  4. The token
  5. Verifying a delivery
  6. How to respond
  7. Receiver examples
  8. When snapshots are sent
  9. Drop-off and quota
  10. The answers object
  11. What we keep
  12. Limits
  13. Versioning

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.

  1. 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.
  2. 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.
  3. 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.

  1. Run an HTTPS endpoint that accepts POST with a JSON body. You enter its URL when you publish.
  2. Choose an audience (optional). The token's aud claim is your webhook URL unless you set a different audience string. Your endpoint checks it.
  3. 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.
  4. Answer the CORS preflight. The request comes from a browser, so your endpoint must answer an OPTIONS preflight allowing POST and the Content-Type and Authorization headers, from the origin your survey links are served from.
  5. 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

request
POST <your webhook URL>
Content-Type: application/json
Authorization: Bearer <jwt>
body
{
  "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"]
    }
  }
}
FieldMeaning
schema_versionAlways "1.0" for this contract. See Versioning.
response_idThe 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_idThe respondent's session. A UUID. Matches the token's sub claim.
sequencePositive 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.
surveyslug and title of the survey, and version, a hash of the survey definition that changes whenever the questionnaire does.
publication_idThe published link this response came through, as a string. Matches the token's pub claim.
dispositionpartial, complete, terminate (screened out) or overquota (sent away because the respondent's quota group was already full).
last_pageThe 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_atWhen the interview started. ISO 8601, UTC.
updated_atWhen this snapshot was taken. A resend carries the original value.
completed_atSet only when disposition is not partial; otherwise null.
metaThe query parameters from the link the respondent opened (panel IDs, source codes), passed through unchanged. null if the link had none.
answersEvery 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.

ClaimValue
iss"srp.solutions"
audYour audience string if you set one, otherwise your webhook URL.
subThe session id. Must equal session_id in the body.
pubThe publication id, as a string. Must equal publication_id in the body.
jtiA 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, expUnix seconds. exp is iat + 900.

Verifying a delivery

  1. Verify the token. Check the HS256 signature with your secret, then exp, iss and aud. Accept HS256 only; reject any other alg, including none.
  2. Check that sub == session_id and pub == 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.
  3. Upsert on response_id. Apply the snapshot only if its sequence is 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:

upsert.sql
-- 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 responseWhat the browser does
2xxMarks the snapshot delivered.
401Gets a new token and resends once. A second 401 is treated as permanent for that snapshot.
Other 4xxPermanent for that snapshot; it is not retried. The next snapshot is still sent.
5xx or network errorRetries 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 — Rack, gem "jwt"
# 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 — standard library, PyJWT
# 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 — Node 18+, no dependencies
// 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 disposition set to complete or terminate. 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:

KeyMeaning
questionTypeThe question type, from the table below.
answeredtrue, except a conjoint choice with no profile chosen.
answeredAtWhen the answer was last changed. ISO 8601, UTC.
selectedValuesThe answer. Its shape depends on the type.
whyTextPresent if the question has a "why?" follow-up box and it was filled in.
otherTextPresent 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.

questionTypeAnswer shape
single_select
dropdown
button_rating
selectedValues: [label]; selectedCodes: [code]. otherText on single select and dropdown.
multi_select
button_checkbox
selectedValues: [label, …]; selectedCodes: [code, …]. otherText on multi select.
open_ended
phone_number
autosuggest
this_or_that
emotion_selector
selectedValues: [text]. For this-or-that, the chosen label; for emotion selector, the emotion's id.
date_pickerselectedValues: ["YYYY-MM-DD"]
number
nps
rating
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_matrix
single_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_matrix
single_select_bipolar_matrix
matrixSelections: {row: {column: value}}. selectedValues: rows answered; selectedColumns: columns with a value.
rankingselectedValues: item texts in ranked order, first is top.
constant_sumselectedValues: {row: amount}. Optional otherText and noneSelected.
heat_map
timed_heat_map
sticky_note
selectedValues: [{x, y, category, timestamp}, …]. x and y are percentages of the image's width and height.
text_highlighterselectedValues: [{start, end, text, category}, …]
max_diffselectedValues: one {best, worst} per set, in order.
card_sortselectedValues: {item: category}
card_ratingselectedValues: {item: rating}
conjoint_choiceselectedValues: the chosen profile's name as a string (not an array), or null with answered: false.
fill_in_the_blankselectedValues: the blanks' values, in order.
ageselectedValues: {month, day, year}
us_addressselectedValues: {street, city, state, zip}
international_addressselectedValues: {street, city, state, postal, country}
media_uploadselectedValues: 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 disposition value 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.