Skip to content
Paykit
Esc
↑↓navigate↵open⌘Jpreview
On this page

Commands

Every write is a named command with an id, and the log records it once.

Nothing changes a balance directly. A write is a command: TrackUsage, Hold, SettleHold, GrantBalance, ChangeTier, Refund. Each command carries an id, appends its events, and answers the state its caller should see next.

const track = yield* Paykit.TrackUsage(account);
yield* track({ featureId: "messages", value: 1, idempotencyKey: requestId });

Paykit.TrackUsage(account) binds the account and returns the verb. The verb takes the rest, and the request has no account field, so a call site cannot name a second account.

An id makes a retry the same command

Every command request carries the key: idempotencyKey on a tracked usage, dedupeKey on a hold or a credit spend. The key is also the command id, so a transport that redelivers the call records one fact and not two.

Order the key by the work, not by the attempt. A request id, a turn id or a purchase id is a stable key. A timestamp is not.

The reply is the command’s own result, not a second read. TrackUsage answers the balance after the spend, and SettleHold answers the units it billed and the units it returned, so the number is the same over every transport.

The event log is the write record

A command appends events to billing_event. The subscription, balances, receipts and read models are folds of that log, so a number that cannot be reproduced by replaying events is a bug.

A command carries the money it recorded. Prices live on the events, so a catalog change never rewrites what an account was sold at.

One model, several surfaces

Paykit.handler mounts the built-in surfaces at paths a deployment can move:

Surface Default What answers there
Tenant API /api The account’s own reads and commands
Operator API /admin Grants, refunds, grace and expiry
Checkout /checkout The provider’s path to pay a plan
Webhooks /webhooks One route per provider delivery
Plan operations /plans Attach, update and preview
Commands /api/commands The command RPCs Atom calls
const handler = yield* Paykit.handler({
  provider: Provider,
  accountOf: (request) => Paykit.accountFromHeaders(request.headers),
  surfaces: "tenant",
  mount: { tenant: "/api", checkout: "/pay", webhooks: "/hooks" },
});

return { fetch: handler.fetch };

The handler builds no world. Provide one, as Getting started does. surfaces: "tenant" serves the tenant API only, so a process that should not expose operator commands cannot.

The same commands over HTTP, MCP and a CLI

The tenant API is a domain API: it has one command list, and HTTP, MCP and a CLI derive from it. There is no second tool list to keep in step. BillingApiLayer resolves the command bindings, and a client that has only a URL calls the same commands through the RPC surface.

Who may run one

A guard compares the command’s target with the session’s account. The resolver answers who is calling, and the guard refuses a command that names another account with Forbidden. A missing session is Unauthenticated.

See Auth for the resolver and the two guards.

A command that cannot finish

A refusal is a typed error on the command, not a broken response. TrackUsage answers AllowanceExceeded, SpendLimitExceeded or StandingBlocksUsage before it writes anything. The caller maps the error to a status, and the log is unchanged.

See Track usage for the refusals each command can answer.

The layer’s page is BillingApiLayer.

Was this page helpful?