June 19, 2026 · 10 min read
Piping live poll results into our internal dashboard with webhooks
A developer's guide to consuming PollsLive webhooks: verify the HMAC signature, react to vote.created and session events in real time, and fall back to polling 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 hammering the API on a timer. PollsLive webhooks made it a couple of hours of work. Here's the integration, signature verification included, because you should never trust an unverified webhook.
Tip
You do not need to be technical to follow this guide. Every step uses plain buttons in PollsLive - no coding, no app install for your audience.
The events you can subscribe to
You register an endpoint URL and a set of events in Studio → Developers. The ones I care about for a live dashboard:
- `session.started` / `session.ended` - show or tear down the live tile.
- `response.created` - a participant answered a slide in a live session (the high-frequency one).
- `vote.created` - a new vote on an async poll.
- `poll.published` / `poll.closed` - lifecycle, handy for archiving.
Every delivery is a JSON `POST` with the same envelope: an `event` name, a `createdAt` timestamp, and a `data` object.
POST /webhooks/pollslive (from PollsLive → your server)
X-PollsLive-Signature: t=1750320000,v1=4f9a…c2
{
"event": "response.created",
"createdAt": "2026-06-19T16:00:00.000Z",
"data": {
"pollId": "poll_9aZ2kP",
"sessionId": "ses_4dF1",
"slideId": "fix",
"kind": "multiple_choice"
}
}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 → Developers
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
I don't put vote *counts* in the webhook payload on purpose - the event is a nudge that says "something changed", and my worker debounces a burst of `response.created` events (one per answer can be a lot during an all-hands) into a single refresh that calls `GET /polls/{id}/results`. That keeps me well under the 120 requests/min limit even with a busy poll.
const API = process.env.POLLSLIVE_API;
const auth = { Authorization: `Bearer ${process.env.POLLSLIVE_KEY}` };
const pending = new Map(); // pollId → timer
function onEvent(evt) {
if (evt.event === "session.ended") return teardownTile(evt.data.pollId);
const id = evt.data.pollId;
clearTimeout(pending.get(id));
pending.set(id, setTimeout(() => refresh(id), 750)); // debounce bursts
}
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.voteCounts); // websocket → the wall display
}The wall ends up showing the live tile below - fed by webhook nudges, not a polling loop. During the last all-hands it updated within a second of each vote and never tripped the rate limit:
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 runs on a slow safety-net interval (every 30s) while a session is live. Belt and braces: webhooks for instant 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 real-time dashboard that's also 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 include the full result snapshot - store them, do not poll the API on a timer.



Read results in Studio with read and export poll results. See /integrations.
Build on PollsLive - create polls, drive live sessions, pull results, and receive webhooks from your own code.
Read the developer docs