Skip to content
For LLMsView as Markdown·

webhook.receive: PHP (no framework) ​

The endpoint OneHazel delivers events to. Acknowledge fast, deduplicate on deliveryId, process after acknowledging.

Generated from Operator Contract v1.4.1. Key php/plain/webhook.receive; the connect_samples MCP tool returns this page for that key.

What it implements ​

OperationPurposeIdempotency key
POST /webhookReceive a OneHazel event.no

Every delivery is checked before it is parsed: X-OneHazel-Signature is sha256=<hex>, HMAC-SHA256 of the RAW body keyed by the subscription's whsec_ signing secret, compared in constant time. There is no timestamp, no bearer and no X-OneHazel-Env, so the signature is the only proof of origin and deduplicating on deliveryId is the only replay defence. A bad signature answers 401 with the flat { code, message, retryable } envelope.

Run it ​

bash
ONEHAZEL_SIGNING_SECRET=<whsec signing secret> php -S 0.0.0.0:3000 onehazel-webhook-receive.php
# base URL to register: https://<your host>/onehazel/v1 (ONEHAZEL_BASE_PATH changes the path)

Environment: ONEHAZEL_SIGNING_SECRET is the subscription's whsec_ signing secret. During a rotation set ONEHAZEL_SIGNING_SECRET_PREVIOUS to the old one until it stops being used.

Code ​

onehazel-webhook-receive.php

php
<?php

declare(strict_types=1);

// OneHazel webhook.receive module, Operator Contract v1.4.1. Generated; regenerate rather than edit.

/**
 * Shared Operator Contract plumbing: errors, request authentication, validation
 * and a small locked JSON-file store. Identical for every module; the module's
 * own contract data is passed in. PHP 8.1+.
 */

final class OneHazelError extends \Exception
{
    public function __construct(public readonly string $errorCode, string $message)
    {
        parent::__construct($message);
    }
}

final class OneHazelProtocol
{
    /** @param string[] $secrets current signing secret first, then the previous one during a rotation */
    public function __construct(private array $contract, private string $callbackKey, private array $secrets)
    {
    }

    /** Webhook receivers pass $requireCallbackKey = false: deliveries carry no bearer. */
    public static function fromEnv(array $contract, ?callable $env = null, bool $requireCallbackKey = true): self
    {
        $env ??= static fn (string $k): string => (string) getenv($k);
        $key = (string) $env('ONEHAZEL_CALLBACK_KEY');
        $secret = (string) $env('ONEHAZEL_SIGNING_SECRET');
        if ($secret === '' || ($requireCallbackKey && $key === '')) {
            // Name the variables, never print their values.
            throw new \RuntimeException($requireCallbackKey
                ? 'ONEHAZEL_CALLBACK_KEY and ONEHAZEL_SIGNING_SECRET must be set'
                : 'ONEHAZEL_SIGNING_SECRET must be set');
        }
        $secrets = array_values(array_filter([$secret, (string) $env('ONEHAZEL_SIGNING_SECRET_PREVIOUS')]));
        return new self($contract, $key, $secrets);
    }

    /** @return array{0:int,1:array} [HTTP status, flat error envelope] */
    public function errorReply(string $code, string $message): array
    {
        return [
            $this->contract['errorStatus'][$code] ?? 500,
            ['code' => $code, 'message' => $message, 'retryable' => in_array($code, $this->contract['retryable'], true)],
        ];
    }

    public function op(string $path): ?array
    {
        foreach ($this->contract['ops'] as $op) {
            if ($op['path'] === $path) {
                return $op;
            }
        }
        return null;
    }

    /**
     * `X-OneHazel-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + raw body)>`.
     * Runs over the RAW body, before it is parsed. Constant-time compare.
     */
    public static function verifySignature(string $rawBody, ?string $header, array $secrets, int $toleranceSec, ?int $now = null): bool
    {
        if ($header === null || $header === '') {
            return false;
        }
        $t = null;
        $macs = [];
        foreach (explode(',', $header) as $part) {
            $kv = explode('=', trim($part), 2);
            if (count($kv) !== 2) {
                continue;
            }
            if ($kv[0] === 't' && ctype_digit($kv[1])) {
                $t = (int) $kv[1];
            }
            if ($kv[0] === 'v1' && preg_match('/^[0-9a-f]{64}$/', $kv[1])) {
                $macs[] = $kv[1];
            }
        }
        $now ??= time();
        if ($t === null || abs($now - $t) > $toleranceSec || $macs === []) {
            return false;
        }
        foreach ($secrets as $secret) {
            $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
            foreach ($macs as $mac) {
                if (hash_equals($expected, $mac)) {
                    return true;
                }
            }
        }
        return false;
    }

