Appearance
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.
- Authentication.
Authorization: Bearer <callback key>. Reject any other bearer with401 unauthorized. - Signature.
X-OneHazel-Signature: t=<unix seconds>,v1=<hex>where the hex is HMAC-SHA256 of"<t>." + raw bodykeyed 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 with401 signature_invalid. During a rotation accept the new and the previous secret for one hour. - Environment.
X-OneHazel-Env: testorlive. Routetestto a test player or sandbox ledger, never to a real-money wallet. Conformance always sendstest. - Money. Integer minor units (
amount_minor,balance_minor) in the currency's own minor unit. See Currencies and exponents. - Idempotency. Every money-moving call carries the same key in the
Idempotency-Keyheader and theidempotency_keybody field. A replay returns the original response withduplicate: true(never an error, never a second debit). The same key with a different body is409 idempotency_key_conflict. - Errors. Every error is the flat envelope
{ "code", "message", "retryable" }. Never echo an upstream body. Since 1.4.1 an error may also carrydetails: { "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. - Rollback reverses either leg (a bet or a win) by the amount you stored for the target;
metadata.amount_minoris a cross-check, and a difference isrollback_amount_mismatch. - Free rounds. Free-round bets never debit, so OneHazel never sends
wallet/betfor them.freerounds/settlecarries the lump credit itself and is never paired with awallet/winfor 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_minorcounts 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:iso4217for ISO codes,non_isofor the rest (USDT 6, BTC 8, ETH 18). Payment intents also carryexponent, 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 is422 currency_unsupported; one that is not the player's is422 currency_mismatch. - Never round. An amount finer than the currency's minor unit is
400 invalid_request. - Size. Every
*_minorinteger 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) carriesamount_minor_string, base-10 digits of the same minor units, and noamount_minor. Exactly one of the two is present, and a value that fitsamount_minoris always sent asamount_minor. We keptamount_minoras 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'swhsec_signing secret. Verify before parsing, compare in constant time, answer401on 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 | player | webhook.receive |
| TypeScript + Fastify | wallet | player | webhook.receive |
| PHP (no framework) | wallet | player | webhook.receive |
| PHP + Laravel | wallet | player | webhook.receive |
| Python + FastAPI | wallet | player | 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, player, webhook.receive, launch, events.push, attribution, widget, payments.intent.
- Samples guide.