Example file
webhook-receiver.php
PHP: receives Subcontext webhooks, checks the signature and skips repeats.
<?php
// Receives Subcontext webhooks (the "Webhook endpoint" connection), in PHP 8.1
// or later with no packages.
//
// Each delivery is a POST with a JSON body and these headers:
// X-Subcontext-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
// Idempotency-Key: <the same value on every retry of this delivery>
//
// Try it with PHP's built-in web server:
// SUBCONTEXT_SIGNING_SECRET=THE_SIGNING_SECRET php -S localhost:4100 webhook-receiver.php
// Subcontext only sends to https addresses on the public internet, so in real
// use serve it from your usual HTTPS web server.
declare(strict_types=1);
// The Signing secret you entered on the Webhook endpoint connection.
$secret = getenv('SUBCONTEXT_SIGNING_SECRET') ?: '';
const TOLERANCE_SECONDS = 300;
// Idempotency-Keys already handled. Use your database in real code.
$seenFile = sys_get_temp_dir() . '/subcontext-webhooks-seen.txt';
// true when the header was made from this exact body with your secret, recently.
function verifySignature(string $rawBody, string $header, string $secret, ?int $now = null): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', $part, 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
$timestamp = $parts['t'] ?? '';
$signature = $parts['v1'] ?? '';
if (!ctype_digit($timestamp) || $signature === '') {
return false;
}
if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
function handleEvent(array $event): void
{
// Your work goes here: create or update the record in your CRM, and so on.
$name = $event['contact']['name'] ?? 'someone';
error_log(($event['event_type'] ?? $event['type'] ?? 'event') . ": $name");
}
if ($secret === '') {
http_response_code(500);
exit('Set SUBCONTEXT_SIGNING_SECRET to the connection\'s signing secret.');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
// Read the body exactly as it arrived. The signature covers these bytes.
$raw = (string) file_get_contents('php://input');
if (!verifySignature($raw, $_SERVER['HTTP_X_SUBCONTEXT_SIGNATURE'] ?? '', $secret)) {
// 400, 401 or 403 each refuse only this delivery. Later ones still arrive.
http_response_code(400);
exit('bad signature');
}
$key = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '';
$seen = is_file($seenFile) ? file($seenFile, FILE_IGNORE_NEW_LINES) : [];
if ($key !== '' && in_array($key, $seen, true)) {
exit('already had it');
}
$event = json_decode($raw, true);
// Answer quickly (within 15 seconds). In real code, put the slow work on a queue.
echo 'ok';
if ($key !== '') {
file_put_contents($seenFile, $key . "\n", FILE_APPEND | LOCK_EX);
}
handleEvent(is_array($event) ? $event : []);