Skip to content
For LLMsView as Markdown·

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:

DirectionOperationAuth
You call OneHazelPOST /payments/intents creates a deposit or withdrawal intentAuthorization: Bearer oh_live_... and an Idempotency-Key header
You call OneHazelGET /payments/intents/{intent_id} reads an intent's current statusAuthorization: Bearer oh_live_...
OneHazel calls youPOST /payments/webhook under the base URL you register for payments.intentsha256= 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. 2500 is 25.00 EUR; for JPY (exponent 0) 1000 is 1000 JPY; for USDT (exponent 6) 10000000 is 10.000000 USDT. Every intent echoes exponent, so you read the amount without a lookup. A currency code outside the contract's table is 400 invalid_request naming the code, and an amount finer than the minor unit is 400 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 no amount_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 your return_url. A direct method has no redirect_url.
  • Idempotent. The same Idempotency-Key returns the ORIGINAL intent with duplicate: true. The same key with a different body is 409 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-Key header is present and equals the body idempotency_key (otherwise 400 invalid_request);
  • connection_id is one of your own connections (otherwise 404 invalid_request), its connector is a PSP that requires payments.intent, and it is live (otherwise 422 invalid_request);
  • you have registered a payments.intent endpoint, so status changes have somewhere to go (otherwise 422 invalid_request);
  • the currency is in the contract's currency table and the amount is in its minor unit (otherwise 400 invalid_request naming 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 ​

StatusFinalMoney
creatednonone yet
authorisednonone yet
settledyesthe PSP has moved it: credit a deposit, finalise a withdrawal
failedyesnone; release a withdrawal you held
cancelledyesnone; 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.

HeaderValue
X-OneHazel-Signaturesha256=<hex>, HMAC-SHA256 of the raw body keyed by your whsec_ secret. No timestamp.
X-OneHazel-Envtest or live. Conformance always sends test: apply it to a test ledger, never a real wallet.
X-OneHazel-Eventthe same value as body type
X-OneHazel-Deliverythe same value as body deliveryId

Rules your receiver must keep:

  1. Verify the signature over the raw bytes before parsing, in constant time. Answer 401 on a mismatch.
  2. Deduplicate on deliveryId, and apply each status idempotently per intent_id: credit a settled deposit once, whatever the number of deliveries.
  3. Acknowledge a status for an intent_id you have no record of, then read it with GET /payments/intents/{intent_id}. Rejecting it only makes OneHazel retry.
  4. 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.

ScenarioSeverityPassing looks like
intent.createdblocker2xx for a signed payment.intent.created
intent.recreate_same_keyblocker2xx when the same intent and key arrive under a new deliveryId; still one intent
intent.authorisedmajor2xx, and no deposit credited yet
intent.settledblocker2xx, deposit credited once
intent.failedblocker2xx, no money moved
intent.cancelledblocker2xx, no money moved
intent.unknown_then_pollmajor2xx for an unknown intent, then reconciled by polling
intent.tampered_rejectedblockernon-2xx for a body altered after signing
intent.jpy_zero_exponentblocker2xx 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 ​

StackSample
TypeScript + Expresspayments.intent
TypeScript + Fastifypayments.intent
Python + FastAPIpayments.intent
PHP (no framework)payments.intent
PHP + Laravelpayments.intent

Every sample is the status receiver. Creating intents is a plain HTTPS call to OneHazel from your cashier; see the contract.