--- title: "payments.intent sample: PHP (no framework)" description: "Operator Contract v1.4.1 payments.intent module, PHP (no framework). Generated from the contract." --- # payments.intent: PHP (no framework) The endpoint OneHazel delivers payment intent status changes to. OneHazel never holds funds: the PSP settles, OneHazel relays the status, and this module credits a settled deposit once per `intent_id`. You create intents by calling OneHazel's `POST /payments/intents` with an `oh_live_*` key. Generated from Operator Contract v1.4.1. Key `php/plain/payments.intent`; the `connect_samples` MCP tool returns this page for that key. ## What it implements | Operation | Purpose | Idempotency key | |---|---|---| | `POST /payments/webhook` | Receive a payment intent status change. | no | Every delivery is checked before it is parsed: `X-OneHazel-Signature` is `sha256=`, HMAC-SHA256 of the RAW body keyed by the `whsec_` signing secret, compared in constant time. There is no timestamp and no bearer, so deduplicating on `deliveryId` is the replay defence. `X-OneHazel-Env` is then required: `test` traffic goes to a separate test ledger and never touches the live one. A bad signature answers `401` with the flat `{ code, message, retryable }` envelope. ## Run it ```bash ONEHAZEL_SIGNING_SECRET= php -S 0.0.0.0:3000 onehazel-payments-intent.php # base URL to register: https:///onehazel/v1 (ONEHAZEL_BASE_PATH changes the path) ``` Environment: `ONEHAZEL_SIGNING_SECRET` is the `whsec_` secret `connect_register_endpoint` showed you once for `payments.intent`. During a rotation set `ONEHAZEL_SIGNING_SECRET_PREVIOUS` to the old one for the overlap hour. ## Code `onehazel-payments-intent.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); } } } /** * The payments.intent status receiver. OneHazel never holds funds: the PSP settles, * OneHazel records the intent and relays each status here. Deliveries are signed like * webhook.receive (sha256=, no bearer) and also carry X-OneHazel-Env. Deduplicate on * deliveryId AND apply each status idempotently per intent_id: a settled deposit is * credited once, whatever the number of deliveries. The state change is applied inside * the store lock before acknowledging, so an acknowledged credit is never lost. `test` * traffic (every conformance run) only ever touches the test ledger. */ final class OneHazelPaymentsModule { public const CONTRACT_JSON = <<<'JSON' { "version": "1.4.1", "auth": { "scheme": "sha256", "bearer": false, "envHeader": true }, "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": "/payments/webhook", "operationId": "paymentIntentStatusReceive", "summary": "Receive a payment intent status change.", "idempotent": false, "fields": [ { "name": "type", "type": "string", "required": true, "enum": [ "payment.intent.created", "payment.intent.authorised", "payment.intent.settled", "payment.intent.failed", "payment.intent.cancelled" ] }, { "name": "data", "type": "object", "required": true, "fields": [ { "name": "intent_id", "type": "string", "required": true, "minLength": 1 }, { "name": "status", "type": "string", "required": true, "enum": [ "created", "authorised", "settled", "failed", "cancelled" ] }, { "name": "direction", "type": "string", "required": true, "enum": [ "deposit", "withdraw" ] }, { "name": "connection_id", "type": "string", "required": true }, { "name": "player_external_id", "type": "string", "required": true }, { "name": "amount_minor", "type": "integer", "required": false, "minimum": 0 }, { "name": "amount_minor_string", "type": "string", "required": false, "pattern": "^[1-9][0-9]{15,39}$" }, { "name": "currency", "type": "string", "required": true, "pattern": "^[A-Z][A-Z0-9]{2,9}$" }, { "name": "exponent", "type": "integer", "required": true, "minimum": 0 }, { "name": "method", "type": "string", "required": true }, { "name": "redirect_url", "type": "string", "required": false }, { "name": "idempotency_key", "type": "string", "required": false, "minLength": 1, "maxLength": 128 }, { "name": "duplicate", "type": "boolean", "required": false }, { "name": "failure_reason", "type": "string", "required": false }, { "name": "provider_state", "type": "string", "required": false, "enum": [ "no_adapter" ] }, { "name": "created_at", "type": "string", "required": true }, { "name": "updated_at", "type": "string", "required": true } ] }, { "name": "operatorId", "type": "string", "required": true }, { "name": "timestamp", "type": "string", "required": true }, { "name": "deliveryId", "type": "string", "required": true } ], "closed": false } ] } JSON; private const TERMINAL = ['settled', 'failed', 'cancelled']; 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('payments', $env)); } /** The exact amount as base-10 digits, whichever of amount_minor / amount_minor_string carries it. */ private static function amountMinor(array $intent): string { $hasInt = array_key_exists('amount_minor', $intent); if ($hasInt === array_key_exists('amount_minor_string', $intent)) { throw new OneHazelError('invalid_request', 'exactly one of amount_minor and amount_minor_string is required'); } return $hasInt ? (string) $intent['amount_minor'] : (string) $intent['amount_minor_string']; } /** Adds two non-negative base-10 digit strings exactly (no bcmath dependency). */ private static function addMinor(string $a, string $b): string { $out = ''; $carry = 0; for ($i = strlen($a) - 1, $j = strlen($b) - 1; $i >= 0 || $j >= 0 || $carry; $i--, $j--) { $sum = ($i >= 0 ? (int) $a[$i] : 0) + ($j >= 0 ? (int) $b[$j] : 0) + $carry; $out = (string) ($sum % 10) . $out; $carry = intdiv($sum, 10); } return ltrim($out, '0') === '' ? '0' : ltrim($out, '0'); } /** @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); $ledgerEnv = $header('x-onehazel-env'); if ($ledgerEnv !== 'test' && $ledgerEnv !== 'live') { throw new OneHazelError('invalid_request', 'X-OneHazel-Env must be test or live'); } $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'); } $intent = (array) $event['data']; if ($event['type'] !== 'payment.intent.' . $intent['status']) { throw new OneHazelError('invalid_request', 'type must match data.status'); } self::amountMinor($intent); // reject a malformed amount before the delivery is marked seen return $this->store->transaction(function (?array &$state) use ($event, $intent, $ledgerEnv): array { $state ??= ['seen' => [], 'intents' => ['test' => [], 'live' => []], 'ledger' => ['test' => [], 'live' => []], 'reconcile' => []]; if (isset($state['seen'][$event['deliveryId']])) { return [200, ['received' => true, 'duplicate' => true]]; } $state['seen'][$event['deliveryId']] = true; $id = $intent['intent_id']; $known = $state['intents'][$ledgerEnv][$id] ?? null; if ($known === null && $intent['status'] !== 'created') { // Read it with GET /payments/intents/{intent_id} to reconcile. $state['reconcile'][$id] = true; } // A terminal status never changes again; a late or replayed delivery is a no-op. if ($known !== null && in_array($known['status'], self::TERMINAL, true)) { return [200, ['received' => true, 'duplicate' => false]]; } $record = $known ?? ['status' => $intent['status'], 'credited' => false]; $record['status'] = $intent['status']; if ($intent['status'] === 'settled' && $intent['direction'] === 'deposit' && !$record['credited']) { // Per player AND currency (minor units of different currencies never add up), as // digit strings: an amount_minor_string can exceed PHP_INT_MAX. $key = $intent['player_external_id'] . ':' . $intent['currency']; $state['ledger'][$ledgerEnv][$key] = self::addMinor((string) ($state['ledger'][$ledgerEnv][$key] ?? '0'), self::amountMinor($intent)); $record['credited'] = true; } // A withdrawal you held on create is released on failed or cancelled; a settled one is final. $state['intents'][$ledgerEnv][$id] = $record; return [200, ['received' => true, 'duplicate' => false]]; }); } catch (OneHazelError $e) { return $this->protocol->errorReply($e->errorCode, $e->getMessage()); } catch (\Throwable $e) { error_log('payment status 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 = OneHazelPaymentsModule::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) stores with your database. The idempotency store must survive restarts. - Point the `live` ledger at your real wallet; keep `test` traffic on the test ledger. - Credit the player and record the intent in one database transaction, keyed by `intent_id`, so a settled deposit is credited exactly once. - Run conformance (`connect_run_conformance`) until every scenario passes.