--- title: "Connect module: payments.intent" description: Deposit and withdrawal intents through a PSP connector. Who calls whom, integer minor units, the status webhook, conformance scenarios and samples. --- 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.** `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](/connect/contract#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 | 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=`, 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: 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`. | 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](/connect/samples/typescript/express/payments.intent) | | TypeScript + Fastify | [payments.intent](/connect/samples/typescript/fastify/payments.intent) | | Python + FastAPI | [payments.intent](/connect/samples/python/fastapi/payments.intent) | | PHP (no framework) | [payments.intent](/connect/samples/php/plain/payments.intent) | | PHP + Laravel | [payments.intent](/connect/samples/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](/connect/contract).