@receipt-graph/provider-middleware
v0.1.0
Published
Provider-side ReceiptGraph middleware (x402 route hooks, lifecycle events to ReceiptGraph API).
Readme
@receipt-graph/provider-middleware
Composable Hono middleware that observes x402 payment flow and emits lifecycle events to the ReceiptGraph HTTP API. Observation points and ReceiptGraph mapping are specified in code (OBSERVATION_HOOK_SPECS); this package includes the HTTP client and hashing helpers used by those hooks.
Install order (Hono)
Mount ReceiptGraph first, then your existing x402 middleware, then the business handler. The ReceiptGraph layer wraps the downstream stack so it can see the standard x402 PAYMENT-REQUIRED challenge header, the paid retry, and the final response.
import { Hono } from "hono";
import { receiptGraphProvider } from "@receipt-graph/provider-middleware";
const app = new Hono();
app.use(
"/api/weather",
receiptGraphProvider({
serviceId: "weather-api",
merchantName: "Weather Service",
receiptGraphUrl: process.env.RECEIPTGRAPH_URL!,
apiKey: process.env.RECEIPTGRAPH_API_KEY!,
}),
existingX402Middleware,
weatherHandler,
);Settlement (onSettled)
Phase 3 checklist 5: your x402 resource server calls onSettled(txHash) when settlement succeeds. This package binds that callback to the current HTTP request via Node AsyncLocalStorage so you configure onSettled once at process startup and still emit to the correct receiptId.
Use receiptGraphProviderArtifacts (same config as receiptGraphProvider) and call the returned onSettled from the standard @x402/hono resource-server hook:
import { Hono } from "hono";
import {
receiptGraphProviderArtifacts,
} from "@receipt-graph/provider-middleware";
import { x402ResourceServer } from "@x402/hono";
const { middleware: receiptGraph, onSettled } = receiptGraphProviderArtifacts({
serviceId: "weather-api",
merchantName: "Weather Service",
receiptGraphUrl: process.env.RECEIPTGRAPH_URL!,
apiKey: process.env.RECEIPTGRAPH_API_KEY!,
});
const resourceServer = new x402ResourceServer(facilitatorClient)
.register("eip155:84532", exactEvmScheme)
.onAfterSettle(async ({ result }) => {
if (result.success) await onSettled(result.transaction);
});
app.use(
"/api/weather",
receiptGraph,
x402HonoMiddleware,
weatherHandler,
);While handling a paid request, the middleware sets receiptId and verifyExpected automatically when paymentAccept is configured (static x402 v2 accept metadata for paid retries). You can still use assignReceiptGraphRequestContext for advanced cases. If onSettled runs outside receiptGraphProvider / AsyncLocalStorage, the default emitMode: "best_effort" path reports via onError and does not throw.
Emit order: payment_seen runs before await next() so the ReceiptGraph state machine stays ahead of x402 onSettled (which runs during next()). Configure x402 so onSettled fires after payment validation (typical). delivery runs after next() when the response is not 402.
Verification after txHash
When onSettled(txHash) successfully posts POST /api/receipts/:receiptId/settlement, the default verifySettlement: true path immediately posts POST /api/verify-settlement with:
{
receiptId,
txHash,
expectedPayTo,
expectedAsset,
expectedAmount,
}expectedPayTo and expectedAsset are lowercased, and expectedAmount is the trimmed x402 v2 amount string from the selected accept row. In ordinary paid retries, those fields come from paymentAccept; custom stacks can set them with assignReceiptGraphRequestContext({ verifyExpected }) before calling onSettled(txHash).
MVP behavior for not_indexed_yet: the ReceiptGraph API owns its Phase 2 retry policy. If the API still returns not_indexed_yet or any other non-verified outcome, the middleware reports it through onError under the settlement phase. With default emitMode: "best_effort", the paid response still returns to the user; a later reconciler can finish verification. Because the current API accepts delivery only after verified, a deferred verification can also make the later delivery emit return 409/currentStatus until reconciliation advances the receipt. Use emitMode: "strict" for demo or test flows that should fail closed when verification does not reach verified.
Configuration
All keys are typed on ReceiptGraphProviderConfig (exported from the package entry).
| Field | Required | Purpose |
| --- | --- | --- |
| serviceId | yes | Stable slug for service registration and analytics. |
| merchantName | yes | Display default; middleware uses x402 resource serviceName when present, otherwise this fallback. |
| receiptGraphUrl | yes | ReceiptGraph API base URL (no trailing slash; normalized at runtime). |
| apiKey | yes | Secret for authenticating to ReceiptGraph (header shape defined with the HTTP client). |
| network | no | Static network label when not inferred from accepts[].network. |
| paymentAccept | no | Paid retries: static x402 v2 accept metadata (scheme, network, payTo, asset, amount, plus route resource) so receiptId, payment_seen, onSettled, and delivery work when this request has no fresh PAYMENT-REQUIRED header. Must match the row used for receiptId (including optional merchantName). |
| onError | no | Callback for failed emits; default is best-effort (errors do not block payment). |
| verifySettlement | no | Default true: call POST /api/verify-settlement after settlement in demo-style flows. Set false to defer verification. |
| emitMode | no | "best_effort" (default) or "strict". Controls whether a failed ReceiptGraph emit aborts the request after onError. |
Observation hooks
The middleware observes the stack without mutating x402 payment authorization. Each step has a stable ObservationHookPhase (used in onError) and a mapping to ReceiptGraph HTTP routes and Prisma ReceiptEventType values. The canonical table is OBSERVATION_HOOK_SPECS in src/observation-hooks.ts.
| Hook | ReceiptGraph HTTP | Resulting ReceiptEventType (on success) |
| --- | --- | --- |
| request_start | (none — local capture for pending) | — |
| pending | POST /api/receipts/pending | pending_created |
| payment_seen | POST /api/receipts/:receiptId/payment-seen | payment_seen |
| settlement | POST /api/receipts/:receiptId/settlement | settlement_submitted, settled (provider path from payment_seen; see ReceiptGraph postSettlement) |
| delivery | POST /api/receipts/:receiptId/delivery | delivered or delivery_failed (from HTTP status on the business response) |
Emits should use runReceiptGraphEmit so emitMode and onError stay consistent. delivery requires the receipt to be verified on the API; when verifySettlement is true, the middleware should complete verification before emitting delivery.
Hashing and requestHash
Canonical implementations live in src/hashing.ts and src/receipt-id.ts and are re-exported from the package entry:
| Function | Role |
| --- | --- |
| computeRequestHash(method, fullUrl) | SHA-256(method + normalizedUrl + sortedQueryParams) — scheme/host lowercased, trailing slash stripped from path (except /), query names sorted. |
| computeReceiptId(...) | SHA-256 over network \|\| resource \|\| … \|\| requestHash \|\| timestampBucket with field normalization (see buildReceiptIdPreimage). |
| floorTimestampToMinuteBucket | 1-minute unix bucket for timestampBucket. |
| computeRequestBodyHash / computeResponseBodyHash / computePaymentHeaderHash / computeOutputSchemaHash | Privacy and schema hashing (UTF-8 or stable JSON). |
The ./api workspace includes tests/request-hash-alignment.test.ts so ReceiptGraph and middleware stay bit-identical on golden vectors.
ReceiptGraphClient
ReceiptGraphClient calls:
POST /api/receipts/pendingPOST /api/receipts/:receiptId/payment-seenPOST /api/receipts/:receiptId/settlementPOST /api/receipts/:receiptId/deliveryPOST /api/verify-settlement
Authentication header: Authorization: Bearer <apiKey> (see api/.env.example for RECEIPTGRAPH_API_KEY).
pendingNetworkRetries: onlypostPendingretries whenfetchrejects (e.g.TypeError); HTTP 409 and other response errors are not retried.onDebug: optional callback; on 409 responses receivesreceipt_graph_409withcurrentStatusand parsed JSON body for operator logs.ReceiptGraphHttpError: thrown on non-success status; exposesstatus,body, andcurrentStatuswhen present.
Scripts
npm run typecheck—tsc --noEmitnpm test— Vitest unit tests (no network, no Postgres)npm run release:dry-run— run the full npm publish dry runnpm run release— publish to npm
