Documentation

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:

  1. accepts only POST requests
  2. checks the secret token in the webhook URL, using a constant-time comparison from the Web Crypto API
  3. reads the JSON body and rejects anything that isn't a Feed Control item
  4. skips deliveries it has already handled, using the Idempotency-Key header
  5. stores the item's title under the key last_title and the whole item under the key data in KV
  6. answers 200 with 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_TOKEN is a Worker secret and env.FEEDCONTROL is 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 gets 400. Both count as failures and are retried, so mistakes show up clearly in the delivery history. A repeat gets 200, 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

  1. 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
    
  2. Save the code above as src/index.js.

  3. Create the KV namespace:

    npx wrangler kv namespace create FEEDCONTROL
    

    Wrangler prints the new namespace's ID. Copy it.

  4. Create wrangler.toml in 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 binding name is what makes the namespace available to the code as env.FEEDCONTROL.

Deploy and add the token

  1. Deploy the Worker:

    npx wrangler deploy
    

    Wrangler prints the Worker's URL, such as https://feedcontrol-webhook.<your-subdomain>.workers.dev.

  2. Generate a token, for example with openssl rand -hex 32, and store it as a Worker secret:

    npx wrangler secret put WEBHOOK_TOKEN
    

    Paste 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

  1. 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>.

  2. 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 …"}.

  3. Check that the item was stored:

    npx wrangler kv key get --binding=FEEDCONTROL "last_title" --text --remote
    

    You 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, data and one seen:… key per delivery.

  4. 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.

  1. Go to Workers & Pages, select Create application, start from the Hello World template, name the Worker and deploy it.
  2. Open the Worker, select Edit code, replace the code with the code above and deploy again.
  3. Go to Workers KV and create a namespace.
  4. In the Worker's Settings, under Bindings, add a KV namespace binding with the variable name FEEDCONTROL and choose the namespace you created.
  5. In the Worker's Settings, under Variables and Secrets, add a variable of type Secret named WEBHOOK_TOKEN with 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.