Appearance
For the operator's engineer building the payments.intent module.
payments.intent
The payments.intent module moves player deposits and withdrawals through a payment service provider (PSP) connector you activated from the Marketplace. OneHazel never holds funds. The PSP settles the money; OneHazel records the intent and relays its status to you. Added in Operator Contract 1.1.0.
Who calls whom
The module has two halves:
| Direction | Operation | Auth |
|---|---|---|
| You call OneHazel | POST /payments/intents creates a deposit or withdrawal intent | Authorization: Bearer oh_live_... and an Idempotency-Key header |
| You call OneHazel | GET /payments/intents/{intent_id} reads an intent's current status | Authorization: Bearer oh_live_... |
| OneHazel calls you | POST /payments/webhook under the base URL you register for payments.intent | sha256= signature, see below |
OneHazel's half lives at https://api.onehazel.com/connect/v1.
The full URLs are POST https://api.onehazel.com/connect/v1/payments/intents and GET https://api.onehazel.com/connect/v1/payments/intents/{intent_id}. Both answer in the contract wire format: a bare PaymentIntent on success, and the { code, message, retryable } error envelope otherwise.
Register your half with connect_register_endpoint (or POST /operators/me/connect/endpoints) using module: "payments.intent"; the response shows the whsec_ signing secret once.
Creating an intent
Send the player, the amount, the currency, the PSP method and the conn_* of the activated PSP connector:
json
{
"idempotency_key": "dep_8f2c1a",
"connection_id": "conn_your_psp",
"direction": "deposit",
"player_external_id": "player-123",
"amount_minor": 2500,
"currency": "EUR",
"method": "card",
"return_url": "https://casino.example/cashier/done"
}- Money is integer minor units at the currency's exponent.
2500is 25.00 EUR; for JPY (exponent 0)1000is 1000 JPY; for USDT (exponent 6)10000000is 10.000000 USDT. Every intent echoesexponent, so you read the amount without a lookup. A currency code outside the contract's table is400 invalid_requestnaming the code, and an amount finer than the minor unit is400 invalid_request; nothing is ever rounded. The full rule and table: Currencies and exponents. - Above 2^53 - 1, the amount is a string. An amount too large for an exact JSON integer (10 ETH is 10^19 wei) is sent as
"amount_minor_string": "10000000000000000000"with noamount_minor. Exactly one of the two is ever present, so read whichever is there and keep balances per player and currency. - Hosted page or direct is a property of the response, not a separate module. When the PSP hosts the payment page, the intent carries
redirect_url: send the player there, and the PSP returns them to yourreturn_url. A direct method has noredirect_url. - Idempotent. The same
Idempotency-Keyreturns the ORIGINAL intent withduplicate: true. The same key with a different body is409 idempotency_key_conflict.
OneHazel answers 201 with the new intent, or 200 with the original on a replay. Before it records anything it checks that:
- the
Idempotency-Keyheader is present and equals the bodyidempotency_key(otherwise400 invalid_request); connection_idis one of your own connections (otherwise404 invalid_request), its connector is a PSP that requirespayments.intent, and it is live (otherwise422 invalid_request);- you have registered a
payments.intentendpoint, so status changes have somewhere to go (otherwise422 invalid_request); - the currency is in the contract's currency table and the amount is in its minor unit (otherwise
400 invalid_requestnaming the code; an unknown code is never treated as 2-decimal).
When the PSP connector has no payments adapter
OneHazel opens an intent at the PSP through a payments adapter, one per PSP connector. A connector without an adapter still gets an intent, recorded truthfully:
json
{
"intent_id": "pi_3f6c0d2a9b8e4c71a5d0e6f1b2c3d4e5",
"status": "created",
"provider_state": "no_adapter",
"direction": "deposit",
"amount_minor": 2500,
"currency": "EUR",
"exponent": 2,
"...": "..."
}provider_state: "no_adapter" means no PSP was called and the intent will not move until the connector supports hosted intents. There is no redirect_url. Do not show the player a pending payment for it. Once a connector has an adapter its intents carry no provider_state.
Why an intent rather than a refusal: the create and read path, the Idempotency-Key behaviour and the status webhook can then be exercised end to end before a PSP adapter exists, and an operator integration built today keeps working unchanged when one arrives. The field is in the contract since 1.2.0 (provider_state, enum no_adapter) and is absent whenever an adapter exists.
Statuses
| Status | Final | Money |
|---|---|---|
created | no | none yet |
authorised | no | none yet |
settled | yes | the PSP has moved it: credit a deposit, finalise a withdrawal |
failed | yes | none; release a withdrawal you held |
cancelled | yes | none; release a withdrawal you held |
A terminal status never changes again.
Reading an intent with GET /payments/intents/{intent_id} returns its current status. An id that is unknown, or that belongs to another operator, is 404 transaction_unknown.
The status webhook
Each change is delivered to POST /payments/webhook by OneHazel's webhook delivery engine, with event types payment.intent.created, payment.intent.authorised, payment.intent.settled, payment.intent.failed and payment.intent.cancelled. The body is { type, data, operatorId, timestamp, deliveryId }, and data is the intent.
| Header | Value |
|---|---|
X-OneHazel-Signature | sha256=<hex>, HMAC-SHA256 of the raw body keyed by your whsec_ secret. No timestamp. |
X-OneHazel-Env | test or live. Conformance always sends test: apply it to a test ledger, never a real wallet. |
X-OneHazel-Event | the same value as body type |
X-OneHazel-Delivery | the same value as body deliveryId |
Rules your receiver must keep:
- Verify the signature over the raw bytes before parsing, in constant time. Answer
401on a mismatch. - Deduplicate on
deliveryId, and apply each status idempotently perintent_id: credit a settled deposit once, whatever the number of deliveries. - Acknowledge a status for an
intent_idyou have no record of, then read it withGET /payments/intents/{intent_id}. Rejecting it only makes OneHazel retry. - Answer 2xx within 5 seconds.
Today OneHazel sends each status once, when it changes, and records the outcome on the intent. A status you did not receive is still visible through GET /payments/intents/{intent_id}, which is the reconciliation path.
Conformance
Run conformance for payments.intent once your receiver is registered. The profile is operator-payments-v1.
| Scenario | Severity | Passing looks like |
|---|---|---|
intent.created | blocker | 2xx for a signed payment.intent.created |
intent.recreate_same_key | blocker | 2xx when the same intent and key arrive under a new deliveryId; still one intent |
intent.authorised | major | 2xx, and no deposit credited yet |
intent.settled | blocker | 2xx, deposit credited once |
intent.failed | blocker | 2xx, no money moved |
intent.cancelled | blocker | 2xx, no money moved |
intent.unknown_then_poll | major | 2xx for an unknown intent, then reconciled by polling |
intent.tampered_rejected | blocker | non-2xx for a body altered after signing |
intent.jpy_zero_exponent | blocker | 2xx for created and settled of a 1000 JPY intent (amount_minor 1000, exponent 0), applied unscaled |
Every scenario runs in the currency your conformance run is set to (EUR by default) at 10 major units, never fewer than 1000 minor units: 1000 for EUR and JPY, 10000000 for USDT. intent.jpy_zero_exponent always uses JPY, so a receiver that only handles 2-decimal currencies fails it.
The module is conformant when the latest finished run passed. Until then a PSP connector that requires payments.intent reports it as missing, and connect_activate creates nothing.
Activating a PSP
connect_connector_requirements lists payments.intent for a PSP connector. When the PSP publishes a hosted-page client snippet, connect_activate returns it in snippets under the payments.intent module. A PSP with no snippet is direct-only, which is a complete integration.
Samples
| Stack | Sample |
|---|---|
| TypeScript + Express | payments.intent |
| TypeScript + Fastify | payments.intent |
| Python + FastAPI | payments.intent |
| PHP (no framework) | payments.intent |
| PHP + Laravel | payments.intent |
Every sample is the status receiver. Creating intents is a plain HTTPS call to OneHazel from your cashier; see the contract.