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.
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.
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.
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.
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.
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:
Where should we put this year's hack week?
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