--- title: "payments.intent sample: TypeScript + Express" description: "Operator Contract v1.4.1 payments.intent module, TypeScript + Express. Generated from the contract." --- # payments.intent: TypeScript + Express The endpoint OneHazel delivers payment intent status changes to. OneHazel never holds funds: the PSP settles, OneHazel relays the status, and this module credits a settled deposit once per `intent_id`. You create intents by calling OneHazel's `POST /payments/intents` with an `oh_live_*` key. Generated from Operator Contract v1.4.1. Key `typescript/express/payments.intent`; the `connect_samples` MCP tool returns this page for that key. ## What it implements | Operation | Purpose | Idempotency key | |---|---|---| | `POST /payments/webhook` | Receive a payment intent status change. | no | Every delivery is checked before it is parsed: `X-OneHazel-Signature` is `sha256=`, HMAC-SHA256 of the RAW body keyed by the `whsec_` signing secret, compared in constant time. There is no timestamp and no bearer, so deduplicating on `deliveryId` is the replay defence. `X-OneHazel-Env` is then required: `test` traffic goes to a separate test ledger and never touches the live one. A bad signature answers `401` with the flat `{ code, message, retryable }` envelope. ## Run it ```bash npm install express npm install --save-dev tsx @types/express @types/node ONEHAZEL_SIGNING_SECRET= npx tsx onehazel-payments-intent.ts # base URL to register: https:///onehazel/v1 (ONEHAZEL_BASE_PATH changes the path) ``` Environment: `ONEHAZEL_SIGNING_SECRET` is the `whsec_` secret `connect_register_endpoint` showed you once for `payments.intent`. During a rotation set `ONEHAZEL_SIGNING_SECRET_PREVIOUS` to the old one for the overlap hour. ## Code `onehazel-payments-intent.ts` ```ts import express from "express"; import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto"; // Generated from the OneHazel Operator Contract v1.4.1: operations, request fields, // error codes with their HTTP status, and the signature tolerance. Do not hand-edit; regenerate. const CONTRACT = { "version": "1.4.1", "auth": { "scheme": "sha256", "bearer": false, "envHeader": true }, "errorStatus": { "unauthorized": 401, "signature_invalid": 401, "idempotency_key_conflict": 409, "launch_token_consumed": 409, "round_closed": 409, "currency_unsupported": 422, "currency_mismatch": 422, "player_blocked": 422, "rollback_amount_mismatch": 422, "insufficient_funds": 402, "internal_error": 500, "unavailable": 500, "invalid_request": 400, "session_unknown": 404, "session_expired": 404, "launch_token_unknown": 404, "launch_token_expired": 404, "player_unknown": 404, "game_unknown": 404, "transaction_unknown": 404, "freeround_unknown": 404 }, "retryable": [ "internal_error", "unavailable" ], "ops": [ { "path": "/payments/webhook", "operationId": "paymentIntentStatusReceive", "summary": "Receive a payment intent status change.", "idempotent": false, "fields": [ { "name": "type", "type": "string", "required": true, "enum": [ "payment.intent.created", "payment.intent.authorised", "payment.intent.settled", "payment.intent.failed", "payment.intent.cancelled" ] }, { "name": "data", "type": "object", "required": true, "fields": [ { "name": "intent_id", "type": "string", "required": true, "minLength": 1 }, { "name": "status", "type": "string", "required": true, "enum": [ "created", "authorised", "settled", "failed", "cancelled" ] }, { "name": "direction", "type": "string", "required": true, "enum": [ "deposit", "withdraw" ] }, { "name": "connection_id", "type": "string", "required": true }, { "name": "player_external_id", "type": "string", "required": true }, { "name": "amount_minor", "type": "integer", "required": false, "minimum": 0 }, { "name": "amount_minor_string", "type": "string", "required": false, "pattern": "^[1-9][0-9]{15,39}$" }, { "name": "currency", "type": "string", "required": true, "pattern": "^[A-Z][A-Z0-9]{2,9}$" }, { "name": "exponent", "type": "integer", "required": true, "minimum": 0 }, { "name": "method", "type": "string", "required": true }, { "name": "redirect_url", "type": "string", "required": false }, { "name": "idempotency_key", "type": "string", "required": false, "minLength": 1, "maxLength": 128 }, { "name": "duplicate", "type": "boolean", "required": false }, { "name": "failure_reason", "type": "string", "required": false }, { "name": "provider_state", "type": "string", "required": false, "enum": [ "no_adapter" ] }, { "name": "created_at", "type": "string", "required": true }, { "name": "updated_at", "type": "string", "required": true } ] }, { "name": "operatorId", "type": "string", "required": true }, { "name": "timestamp", "type": "string", "required": true }, { "name": "deliveryId", "type": "string", "required": true } ], "closed": false } ] } as const; // ───────────────────────────── configuration ───────────────────────────── function requiredEnv(name: string): string { const value = process.env[name]; if (!value) { // Never print the value of a secret, only its name. console.error(`${name} is not set`); process.exit(1); } return value; } // During a rotation OneHazel signs with the new secret; the old one stays valid for 1 hour. const SIGNING_SECRETS = [requiredEnv("ONEHAZEL_SIGNING_SECRET"), process.env.ONEHAZEL_SIGNING_SECRET_PREVIOUS] .filter((s): s is string => Boolean(s)); const PORT = Number(process.env.PORT ?? "3000"); const BASE_PATH = (process.env.ONEHAZEL_BASE_PATH ?? "/onehazel/v1").replace(/\/+$/, ""); // ───────────────────────────── errors ───────────────────────────── type ErrorCode = keyof typeof CONTRACT.errorStatus; interface Reply { status: number; body: unknown; } /** Thrown anywhere in a handler; becomes the flat `{ code, message, retryable }` envelope. */ class ContractError extends Error { constructor(public readonly code: ErrorCode, message: string) { super(message); } } function errorReply(code: ErrorCode, message: string): Reply { return { status: CONTRACT.errorStatus[code], body: { code, message, retryable: (CONTRACT.retryable as readonly string[]).includes(code) }, }; } // ───────────────────────────── request authentication ───────────────────────────── type HeaderGetter = (name: string) => string | undefined; function constantTimeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); // Compare against itself on a length mismatch so the timing does not reveal the length. return ab.length === bb.length ? timingSafeEqual(ab, bb) : (timingSafeEqual(ab, ab), false); } // ───────────────────────────── request validation ───────────────────────────── type Body = Record; interface Field { readonly name: string; readonly type: string; readonly required: boolean; readonly minimum?: number; readonly exclusiveMinimum?: number; readonly minLength?: number; readonly maxLength?: number; readonly pattern?: string; readonly enum?: readonly string[]; readonly fields?: readonly Field[]; } function checkFields(value: unknown, fields: readonly Field[], closed: boolean, where: string): Body { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new ContractError("invalid_request", `${where || "body"} must be a JSON object`); } const obj = value as Body; if (closed) { for (const key of Object.keys(obj)) { if (!fields.some((f) => f.name === key)) throw new ContractError("invalid_request", `unknown field ${where}${key}`); } } for (const f of fields) { const v = obj[f.name]; const name = `${where}${f.name}`; if (v === undefined || v === null) { if (f.required) throw new ContractError("invalid_request", `${name} is required`); continue; } const ok = f.type === "integer" ? Number.isSafeInteger(v) : f.type === "array" ? Array.isArray(v) : f.type === "object" ? typeof v === "object" && !Array.isArray(v) : typeof v === f.type; if (!ok) throw new ContractError("invalid_request", `${name} must be ${f.type}`); if (typeof v === "number") { if (f.minimum !== undefined && v < f.minimum) throw new ContractError("invalid_request", `${name} is below ${f.minimum}`); if (f.exclusiveMinimum !== undefined && v <= f.exclusiveMinimum) { throw new ContractError("invalid_request", `${name} must be greater than ${f.exclusiveMinimum}`); } } if (typeof v === "string") { if (f.minLength !== undefined && v.length < f.minLength) throw new ContractError("invalid_request", `${name} is too short`); if (f.maxLength !== undefined && v.length > f.maxLength) throw new ContractError("invalid_request", `${name} is too long`); if (f.pattern !== undefined && !new RegExp(f.pattern).test(v)) throw new ContractError("invalid_request", `${name} is malformed`); if (f.enum !== undefined && !f.enum.includes(v)) throw new ContractError("invalid_request", `${name} is not an allowed value`); } if (f.fields) checkFields(v, f.fields, false, `${name}.`); } return obj; } function parseBody(rawBody: Buffer): unknown { try { return JSON.parse(rawBody.toString("utf8")); } catch { throw new ContractError("invalid_request", "body is not valid JSON"); } } // ───────────────────────────── delivery authentication ───────────────────────────── // // Payment status deliveries use the OneHazel webhook engine's format: // `X-OneHazel-Signature: sha256=`, no timestamp and no // bearer. Unlike webhook.receive they also carry `X-OneHazel-Env` (test or live), checked // after the signature. Verify over the RAW bytes, before the body is parsed. function verifyWebhookSignature(rawBody: Buffer, header: string | undefined, secrets: string[]): boolean { const match = /^sha256=([0-9a-f]{64})$/.exec((header ?? "").trim()); if (!match) return false; let ok = false; for (const secret of secrets) { const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); // No early exit: every secret is compared. ok = constantTimeEqual(match[1], expected) || ok; } return ok; } function authenticateWebhook(header: HeaderGetter, rawBody: Buffer): "test" | "live" { if (!verifyWebhookSignature(rawBody, header("x-onehazel-signature"), SIGNING_SECRETS)) { throw new ContractError("signature_invalid", "bad X-OneHazel-Signature"); } const env = header("x-onehazel-env"); if (env !== "test" && env !== "live") throw new ContractError("invalid_request", "X-OneHazel-Env must be test or live"); return env; } // ───────────────────────────── payment status ───────────────────────────── // // OneHazel never holds funds: the PSP settles, OneHazel records the intent and relays each // status here. Deliveries are at-least-once, so deduplicate on deliveryId AND apply each // status idempotently per intent_id: a settled deposit is credited once, whatever the number // of deliveries. The state change is applied before acknowledging (it is a map update, well // inside the 5 s budget), so an acknowledged credit is never lost. `test` traffic (every // conformance run) only ever touches the test ledger. Everything lives in memory here; use // your database, with the credit and the intent update in one transaction. type IntentStatus = "created" | "authorised" | "settled" | "failed" | "cancelled"; interface PaymentIntent { intent_id: string; status: IntentStatus; direction: "deposit" | "withdraw"; player_external_id: string; /** Exactly one of amount_minor and amount_minor_string (above 2^53 - 1) is present. */ amount_minor?: number; amount_minor_string?: string; currency: string; /** Decimal places of `currency` (JPY 0, EUR 2, USDT 6, ETH 18): amount_minor is in units of 10^-exponent. */ exponent: number; [key: string]: unknown; } interface Delivery { type: string; data: PaymentIntent; operatorId: string; timestamp: string; deliveryId: string; } interface IntentRecord { status: IntentStatus; credited: boolean; } const TERMINAL: readonly IntentStatus[] = ["settled", "failed", "cancelled"]; const SEEN = new Set(); const INTENTS = { test: new Map(), live: new Map() }; /** Balances per player AND currency (minor units of different currencies never add up), as bigint: amounts can exceed 2^53. */ const LEDGER = { test: new Map(), live: new Map() }; /** Intents a status arrived for before we knew them: read each with GET /payments/intents/{intent_id}. */ const TO_RECONCILE = new Set(); /** The exact amount, whichever of the two fields carries it. */ function amountMinor(intent: PaymentIntent): bigint { const hasInt = intent.amount_minor !== undefined; if (hasInt === (intent.amount_minor_string !== undefined)) { throw new ContractError("invalid_request", "exactly one of amount_minor and amount_minor_string is required"); } return hasInt ? BigInt(intent.amount_minor as number) : BigInt(intent.amount_minor_string as string); } function applyStatus(env: "test" | "live", intent: PaymentIntent): void { const intents = INTENTS[env]; const known = intents.get(intent.intent_id); if (!known && intent.status !== "created") TO_RECONCILE.add(intent.intent_id); const record = known ?? { status: intent.status, credited: false }; // A terminal status never changes again; a late or replayed delivery is a no-op. if (known && TERMINAL.includes(known.status)) return; record.status = intent.status; if (intent.status === "settled" && intent.direction === "deposit" && !record.credited) { const ledger = LEDGER[env]; const key = `${intent.player_external_id}:${intent.currency}`; ledger.set(key, (ledger.get(key) ?? 0n) + amountMinor(intent)); record.credited = true; } // A withdrawal you held on create is released on failed or cancelled; a settled one is final. intents.set(intent.intent_id, record); } /** The whole module: one call per request, framework-agnostic. */ async function handle(path: string, header: HeaderGetter, rawBody: Buffer): Promise { try { const op = CONTRACT.ops.find((o) => o.path === path); if (!op) return { status: 404, body: { code: "invalid_request", message: "unknown operation", retryable: false } }; const env = authenticateWebhook(header, rawBody); const event = checkFields(parseBody(rawBody), op.fields, op.closed, "") as unknown as Delivery; if (header("x-onehazel-delivery") !== event.deliveryId || header("x-onehazel-event") !== event.type) { throw new ContractError("invalid_request", "X-OneHazel-Delivery and X-OneHazel-Event must match the body"); } if (event.type !== `payment.intent.${event.data.status}`) { throw new ContractError("invalid_request", "type must match data.status"); } amountMinor(event.data); // reject a malformed amount before the delivery is marked seen if (SEEN.has(event.deliveryId)) return { status: 200, body: { received: true, duplicate: true } }; SEEN.add(event.deliveryId); applyStatus(env, event.data); return { status: 200, body: { received: true, duplicate: false } }; } catch (e) { if (e instanceof ContractError) return errorReply(e.code, e.message); console.error("payment status handler failed", e instanceof Error ? e.message : "unknown error"); return errorReply("internal_error", "internal error"); } } // ───────────────────────────── Express wiring ───────────────────────────── const app = express(); // Keep the body RAW: the signature is computed over the exact bytes OneHazel sent. app.use(BASE_PATH, express.raw({ type: () => true, limit: "1mb" }), async (req, res) => { if (req.method !== "POST") { res.status(405).json({ code: "invalid_request", message: "POST only", retryable: false }); return; } const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); const header: HeaderGetter = (name) => { const v = req.headers[name]; return Array.isArray(v) ? v[0] : v; }; const reply = await handle(req.path, header, raw); res.status(reply.status).json(reply.body); }); app.listen(PORT, () => console.log(`OneHazel module listening on :${PORT}${BASE_PATH}`)); ``` ## Before you go live - Replace the in-memory (or file) stores with your database. The idempotency store must survive restarts. - Point the `live` ledger at your real wallet; keep `test` traffic on the test ledger. - Credit the player and record the intent in one database transaction, keyed by `intent_id`, so a settled deposit is credited exactly once. - Run conformance (`connect_run_conformance`) until every scenario passes.