Skip to content
digitalsign.co

Webhooks

DigitalSign sends a signed HTTPS POST to your server when screens go offline, schedules publish, start or end, content plays, or automations fire. Available on the Pro plan and up.

Add an endpoint

  1. As an owner or admin, open Settings → Alerts & API and go to the Webhooks section.
  2. Enter your endpoint URL and tick the events you want. screen.offline and screen.online are ticked to start with.
  3. Click Add endpoint. The signing secret (it starts with whsec_) is shown once. Copy it into your server's configuration; it is stored encrypted and can't be shown again.
  4. Click Send test to queue a test delivery. It goes out within about a minute.

Each endpoint shows its last response status, a Failing badge while deliveries fail, and its most recent deliveries with any error. Use the On switch to pause or resume it.

Endpoint URL requirements, checked when you add the endpoint and again on every delivery:

  • Must use https:// on the standard port (443).
  • The host name must resolve, and only to public IP addresses. Private, loopback and link-local addresses are refused.
  • Must answer directly. Redirects are not followed: a 3xx response counts as a failed attempt, so use the final URL.

Events

EventSent when
screen.offlineA paired screen has not checked in for your offline alert threshold.
screen.onlineA screen that was reported offline checks in again.
content.publishedA schedule is created or updated.
playback.completedA screen reports the content it played.
schedule.startedA schedule with an exact start time reaches it.
schedule.endedA schedule with an exact end time reaches it.
automation.firedAn automation publishes its content.

Events are only sent while your organization is on the Pro plan or higher.

Payload

Every request body is a JSON object with the same envelope:

{
  "id": "whd_3k5m7q9w1e2r4t6y8u0i",
  "event": "screen.offline",
  "createdAt": "2026-10-02T14:12:00.418Z",
  "data": { … }
}
  • id: the delivery id. It stays the same across retries of this delivery, so use it to ignore duplicates. If two endpoints subscribe to the same event, each gets its own id.
  • createdAt: when the event happened (was queued), not when this attempt was sent.
  • data: depends on the event, as shown below.

screen.offline

Sent once per outage when a paired screen has not checked in for the offline alert threshold you choose under Settings → Alerts & API → Screen alerts (5, 10, 30 minutes, 1 hour or 4 hours; 10 minutes by default). Screens are checked once a minute. If the threshold is set to never (off), neither screen.offline nor screen.online is sent.

{
  "id": "whd_3k5m7q9w1e2r4t6y8u0i",
  "event": "screen.offline",
  "createdAt": "2026-10-02T14:12:00.418Z",
  "data": {
    "screenId": "scr_4k8m2q7v9x1c3b5n6p0w",
    "name": "Lobby",
    "lastSeenAt": "2026-10-02T14:01:47.902Z"
  }
}

screen.online

Sent when a screen that triggered screen.offline checks in again. offlineSince is when it was reported offline.

{
  "id": "whd_8p0o2i4u6y1t3r5e7w9q",
  "event": "screen.online",
  "createdAt": "2026-10-02T14:31:00.207Z",
  "data": {
    "screenId": "scr_4k8m2q7v9x1c3b5n6p0w",
    "name": "Lobby",
    "offlineSince": "2026-10-02T14:12:00.418Z"
  }
}

content.published

Sent when any schedule is created or updated: in the dashboard, through the API (including overrides), on final approval, or by an automation. Turning a schedule on or off, ending it early or deleting it does not send this event.

{
  "id": "whd_1a3s5d7f9g2h4j6k8l0z",
  "event": "content.published",
  "createdAt": "2026-10-02T15:00:12.661Z",
  "data": {
    "scheduleId": "sch_4r6t8y0u2i1o3p5a7s9d",
    "name": "Override (1 h)",
    "kind": "override",
    "priority": 70,
    "playlistId": "pls_6h8j0k2l4z1x3c5v7b9n",
    "targets": [{ "kind": "all" }],
    "startsAt": "2026-10-02T15:00:12.598Z",
    "endsAt": "2026-10-02T16:00:12.598Z"
  }
}

When an existing schedule is updated, data has "updated": true and no startsAt or endsAt. targets uses the same format as the API: kind is all, location, group, screen or tag, with refId or tag where needed.

playback.completed

