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:
- accepts only
POSTrequests - checks the secret token in the webhook URL with
hash_equals(), which takes the same time however much of the token matches - reads the raw request body and decodes the JSON, rejecting anything that isn't a Feed Control item
- skips deliveries it has already handled, using the
Idempotency-Keyheader - appends a line such as
New item from Local news: transport: City council approves new cycle lanesto a log file - 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 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
$_POSTis empty. Read the body fromphp://input. - Status codes. A bad token gets
401, which Feed Control shows as Not authorised. A body that isn't JSON gets400. Both count as failures and are retried, so mistakes show up clearly in the delivery history. - Repeats. Opening the marker file with mode
xfails if the file already exists, so two copies of the same delivery arriving at once can't both be handled. A repeat still gets200, so Feed Control stops retrying it. If writing the log fails, the script removes the marker and answers500, 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.nameis the name you gave the feed in Feed Control. The script falls back to the feed's title if it's empty.
Install it
-
Generate a token:
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' -
Make the token available to PHP as the environment variable
FEEDCONTROL_WEBHOOK_TOKEN. How depends on your server, for exampleSetEnv FEEDCONTROL_WEBHOOK_TOKEN …in an Apache configuration, orenv[FEEDCONTROL_WEBHOOK_TOKEN] = …in a PHP-FPM pool. Keep it out of version control. -
Upload
feedcontrol-webhook.phpto your web root. The script creates thefeedcontrol-datadirectory one level above it; make sure PHP can write there, or changeDATA_DIR. -
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>. -
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. -
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.