Receive signed HTTP POST notifications on your own endpoints whenever a survey attached to a webhook gets a new response. Great for piping submissions into Zapier, n8n, Make, your internal backend, or anything else that speaks HTTP.
X-IndiePolls-Signature header you can verify with standard HMAC-SHA256 whsec_…) appears in the row — click Reveal to see it and Copy to copy it to your clipboard. Real-time webhook deliveries are available on premium plans. Free accounts can configure webhooks but nothing will be delivered until the workspace is upgraded.
| Header | Value |
|---|---|
Content-Type |
application/json |
X-IndiePolls-Event |
poll.submitted |
X-IndiePolls-Signature |
t=<unix_timestamp>,v1=<hex_hmac_sha256> |
User-Agent |
IndiePolls-Webhooks/1.0 |
Indie Polls signs every webhook body with HMAC-SHA256 using the per-webhook signing secret. The format is intentionally identical to Stripe’s webhook signature scheme, so libraries and code snippets written for Stripe will Just Work with a simple header-name swap.
"{timestamp}.{raw_json_body}" t=<unix_timestamp>,v1=<hmac> Always verify against the raw request body. If you parse the JSON first and re-stringify it, the signature will not match.
import crypto from "node:crypto";
export function verifyIndiePollsSignature(rawBody, header, secret, toleranceSeconds = 300) {
// header looks like: "t=1745000000,v1=abc123..."
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
);
const t = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!t || !v1) throw new Error("Malformed signature header");
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) {
throw new Error("Timestamp outside tolerance");
}
const signed = `${t}.${rawBody}`;
const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Signature mismatch");
}
}
Wire it into Express by reading the raw body:
app.post(
"/indiepolls/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
verifyIndiePollsSignature(
req.body.toString("utf8"),
req.get("X-IndiePolls-Signature"),
process.env.INDIEPOLLS_WEBHOOK_SECRET
);
const event = JSON.parse(req.body.toString("utf8"));
// …handle event.type === "poll.submitted"
res.sendStatus(200);
}
);
def verify!(raw_body, header, secret, tolerance \\ 300) do
%{"t" => ts, "v1" => v1} =
Regex.named_captures(~r/t=(?<t>\d+),v1=(?<v1>[a-f0-9]+)/, header) ||
raise "Malformed signature header"
ts = String.to_integer(ts)
if abs(System.system_time(:second) - ts) > tolerance do
raise "Timestamp outside tolerance"
end
expected =
:crypto.mac(:hmac, :sha256, secret, "#{ts}.#{raw_body}")
|> Base.encode16(case: :lower)
unless Plug.Crypto.secure_compare(v1, expected) do
raise "Signature mismatch"
end
:ok
end
Make sure your Plug pipeline preserves the raw body (e.g. with
Plug.Parsers :body_reader option).
Currently a single event type is emitted:
poll.submitted — fired once per contributor submission, containing every answer in that submission
More event types may be added in the future; check X-IndiePolls-Event and type before acting.
{
"id": "evt_6c8a0f5c-19d9-4d9b-b6b4-67a8b0f4f6f1",
"type": "poll.submitted",
"created_at": "2026-04-17T12:34:56Z",
"data": {
"poll": {
"id": "8bf94c20-8e96-4d4a-a7aa-5b3a6a6b9e7b",
"title": "Q2 NPS Survey",
"user_id": "f2b0b8e2-1a24-4b6e-8e82-8f6b0b1f9a21"
},
"contributor": {
"id": "d1d1b9c1-4f7b-4d3d-8d4c-4e5a6b7c8d9e",
"name": "Ada Lovelace",
"email": "ada@example.com"
},
"submission": {
"answer_count": 2,
"results_url": "https://indiepolls.io/app/surveys/8bf94c20.../results?contributors%5B%5D=d1d1b9c1...&tab=questions",
"answers": [
{
"question_id": "q-uuid",
"question_text": "How likely are you to recommend us?",
"selected_options": [
{ "id": "opt-uuid", "text": "10" }
],
"free_text": {}
},
{
"question_id": "q2-uuid",
"question_text": "What could we improve?",
"selected_options": [
{ "id": "opt3-uuid", "text": "Other" }
],
"free_text": { "opt3-uuid": "Faster CSV exports" }
}
]
}
}
}
id — unique per event (evt_<uuid>). Use this to dedupe if you persist deliveries. type — event type string, currently always poll.submitted. created_at — ISO-8601 UTC timestamp of when the event was generated. data.poll — minimal poll reference (id, title, owner user id). data.contributor — may be null for anonymous submissions. When present, contains id, name, email (any of which may be null). data.submission.answer_count — number of answers in this submission. data.submission.results_url — deep link to this contributor’s answers in the Indie Polls dashboard (requires login). data.submission.answers[] — one entry per question answered. question_id, question_text — the question that was answered. selected_options[] — all options chosen, each with id and text. free_text — a map of option_id -> text for options that accept free-text input (e.g. “Other”). id as your idempotency key — we may deliver a duplicate event after a transient failure in future versions. Drop a note on the Indie Polls landing page or visit Releasy CORP.