A roll-up of what a screen played: at most one event per screen every 10 minutes, covering the plays reported since the last one. count is the number of plays in the window; plays lists up to 100 of them (use the API's GET /playback for everything). completed is whether the item played to the end.

{
  "id": "whd_5g7h9j1k3l2z4x6c8v0b",
  "event": "playback.completed",
  "createdAt": "2026-10-02T15:04:31.020Z",
  "data": {
    "screenId": "scr_4k8m2q7v9x1c3b5n6p0w",
    "count": 2,
    "plays": [
      {
        "playlistId": "pls_6h8j0k2l4z1x3c5v7b9n",
        "sceneId": "scn_8u0i2o4p6a1s3d5f7g9h",
        "assetId": null,
        "startedAt": "2026-10-02T15:02:10.004Z",
        "endedAt": "2026-10-02T15:02:20.010Z",
        "completed": true
      },
      {
        "playlistId": "pls_6h8j0k2l4z1x3c5v7b9n",
        "sceneId": null,
        "assetId": "ast_3s5d7f9g1h2j4k6l8z0x",
        "startedAt": "2026-10-02T15:02:20.031Z",
        "endedAt": "2026-10-02T15:02:50.040Z",
        "completed": true
      }
    ]
  }
}

schedule.started

Sent when an enabled schedule with an exact start time (startsAt) reaches it: overrides, takeovers, emergency messages, automation content, and any schedule created with exact times. Schedules that only use recurring time windows do not send it. Start times are checked once a minute, so the event can arrive up to about a minute after the start. Start times are reported only as they pass, so a schedule saved with a start time more than about a minute in the past does not send it.

{
  "id": "whd_9q7w5e3r1t2y4u6i8o0p",
  "event": "schedule.started",
  "createdAt": "2026-10-02T15:01:00.114Z",
  "data": {
    "scheduleId": "sch_4r6t8y0u2i1o3p5a7s9d",
    "name": "Override (1 h)",
    "kind": "override",
    "playlistId": "pls_6h8j0k2l4z1x3c5v7b9n",
    "startsAt": "2026-10-02T15:00:12.598Z",
    "endsAt": "2026-10-02T16:00:12.598Z"
  }
}

schedule.ended

Sent when a schedule with an exact end time (endsAt) reaches it, including when someone ends an override or emergency early. Checked once a minute. Deleting a schedule does not send it.

{
  "id": "whd_2z4x6c8v0b1n3m5q7w9e",
  "event": "schedule.ended",
  "createdAt": "2026-10-02T16:01:00.093Z",
  "data": {
    "scheduleId": "sch_4r6t8y0u2i1o3p5a7s9d",
    "name": "Override (1 h)",
    "kind": "override",
    "playlistId": "pls_6h8j0k2l4z1x3c5v7b9n",
    "endedAt": "2026-10-02T16:00:12.598Z"
  }
}

automation.fired

Sent when an automation publishes its content. The automation's content goes out as a schedule, so a content.published event for scheduleId is sent too.

{
  "id": "whd_6v8b0n2m4q1w3e5r7t9y",
  "event": "automation.fired",
  "createdAt": "2026-10-02T17:20:03.775Z",
  "data": {
    "automationId": "aut_0i2o4p6a8s1d3f5g7h9j",
    "name": "Heat wave drinks",
    "runId": "aur_7k9l1z3x5c2v4b6n8m0q",
    "scheduleId": "sch_3e5r7t9y1u2i4o6p8a0s",
    "playlistId": "pls_1a3s5d7f9g2h4j6k8l0z"
  }
}

Test deliveries

Send test uses the first event the endpoint subscribes to as its event name, adds "test": true, and carries a placeholder data. Check for test before acting on an event.

{
  "id": "whd_4f6g8h0j2k1l3z5x7c9v",
  "event": "screen.offline",
  "test": true,
  "createdAt": "2026-10-02T13:30:44.120Z",
  "data": { "message": "Test delivery from digitalsign.co" }
}

Request format

Each delivery is a POST with these headers:

HeaderValue
Content-Typeapplication/json
User-Agentdigitalsign.co-webhooks/1
DigitalSign-EventThe event name, same as event in the body.
DigitalSign-DeliveryThe delivery id, same as id in the body.
DigitalSign-Signaturet=<unix seconds>,v1=<hex signature>

Verify the signature

Verify every request before trusting it:

  1. Read the raw request body as bytes. Do not parse and re-serialize the JSON first; any change to the bytes breaks the signature.
  2. Split DigitalSign-Signature on commas into t and v1.
  3. Compute HMAC-SHA256 with your signing secret as the key (the whole string, including whsec_) over <t>.<raw body>: the t value, a period, then the body. Hex-encode it.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the request if t is more than 5 minutes from your server's clock. Each attempt, including retries, is signed with a fresh timestamp, so a legitimate delivery is never older than that.

Node.js (Express)

import crypto from 'node:crypto'
import express from 'express'

const SECRET = process.env.DIGITALSIGN_WEBHOOK_SECRET // the full whsec_… value
const TOLERANCE_SECONDS = 5 * 60

function verifySignature(rawBody, header) {
  if (!header) return false
  const parts = {}
  for (const piece of header.split(',')) {
    const i = piece.indexOf('=')
    if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1)
  }
  if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) return false

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(parts.t + '.')
    .update(rawBody)
    .digest()
  const received = Buffer.from(parts.v1, 'hex')
  return received.length === expected.length && crypto.timingSafeEqual(received, expected)
}

