Webhooks
Add an endpoint
- As an owner or admin, open Settings → Alerts & API and go to the Webhooks section.
- Enter your endpoint URL and tick the events you want.
screen.offlineandscreen.onlineare ticked to start with. - 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. - 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
3xxresponse counts as a failed attempt, so use the final URL.
Events
| Event | Sent when |
|---|---|
screen.offline | A paired screen has not checked in for your offline alert threshold. |
screen.online | A screen that was reported offline checks in again. |
content.published | A schedule is created or updated. |
playback.completed | A screen reports the content it played. |
schedule.started | A schedule with an exact start time reaches it. |
schedule.ended | A schedule with an exact end time reaches it. |
automation.fired | An 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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | digitalsign.co-webhooks/1 |
DigitalSign-Event | The event name, same as event in the body. |
DigitalSign-Delivery | The delivery id, same as id in the body. |
DigitalSign-Signature | t=<unix seconds>,v1=<hex signature> |
Verify the signature
Verify every request before trusting it:
- Read the raw request body as bytes. Do not parse and re-serialize the JSON first; any change to the bytes breaks the signature.
- Split
DigitalSign-Signatureon commas intotandv1. - Compute HMAC-SHA256 with your signing secret as the key (the whole string, including
whsec_) over<t>.<raw body>: thetvalue, a period, then the body. Hex-encode it. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis 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 "", 204Delivery 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
2xxstatus within 5 seconds. The response body is ignored. - Anything else is a failure: a
3xxredirect (not followed), any other non-2xxstatus, 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
createdAtif order matters.
| Attempt | Wait after the previous attempt | Time since the first attempt |
|---|---|---|
| 1 | 0 | |
| 2 | 1 minute | 1 minute |
| 3 | 2 minutes | 3 minutes |
| 4 | 4 minutes | 7 minutes |
| 5 | 8 minutes | 15 minutes |
| 6 | 16 minutes | 31 minutes |
| 7 | 32 minutes | about 1 hour |
| 8 | 64 minutes | about 2 hours |
| 9 | 128 minutes | about 4 hours |
| 10 | 256 minutes | about 8.5 hours |
| 11 | 6 hours | about 14.5 hours |
| 12 | 6 hours | about 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
2xxright 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
idyou have handled and skip repeats. - Ignore test deliveries by checking for
"test": true. - Check the event name in
event(orDigitalSign-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}afterscreen.online, rather than relying only on event order.