Indie Polls Custom Webhooks

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.

✨ Features

  • 🔔 Real-time delivery — posted as soon as a response is submitted
  • 🔐 Stripe-compatible signatures — every request carries an X-IndiePolls-Signature header you can verify with standard HMAC-SHA256
  • 📋 Rich JSON payload — poll info, contributor, and the full answers tree
  • 🧷 Per-webhook secret — regenerate any time by deleting and re-creating the webhook

How to register a webhook

  1. Sign in to Indie Polls and go to /app/webhooks.
  2. Click New webhook and fill in:
    • Name — any label you want to see in the list
    • Endpoint URL — the HTTPS URL we should POST to
    • Active — toggle on to start receiving deliveries
    • Enabled surveys — the polls that should trigger this webhook
  3. Save. Your signing secret (prefixed 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.

Headers sent with every delivery

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

Signature format

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.

  • Signed string: "{timestamp}.{raw_json_body}"
  • Algorithm: HMAC-SHA256
  • Encoding: lowercase hex (64 chars)
  • Header value: 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.

Verify in Node.js

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);
  }
);

Verify in Elixir (Plug)

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).

Payload

Event types

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.

Example body

{
  "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" }
        }
      ]
    }
  }
}

Field reference

  • 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”).

Best practices

  • Respond fast: return a 2xx within a few seconds; do heavy processing asynchronously on your side.
  • Verify before trusting: always check the signature; don’t rely on network-level trust alone.
  • Dedupe: use id as your idempotency key — we may deliver a duplicate event after a transient failure in future versions.
  • Tolerate extra fields: we may add new fields to payloads without notice; don’t reject unknown keys.

Limitations in v1

  • No automatic retries on delivery failure.
  • No per-webhook delivery history UI yet.
  • Signing secrets can be rotated only by deleting + re-creating the webhook.

Questions?

Drop a note on the Indie Polls landing page or visit Releasy CORP.