HTTP service

The HTTP service: submit operations, receive provider webhooks, and health/readiness endpoints.

Source src/server.ts#L81 · createServerscripts/main.ts#L471 · runServetest/server.test.ts

The request-path counterpart to the Economy

Most of these docs talk about the Economy as an in-process object: you hold a reference and call submit against it directly. But a deployed service has to take requests off a socket. That's what the HTTP service is — a thin wrapper that turns a Fetch Request into one submit call and a Response.

The wrapper is createServer. It takes an Economy and returns a handler with the signature (request: Request) => Promise<Response>:

import { createServer } from '#src/server.ts';

let handler = createServer(economy);
let response = await handler(request); // Request → Response

It uses only Fetch globals — Request, Response, URL, crypto.subtle — and no Node APIs. So the same handler runs on Node, Bun, Deno, and Cloudflare Workers. The wiring in scripts/main.ts (covered below) is what bridges it onto a concrete runtime.

What it exposes

The handler routes four paths and answers 404 to everything else. Two move money or report state; two are for your orchestrator.

Method · PathWhat it does
POST /submitDecode one operation from the JSON body, run it through submit, return the Outcome.
POST /webhooks/:providerVerify an inbound provider callback, then hand it to the injected handler.
GET /healthzReport liveness without touching storage.
GET /readyzReport readiness via one cheap store-touching read.

Submitting an operation

POST /submit is the request-path version of a submit call. You send one operation as a JSON object; you get back its Outcome. The body's kind selects the operation — topUp, spend, refund, and the rest.

One detail matters on the wire: money never travels as a JSON number. A JSON number can't safely hold the integer minor-units an Amount carries, so money fields arrive as decimal strings like CREDIT:10.00 and the server decodes them back into Amount values. Here's a topUp body:

{
  "kind": "topUp",
  "idempotencyKey": "idem_buyer_10",
  "actor": { "kind": "system", "service": "checkout" },
  "userId": "usr_buyer",
  "source": "card",
  "amount": "CREDIT:10.00"
}

A committed operation comes back 200 with its transaction, each leg's amount written as the same decimal string:

{
  "status": "committed",
  "transaction": { "legs": [{ "account": "...", "amount": "CREDIT:10.00" }] }
}

A rejected outcome is not an error. When the economy declines a valid request for a business reason — say a spend the buyer can't cover — the response is still 200, carrying the decline:

{ "status": "rejected", "reason": "INSUFFICIENT_FUNDS" }

That distinction is the whole point of the status mapping below: a decline is a normal answer, a fault is an HTTP error.

How thrown faults map to status codes

When submit throws an EconomyError, statusFor maps it to a status code, and the response carries only the error's message — never its detail, cause, or stack. Those stay server-side.

The mapping is by the error's stable code:

ConditionStatus
Missing permission (UNAUTHORIZED) or bad webhook signature (INVALID_SIGNATURE)401
Caller's request was wrong — malformed operation, invalid amount, currency mismatch400
Retryable fault, like a transient storage failure503
Anything else500

An unexpected throw that isn't an EconomyError is normalized into a retryable storage fault, so it goes out as a 503 with a generic message. The internals never reach the client.

Receiving provider webhooks

The other money-moving path is inbound. A payment provider calls POST /webhooks/:provider to report a real-world event — a user's purchase cleared, a payout settled, a charge was disputed. The :provider segment names who's calling (steam, billing, and so on); it comes from the route, not the body, so a caller can't spoof it.

The server doesn't apply these itself. It verifies the callback, then hands the trusted bytes to a WebhookHandler you inject through ServerOptions. With no handler wired, the path answers 404. The handler is where the callback becomes a ledger operation — a cleared purchase maps to a topUp, a settled payout to a settlePayout, a dispute to a clawback. Those mappers (toTopUp, toSettlePayout, toClawback) and the event shapes they consume belong to the processor port, so see that page for the dispatch.

The verification gate

When you pass a Config with a webhook secret, the server gates every callback before the handler runs. A forged request never reaches code that changes balances. The checks run in order:

  1. Signature. The hex x-signature header must be an HMAC-SHA256 of the raw body, keyed with the configured secret. A mismatch is 401 INVALID_SIGNATURE and nothing downstream runs. The check uses crypto.subtle.verify, which compares in constant time.
  2. Freshness. The x-timestamp header must be finite and within config.replayWindowMs of the clock. A stale or missing timestamp is answered 200 with { "status": "duplicate" } — not a 5xx that would invite a retry storm.
  3. Replay. When you also wire a ReplayStore, the server claims the provider's eventId last, after signature and freshness. A repeat eventId is answered 200 and the handler never runs, so its work happens once. Claiming last means a forged or stale delivery can never burn a real eventId and block a later genuine one.

This is the request-path half of idempotency: the replay store drops most redeliveries here, at the edge, before they reach the handler.

When no secret is configured, the webhook path is a bare pass-through: the server forwards straight to the handler and the host owns verification. That's a deliberate fallback, not the production posture — the wiring below fails to start without a secret.

Health and readiness

The two GET probes are for your orchestrator, and they answer different questions.

GET /healthz is liveness: the process is up and can serve a response. It does no I/O, so it answers even when a downstream dependency is down. The Dockerfile health check targets this path.

{ "status": "ok" }

GET /readyz is readiness: a dependency is reachable, so the orchestrator can route traffic here. It does one cheap store-touching read through the economy — the balance of a known SYSTEM account. Any throw means the store is unreachable, reported as 503 with no detail:

{ "status": "ready" }     // 200, store reachable
{ "status": "unavailable" } // 503, store read threw

The probe goes through the economy's public read surface, not the ledger directly — createServer only ever receives an Economy, so it can't reach past that boundary.

Wiring it onto a runtime

createServer is runtime-agnostic on purpose, which means something has to mount it on a real listener. That's scripts/main.ts — the app entry point, and the one place environment variables are read.

It runs in three modes, selected by argv[2]:

ModeWhat it starts
serveThe HTTP API on $PORT (default 3000); store, cache, and dispatcher come from the environment.
devThe same API, forced to in-memory adapters with dev secrets — no infrastructure, for make dev.
workerThe background worker loop, not the HTTP service.

In serve and dev, runServe builds the economy from the environment and mounts the handler. It passes three things into createServer: the purchase-webhook handler, the Config, and the clock. Those last two are what activate the verification gate — so a genuine callback is persisted and a forged or stale one is rejected before it changes anything.

let handler = createServer(economy, {
  webhook: purchaseWebhook(caps.store, caps.ids, caps.clock),
  config,
  clock: defaults.clock,
});

On Bun and Deno, the Fetch handler is served directly. On Node, the entry bridges node:http requests into web Request/Response objects so the same Fetch-only handler runs unchanged — which is exactly why this translation lives in scripts/ and the rest of src/ stays runtime-agnostic.

What's stubbed versus production-grade

The HTTP edge itself — the routing, the wire codec, the webhook gate, the status mapping — is the real thing the tests exercise (test/server.test.ts covers /submit, the HMAC and freshness checks, the leak-proof error body, and both probes).

The runtime bridge in scripts/main.ts is honest dev plumbing, not a hardened gateway. It reads the whole request body into memory before handing it over, applies no rate limiting or request-size cap, and the Node path is a minimal node:http translation. In a real deployment you'd typically sit this behind a reverse proxy that owns TLS, limits, and timeouts. The service's job is the economy boundary; the front door is the host's.

See also