--- title: "webhook.receive sample: PHP + Laravel" description: "Operator Contract v1.4.1 webhook.receive module, PHP + Laravel. Generated from the contract." --- # webhook.receive: PHP + Laravel The endpoint OneHazel delivers events to. Acknowledge fast, deduplicate on `deliveryId`, process after acknowledging. Generated from Operator Contract v1.4.1. Key `php/laravel/webhook.receive`; the `connect_samples` MCP tool returns this page for that key. ## What it implements | Operation | Purpose | Idempotency key | |---|---|---| | `POST /webhook` | Receive a OneHazel event. | no | Every delivery is checked before it is parsed: `X-OneHazel-Signature` is `sha256=`, 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 php artisan install:api # Laravel 11+, only if routes/api.php does not exist yet # add ONEHAZEL_SIGNING_SECRET to .env, then: php artisan serve --port=3000 # base URL to register: https:///api/onehazel/webhook ``` 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 `app/OneHazel/common.php` ```php (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=,v1=." + 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=`, 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); } } } ``` `app/OneHazel/webhook.php` ```php (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'); } } } ``` `app/Http/Controllers/OneHazelWebhookController.php` ```php (string) env($key, '')); // getContent() is the RAW body: the signature is computed over the exact bytes OneHazel sent. [$status, $body] = $module->handle('/' . $operation, fn (string $name): ?string => $request->header($name), $request->getContent()); return response()->json($body, $status, [], JSON_UNESCAPED_SLASHES); } } ``` `routes/api.php (add)` ```php /api/onehazel/webhook as this module's base URL. Route::post('onehazel/webhook/{operation}', OneHazelWebhookController::class)->where('operation', '.*'); ``` ## 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.