Skip to content
For LLMsView as Markdown·

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 ​

OperationPurposeIdempotency key
POST /payments/webhookReceive a payment intent status change.no

Every delivery is checked before it is parsed: X-OneHazel-Signature is sha256=<hex>, 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=<whsec signing secret> npx tsx onehazel-payments-intent.ts
# base URL to register: https://<your host>/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<string, unknown>;

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=<hex HMAC-SHA256(whsec, raw body)>`, 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<string>();
const INTENTS = { test: new Map<string, IntentRecord>(), live: new Map<string, IntentRecord>() };
/** 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<string, bigint>(), live: new Map<string, bigint>() };
/** Intents a status arrived for before we knew them: read each with GET /payments/intents/{intent_id}. */
const TO_RECONCILE = new Set<string>();

/** 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<Reply> {
  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.