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.
A webhook needs somewhere to land, and standing up an HTTPS endpoint just to find out that someone answered a survey is more work than the notification is worth.
Hookfire supports Indie Polls natively: create an integration there, paste the URL it gives you into the Endpoint URL field above, and paste its signing secret back into Hookfire so every delivery is verified. Responses then arrive as push notifications on your phone, with the survey title and the answer count in the notification itself.
Nothing to deploy, nothing to keep running, and the signature verification below is handled for you. If you would rather build your own endpoint, the rest of this page is everything you need.
| 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.