    /** Bearer, then signature over the raw body, then X-OneHazel-Env. Returns "test" or "live". */
    public function authenticate(callable $header, string $rawBody): string
    {
        $auth = (string) $header('authorization');
        if (!hash_equals('Bearer ' . $this->callbackKey, $auth)) {
            throw new OneHazelError('unauthorized', 'missing or wrong callback key');
        }
        if (!self::verifySignature($rawBody, $header('x-onehazel-signature'), $this->secrets, (int) $this->contract['toleranceSec'])) {
            throw new OneHazelError('signature_invalid', 'bad or stale X-OneHazel-Signature');
        }
        $env = $header('x-onehazel-env');
        if ($env !== 'test' && $env !== 'live') {
            throw new OneHazelError('invalid_request', 'X-OneHazel-Env must be test or live');
        }
        return $env;
    }

    /**
     * Webhook deliveries: `X-OneHazel-Signature: sha256=<hex HMAC-SHA256(whsec, raw body)>`, the
     * OneHazel webhook engine's format. No timestamp, no bearer, no X-OneHazel-Env, so the
     * signature is the only proof of origin and deduplicating on deliveryId is the only replay
     * defence. Runs over the RAW body, before it is parsed. Constant-time compare.
     */
    public static function verifyWebhookSignature(string $rawBody, ?string $header, array $secrets): bool
    {
        if ($header === null || !preg_match('/^sha256=([0-9a-f]{64})$/', trim($header), $m)) {
            return false;
        }
        $ok = false;
        foreach ($secrets as $secret) {
            // No early exit: every secret is compared.
            $ok = hash_equals(hash_hmac('sha256', $rawBody, $secret), $m[1]) || $ok;
        }
        return $ok;
    }

    public function authenticateWebhook(callable $header, string $rawBody): void
    {
        if (!self::verifyWebhookSignature($rawBody, $header('x-onehazel-signature'), $this->secrets)) {
            throw new OneHazelError('signature_invalid', 'bad X-OneHazel-Signature');
        }
    }

