# 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 `POST`s, 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:

| Header | Value |
|---|---|
| `X-Lumicast-Signature` | Lowercase hex of `HMAC-SHA256(secret, "{timestamp}.{body}")` |
| `X-Lumicast-Timestamp` | Current 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.

## Related

- [Data Sources](/features/datasources) — overview of webhook and polling data sources.
- [Webhooks](/features/webhooks) — *outbound* webhooks (Lumicast → your server). Signing works the same way there, but the secret is on the webhook, not the data source.
