webhooks
use webhooks to kick off other automation for stuff that you manage. you can centralize your watch history, add films to lists on your local plex or jellyfin server, etc. we aren’t responsible for what you do with your spooky.mov settings and information, and we can’t help you troubleshoot your own webhook infrastructure.
set one up
- sign in at spooky.mov and click account.
- open developer, then under automation click create webhook.
- name it, give it your destination (an
httpsaddress) and pick when it fires. - send test posts a sample event to the destination before you save.
- save. your webhook secret is shown once: keep it to check the signature.
events
| event | fires when |
|---|---|
watchlist.added | a film is added to your watchlist. |
list.added | a film is added to the list you picked. |
film.watched | you marked a film as watched from spooky.mov. |
request
spooky.mov sends a POST to your destination.
headers
| header | value |
|---|---|
Content-Type | application/json |
Spooky-Event | the event, e.g. film.watched. |
Spooky-Delivery | the event’s id, e.g. evt_…. |
Spooky-Signature | t=<unix seconds>,v1=<hex>: see verifying. |
User-Agent | spooky.mov webhooks (+https://dev.spooky.mov/webhooks) |
body
{
"id": "evt_6f1c2b9e-3a4d-4c8b-9f0e-1d2c3b4a5f60",
"event": "film.watched",
"created_at": "2026-10-18T21:30:00.000Z",
"test": false,
"webhook": {"id": 3, "name": "my plex"},
"films": [
{"tmdb_id": 694, "imdb_id": "tt0081505", "title": "The Shining", "year": 1980,
"url": "https://spooky.mov/film/694", "watched_date": null}
]
}
| field | type | description |
|---|---|---|
id | string | the event’s id (the Spooky-Delivery header). |
event | string | watchlist.added, list.added or film.watched. |
created_at | string | when it was sent. |
test | boolean | true for send test. |
webhook | object | the webhook’s id and name. |
list | object | watchlist.added and list.added only: the list’s id and name. |
films | array | the films: tmdb_id, imdb_id (when known), title, year, url, and for film.watched the watched_date (a day, or null). |
verifying
v1 is an HMAC-SHA256 of <t>.<the raw body> with your webhook secret, in hex. compute it, compare it in constant time, and refuse a t more than five minutes old.
testing
send test posts a sample film.watched (or the event you picked) for The Shining with "test": true, and shows the status and how long your destination took. before you save there’s no secret yet, so that test is unsigned; once saved, open edit and its test is signed with the webhook’s secret.
delivery
- answer with a
2xxwithin 10 seconds. - no retries or redirects.
example
a cloudflare worker that checks the signature and logs each film. set the secret with wrangler secret put SPOOKY_WEBHOOK_SECRET.
export default {
async fetch(request, env) {
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
const body = await request.text();
if (!(await verify(env.SPOOKY_WEBHOOK_SECRET, request.headers.get('Spooky-Signature'), body))) {
return new Response('bad signature', { status: 401 });
}
const event = JSON.parse(body);
for (const film of event.films) {
// do something with it: add film.tmdb_id to a list on your own server, keep a log, …
console.log(`${event.event}: ${film.title} (${film.year})`);
}
return new Response('ok');
},
};
async function verify(secret, header, body) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
const signature = new Uint8Array(m[2].match(/../g).map((h) => parseInt(h, 16)));
return crypto.subtle.verify('HMAC', key, signature, new TextEncoder().encode(`${m[1]}.${body}`));
}