Skip to content
For LLMsView as Markdown·

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 below.

  1. Authentication. Authorization: Bearer <callback key>. Reject any other bearer with 401 unauthorized.
  2. Signature. X-OneHazel-Signature: t=<unix seconds>,v1=<hex> where the hex is HMAC-SHA256 of "<t>." + 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.
  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.
CurrencyExponentamount_minor for this amount
EUR21050 is 10.50 EUR
JPY01000 is 1000 JPY
BHD31500 is 1.500 BHD
USDT610000000 is 10.000000 USDT
BTC8100000000 is 1 BTC
ETH18"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=<hex>, 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 ​

ModuleOperationIdempotency key
walletPOST /session/start, /session/exchangeyes
walletPOST /session/balance, /freerounds/getno
walletPOST /wallet/bet, /wallet/win, /wallet/rollbackyes
walletPOST /freerounds/issue, /freerounds/cancel, /freerounds/settleyes
playerPOST /player/lookupno
webhook.receivePOST /webhook (deduplicate on deliveryId, answer 2xx within 5 s)no
launchPOST https://api.onehazel.com/connect/v1/launch, /demo-launch (you call OneHazel with your API key)no

Error codes ​

RetryableCodes
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
yesinternal_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).

Stackwalletplayerwebhook.receive
TypeScript + Expresswalletplayerwebhook.receive
TypeScript + Fastifywalletplayerwebhook.receive
PHP (no framework)walletplayerwebhook.receive
PHP + Laravelwalletplayerwebhook.receive
Python + FastAPIwalletplayerwebhook.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 ​