    public static function parseBody(string $rawBody): mixed
    {
        try {
            return json_decode($rawBody, false, 64, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            throw new OneHazelError('invalid_request', 'body is not valid JSON');
        }
    }

    /** Checks a decoded body against the contract's field list; returns it as an array. */
    public static function checkFields(mixed $value, array $fields, bool $closed, string $where): array
    {
        if (!is_object($value)) {
            throw new OneHazelError('invalid_request', ($where === '' ? 'body' : $where) . ' must be a JSON object');
        }
        $obj = get_object_vars($value);
        if ($closed) {
            $known = array_column($fields, 'name');
            foreach (array_keys($obj) as $key) {
                if (!in_array($key, $known, true)) {
                    throw new OneHazelError('invalid_request', "unknown field {$where}{$key}");
                }
            }
        }
        foreach ($fields as $f) {
            $name = $where . $f['name'];
            $v = $obj[$f['name']] ?? null;
            if ($v === null) {
                if ($f['required']) {
                    throw new OneHazelError('invalid_request', "{$name} is required");
                }
                continue;
            }
            $ok = match ($f['type']) {
                'integer' => is_int($v),
                'string' => is_string($v),
                'boolean' => is_bool($v),
                'array' => is_array($v),
                'object' => is_object($v),
                default => true,
            };
            if (!$ok) {
                throw new OneHazelError('invalid_request', "{$name} must be {$f['type']}");
            }
            if (is_int($v)) {
                if (isset($f['minimum']) && $v < $f['minimum']) {
                    throw new OneHazelError('invalid_request', "{$name} is below {$f['minimum']}");
                }
                if (isset($f['exclusiveMinimum']) && $v <= $f['exclusiveMinimum']) {
                    throw new OneHazelError('invalid_request', "{$name} must be greater than {$f['exclusiveMinimum']}");
                }
            }
            if (is_string($v)) {
                $len = mb_strlen($v);
                if (isset($f['minLength']) && $len < $f['minLength']) {
                    throw new OneHazelError('invalid_request', "{$name} is too short");
                }
                if (isset($f['maxLength']) && $len > $f['maxLength']) {
                    throw new OneHazelError('invalid_request', "{$name} is too long");
                }
                if (isset($f['pattern']) && !preg_match('/' . str_replace('/', '\/', $f['pattern']) . '/u', $v)) {
                    throw new OneHazelError('invalid_request', "{$name} is malformed");
                }
                if (isset($f['enum']) && !in_array($v, $f['enum'], true)) {
                    throw new OneHazelError('invalid_request', "{$name} is not an allowed value");
                }
            }
            if (isset($f['fields'])) {
                self::checkFields($v, $f['fields'], false, "{$name}.");
            }
        }
        return json_decode(json_encode($value), true);
    }

    /** Stable fingerprint of a body, so a replay with reordered keys still matches. */
    public static function fingerprint(array $value): string
    {
        $canonical = function (mixed $v) use (&$canonical): mixed {
            if (!is_array($v)) {
                return $v;
            }
            if (!array_is_list($v)) {
                ksort($v);
            }
            return array_map($canonical, $v);
        };
        return hash('sha256', json_encode($canonical($value)));
    }
}

/**
 * State that survives between PHP requests: one JSON file, locked for the whole
 * request. Fine for a sample; use your database in production.
 */
final class OneHazelFileStore
{
    public function __construct(private string $path)
    {
    }

    public static function fromEnv(string $name, ?callable $env = null): self
    {
        $env ??= static fn (string $k): string => (string) getenv($k);
        $path = (string) $env('ONEHAZEL_STORE_PATH');
        return new self($path !== '' ? $path : sys_get_temp_dir() . "/onehazel-{$name}-store.json");
    }

    /** Runs $fn(&$state) under an exclusive lock and saves whatever it leaves in $state. */
    public function transaction(callable $fn): mixed
    {
        $fh = fopen($this->path, 'c+');
        if ($fh === false || !flock($fh, LOCK_EX)) {
            throw new OneHazelError('unavailable', 'store is unavailable');
        }
        try {
            $raw = stream_get_contents($fh);
            $state = $raw ? json_decode($raw, true) : null;
            $result = $fn($state);
            ftruncate($fh, 0);
            rewind($fh);
            fwrite($fh, json_encode($state));
            return $result;
        } finally {
            flock($fh, LOCK_UN);
            fclose($fh);
        }
    }
}

/**
 * The webhook.receive module. Acknowledge within 5 s, deduplicate on deliveryId
 * (deliveries are at-least-once), and do the real work AFTER acknowledging: this
 * sample appends each new event to an inbox in the store for a worker to drain.
 */
final class OneHazelWebhookModule
{
    public const CONTRACT_JSON = <<<'JSON'
{
  "version": "1.4.1",
  "auth": {
    "scheme": "sha256",
    "bearer": false,
    "envHeader": false
  },
  "errorStatus": {
    "unauthorized": 401,
    "signature_invalid": 401,
    "idempotency_key_conflict": 409,
    "launch_token_consumed": 409,
    "round_closed": 409,
    "currency_unsupported": 422,
    "currency_mismatch": 422,
    "player_blocked": 422,
    "rollback_amount_mismatch": 422,
    "insufficient_funds": 402,
    "internal_error": 500,
    "unavailable": 500,
    "invalid_request": 400,
    "session_unknown": 404,
    "session_expired": 404,
    "launch_token_unknown": 404,
    "launch_token_expired": 404,
    "player_unknown": 404,
    "game_unknown": 404,
    "transaction_unknown": 404,
    "freeround_unknown": 404
  },
  "retryable": [
    "internal_error",
    "unavailable"
  ],
  "ops": [
    {
      "path": "/webhook",
      "operationId": "webhookReceive",
      "summary": "Receive a OneHazel event.",
      "idempotent": false,
      "fields": [
        {
          "name": "type",
          "type": "string",
          "required": true
        },
        {
          "name": "data",
          "type": "object",
          "required": true
        },
        {
          "name": "operatorId",
          "type": "string",
          "required": true
        },
        {
          "name": "timestamp",
          "type": "string",
          "required": true
        },
        {
          "name": "deliveryId",
          "type": "string",
          "required": true
        }
      ],
      "closed": false
    }
  ]
}
JSON;

