Skip to content

Webhook Signing for Data Sources

If you use a webhook data source (Lumicast receives HTTP POST requests from your system instead of polling), you can require that every incoming request is signed with a shared secret. Unsigned requests are rejected, so nobody who happens to learn the webhook URL can push data into your screens.

Signing is opt-in. A webhook data source without a signing secret keeps accepting unsigned POSTs, exactly like before — nothing changes until you turn this on.

When to turn this on

The webhook URL is a random identifier, not a secret. Anyone who gets hold of it — a shared Slack thread, a log file, a screenshot — can post arbitrary JSON to your data source. For low-stakes dashboards that's usually fine. Turn signing on when:

  • The data source drives something people trust (room bookings, queue numbers, safety notices).
  • The sender is a system you control and can update to sign requests.
  • You want to rotate away from a URL that may have leaked, without changing the URL itself.

Enabling signing

  1. Go to Data Sources and either create a new webhook data source or open an existing one.
  2. Set Update method to Webhook.
  3. Toggle Require signed requests on.
  4. Save.

When you save, Lumicast generates a signing secret and shows it once in a copy-to-clipboard popup. Copy it into your sender (e.g. an environment variable) before you close the popup — the plaintext secret is never shown again.

WARNING

The secret is shown exactly once. If you lose it, you cannot retrieve it — you have to rotate and update your sender with the new one.

Disabling the toggle and saving clears the stored secret; the webhook goes back to accepting unsigned requests.

Rotating the secret

Open the data source and click Rotate signing secret. A new secret is generated and shown once. The previous secret stops working immediately, so update your sender straight away — any request signed with the old secret will be rejected with 401.

How the signature works

Your sender computes an HMAC-SHA256 over a short string that ties the body to a timestamp, and sends the result along with the timestamp itself:

HeaderValue
X-Lumicast-SignatureLowercase hex of HMAC-SHA256(secret, "{timestamp}.{body}")
X-Lumicast-TimestampCurrent time in milliseconds since the Unix epoch
  • {body} is the exact raw request body bytes — the same bytes you send. Don't re-serialise the JSON after signing.
  • {timestamp} is the same value you put in the header, as a string.
  • Requests whose timestamp is more than 5 minutes away from Lumicast's clock are rejected, which caps replay risk and tolerates modest clock skew.

If either header is missing, the timestamp is stale, or the signature doesn't match, Lumicast responds with 401 and drops the request.

Example senders

The same signing guide is available inside Lumicast — open the data source and expand How to sign requests to copy a snippet that already has your webhook URL filled in.

Node.js

js
import crypto from 'crypto';

const url = 'https://…/hooks/<identifier>';
const secret = process.env.LUMICAST_WEBHOOK_SECRET;
const body = JSON.stringify({ hello: 'world' });
const timestamp = Date.now().toString();

const signature = crypto.createHmac('sha256', secret)
  .update(`${timestamp}.${body}`)
  .digest('hex');

await fetch(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Lumicast-Signature': signature,
    'X-Lumicast-Timestamp': timestamp,
  },
  body,
});

Python

python
import hmac, hashlib, json, os, time, requests

url = "https://…/hooks/<identifier>"
secret = os.environ["LUMICAST_WEBHOOK_SECRET"]
body = json.dumps({"hello": "world"}, separators=(",", ":"))
timestamp = str(int(time.time() * 1000))

signature = hmac.new(
    secret.encode(),
    f"{timestamp}.{body}".encode(),
    hashlib.sha256,
).hexdigest()

requests.post(
    url,
    data=body,
    headers={
        "Content-Type": "application/json",
        "X-Lumicast-Signature": signature,
        "X-Lumicast-Timestamp": timestamp,
    },
)

curl

bash
SECRET="${LUMICAST_WEBHOOK_SECRET}"
BODY='{"hello":"world"}'
TS=$(date +%s%3N)   # use gdate on macOS if needed
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -X POST 'https://…/hooks/<identifier>' \
  -H 'Content-Type: application/json' \
  -H "X-Lumicast-Signature: $SIG" \
  -H "X-Lumicast-Timestamp: $TS" \
  --data-raw "$BODY"

Common mistakes

  • Re-serialising the body after signing. Sign the exact bytes you send. Pretty-printing or re-stringifying a JSON object changes the bytes and invalidates the signature.
  • Timestamp in seconds instead of milliseconds. Date.now() in JS and time.time() * 1000 in Python both give milliseconds — any second-based timestamp will look 5 minutes stale right away.
  • Clock drift on the sender. If your sender's clock is off by more than 5 minutes, every request fails with 401. Fix NTP before debugging the signing code.
  • Storing the secret in source control. Treat it like any other credential — environment variable or secrets manager only.
  • Data Sources — overview of webhook and polling data sources.
  • Webhooksoutbound webhooks (Lumicast → your server). Signing works the same way there, but the secret is on the webhook, not the data source.