Appearance
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=<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
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://<your host>/api/onehazel/webhookEnvironment: 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
<?php
declare(strict_types=1);
// Shared by every OneHazel module; identical in each module's sample.
/**
* 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);
}
}
}app/OneHazel/webhook.php
php
<?php
declare(strict_types=1);
// OneHazel webhook.receive module, Operator Contract v1.4.1. Generated; regenerate rather than edit.
/**
* 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');
}
}
}app/Http/Controllers/OneHazelWebhookController.php
php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
require_once app_path('OneHazel/common.php');
require_once app_path('OneHazel/webhook.php');
final class OneHazelWebhookController extends Controller
{
public function __invoke(Request $request, string $operation): JsonResponse
{
// Reads ONEHAZEL_* from .env. If you cache config, move them into config/services.php.
$module = \OneHazelWebhookModule::fromEnv(fn (string $key): string => (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
<?php
use App\Http\Controllers\OneHazelWebhookController;
use Illuminate\Support\Facades\Route;
// Register https://<your host>/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
deliveryIdin your database, so dedup survives restarts. - Run conformance (
connect_run_conformance) until every scenario passes.