    public function __construct(private OneHazelProtocol $protocol, private OneHazelFileStore $store)
    {
    }

    public static function contract(): array
    {
        return json_decode(self::CONTRACT_JSON, true);
    }

    /** @param callable|null $env reads a setting by name; defaults to getenv() */
    public static function fromEnv(?callable $env = null): self
    {
        $env ??= static fn (string $k): string => (string) getenv($k);
        return new self(OneHazelProtocol::fromEnv(self::contract(), $env, false), OneHazelFileStore::fromEnv('webhook', $env));
    }

    /** @return array{0:int,1:array} [HTTP status, JSON body] */
    public function handle(string $path, callable $header, string $rawBody): array
    {
        try {
            $op = $this->protocol->op($path);
            if ($op === null) {
                return [404, ['code' => 'invalid_request', 'message' => 'unknown operation', 'retryable' => false]];
            }
            $this->protocol->authenticateWebhook($header, $rawBody);
            $event = OneHazelProtocol::checkFields(OneHazelProtocol::parseBody($rawBody), $op['fields'], $op['closed'], '');
            if ($header('x-onehazel-delivery') !== $event['deliveryId'] || $header('x-onehazel-event') !== $event['type']) {
                throw new OneHazelError('invalid_request', 'X-OneHazel-Delivery and X-OneHazel-Event must match the body');
            }
            return $this->store->transaction(function (?array &$state) use ($event): array {
                $state ??= ['seen' => [], 'inbox' => []];
                if (isset($state['seen'][$event['deliveryId']])) {
                    return [200, ['received' => true, 'duplicate' => true]];
                }
                $state['seen'][$event['deliveryId']] = true;
                // Conformance deliveries use the type `cert.conformance`: never act on players for them.
                $state['inbox'][] = $event;
                return [200, ['received' => true, 'duplicate' => false]];
            });
        } catch (OneHazelError $e) {
            return $this->protocol->errorReply($e->errorCode, $e->getMessage());
        } catch (\Throwable $e) {
            error_log('webhook handler failed: ' . get_class($e));
            return $this->protocol->errorReply('internal_error', 'internal error');
        }
    }
}

// ───────────────────────────── entry point (php -S or PHP-FPM) ─────────────────────────────

function onehazel_request_headers(): array
{
    if (function_exists('getallheaders')) {
        return array_change_key_case(getallheaders(), CASE_LOWER);
    }
    $out = [];
    foreach ($_SERVER as $k => $v) {
        if (str_starts_with($k, 'HTTP_')) {
            $out[strtolower(str_replace('_', '-', substr($k, 5)))] = $v;
        }
    }
    return $out;
}

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

$basePath = rtrim((string) (getenv('ONEHAZEL_BASE_PATH') ?: '/onehazel/v1'), '/');
$uri = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !str_starts_with($uri, $basePath . '/')) {
    onehazel_send(404, ['code' => 'invalid_request', 'message' => 'unknown operation', 'retryable' => false]);
    return;
}
try {
    $module = OneHazelWebhookModule::fromEnv();
} catch (\RuntimeException $e) {
    error_log($e->getMessage());
    onehazel_send(500, ['code' => 'internal_error', 'message' => 'module is not configured', 'retryable' => true]);
    return;
}
$headers = onehazel_request_headers();
// Read the RAW body: the signature is computed over the exact bytes OneHazel sent.
[$status, $body] = $module->handle(substr($uri, strlen($basePath)), fn (string $name): ?string => $headers[$name] ?? null, (string) file_get_contents('php://input'));
onehazel_send($status, $body);

Before you go live ​

  • Replace the in-memory (or file) seen-set with a unique index on deliveryId in your database, so dedup survives restarts.
  • Run conformance (connect_run_conformance) until every scenario passes.