Naar inhoud springen

June 19, 2026 · 10 min read

Live Poll Results Dashboard with PollsLive Webhooks

A developer guide to PollsLive webhooks: verify the HMAC signature, react to vote.created and session events live, and fall back to the results endpoint.

By the PollsLive team·DevelopersHow-toTeams & meetings

We run a wall-mounted dashboard in our office that shows what's happening across the product. I wanted live poll results on it during company all-hands, without anyone starting or stopping it by hand. PollsLive webhooks tell it when a session starts and ends, and the results endpoint supplies the numbers. Here's the integration, signature verification included, because you should never trust an unverified webhook.

The events you can subscribe to

You register an endpoint URL and a set of events in Studio → Developer API. The ones I care about for a live dashboard:

  • `session.started` / `session.ended`: show or tear down the live tile.
  • `response.created`: an async response to a slide (the high-frequency one). It names the poll and the slide but has no totals.
  • `vote.created`: a new vote on an async poll. It includes `totalVotes` and `voteCounts`.
  • `poll.published` / `poll.closed`: lifecycle, handy for archiving.

One thing shapes the whole design: answers given in a live session send no event. `response.created` and `vote.created` only fire for async polls, the ones people answer from a link in their own time. A live session sends `session.started` and `session.ended` and nothing in between, so those two events start and stop the tile, and the numbers come from the results endpoint.

Every delivery is a JSON `POST` with the same envelope: an `event` name, a `createdAt` timestamp, and a `data` object. What's in `data` depends on the event. `vote.created` and `response.created` have `pollId` at the top level, session events have it at `data.session.pollId`, and poll events send the poll as `data.poll`. A `response.created` delivery carries `pollId`, `slug`, `slideId`, `kind`, and `isCorrect` for quiz answers.

Example delivery: vote.created
POST /webhooks/pollslive  (from PollsLive → your server)
X-PollsLive-Signature: t=1750320000,v1=4f9a…c2

{
  "event": "vote.created",
  "createdAt": "2026-06-19T16:00:00.000Z",
  "data": {
    "pollId": "poll_9aZ2kP",
    "slug": "hack-week",
    "optionIds": ["ai"],
    "totalVotes": 193,
    "voteCounts": { "ai": 79, "debt": 64, "tools": 37, "wild": 13 }
  }
}

Verify the signature first, always

Each request carries `X-PollsLive-Signature: t=<unix>,v1=<hex>`. The `v1` value is an HMAC-SHA256 of `<t>.<rawBody>` using your endpoint's signing secret. You must compute it over the raw request body (not the parsed JSON), compare in constant time, and reject anything where the timestamp is too old to block replays.

verify.mjs
import crypto from "node:crypto";

const SECRET = process.env.POLLSLIVE_WEBHOOK_SECRET; // from Studio → Developer API

export function verifyPollsLive(rawBody, header, toleranceSec = 300) {
  // header looks like: "t=1750320000,v1=4f9a…c2"
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The one gotcha in Express: you need the raw bytes, so register the verify-handler with `express.raw()` (or capture the buffer) instead of `express.json()` for that route. Then acknowledge fast: return `2xx` immediately and do the real work asynchronously, or PollsLive will treat a slow response as a failed delivery.

server.mjs
import express from "express";
import { verifyPollsLive } from "./verify.mjs";

const app = express();

app.post(
  "/webhooks/pollslive",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.get("X-PollsLive-Signature") ?? "";
    if (!verifyPollsLive(req.body.toString("utf8"), sig)) {
      return res.status(400).send("bad signature");
    }
    const evt = JSON.parse(req.body.toString("utf8"));

    // ACK first, then process out of band.
    res.sendStatus(200);
    queue.add(evt); // e.g. push to a worker / pub-sub / websocket fan-out
  },
);

app.listen(3000);

Turning events into a live tile

The worker handles the two cases separately. For a live session, `session.started` starts a refresh every five seconds and `session.ended` stops it. For an async poll, each `vote.created` or `response.created` is a nudge that says "something changed", and the worker debounces a burst of them (one per answer can be a lot) into a single refresh. `vote.created` does carry counts, but the other events don't, so every path ends in the same call to `GET /polls/{id}/results`. That keeps request volume low even with a busy poll; the rate limits page has the current numbers.

worker.mjs: debounce + fetch results
const API = process.env.POLLSLIVE_API;
const auth = { Authorization: `Bearer ${process.env.POLLSLIVE_KEY}` };
const pending = new Map(); // pollId → debounce timer
const loops = new Map(); // pollId → refresh loop for a live session

function onEvent(evt) {
  switch (evt.event) {
    case "session.started":
      return startLoop(evt.data.session.pollId);
    case "session.ended":
      return stopLoop(evt.data.session.pollId);
    case "vote.created":
    case "response.created":
      return nudge(evt.data.pollId);
  }
}

function startLoop(pollId) {
  if (loops.has(pollId)) return; // a retried delivery can arrive twice
  refresh(pollId);
  loops.set(pollId, setInterval(() => refresh(pollId), 5000));
}

function stopLoop(pollId) {
  clearInterval(loops.get(pollId));
  loops.delete(pollId);
  teardownTile(pollId);
}

function nudge(pollId) {
  if (pending.has(pollId)) return; // a refresh is already due: debounce bursts
  pending.set(pollId, setTimeout(() => {
    pending.delete(pollId);
    refresh(pollId);
  }, 750));
}

async function refresh(pollId) {
  const res = await fetch(`${API}/polls/${pollId}/results`, { headers: auth });
  if (res.status === 429) {                 // backed-off retry on rate limit
    return setTimeout(() => refresh(pollId), 2000);
  }
  const { data } = await res.json();
  pushToDashboard(pollId, data.slides); // websocket → the wall display
}

The key needs the `results:read` scope. The tile reads `slides` from the results, not the top-level `voteCounts`: each entry in `slides` counts live answers and async votes together, while `voteCounts` counts async votes only and stays empty for a poll that has only run live. If a poll has been presented more than once, `slides` adds up all of its sessions.

The wall ends up showing a tile like the one below. During a live session it refreshes every five seconds, which is close enough to the room for a wall display:

Live poll

Where should we put this year's hack week?

AI features for the core product41% · 79
Pay down tech debt / reliability33% · 64
Internal tools that save us time19% · 37
Wildcard (anything goes)7% · 13
An example tile, filled from GET /polls/{id}/results.

Always have a fallback

Webhooks can be delayed or dropped. Networks are networks. So the same `refresh()` function also runs on a slow safety-net interval (every 30s) for every poll on the wall. Belt and braces: webhooks for quick updates, a gentle poll so the tile is never wrong for long. If you only do one, do verification; if you do two, add the fallback.

Treat the webhook as a 'go look' signal, not the source of truth. Verify it, debounce it, then read the results endpoint, and you get a dashboard that's up to date and resilient.

The full list of events and the signature format live in the developer guide and the interactive API reference. If you're automating the *creation* side too, my colleague's write-up on running retros from the API is the companion piece.

Tip

Webhook payloads are nudges, not a results feed. Most carry no counts, so debounce them and read the results endpoint for the numbers.

Read results in Studio with read and export poll results. See the integrations guide for webhooks and API endpoints.

Build on PollsLive: create polls, drive live sessions, pull results, and receive webhooks from your own code.

Read the developer docs

We gebruiken essentiële cookies om je ingelogd te houden en dubbele stemmen te voorkomen. Met je toestemming gebruiken we ook Cloudflare Web Analytics om paginaweergaven te tellen en de Whop Pixel om te zien welke bezoeken tot een aankoop leiden. Cookie Policy