const app = express()

// express.raw keeps the body as a Buffer, exactly as sent.
app.post('/hooks/digitalsign', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifySignature(req.body, req.get('DigitalSign-Signature'))) {
    return res.status(400).send('invalid signature')
  }
  const event = JSON.parse(req.body.toString('utf8'))
  res.sendStatus(204) // acknowledge quickly
  queueForProcessing(event) // your code: dedupe on event.id, then handle
})

app.listen(3000)

Python (Flask)

import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["DIGITALSIGN_WEBHOOK_SECRET"].encode()  # the full whsec_… value
TOLERANCE_SECONDS = 5 * 60


def verify_signature(raw_body: bytes, header: str | None) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1:
        return False
    if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


app = Flask(__name__)


@app.post("/hooks/digitalsign")
def digitalsign_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_signature(raw, request.headers.get("DigitalSign-Signature")):
        abort(400)
    event = json.loads(raw)
    queue_for_processing(event)  # your code: dedupe on event["id"], then handle
    return "", 204

Delivery and retries

  • Events are queued as they happen and sent by a background job that runs once a minute, so expect deliveries within about a minute.
  • A delivery succeeds when your endpoint returns any 2xx status within 5 seconds. The response body is ignored.
  • Anything else is a failure: a 3xx redirect (not followed), any other non-2xx status, a timeout, a connection or TLS error, or a refused address. The status is recorded and shown with the delivery.
  • Failed deliveries are retried with exponential backoff, up to 12 attempts in total. After that the delivery is marked failed and is not retried.
  • Deliveries are not guaranteed to arrive in order; a retry can arrive after a newer event. Use createdAt if order matters.
AttemptWait after the previous attemptTime since the first attempt
10
21 minute1 minute
32 minutes3 minutes
44 minutes7 minutes
58 minutes15 minutes
616 minutes31 minutes
732 minutesabout 1 hour
864 minutesabout 2 hours
9128 minutesabout 4 hours
10256 minutesabout 8.5 hours
116 hoursabout 14.5 hours
126 hoursabout 20.5 hours

Times are minimums; each attempt goes out on the first run of the background job after it is due.

Failing and disabled endpoints

  • An endpoint is marked Failing from its first failed delivery until a delivery to it succeeds.
  • If an endpoint is still failing after 3 days, its next failed attempt switches it off.
  • While an endpoint is off, new events are not queued for it. Deliveries that were already waiting are held, and are sent when you turn the endpoint back on with the On switch, which also clears the failing state.

Best practices

  • Respond fast. Verify the signature, store or queue the event, and return 2xx right away. Do slow work afterwards; requests time out after 5 seconds.
  • Handle duplicates. If your server processes an event but the response is lost or late, the same delivery is sent again. Record each id you have handled and skip repeats.
  • Ignore test deliveries by checking for "test": true.
  • Check the event name in event (or DigitalSign-Event) and ignore ones you don't handle.
  • Keep the secret secret. If it leaks, delete the endpoint and add it again to get a new one.
  • Fetch current state when you need it. Use the REST API, for example GET /screens/{id} after screen.online, rather than relying only on event order.