Example file

webhook-receiver.php

PHP: receives Subcontext webhooks, checks the signature and skips repeats.

webhook-receiver.php · PHP Download
<?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 : []);

Stuck, or something missing?

Get in touch and a person on the team will help.

Contact us