Webhooks
Handling webhooks: Cloudflare Workers example
This page shows how to receive Feed Control webhooks with a Cloudflare Worker, a serverless function that runs only when a request arrives. The Worker checks that the request comes from your Feed Control account, ignores repeats, and stores the latest item in Workers KV, Cloudflare's key-value store, so you can see that it works. It's a starting point for your own code.
You need a Cloudflare account and Node.js on your computer. The steps use Wrangler, Cloudflare's command-line tool.
What the Worker does
For each request, the Worker:
- accepts only
POSTrequests - checks the secret token in the webhook URL, using a constant-time comparison from the Web Crypto API
- reads the JSON body and rejects anything that isn't a Feed Control item
- skips deliveries it has already handled, using the
Idempotency-Keyheader - stores the item's title under the key
last_titleand the whole item under the keydatain KV - answers
200with a short JSON message, which you'll see in Feed Control's delivery history
Feed Control doesn't sign requests, so the token in the URL is how the Worker knows a request is genuine. See Authenticating requests.
The code
Save this as src/index.js:
const encoder = new TextEncoder();
// Compare two strings in constant time. Hashing both first means the
// comparison doesn't reveal the length of the expected token either.
async function safeEqual(a, b) {
const [hashA, hashB] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(a)),
crypto.subtle.digest("SHA-256", encoder.encode(b)),
]);
return crypto.subtle.timingSafeEqual(hashA, hashB);
}
// Repeats are recognised for 15 days: Feed Control keeps deliveries,
// and so can retry them, for 14
const SEEN_TTL_SECONDS = 15 * 24 * 60 * 60;
export default {
async fetch(request, env, ctx) {
// 1. Only accept POST requests
if (request.method !== "POST") {
return new Response("Only POST requests are accepted.", {
status: 405,
headers: { Allow: "POST" },
});
}
// 2. Check the secret token in the webhook URL (…?token=…)
const token = new URL(request.url).searchParams.get("token") ?? "";
if (!env.WEBHOOK_TOKEN || !(await safeEqual(token, env.WEBHOOK_TOKEN))) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
// 3. Read and check the JSON body
let item;
try {
item = await request.json();
} catch {
return Response.json({ error: "The body is not valid JSON" }, { status: 400 });
}
if (typeof item?.id !== "number" || typeof item?.feed?.id !== "number") {
return Response.json({ error: "The body is not a Feed Control item" }, { status: 400 });
}
// 4. Skip repeats: every attempt of the same delivery has the same Idempotency-Key
const key = request.headers.get("Idempotency-Key") ?? `item-${item.id}`;
if ((await env.FEEDCONTROL.get(`seen:${key}`)) !== null) {
return Response.json({ status: "duplicate", id: item.id });
}
// 5. Store the item (quick, so do it before answering)
const feedName = item.feed.name || item.feed.title;
const isTest = request.headers.get("X-FeedControl-Test") === "true";
await Promise.all([
env.FEEDCONTROL.put("last_title", item.title),
env.FEEDCONTROL.put("data", JSON.stringify(item)),
env.FEEDCONTROL.put(`seen:${key}`, new Date().toISOString(), {
expirationTtl: SEEN_TTL_SECONDS,
}),
]);
// 6. Tell Feed Control the item arrived
return Response.json({
status: "ok",
id: item.id,
message: `${isTest ? "[test] " : ""}New item from ${feedName}: ${item.title}`,
});
},
};
A few details worth knowing:
- Secrets and bindings come from
env.env.WEBHOOK_TOKENis a Worker secret andenv.FEEDCONTROLis the KV namespace binding, both set up below. Nothing secret is in the code. - Status codes. A bad token gets
401, which Feed Control shows as Not authorised. A body that isn't a Feed Control item gets400. Both count as failures and are retried, so mistakes show up clearly in the delivery history. A repeat gets200, so Feed Control stops retrying it. - Test sends carry
X-FeedControl-Test: true; the Worker adds[test]to its message. - KV is eventually consistent. A key written in one Cloudflare location can take a minute or so to be visible everywhere. That's fine for Feed Control's retries, which come at least an hour apart, but two copies of a delivery arriving within seconds could both get through. If that matters, record keys in a store with strong consistency, such as a database with a unique constraint.
Set up the project
-
Create a folder for the Worker, install Wrangler and log in to Cloudflare:
mkdir feedcontrol-webhook cd feedcontrol-webhook npm init -y npm install --save-dev wrangler npx wrangler login -
Save the code above as
src/index.js. -
Create the KV namespace:
npx wrangler kv namespace create FEEDCONTROLWrangler prints the new namespace's ID. Copy it.
-
Create
wrangler.tomlin the project folder, with the namespace ID you copied and today's date as the compatibility date:name = "feedcontrol-webhook" main = "src/index.js" compatibility_date = "2026-10-23" [[kv_namespaces]] binding = "FEEDCONTROL" id = "<your KV namespace ID>"The
bindingname is what makes the namespace available to the code asenv.FEEDCONTROL.
Deploy and add the token
-
Deploy the Worker:
npx wrangler deployWrangler prints the Worker's URL, such as
https://feedcontrol-webhook.<your-subdomain>.workers.dev. -
Generate a token, for example with
openssl rand -hex 32, and store it as a Worker secret:npx wrangler secret put WEBHOOK_TOKENPaste the token when prompted. Secrets are encrypted, hidden in the dashboard, and never stored in your project files. Setting a secret deploys the Worker again with it.
Connect Feed Control
-
In Feed Control, create a webhook integration with the Worker's URL and your token, for example
https://feedcontrol-webhook.<your-subdomain>.workers.dev/?token=<your token>. -
Click Send test webhook on the integration's page. The dialog should say Your endpoint accepted the test webhook, with a response body like
{"status":"ok","id":…,"message":"[test] New item from …"}. -
Check that the item was stored:
npx wrangler kv key get --binding=FEEDCONTROL "last_title" --text --remoteYou can also see the stored keys in the Cloudflare dashboard: go to Workers KV, open the namespace you created, and look at its key-value pairs. You'll find
last_title,dataand oneseen:…key per delivery. -
Connect the feeds or bundles you want to receive. See Setting up a webhook.
To watch requests as they arrive, run npx wrangler tail in the project folder. If a delivery fails, Feed Control's delivery history shows what the Worker answered. See Testing and delivery history.
Develop locally
npx wrangler dev runs the Worker on your computer at http://localhost:8787, with a local copy of KV. For the token, create a file called .dev.vars in the project folder (and keep it out of version control):
WEBHOOK_TOKEN="dev-token"
Feed Control can't reach your computer, so send a sample delivery yourself. Save the example payload as payload.json and run:
curl -i -X POST "http://localhost:8787/?token=dev-token" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: feedcontrol-event-1" \
--data @payload.json
The first request answers {"status":"ok", …}. Send it again and it answers {"status":"duplicate", …}.
Using the dashboard instead of Wrangler
You can set up the same Worker in the Cloudflare dashboard. The labels below were current when this page was written and may change.
- Go to Workers & Pages, select Create application, start from the Hello World template, name the Worker and deploy it.
- Open the Worker, select Edit code, replace the code with the code above and deploy again.
- Go to Workers KV and create a namespace.
- In the Worker's Settings, under Bindings, add a KV namespace binding with the variable name
FEEDCONTROLand choose the namespace you created. - In the Worker's Settings, under Variables and Secrets, add a variable of type Secret named
WEBHOOK_TOKENwith your token as the value, then deploy.
Then connect Feed Control as above.
Doing slower work
Feed Control waits up to 30 seconds for an answer. For work that can happen after you've answered, such as calling another API, pass it to ctx.waitUntil(). The Worker answers straight away and keeps running until the work is done:
// … after storing the item and recording the Idempotency-Key …
ctx.waitUntil(
fetch("https://api.example.com/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: item.title, url: item.url }),
}),
);
return Response.json({ status: "ok", id: item.id });
Once you've answered 200, Feed Control won't retry the delivery, so work started with waitUntil() has to handle its own failures. For longer or more important jobs, put the item on a queue, such as Cloudflare Queues, and process it from there.
Can't find what you need? Contact support.