@zeroclickai/paywrap
v0.0.22
Published
Framework-agnostic primitives for building agent-callable paid APIs over HTTP: MPP/x402 gates, OpenAPI payment metadata, credential verification, and settlement logs.
Readme
@zeroclickai/paywrap
Framework-agnostic primitives for building agent-callable paid APIs over HTTP. Gate routes with MPP (session + charge intents on Tempo, USDC settlement) or x402 (facilitator-mediated USDC settlement on Base / Base Sepolia), publish OpenAPI payment metadata, verify payment credentials, and emit structured settlement logs. Each protocol lives behind its own subpath — pick one or run both side-by-side per route.
Two ways in:
- Already have an API?
pnpm add @zeroclickai/paywrap @zeroclickai/paywrap-adapter-fastifyor@zeroclickai/paywrap-adapter-hono— 10 lines of middleware to gate any route (see Path A below). - Starting fresh?
npx @zeroclickai/paywrap-cli create my-service— interactive scaffold with routes, env schema, Dockerfile, optional DB + worker (see Path B below). Nothing to install up-front.
The kit is intentionally narrow — everything here is runnable from any Node HTTP framework. For Fastify, layer @zeroclickai/paywrap-adapter-fastify on top; for Hono / Workers / Bun, use @zeroclickai/paywrap-adapter-hono. The CLI is a separate package so runtime services don't carry scaffolder deps.
Building your first paid API? Start with the Service Builder Guide. It is the canonical walkthrough for choosing between charge-based, session-based, metered, and charge-plus-proof services.
Documentation
Use the npm page as a fast front door, then jump into the page that matches the work in front of you:
| Page | Use it when... |
|---|---|
| Service Builder Guide | You are designing a new paid service or deciding which payment model fits. |
| List Your Service On Zero | You are ready to register a deployed service with Zero's /v1/register endpoint. |
| Package Reference | You need the import map, companion packages, and manifest/OpenAPI helpers. |
| Operations Guide | You are preparing a service for production logging, validation, refunds, and smoke tests. |
Two quickstart paths
Both paths are first-class. Pick the one that matches your starting point.
Path A — Drop-in to an existing Fastify API
"I already have a service and want to MPP-gate one or more endpoints."
pnpm add @zeroclickai/paywrap @zeroclickai/paywrap-adapter-fastifyStand up mppx, wire it onto a fastify instance, and gate any route with the app.mppGated(...) preHandler:
import Fastify from "fastify";
import { createPaywrapMpp } from "@zeroclickai/paywrap/mpp";
import { createFastifyApp } from "@zeroclickai/paywrap-adapter-fastify";
import { consoleJsonLogger } from "@zeroclickai/paywrap/logger";
// Default mode: pass `walletAddress` only — buyer signs and pays gas, so
// the seller never holds a private key. Use `walletPrivateKey` for
// session-intent or `feePayer: true` charge (see callout below).
const mpp = createPaywrapMpp({
walletAddress: process.env.WALLET_ADDRESS as `0x${string}`,
mppSecretKey: process.env.MPP_SECRET_KEY!,
publicBaseUrl: process.env.PUBLIC_BASE_URL!,
tempoRpcUrl: "https://rpc.tempo.xyz",
logger: consoleJsonLogger, // structured payment_required/settled/failed events on stdout
// `store` omitted → in-memory by default when REDIS_URL is unset.
});
// Your existing ctx — typically env, logger, db, services. The adapter only
// needs `logger` + `mppx` + `mppxChannelStore` for mppGated to work.
const app = createFastifyApp({
logger: yourPinoInstance,
mppx: mpp.mppx,
mppxChannelStore: mpp.channelStore,
});
app.post(
"/generate",
{ preHandler: app.mppGated({ scope: "gen:1", amount: 50_000n, intent: "charge" }) },
async (req) => {
// req.payer is typed Hex; req.verifiedCredential is the branded VerifiedCredential.
const result = await yourBusinessLogic(req.body, req.payer);
return { result };
},
);
await app.listen({ port: 3000 });No Redis required for dev. paywrap uses an in-memory channel store when
REDIS_URLis unset. Add Redis before scaling to multiple replicas — see@zeroclickai/paywrap/mpp'sredisStore. On Cloudflare Workers, useworkersKvStore(charge-safe; non-atomic for high-concurrency session — read the docblock).
Wallet bootstrap. Who pays gas depends on the intent:
chargeintent — buyer pays gas. The buyer signs a Tempo tx withfeeToken: USDC, the seller broadcasts it as-is. Seller wallet needs NO funding; it's just a signer (for HMAC-bound challenges) + settlement recipient.sessionintent — seller pays gas. The seller submitsopenChannel/closeChanneltxs and needs USDC to cover those. Usepaywrap prefundto seed ~$0.05.proofintent — zero-amount wallet-auth; no on-chain activity, no funding.Opting into
feePayer: trueon charge sponsors the buyer's gas — then the seller pays. mppx's default is no fee-payer. ThepaywrapCLI providespaywrap generate-wallet+paywrap prefund.
Default mode is keyless — pass a
walletAddress. For charge-intent and proof-intent services (the common case for stateless paid APIs) the seller never needs a private key in the runtime.createPaywrapMpp({ walletAddress })returns the basicPaywrapMppshape:{ mppx, channelStore, walletAddress }. No signing material is provisioned, rotated, or accidentally logged.import { createPaywrapMpp, type PaywrapMpp } from "@zeroclickai/paywrap/mpp"; const mpp: PaywrapMpp = createPaywrapMpp({ walletAddress: process.env.WALLET_ADDRESS as `0x${string}`, // public address only mppSecretKey: process.env.MPP_SECRET_KEY!, publicBaseUrl: process.env.PUBLIC_BASE_URL!, tempoRpcUrl: "https://rpc.tempo.xyz", });Pass
walletPrivateKeyinstead when you need a signer: session intent (server-sideopenChannel/closeChannelwrites), session settle helpers likecloseSessionOnChain, metered close helpers, orfeePayer: truecharge variants. That returnsPaywrapMppKeyed, which structurally extendsPaywrapMppwithaccount+client— so anywhere that takesPaywrapMppalso accepts a keyed bundle, but helpers that need a signer (e.g.closeSessionOnChain(mpp: PaywrapMppKeyed)) reject the keyless variant at the type level. The config is discriminated: passing both is a compile error. CallingmppGated({ intent: "session", ... })ormppMetered({ ... })against a keylessmppx500s every unpaid request withchallenge_generation_failed: tempo.session is not a function— the property is undefined because no session method was registered.
Metered / session checklist — both are required to ship, both have bitten consumers twice. If your service has any
priceModel: 'metered'route OR anymppGated({ intent: 'session' })route, you need:
- Keyed mode —
walletPrivateKey, per the paragraph above. Address-only mppx silently breaks every metered 402.- A durable, linearizable channel store —
redisStore(new IORedis(REDIS_URL, { db: 9 }))is the supported production choice. The kit's auto-default in-memory store is dev-only: under multi-instance Cloud Run, server restarts, or horizontal scale, open channels strand their on-chain deposit and the metered close-voucher path races between replicas. Workers KV is supported for charge-intent ONLY (the kit refuses it for delegated mode); its ~60s eventual-consistency window is unsafe for session under concurrent dispatch.Both pre-reqs are infra you provision (a wallet secret + a Memorystore instance), so the kit doesn't enforce them at construction time — but skipping either means the paywall passes dev tests and breaks in prod.
First-pass curl against a paid route:
# 1. Buyer hits the endpoint with no payment header.
curl -X POST "https://api.example.com/generate" -i
# HTTP/1.1 402 Payment Required
# www-authenticate: Payment eyJyZWFsbSI6ImFwaS5leGFtcGxlLmNvbSIs...
# {"challenge":{...},"detail":"payment_required"}
# 2. Buyer signs the challenge (the `zero` CLI does this transparently)
# and retries with Authorization: Payment <credential>.
curl -X POST "https://api.example.com/generate" \
-H "Authorization: Payment eyJjaGFsbGVuZ2UiOnsiLi4uIjp9fQ=="
# HTTP/1.1 200 OK
# {"result":"..."}Authorization header is ready-to-send.
buildVoucherCredentialandbuildChargeCredential(from@zeroclickai/paywrap/signing) return the FULL Authorization header value, including thePaymentprefix. Pass it as-is:fetch(url, { headers: { authorization: await buildChargeCredential(...) } }). Do not wrap it in another"Payment ".
Path A2 — Drop-in to an existing Hono / Workers API
"I already have a Worker or Hono service and want route-level gates."
pnpm add @zeroclickai/paywrap @zeroclickai/paywrap-adapter-honoWorkers bindings only exist per request, so build the paywrap ctx inside the createHonoApp factory:
import { createHonoApp, mppGated } from "@zeroclickai/paywrap-adapter-hono";
import { buildOpenApiSpec } from "@zeroclickai/paywrap/manifest";
import { createPaywrapMpp, workersKvStore } from "@zeroclickai/paywrap/mpp";
type Env = {
PAYWRAP_KV: KVNamespace;
WALLET_ADDRESS: string;
MPP_SECRET_KEY: string;
PUBLIC_BASE_URL: string;
TEMPO_RPC_URL: string;
SKU_PRICE_USDC_MICRO: string;
};
const SKU = "diagram-render:v1";
const PRICING_VERSION = 1;
const SCOPE = `${SKU}:${PRICING_VERSION}`;
const app = createHonoApp<{ Bindings: Env }>((c) => {
// Cast the env string to viem's branded Hex once. Env bindings are
// always `string`; `createPaywrapMpp` wants `0x${string}`.
const walletAddress = c.env.WALLET_ADDRESS as `0x${string}`;
const mpp = createPaywrapMpp({
walletAddress,
mppSecretKey: c.env.MPP_SECRET_KEY,
publicBaseUrl: c.env.PUBLIC_BASE_URL,
tempoRpcUrl: c.env.TEMPO_RPC_URL,
store: workersKvStore(c.env.PAYWRAP_KV),
});
return {
mppx: mpp.mppx,
mppxChannelStore: mpp.channelStore,
walletAddress: mpp.walletAddress, // canonical — present in both keyed and address-only modes
priceUsdcMicro: BigInt(c.env.SKU_PRICE_USDC_MICRO),
};
});
const buildManifest = (ctx) => ({
wallet: ctx.walletAddress,
paidRoutes: [
{
method: "POST" as const,
path: "/v1/render",
protocol: "mpp" as const,
sku: SKU,
priceUsdcMicro: ctx.priceUsdcMicro.toString(),
pricingVersion: PRICING_VERSION,
description: "Render a diagram and return SVG bytes.",
requestContentType: "application/json",
responseContentType: "image/svg+xml",
},
],
freeRoutes: [{ method: "GET" as const, path: "/healthz" }],
});
// `/openapi.json` is the public discovery contract — paired with real `402`
// responses on paid routes, it tells indexers and agents exactly what this
// service costs and how to call it.
app.get("/openapi.json", (c) => {
const ctx = c.get("paywrapApp").ctx;
return c.json(
buildOpenApiSpec(
buildManifest(ctx),
{ title: "Diagram Service", version: "1.0.0" },
{ serverUrl: c.env.PUBLIC_BASE_URL },
),
);
});
app.post(
"/v1/render",
async (c, next) => {
const ctx = c.get("paywrapApp").ctx;
const gate = mppGated({
scope: SCOPE,
intent: "charge",
amount: ctx.priceUsdcMicro,
meta: { sku: SKU, pricingVersion: String(PRICING_VERSION) },
preCheck: async ({ c }) => {
const body = await c.req.json().catch(() => null);
if (!body || typeof body.diagram !== "string") {
return { ok: false, status: 400, body: { error: "invalid_body" } };
}
c.set("renderBody" as never, body as never);
return { ok: true };
},
});
// Hono's middleware type can't see the extra paywrapApp binding here.
return gate(c as never, next);
},
async (c) => {
const body = c.get("renderBody" as never) as { diagram: string };
const result = await renderDiagram(body.diagram, c.var.payer);
return c.body(result.svg, 200, { "content-type": "image/svg+xml" });
},
);
export default { fetch: app.fetch };This is the same route-level gate as Fastify, with Worker-safe state. Use workersKvStore for charge-intent replay protection; for Node/Bun services use redisStore(...) or the default in-memory store for local dev.
Path B — Start from scratch with the CLI
"I don't have a service yet; I want the full scaffold."
npx @zeroclickai/paywrap-cli create my-service
cd my-service
pnpm devThe CLI walks through:
- intent —
session(channel vouchers, extend semantics),charge(atomic single-shot), orproof(wallet-auth only). - price + scope — micro-USDC price per call; HMAC-bound scope string.
- framework + storage + queue + hosting — fastify, postgres/sqlite/redis, bullmq/none, render/fly/self-host.
- wallet — generates a fresh private key inline, prints the address + private key once.
- prefund — session intent only; tops up the seller wallet with ~$0.05 USDC on Tempo so
openChannel/closeChannelcan pay gas. Charge and proof services skip this (buyer-paid gas / zero on-chain).
Post-scaffold you have a working 402 paid endpoint in under 3 minutes. Business logic lives behind TODO(paywrap) markers — everything surrounding it (challenge minting, verification, payer resolution, manifest, healthz) is already wired.
Path B is right when you want the full service (DB + worker + reaper + manifest). Path A is right when you already have an API and just want to charge for it.
Subpaths
The kit ships no root barrel — import only the subpath you need. This keeps your bundle tight and the dependency graph honest.
| Subpath | Exports | Reach for it when… |
|---|---|---|
| @zeroclickai/paywrap/mpp | createPaywrapMpp, memoryStore, redisStore, workersKvStore, closeSessionOnChain, rollbackSessionVoucher, verifyWithScope, assertVoucherAdvances, TEMPO_ESCROW, TEMPO_USDC, TEMPO_CHAIN_ID, tempoChain | Bootstrapping mppx, choosing a channel-state store (in-memory / Redis / Workers KV), verifying credentials at route handlers, closing channels on-chain. rollbackSessionVoucher reverts highestVoucher to a prior value so the failed call's amount stays in escrow and refunds on close — see the Hono adapter's mppGated({ refundOnFailure: true }) for the auto-wired version. |
| @zeroclickai/paywrap/mpp/metered | metered-settlement helpers | Helpers for metered (max-authorized, actual-usage) settlement flows. Pair with mppMetered from the Hono adapter for routes whose final price is only known after the handler runs. |
| @zeroclickai/paywrap/x402 | createPaywrapX402, BASE_NETWORK, BASE_SEPOLIA_NETWORK, BASE_USDC, BASE_SEPOLIA_USDC, x402NetworkId, x402UsdcAsset | Bootstrapping the seller-side x402 resource server (Base / Base Sepolia, exact-evm, USDC). Pair with the hono adapter's x402Gated() to gate routes. Settlement runs through the configured x402 facilitator (defaults to https://x402.org/facilitator). |
| @zeroclickai/paywrap/auth | buildSessionChallenge, buildChargeChallenge, buildProofChallenge, payerFromCredential, fingerprintCredential, VerifiedCredential, VERIFIED (brand symbol) | Minting 402 challenges from any framework, resolving the authenticated payer address from a verified credential, computing a stable idempotency key from a Payment … header. |
| @zeroclickai/paywrap/signing | signVoucher, buildVoucherCredential, buildChargeCredential, channelIdFromLabel | Buyer-side code (CLI, agent) producing signed Tempo vouchers / charge credentials. Useful in integration tests too. |
| @zeroclickai/paywrap/testing | seedChannel, stubVerifyCredential | Seeding a ChannelStore with a fake-open channel in tests; stubbing mppx.verifyCredential to skip on-chain settlement. Not for production. |
| @zeroclickai/paywrap/crypto | encryptSecret, decryptSecret, AES-256-GCM helpers | At-rest encryption of upstream credentials (connection strings, API tokens) stored in your DB. |
| @zeroclickai/paywrap/manifest | buildOpenApiSpec, PaywrapManifest, PaidRoute, FreeRoute | Keeping route pricing in one typed manifest and emitting OpenAPI with x-payment-info + 402 responses. buildOpenApiSpec is the public discovery contract — serve it at /openapi.json. |
| @zeroclickai/paywrap/health | aggregateHealthProbes | Assembling /healthz responses from per-subsystem probes. |
| @zeroclickai/paywrap/setup | generateWallet, generateMppSecretKey, prefundWallet | One-shot setup scripts the CLI wraps; callable from a consumer's own pnpm setup. |
| @zeroclickai/paywrap/proxy | proxyUpstreamRequest, UpstreamProxyResponse | Charge-intent services proxying an upstream API. Sniffs Content-Type and returns a discriminated {kind: "json" \| "binary"} so PNG/PDF endpoints don't get JSON-corrupted. |
| @zeroclickai/paywrap/logger | LoggerCallback, PaywrapLogEvent, consoleJsonLogger, safeLog, shortFingerprint, logRefundOwed, RefundReason | Structured-event logging hook. Pass a logger to createPaywrapMpp / createPaywrapX402 and adapters emit payment_required, payment_settled, payment_failed, and request_completed. Also exports logRefundOwed for emitting paywrap_refund_owed events on post-settlement failures (typed reason bucket; see "Refund-eligible failures" below). Sink-agnostic — see "Observability" below. |
| @zeroclickai/paywrap/refund | refundCharge, RefundChargeInput, RefundSentEvent | Send a USDC refund from the seller wallet to the original payer. Requires keyed mode (PaywrapMppKeyed). Single-tx primitive — caller decides when to refund and tracks idempotency. See "Refunding charges" below. |
Service discovery: OpenAPI + 402
/openapi.json is the public discovery surface, paired with real 402 Payment Required responses from paid routes. Generate it with buildOpenApiSpec from @zeroclickai/paywrap/manifest: paid operations get x-payment-info (method, currency, amount, sku, pricingVersion) plus a 402 response, and the live route returns the actual payment challenge header when called without a credential. Together those two surfaces are the entire contract — indexers and agents do not need anything else to discover or call your service.
import { buildOpenApiSpec } from "@zeroclickai/paywrap/manifest";
const buildManifest = (ctx) => ({
wallet: ctx.walletAddress,
paidRoutes: [
{
method: "POST",
path: "/v1/render",
protocol: "mpp",
sku: "mermaid-render:v1",
priceUsdcMicro: "1000",
pricingVersion: 1,
description: "Render Mermaid diagram text to SVG, PNG, JPEG, WebP, or PDF bytes.",
requestContentType: "application/json",
responseContentType: "image/svg+xml",
},
],
freeRoutes: [
{ method: "GET", path: "/openapi.json" },
{ method: "GET", path: "/healthz" },
],
});
app.get("/openapi.json", (c) =>
c.json(
buildOpenApiSpec(
buildManifest(ctx),
{ title: "My Service", version: "1.0", description: "What it does." },
{ serverUrl: ctx.env.PUBLIC_BASE_URL },
),
),
);buildOpenApiSpec emits one operation per route. Paid operations include x-payment-info (method, currency, amount, sku, pricingVersion) and a 402 response. sku is seller-defined and should change when the priced unit changes. pricingVersion lets buyers bind a credential to a specific price contract without encoding every version detail into the path.
Validate before charging
For charge-intent routes, mppGated verifies and settles before your handler runs. Use preCheck for business validation that must happen before the buyer pays: malformed JSON, name collisions, unsupported enum values, quota checks, idempotent retries, and other cheap local checks.
app.post(
"/v1/things",
mppGated({
scope: "thing-create:v1",
intent: "charge",
amount: 50_000n,
preCheck: async ({ c, claimedPayer }) => {
const body = await c.req.json().catch(() => null);
if (!body || typeof body.name !== "string") {
return { ok: false, status: 400, body: { error: "invalid_body" } };
}
if (await nameAlreadyExists(body.name, claimedPayer)) {
return { ok: false, status: 409, body: { error: "name_taken" } };
}
c.set("thingInput" as never, body as never);
return { ok: true };
},
}),
async (c) => {
const body = c.get("thingInput" as never);
return c.json(await createThing(body, c.var.payer), 201);
},
);claimedPayer is only a hint extracted before verification. Use it for reads and routing decisions, never for irreversible writes. The adapter emits payment_failed with stage: "precheck" if your pre-check throws.
Dynamic pricing
If price comes from env, tenant config, or a route table, build the gate inside a small middleware so the amount in the challenge matches the manifest and runtime config.
app.post(
"/v1/render",
async (c, next) => {
const price = BigInt(c.env.SKU_PRICE_USDC_MICRO);
const gate = mppGated({
scope: "render:v1",
intent: "charge",
amount: price,
meta: { sku: "render:v1", pricingVersion: "1" },
});
return gate(c as never, next);
},
async (c) => c.json({ ok: true }),
);Refund-eligible failures: emit paywrap_refund_owed
When charge-intent settles and the upstream call fails, the buyer paid for nothing. The kit does not ship an automated refund executor — refunds happen out-of-band by an operator process. Paywrap's job is making them auditable: emit one structured event per post-settlement failure in a fixed shape, so a single grep across log streams produces the refund queue.
Use logRefundOwed from @zeroclickai/paywrap/logger:
import { logRefundOwed } from "@zeroclickai/paywrap/logger";
logRefundOwed({
payer: req.payer,
sku: "jigsaw-image-gen:v2",
amountUsdcMicro: "50000",
reason: "upstream_5xx", // typed RefundReason bucket
details: { upstreamStatus: 503 },
chargeHash, // tx hash or shortFingerprint(fingerprintCredential(authHeader))
});reason is a typed RefundReason union (upstream_5xx, upstream_4xx_post_settlement, upstream_timeout, upstream_rate_limit, worker_crash, post_settlement_validation, unknown). Service-specific specifics go in details. The helper writes a JSON line to console.error; pass a sink to redirect.
Use this for post-settlement failures you would not intentionally bill for: upstream 5xx, upstream rate limits, network timeouts, worker crashes after settlement, and validation you could only do after the paid side effect began. Do not emit for preCheck rejections — those happen before settlement. For upstream/user 4xx, decide per product: if the user paid for validation or linting, return the 4xx as the paid result; if the upstream rejected a request your service should have caught before settlement, log the refund event.
The helper has no field for the raw Payment ... header — privacy is enforced at the type level. See Operations Guide § Refund-Eligible Failures for the full reason taxonomy.
Refunding charges (opt-in)
When you've decided a refund is owed, refundCharge from @zeroclickai/paywrap/refund sends USDC from the seller wallet back to the original payer:
import { refundCharge } from "@zeroclickai/paywrap/refund";
const { txHash } = await refundCharge(mpp, {
payer: event.payer,
amountUsdcMicro: BigInt(event.amountUsdcMicro),
note: `auto-refund for ${event.reason}`,
chargeHash: event.chargeHash,
sku: event.sku,
});Three things to know:
- Requires keyed mode. The helper takes
PaywrapMppKeyed(the seller wallet must have a private key in the runtime — address-only mode is a compile error). Charge-intent's just-settled USDC funds the refund tx; gas is paid in USDC viafeeToken: USDC. - Caller owns idempotency. The kit does not track which charges have been refunded. Use your own DB / KV /
chargeHashdedup before calling. Calling twice will send two refunds. - Decision is yours. Paywrap deliberately does NOT auto-refund from the request path — that masks bugs and creates abuse vectors. Wire
refundChargeinto an operator script, a scheduled job that drains a refund queue, or an in-handler post-failure call where YOU decide a refund is owed. The kit ships the primitive; you ship the policy.
A paywrap_refund_sent JSON line is emitted to console.error on success — pair with grep paywrap_refund_(owed|sent) to reconcile owed vs sent across log streams.
Observability
Pass a logger to either factory and the kit emits structured events at every interesting moment of a paid request — payment_required, payment_settled, payment_failed, request_completed. The kit is sink-agnostic: pick a destination that fits your runtime.
Default: stdout JSON
import { createPaywrapMpp } from "@zeroclickai/paywrap/mpp";
import { consoleJsonLogger } from "@zeroclickai/paywrap/logger";
const mpp = createPaywrapMpp({
walletPrivateKey: env.WALLET_PRIVATE_KEY,
publicBaseUrl: env.PUBLIC_BASE_URL,
mppSecretKey: env.MPP_SECRET_KEY,
tempoRpcUrl: env.TEMPO_RPC_URL,
logger: consoleJsonLogger, // ← one JSON line per event on stdout
});This works everywhere console.log does — captured by wrangler tail (Workers), Render's log viewer, journalctl, etc.
Cloudflare Workers + Analytics Engine
import { createPaywrapX402 } from "@zeroclickai/paywrap/x402";
import type { LoggerCallback } from "@zeroclickai/paywrap/logger";
const aeLogger: LoggerCallback = (event) => {
c.env.PAYMENTS_AE.writeDataPoint({
indexes: [event.kind === "payment_settled" ? event.payer : ""],
blobs: [event.kind, event.protocol ?? "", JSON.stringify(event)],
doubles: [
event.kind === "payment_settled" ? Number(event.amountUsdcMicro) / 1e6 : 0,
event.kind === "request_completed" ? event.latencyMs : 0,
],
});
};
const x402 = createPaywrapX402({ payTo, network: "base", logger: aeLogger });Workers Analytics Engine is free up to 25M data points/month and queryable via the Workers Analytics SQL API.
Render / Node + Datadog
import type { LoggerCallback } from "@zeroclickai/paywrap/logger";
const ddLogger: LoggerCallback = async (event) => {
// Don't await this in the hot path — fire-and-forget on Workers, or
// wrap with `c.executionCtx.waitUntil(...)` if you need delivery
// guarantees.
fetch("https://http-intake.logs.datadoghq.com/api/v2/logs", {
method: "POST",
headers: { "DD-API-KEY": process.env.DD_API_KEY!, "Content-Type": "application/json" },
body: JSON.stringify({ ...event, ddsource: "paywrap", service: "my-service" }),
}).catch(() => {});
};For Render specifically, consoleJsonLogger plus a configured Log Stream in the Render dashboard forwards everything to Logtail / Datadog / Papertrail without code changes.
What's in each event
type PaywrapLogEvent =
| { v: 1; kind: "payment_required"; protocol: "mpp" | "x402"; route, scope, intent?, amountUsdcMicro?, meta? }
| { v: 1; kind: "payment_settled"; protocol: "mpp" | "x402"; payer, seller, amountUsdcMicro, route, scope?, sku?, latencyMs, txHash?, network?, sessionId?, credentialFingerprint? }
| { v: 1; kind: "payment_failed"; protocol: "mpp" | "x402"; stage: "verify" | "settle" | "precheck" | "unknown"; reason, scope?, route? }
| { v: 1; kind: "request_completed"; route, status, latencyMs, payer? };Schema is versioned (v: 1) so dashboards can pin to a known shape.
Privacy invariants enforced by the kit
- The raw
Payment …Authorization header never appears in any event. Only the first 16 hex chars offingerprintCredential(rawHeader)are surfaced (ascredentialFingerprint). - No private keys, no signatures. Payer addresses are public on-chain so they're fine to log.
Logger errors are never your problem
Adapters call your logger through safeLog, which swallows synchronous throws and async rejections. A flaky sink (Datadog 5xx, network blip) cannot break a paid call. If your logger silently no-ops, the kit logs nothing — but the request still settles correctly.
x402 settle events come "for free"
For x402, the kit registers onAfterSettle / onSettleFailure hooks on the underlying x402ResourceServer inside createPaywrapX402. That means payment_settled events include the on-chain txHash and verified payer address from the facilitator response — no glue code needed in your service.
Manual route pattern (when you can't use mppGated)
app.mppGated(...) fits most paid routes, but sometimes you need to run business validation before settling — e.g. a POST /deploys endpoint that must reject a name collision without charging. For those cases, drop the preHandler and do the three-step pattern by hand:
import { extractCredential, sendSessionChallenge } from "@zeroclickai/paywrap-adapter-fastify";
import { payerFromCredential } from "@zeroclickai/paywrap/auth";
import { verifyWithScope } from "@zeroclickai/paywrap/mpp";
app.post("/v1/things", async (req, reply) => {
const header = (req.headers.authorization ?? req.headers.payment) as string | undefined;
const credential = extractCredential(header);
if (!credential) {
return sendSessionChallenge(app, reply, {
amount: "0.02", suggestedDeposit: "0.02", unitType: "request",
scope: "my-svc:1", detail: "payment_required",
});
}
// ... business pre-checks that must NOT consume a charge ...
// verifyWithScope is the ONLY factory for VerifiedCredential — forget the
// scope check and TypeScript rejects any downstream call.
const verified = await verifyWithScope(app.ctx.mppx, credential, "my-svc:1");
const payer = await payerFromCredential(app.ctx.mppxChannelStore, verified);
if (!payer) return reply.status(500).send({ error: "channel_state_missing" });
// ... create your resource, return 202 ...
});Security contract
verifyWithScope is the only factory that produces a VerifiedCredential. The branded type uses a module-private symbol, so no caller can forge the verified shape to hand to payerFromCredential without actually running the scope check. This closes a class of footguns where a route forgets to check the credential's scope and accepts a cross-route replay.
HMAC-bound challenge ids + scope enforcement are load-bearing: the kit enforces them at the type level.
Related packages
@zeroclickai/paywrap-adapter-fastify— Fastify adapter:app.mppGated(...),sendSessionChallenge,sendChargeChallenge,sendProofChallenge,extractCredential,createFastifyApp.@zeroclickai/paywrap-adapter-hono— Hono / Workers / Bun adapter:createHonoApp(...),mppGated(...),x402Gated(...), challenge helpers, Worker KV state examples.@zeroclickai/paywrap-cli(bin:paywrap) — interactive scaffolder (paywrap create), wallet generator.
