Documentation

Webhooks

Handling webhooks: PHP example

This page walks through a complete webhook handler in plain PHP. It checks that the request comes from your Feed Control account, reads the item, ignores repeats and writes a line to a log file for each new item. Use it as a starting point for your own code. It needs PHP 8.1 or later and works on any web host that runs PHP.

What the handler does

For each request, the script:

  1. accepts only POST requests
  2. checks the secret token in the webhook URL with hash_equals(), which takes the same time however much of the token matches
  3. reads the raw request body and decodes the JSON, rejecting anything that isn't a Feed Control item
  4. skips deliveries it has already handled, using the Idempotency-Key header
  5. appends a line such as New item from Local news: transport: City council approves new cycle lanes to a log file
  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 script knows a request is genuine. See Authenticating requests.

The script

Save this as feedcontrol-webhook.php in a public directory on your server:

<?php

declare(strict_types=1);

// Where to keep the log and the record of deliveries already handled.
// Keep this directory outside your web root so nobody can download it.
const DATA_DIR = __DIR__ . '/../feedcontrol-data';

function respond(int $status, array $body): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($body);
    exit;
}

// 1. Only accept POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    respond(405, ['error' => 'Only POST requests are accepted']);
}

// 2. Check the secret token in the webhook URL (…?token=…)
$expected = (string) getenv('FEEDCONTROL_WEBHOOK_TOKEN');
$provided = $_GET['token'] ?? '';
if ($expected === '' || !is_string($provided) || !hash_equals($expected, $provided)) {
    respond(401, ['error' => 'Invalid token']);
}

// 3. Read the raw body and decode the JSON
$raw = file_get_contents('php://input');
try {
    $item = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    respond(400, ['error' => 'The body is not valid JSON']);
}
if (!is_array($item) || !isset($item['id'], $item['feed']['id'])) {
    respond(400, ['error' => 'The body is not a Feed Control item']);
}

// 4. Skip repeats: every attempt of the same delivery has the same Idempotency-Key
if (!is_dir(DATA_DIR) && !mkdir(DATA_DIR, 0700, true) && !is_dir(DATA_DIR)) {
    respond(500, ['error' => 'Could not create the data directory']);
}
$key = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? 'item-' . $item['id'];
$marker = DATA_DIR . '/seen-' . hash('sha256', $key);
$handle = @fopen($marker, 'x'); // fails if the file already exists
if ($handle === false) {
    respond(200, ['status' => 'duplicate', 'id' => $item['id']]);
}
fclose($handle);

// 5. Do the work. Keep it quick: Feed Control waits at most 30 seconds.
$isTest = ($_SERVER['HTTP_X_FEEDCONTROL_TEST'] ?? '') === 'true';
$feedName = ($item['feed']['name'] ?? '') !== '' ? $item['feed']['name'] : ($item['feed']['title'] ?? '');
$line = sprintf(
    "%s %sNew item from %s: %s (%s)\n",
    date(DATE_ATOM),
    $isTest ? '[test] ' : '',
    $feedName,
    $item['title'] ?? '',
    $item['url'] ?? 'no URL'
);
if (file_put_contents(DATA_DIR . '/webhook_log.txt', $line, FILE_APPEND | LOCK_EX) === false) {
    unlink($marker); // let Feed Control's retry through
    respond(500, ['error' => 'Could not write to the log']);
}

// 6. Tell Feed Control the item arrived
respond(200, ['status' => 'ok', 'id' => $item['id']]);

A few details worth knowing:

  • Raw body. Feed Control sends JSON, not form data, so $_POST is empty. Read the body from php://input.
  • Status codes. A bad token gets 401, which Feed Control shows as Not authorised. A body that isn't JSON gets 400. Both count as failures and are retried, so mistakes show up clearly in the delivery history.
  • Repeats. Opening the marker file with mode x fails if the file already exists, so two copies of the same delivery arriving at once can't both be handled. A repeat still gets 200, so Feed Control stops retrying it. If writing the log fails, the script removes the marker and answers 500, so Feed Control's retry gets another chance.
  • Test sends carry X-FeedControl-Test: true; the script marks them with [test] in the log.
  • Feed name. feed.name is the name you gave the feed in Feed Control. The script falls back to the feed's title if it's empty.

Install it

  1. Generate a token:

    php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
    
  2. Make the token available to PHP as the environment variable FEEDCONTROL_WEBHOOK_TOKEN. How depends on your server, for example SetEnv FEEDCONTROL_WEBHOOK_TOKEN … in an Apache configuration, or env[FEEDCONTROL_WEBHOOK_TOKEN] = … in a PHP-FPM pool. Keep it out of version control.

  3. Upload feedcontrol-webhook.php to your web root. The script creates the feedcontrol-data directory one level above it; make sure PHP can write there, or change DATA_DIR.

  4. In Feed Control, create a webhook integration with the script's URL and your token, for example https://www.example.com/feedcontrol-webhook.php?token=<your token>.

  5. Click Send test webhook on the integration's page. The dialog should say Your endpoint accepted the test webhook and show {"status":"ok", …} as the response body.

  6. Check feedcontrol-data/webhook_log.txt. It should contain a line with the date followed by [test] New item from.

If the test fails, the dialog and the delivery's details tell you why. See Testing and delivery history.

Try it on your computer

Before uploading, you can run the script with PHP's built-in web server and send it a sample payload with curl. Feed Control itself can't reach your computer, so this is only for checking your code.

Put the script in a folder called public, then run this from the folder above it:

FEEDCONTROL_WEBHOOK_TOKEN=dev-token php -S localhost:8000 -t public

In another terminal, save the example payload as payload.json and run:

curl -i -X POST "http://localhost:8000/feedcontrol-webhook.php?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", …}.

Doing slower work

Feed Control waits up to 30 seconds for an answer. If handling an item takes longer (calling other APIs, downloading images, sending emails), store the item first, answer, and do the slow part afterwards: with a job queue, a cron job that processes stored items, or, under PHP-FPM, fastcgi_finish_request():

// … after storing $item and recording the Idempotency-Key …
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['status' => 'ok', 'id' => $item['id']]);

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request(); // sends the response now; the script keeps running
}

// slow work here

Once you've answered 200, Feed Control won't retry the delivery, so make sure anything that happens after the response can be retried on your side.

Using a framework

The same steps work in Laravel, Symfony or any other framework. Read the raw body with the framework's request object (for example $request->getContent()), compare the token with hash_equals(), and exclude the route from CSRF protection, because Feed Control can't send a CSRF token.

Can't find what you need? Contact support.