← Back to home

Webhook Documentation

Learn how to receive real-time updates from TallyDeck widgets and securely authenticate them on your server.

Authentication & Security

When an event triggers in a TallyDeck widget, an HTTP POST request is dispatched to your configured endpoint. We sign all outgoing webhook payloads so you can verify they genuinely originated from our servers.

The Signature Header

Each request includes a Tallydeck-Signature HTTP header containing a Unix timestamp and an HMAC SHA-256 signature.

Tallydeck-Signature: t=1734567890,v1=a1b2c3d4e5f6...

â„šī¸ How Signatures Work

The signature (v1) changes on every single request because it hashes both the unique timestamp and the JSON body together. However, your Webhook Secret (the whsec_... string found in your dashboard) is your static environment variable. You never change it. You use your static secret key to perform the HMAC hash locally on your server and see if it mathematically matches our dynamic v1 signature.

Verification Process

To verify the signature in your application backend:

  1. Extract values: Split the header using the comma to obtain the timestamp (t) and the signature (v1).
  2. Prevent Replay Attacks: Compare the timestamp t to your server's current time. Reject the webhook if the timestamp is unreasonably old (e.g., > 5 minutes).
  3. Compute the expected signature:
    • Create the signature payload string by concatenating the timestamp, a period (.), and the exact raw string JSON of the request body: ${t}.${raw_body}.
    • Hash the resulting string using HMAC SHA-256 and your static Webhook Endpoint Secret (whsec_...).
    • Compare your computed hexadecimal hash to the provided v1 signature. If they match, the request securely originated from us.

Verification Example (Node.js)

Here is a complete, zero-dependency Node.js example demonstrating how to parse the header, prevent replay attacks, and securely verify the HMAC SHA-256 signature using the built-in crypto module.

const crypto = require('crypto');

// 1. Extract and Parse the Header
// e.g., "t=1734567890,v1=abcdef..."
const signatureHeader = req.headers['tallydeck-signature']; 
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
const receivedSignature = parts.find(p => p.startsWith('v1=')).split('=')[1];

// 2. Prevent Replay Attacks (Check if older than 5 minutes)
const currentTimestamp = Math.floor(Date.now() / 1000);
if (currentTimestamp - parseInt(timestamp, 10) > 300) {
  throw new Error("Webhook timestamp is too old (replay attack risk)");
}

// 3. Compute the expected signature
// WARNING: Use the exact raw string JSON of the body, NOT the parsed object!
const signedPayload = `${timestamp}.${rawBodyString}`;
const expectedSignature = crypto
  .createHmac('sha256', process.env.TALLYDECK_WEBHOOK_SECRET)
  .update(signedPayload)
  .digest('hex');

// 4. Compare signatures safely to prevent timing attacks
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
const receivedBuffer = Buffer.from(receivedSignature, 'hex');

if (
  expectedBuffer.length !== receivedBuffer.length || 
  !crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
) {
  throw new Error("Signature Verification Failed!");
}

console.log("✅ Webhook securely authenticated!");

Request Body Envelope

Every webhook utilizes a standardized JSON envelope containing the event metadata, the contextual widget state, and an inner data object for event-specific payload parameters:

{
  "id": "evt_abc123def4567890...",
  "type": "widget.started",
  "created": 1734567890,
  "widget": {
    "id": "e98e4f5a-c454-4632...",
    "name": "Production Timer",
    "type": "countdown",
    "duration": 3600,
    "remaining_time": 3598,
    "start_time": "2026-03-28T19:00:00.000Z",
    "is_running": true,
    "config": {
       // The full configuration object of the widget
       // at the exact time of the event.
    }
  },
  "data": {
    // Event-specific payload fields (see tables below)
    "action": "start" 
  }
}

Payload Structures by Widget & Event

The internal fields of the data property vary depending on the widget type and the specific action that occurred.

🌍 Global Events (All Widgets)

Event TypeDescriptionPayload data
widget.updatedSent when core configuration definitions are modified.{ "configKeys": ["duration"] }

âąī¸ Timers (Countdown, Pomodoro, Stopwatch, Agenda)

Event TypeDescriptionPayload data
widget.startedSent when the timer natively starts or resumes.{ "action": "start" }
widget.pausedSent when the timer is paused.{ "action": "pause" }
widget.resetSent when the timer resets its duration/position.{ "action": "reset" }
timer.finishedSent when a countdown duration naturally reaches 0.{}
pomodoro.phase_changedPomodoroSent when shifting between focus or breaks.{}
agenda.item_changedAgendaSent specifically when the active index skips/jumps.{ "index": 2 }

📊 Scoreboard

Event TypeDescriptionPayload data
scoreboard.updatedSent when points are modified for a specific team.{ "teamId": "uuid-str", "action": "increment", "delta": 1 }

đŸ”ĸ Counter

Event TypeDescriptionPayload data
counter.incrementedSent when the counter increases.{ "action": "increment" }
counter.decrementedSent when the counter decreases.{ "action": "decrement" }
counter.resetSent when the counter is reset.{ "action": "reset" }

📝 Notes

Event TypeDescriptionPayload data
note.sentSent when a message is dispatched to attached screens.{ "message": "The content" }
note.clearedSent when the current display message is wiped.{}

â˜‘ī¸ Todo List

Event TypeDescriptionPayload data
todo.updatedDispatched as a holistic update event for list mutations.{ "action": "add" }

đŸ“ē Prompter

Event TypeDescriptionPayload data
prompter.script_selectedSent when a script becomes active.{ "script_id": "uuid-str" }
prompter.scroll_startedSent when scrolling activates.{}
prompter.scroll_pausedSent when scrolling halts.{}
prompter.scroll_resetSent when the position is reset to the top.{}