--- title: "Connect: the Operator Contract" description: Wire rules, operations, error codes, generated samples and the @onehazel/connect helper for the OneHazel Operator Contract v1. --- # The Operator Contract v1 The contract is one OpenAPI 3.1 document. Your coding agent reads it through the `connect_contract` MCP tool (filtered to your modules, with your registered URLs filled in) or the `onehazel://contract/v1/openapi.json` resource. This page is the human summary; where they differ, the document wins. ## Wire rules These hold for every wallet and player call OneHazel makes to you. Webhook deliveries are different; see [Webhook deliveries](#webhook-deliveries) below. 1. **Authentication.** `Authorization: Bearer `. Reject any other bearer with `401 unauthorized`. 2. **Signature.** `X-OneHazel-Signature: t=,v1=` where the hex is HMAC-SHA256 of `"." + raw body` keyed by your signing secret. Verify it over the **raw** bytes before parsing, compare in constant time, and reject a timestamp more than **300 s** from your clock with `401 signature_invalid`. During a rotation accept the new and the previous secret for one hour. 3. **Environment.** `X-OneHazel-Env: test` or `live`. Route `test` to a test player or sandbox ledger, never to a real-money wallet. Conformance always sends `test`. 4. **Money.** Integer minor units (`amount_minor`, `balance_minor`) in the currency's own minor unit. See [Currencies and exponents](#currencies-and-exponents). 5. **Idempotency.** Every money-moving call carries the same key in the `Idempotency-Key` header and the `idempotency_key` body field. A replay returns the **original** response with `duplicate: true` (never an error, never a second debit). The same key with a different body is `409 idempotency_key_conflict`. 6. **Errors.** Every error is the flat envelope `{ "code", "message", "retryable" }`. Never echo an upstream body. Since 1.4.1 an error may also carry `details: { "native_status", "native_code" }`, your platform's own status and code, for diagnostics only: OneHazel shows it in conformance failures and never acts on it. 7. **Rollback** reverses either leg (a bet or a win) by the amount **you** stored for the target; `metadata.amount_minor` is a cross-check, and a difference is `rollback_amount_mismatch`. 8. **Free rounds.** Free-round bets never debit, so OneHazel never sends `wallet/bet` for them. `freerounds/settle` carries the lump credit itself and is never paired with a `wallet/win` for the same grant. ## Currencies and exponents Since contract 1.2.0 every currency in the contract's table is accepted, not only 2-decimal ones. - **The exponent.** `amount_minor` counts units of 10^-exponent. An ISO 4217 code uses its ISO exponent; a non-ISO code uses the contract's own table. You never rescale an amount: you store and compare it as the integer it is. | Currency | Exponent | `amount_minor` for this amount | |---|---|---| | EUR | 2 | `1050` is 10.50 EUR | | JPY | 0 | `1000` is 1000 JPY | | BHD | 3 | `1500` is 1.500 BHD | | USDT | 6 | `10000000` is 10.000000 USDT | | BTC | 8 | `100000000` is 1 BTC | | ETH | 18 | `"10000000000000000000"` is 10 ETH, sent as `amount_minor_string` | - **The table.** Every accepted code is listed in the contract document under `x-onehazel-currency-exponents`: `iso4217` for ISO codes, `non_iso` for the rest (USDT 6, BTC 8, ETH 18). Payment intents also carry `exponent`, so you never need the table to read one. - **Unknown codes.** A code outside the table is `400 invalid_request`, and the message names the code. It is never treated as 2 decimals. A listed currency you do not support is `422 currency_unsupported`; one that is not the player's is `422 currency_mismatch`. - **Never round.** An amount finer than the currency's minor unit is `400 invalid_request`. - **Size.** Every `*_minor` integer is at most 2^53 - 1 (9007199254740991), the largest integer every JSON parser reads exactly. A payment intent above that (an 18-decimal currency above about 0.009 units) carries `amount_minor_string`, base-10 digits of the same minor units, and no `amount_minor`. Exactly one of the two is present, and a value that fits `amount_minor` is always sent as `amount_minor`. We kept `amount_minor` as an integer rather than moving every amount to a string so that every existing reader keeps working unchanged. Wallet, player and free-round calls have no string field, and OneHazel never sends them an amount above 2^53 - 1. ## Webhook deliveries `webhook.receive` keeps the format of OneHazel's existing webhook engine, so a receiver you already run keeps working: - `X-OneHazel-Signature: sha256=`, HMAC-SHA256 of the **raw** body keyed by the subscription's `whsec_` signing secret. Verify before parsing, compare in constant time, answer `401` on a mismatch. - **No timestamp, no bearer, no `X-OneHazel-Env`.** The signature is the only proof of origin. - A replayed delivery verifies, so **deduplicate on `deliveryId`**: that is the replay defence. Answer 2xx within 5 s and do slow work after acknowledging. ## Operations | Module | Operation | Idempotency key | |---|---|---| | wallet | `POST /session/start`, `/session/exchange` | yes | | wallet | `POST /session/balance`, `/freerounds/get` | no | | wallet | `POST /wallet/bet`, `/wallet/win`, `/wallet/rollback` | yes | | wallet | `POST /freerounds/issue`, `/freerounds/cancel`, `/freerounds/settle` | yes | | player | `POST /player/lookup` | no | | webhook.receive | `POST /webhook` (deduplicate on `deliveryId`, answer 2xx within 5 s) | no | | launch | `POST https://api.onehazel.com/connect/v1/launch`, `/demo-launch` (you call OneHazel with your API key) | no | ## Error codes | Retryable | Codes | |---|---| | no (terminal) | `invalid_request`, `unauthorized`, `signature_invalid`, `session_unknown`, `session_expired`, `launch_token_unknown`, `launch_token_expired`, `launch_token_consumed`, `player_unknown`, `player_blocked`, `game_unknown`, `currency_unsupported`, `currency_mismatch`, `insufficient_funds`, `round_closed`, `transaction_unknown`, `rollback_amount_mismatch`, `freeround_unknown`, `idempotency_key_conflict` | | yes | `internal_error`, `unavailable` (OneHazel retries with the same idempotency key) | Status codes: `400` malformed, `401` bearer or signature, `402` insufficient funds, `404` unknown session, player, transaction or grant, `409` idempotency conflict, consumed launch token or closed round, `422` business rule, `500` retryable. ## Samples Every sample is **generated from the contract** and committed, so the docs and the `connect_samples` MCP tool serve the same bytes. Each is a complete, runnable module: signature verification over the raw body, bearer check, an idempotency store that returns the original response, test-ledger routing on `X-OneHazel-Env`, the flat error envelope and integer money. OneHazel's test suite boots the samples and drives them through these rules (an opt-in integration test, not every CI run). | Stack | wallet | player | webhook.receive | |---|---|---|---| | TypeScript + Express | [wallet](/connect/samples/typescript/express/wallet) | [player](/connect/samples/typescript/express/player) | [webhook.receive](/connect/samples/typescript/express/webhook.receive) | | TypeScript + Fastify | [wallet](/connect/samples/typescript/fastify/wallet) | [player](/connect/samples/typescript/fastify/player) | [webhook.receive](/connect/samples/typescript/fastify/webhook.receive) | | PHP (no framework) | [wallet](/connect/samples/php/plain/wallet) | [player](/connect/samples/php/plain/player) | [webhook.receive](/connect/samples/php/plain/webhook.receive) | | PHP + Laravel | [wallet](/connect/samples/php/laravel/wallet) | [player](/connect/samples/php/laravel/player) | [webhook.receive](/connect/samples/php/laravel/webhook.receive) | | Python + FastAPI | [wallet](/connect/samples/python/fastapi/wallet) | [player](/connect/samples/python/fastapi/player) | [webhook.receive](/connect/samples/python/fastapi/webhook.receive) | Other languages: generate a server stub from the contract with OpenAPI Generator, then add the wire rules above by hand. ## Helper `@onehazel/connect` (TypeScript, zero dependencies) carries the functions every integration needs: `verifySignature` for wallet and player calls, `verifyWebhookSignature` for webhook deliveries, `signForTest` / `signWebhookForTest` for your own tests, and `launch` / `demoLaunch`. The PHP and Python samples include the same verifiers inline. ```ts import { verifySignature, verifyWebhookSignature, signForTest, launch, demoLaunch, ConnectError } from "@onehazel/connect"; // In your handler, over the RAW body: if (!verifySignature(rawBody, req.headers["x-onehazel-signature"], process.env.ONEHAZEL_SIGNING_SECRET!, { toleranceSec: 300 })) { return res.status(401).json(new ConnectError("signature_invalid", "bad signature").toEnvelope()); } // In a webhook receiver (sha256=, no timestamp), then dedupe on deliveryId: if (!verifyWebhookSignature(rawBody, req.headers["x-onehazel-signature"], process.env.ONEHAZEL_WEBHOOK_SECRET!)) { return res.status(401).json(new ConnectError("signature_invalid", "bad signature").toEnvelope()); } // In your tests: const header = signForTest(JSON.stringify(body), "whsec_test"); // Launching a game for a player: const { game_url } = await launch(process.env.ONEHAZEL_API_KEY!, { connection_id: "conn_...", game_code: "g1", player_external_id: "p_123", currency: "EUR", }); ``` `ConnectError` exposes `code`, `retryable` and `status`; `TERMINAL_CODES` and `RETRYABLE_CODES` list the split above. ## See also - One page per module: [wallet](/connect/modules/wallet), [player](/connect/modules/player), [webhook.receive](/connect/modules/webhook-receive), [launch](/connect/modules/launch), [events.push](/connect/modules/events-push), [attribution](/connect/modules/attribution), [widget](/connect/modules/widget), [payments.intent](/connect/modules/payments-intent). - [Samples guide](/connect/samples).