Webhooks

Receive your sessions' events in your backend: every delivery is HMAC-SHA256 signed, retried with exponential backoff, and carries a unique id so your receiver can be idempotent.

When to use webhooks

To react to what happens in the room without polling: the platform calls you when a session starts or ends, a question closes, or its results are finalized. Delivery is at-least-once — it can repeat, it never fails silently — which is why your receiver must be idempotent.

Event catalogue

Served by the API at /public/v1/limits — this page keeps no copy.

  • presentation.import.completed
  • presentation.import.failed
  • question.activated
  • question.closed
  • question.results.finalized
  • report.ready
  • session.ended
  • session.paused
  • session.resumed
  • session.started

Headers on every delivery

  • X-Votinova-EventThe event type, exactly as it appears in the catalogue.
  • X-Votinova-Delivery-IdUnique per delivery — your deduplication key.
  • X-Votinova-TimestampEpoch seconds; part of what is signed, so a replayed delivery cannot be re-dated.
  • X-Votinova-SignatureHMAC-SHA256 in hex, unprefixed.

The body is compact JSON with the event and every id involved — this is, byte for byte, the signed example delivery:

{"event":"session.ended","session_id":"68b2e3d4c5f607182930a5bc"}

Verifying the signature

Every delivery is signed with HMAC-SHA256 over the timestamp, a dot, and the body, keyed by the endpoint's secret. Compare the result with the X-Votinova-Signature header.

  • Sign the RAW body. Parsing and re-serialising it changes the whitespace and the signature stops matching.
  • Compare in constant time, not with ==.
Verified against a real delivery
const crypto = require("node:crypto");

function verify(headers, rawBody, secret) {
  const timestamp = headers["x-votinova-timestamp"];
  const signature = headers["x-votinova-signature"];
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
secret     whsec_3f8a1c92d4e5b6a7
timestamp  1785321600
body       {"event":"session.ended","session_id":"68b2e3d4c5f607182930a5bc"}

HMAC-SHA256(secret, timestamp + "." + body)
= 10a86171bd92f5fcf8c28b873978e0214302cc2291c54cc016d18347f3ee7c38

Retries

Webhook deliveries: up to 8 attempts, with increasing waits of 20, 41, 80, 185, 333, 669, 1413 seconds. Once exhausted, the delivery is dead-lettered and can be redelivered by hand.

Idempotency

Use X-Votinova-Delivery-Id as your deduplication key: a delivery can arrive more than once and your effect must apply only once.

X-Votinova-Delivery-Id

Best practices

  • Answer 200 as soon as the delivery is safe and process on your own queue — a slow receiver piles up retries.
  • ALWAYS verify the signature before parsing: without it, anyone who knows your URL can invent events.
  • Deduplicate by X-Votinova-Delivery-Id: at-least-once means the same delivery may call twice.
  • After 10 consecutive failures the endpoint pauses itself; re-enable it from the panel or over the API once your receiver is back.