powertools-x402 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Requires Node 18+. `@aws-lambda-powertools/event-handler` is a peer dependency,
16
16
 
17
17
  - No payment attached? The caller gets a 402 with signed payment requirements
18
18
  - Payment attached? It gets verified with a facilitator before your handler runs
19
- - With the default exact payment flow, settlement happens after your handler succeeds. If the handler throws or returns an error status, settlement does not occur
19
+ - With the default exact payment flow, settlement happens after your handler succeeds. If the handler throws or returns an error status, settlement does not occur. A `billable` predicate can also decline to charge for a response that succeeded
20
20
  - Verified payment details (payer, amount, network) are available in the request store
21
21
 
22
22
  One contract to understand before you ship: in the default exact flow, verify happens before your handler and settlement happens after. That's the right fit for work that can safely run before the money moves, like inference, generation, and data retrieval. It is not a transaction around your business logic. If your handler performs an irreversible side effect and settlement fails afterward, the work already happened. Keep that kind of work reversible or reconcilable, or handle it with your own orchestration.
@@ -76,7 +76,7 @@ const x402 = createX402({
76
76
  });
77
77
  ```
78
78
 
79
- This emits `PaymentRequired`, `PaymentRejected`, `PaymentVerified`, `PaymentSettled`, `PaymentCancelled`, and `SettlementFailed` counts under the `x402` namespace. Metrics publish immediately, so there's no `publishStoredMetrics()` call to remember. Pass your own `metrics` instance if you want a different namespace.
79
+ This emits `PaymentRequired`, `PaymentRejected`, `PaymentVerified`, `PaymentSettled`, `PaymentCancelled`, `PaymentNotBillable`, and `SettlementFailed` counts under the `x402` namespace. Metrics publish immediately, so there's no `publishStoredMetrics()` call to remember. Pass your own `metrics` instance if you want a different namespace.
80
80
 
81
81
  ### Settle on mainnet with the Coinbase facilitator
82
82
 
@@ -162,6 +162,41 @@ x402.paid({
162
162
  });
163
163
  ```
164
164
 
165
+ ### Decline to charge for a successful response
166
+
167
+ Sometimes the work succeeds, the caller should get the payload, and you still don't want the money. A `billable` predicate runs after your handler on the path that would otherwise settle, and returning `false` skips settlement:
168
+
169
+ ```ts
170
+ app.post(
171
+ '/extract',
172
+ [
173
+ x402.paid({
174
+ price: '$0.05',
175
+ billable: (response) =>
176
+ response
177
+ .json()
178
+ .then(({ fields }: { fields: Record<string, string> }) => Object.keys(fields).length > 0),
179
+ }),
180
+ ],
181
+ async (reqCtx) => {
182
+ const { document } = (await reqCtx.req.json()) as { document: string };
183
+ return { fields: await extractFields(document) };
184
+ }
185
+ );
186
+ ```
187
+
188
+ The caller gets their 200 and the empty result either way; they just aren't charged for it. The same shape covers an LLM route that returned a refusal, or a search that legitimately found nothing.
189
+
190
+ It's all or nothing: `billable` decides whether to charge the full price, not how much to charge. If what you want is to bill less for cheaper work, that's partial settlement, which the `exact` scheme doesn't do.
191
+
192
+ The predicate receives a clone of the handler's response, so reading its body is safe, plus the request context, so `reqCtx.get('payment')` is available. It may be async. Skipping settlement leaves the response exactly as your handler produced it, with no `PAYMENT-RESPONSE` receipt header.
193
+
194
+ A skip counts a `PaymentNotBillable` metric, deliberately separate from `PaymentCancelled`, which stays reserved for handlers that threw or failed. An alarm on cancellations won't fire on a route declining to bill. A predicate that throws is treated as not billable and logged at error, so a broken predicate never turns a successful response into a 500.
195
+
196
+ Note the direction: `billable` can only subtract a settlement, never add one. It isn't consulted at all when the handler throws or returns an error status, since those already skip settlement.
197
+
198
+ It also only prevents a charge when the selected payment flow settles *after* the handler. The default `exact` authorization flow does, and that's what you get unless you ask for something else. But `exact` also supports the `upfront` flow, chosen with `extra: { paymentFlow: 'upfront' }` on an `accepts` entry, and `upfront` and `escrow` both settle before your handler runs. By the time `billable` returns `false` there, the money has already moved and nothing can unmake the charge. Rather than report a refund that never happened, the middleware ignores the `false`, settles as normal so the caller still gets the receipt they paid for, and logs at error.
199
+
165
200
  ### Customize the 402 response
166
201
 
167
202
  ```ts
@@ -92,12 +92,20 @@ function createX402(options) {
92
92
  const { logger } = options;
93
93
  const metrics = options.enableMetrics ? options.metrics ?? new Metrics({ namespace: "x402" }) : void 0;
94
94
  const count = (name) => metrics && (metrics.singleMetric?.() ?? metrics).addMetric(name, "Count", 1);
95
+ const isBillable = async (predicate, reqCtx, path) => {
96
+ try {
97
+ return await predicate(reqCtx.res.clone(), reqCtx);
98
+ } catch (error) {
99
+ logger?.error("x402 billable predicate threw", { path, error });
100
+ return false;
101
+ }
102
+ };
95
103
  const payers = /* @__PURE__ */ new WeakMap();
96
104
  resourceServer.onAfterVerify(async ({ paymentPayload, result }) => {
97
105
  if (result.payer) payers.set(paymentPayload, result.payer);
98
106
  });
99
107
  function paid(route) {
100
- const { price, accepts, onProtectedRequest, ...routeConfig } = route;
108
+ const { price, accepts, onProtectedRequest, billable, ...routeConfig } = route;
101
109
  if (accepts === void 0 && price === void 0) {
102
110
  throw new Error("paid() requires a price or an accepts configuration");
103
111
  }
@@ -171,6 +179,18 @@ function createX402(options) {
171
179
  });
172
180
  return;
173
181
  }
182
+ if (billable && !await isBillable(billable, reqCtx, context.path)) {
183
+ if (beforeHandlerSettlement) {
184
+ logger?.error("x402 billable ignored: flow settles before the handler", {
185
+ path: context.path
186
+ });
187
+ } else {
188
+ await cancellationDispatcher.cancel({ reason: "after_verify_aborted" });
189
+ count("PaymentNotBillable");
190
+ logger?.warn("x402 payment not billed: route declined", { path: context.path });
191
+ return;
192
+ }
193
+ }
174
194
  const settlement = await httpServer.processSettlement(
175
195
  paymentPayload,
176
196
  paymentRequirements,
package/dist/index.cjs CHANGED
@@ -114,12 +114,20 @@ function createX402(options) {
114
114
  const { logger } = options;
115
115
  const metrics = options.enableMetrics ? options.metrics ?? new import_metrics.Metrics({ namespace: "x402" }) : void 0;
116
116
  const count = (name) => metrics && (metrics.singleMetric?.() ?? metrics).addMetric(name, "Count", 1);
117
+ const isBillable = async (predicate, reqCtx, path) => {
118
+ try {
119
+ return await predicate(reqCtx.res.clone(), reqCtx);
120
+ } catch (error) {
121
+ logger?.error("x402 billable predicate threw", { path, error });
122
+ return false;
123
+ }
124
+ };
117
125
  const payers = /* @__PURE__ */ new WeakMap();
118
126
  resourceServer.onAfterVerify(async ({ paymentPayload, result }) => {
119
127
  if (result.payer) payers.set(paymentPayload, result.payer);
120
128
  });
121
129
  function paid(route) {
122
- const { price, accepts, onProtectedRequest, ...routeConfig } = route;
130
+ const { price, accepts, onProtectedRequest, billable, ...routeConfig } = route;
123
131
  if (accepts === void 0 && price === void 0) {
124
132
  throw new Error("paid() requires a price or an accepts configuration");
125
133
  }
@@ -193,6 +201,18 @@ function createX402(options) {
193
201
  });
194
202
  return;
195
203
  }
204
+ if (billable && !await isBillable(billable, reqCtx, context.path)) {
205
+ if (beforeHandlerSettlement) {
206
+ logger?.error("x402 billable ignored: flow settles before the handler", {
207
+ path: context.path
208
+ });
209
+ } else {
210
+ await cancellationDispatcher.cancel({ reason: "after_verify_aborted" });
211
+ count("PaymentNotBillable");
212
+ logger?.warn("x402 payment not billed: route declined", { path: context.path });
213
+ return;
214
+ }
215
+ }
196
216
  const settlement = await httpServer.processSettlement(
197
217
  paymentPayload,
198
218
  paymentRequirements,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Middleware } from '@aws-lambda-powertools/event-handler/types';
1
+ import { RequestContext, Middleware } from '@aws-lambda-powertools/event-handler/types';
2
2
  import { HTTPAdapter, FacilitatorClient, FacilitatorConfig, x402ResourceServer, PaywallConfig, RouteConfig, ProtectedRequestHook } from '@x402/core/server';
3
3
  import { PaymentOption } from '@x402/core/http';
4
4
  import { PaymentPayload, PaymentRequirements, VerifyResponse, SettleResponse, SupportedResponse, Network, Price } from '@x402/core/types';
@@ -54,10 +54,19 @@ interface CreateX402Options {
54
54
  enableMetrics?: boolean;
55
55
  metrics?: X402Metrics;
56
56
  }
57
+ /**
58
+ * Decides whether a successful response should be charged for. Receives a
59
+ * clone of the handler's response, so reading its body is safe. Returning
60
+ * false skips settlement. Only prevents a charge when the selected payment
61
+ * flow settles after the handler, as the default exact authorization flow
62
+ * does; see the README for flows that settle before it.
63
+ */
64
+ type BillablePredicate = (response: Response, reqCtx: RequestContext<X402Environment>) => boolean | Promise<boolean>;
57
65
  interface PaidRouteOptions extends Omit<RouteConfig, 'accepts'> {
58
66
  price?: Price;
59
67
  accepts?: PaymentOption | PaymentOption[];
60
68
  onProtectedRequest?: ProtectedRequestHook;
69
+ billable?: BillablePredicate;
61
70
  }
62
71
  type PaymentInfo = {
63
72
  network: Network;
@@ -78,4 +87,4 @@ declare function createX402(options: CreateX402Options): {
78
87
  resourceServer: x402ResourceServer;
79
88
  };
80
89
 
81
- export { CachingFacilitatorClient, type CreateX402Options, type PaidRouteOptions, type PaymentInfo, PowertoolsAdapter, type SchemeRegistrar, type X402Environment, type X402Logger, type X402Metrics, createX402 };
90
+ export { type BillablePredicate, CachingFacilitatorClient, type CreateX402Options, type PaidRouteOptions, type PaymentInfo, PowertoolsAdapter, type SchemeRegistrar, type X402Environment, type X402Logger, type X402Metrics, createX402 };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Middleware } from '@aws-lambda-powertools/event-handler/types';
1
+ import { RequestContext, Middleware } from '@aws-lambda-powertools/event-handler/types';
2
2
  import { HTTPAdapter, FacilitatorClient, FacilitatorConfig, x402ResourceServer, PaywallConfig, RouteConfig, ProtectedRequestHook } from '@x402/core/server';
3
3
  import { PaymentOption } from '@x402/core/http';
4
4
  import { PaymentPayload, PaymentRequirements, VerifyResponse, SettleResponse, SupportedResponse, Network, Price } from '@x402/core/types';
@@ -54,10 +54,19 @@ interface CreateX402Options {
54
54
  enableMetrics?: boolean;
55
55
  metrics?: X402Metrics;
56
56
  }
57
+ /**
58
+ * Decides whether a successful response should be charged for. Receives a
59
+ * clone of the handler's response, so reading its body is safe. Returning
60
+ * false skips settlement. Only prevents a charge when the selected payment
61
+ * flow settles after the handler, as the default exact authorization flow
62
+ * does; see the README for flows that settle before it.
63
+ */
64
+ type BillablePredicate = (response: Response, reqCtx: RequestContext<X402Environment>) => boolean | Promise<boolean>;
57
65
  interface PaidRouteOptions extends Omit<RouteConfig, 'accepts'> {
58
66
  price?: Price;
59
67
  accepts?: PaymentOption | PaymentOption[];
60
68
  onProtectedRequest?: ProtectedRequestHook;
69
+ billable?: BillablePredicate;
61
70
  }
62
71
  type PaymentInfo = {
63
72
  network: Network;
@@ -78,4 +87,4 @@ declare function createX402(options: CreateX402Options): {
78
87
  resourceServer: x402ResourceServer;
79
88
  };
80
89
 
81
- export { CachingFacilitatorClient, type CreateX402Options, type PaidRouteOptions, type PaymentInfo, PowertoolsAdapter, type SchemeRegistrar, type X402Environment, type X402Logger, type X402Metrics, createX402 };
90
+ export { type BillablePredicate, CachingFacilitatorClient, type CreateX402Options, type PaidRouteOptions, type PaymentInfo, PowertoolsAdapter, type SchemeRegistrar, type X402Environment, type X402Logger, type X402Metrics, createX402 };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  CachingFacilitatorClient,
3
3
  PowertoolsAdapter,
4
4
  createX402
5
- } from "./chunk-D46V2DLG.js";
5
+ } from "./chunk-4ZP7AJJC.js";
6
6
  export {
7
7
  CachingFacilitatorClient,
8
8
  PowertoolsAdapter,
package/dist/stripe.cjs CHANGED
@@ -129,12 +129,20 @@ function createX402(options) {
129
129
  const { logger } = options;
130
130
  const metrics = options.enableMetrics ? options.metrics ?? new import_metrics.Metrics({ namespace: "x402" }) : void 0;
131
131
  const count = (name) => metrics && (metrics.singleMetric?.() ?? metrics).addMetric(name, "Count", 1);
132
+ const isBillable = async (predicate, reqCtx, path) => {
133
+ try {
134
+ return await predicate(reqCtx.res.clone(), reqCtx);
135
+ } catch (error) {
136
+ logger?.error("x402 billable predicate threw", { path, error });
137
+ return false;
138
+ }
139
+ };
132
140
  const payers = /* @__PURE__ */ new WeakMap();
133
141
  resourceServer.onAfterVerify(async ({ paymentPayload, result }) => {
134
142
  if (result.payer) payers.set(paymentPayload, result.payer);
135
143
  });
136
144
  function paid(route) {
137
- const { price, accepts, onProtectedRequest, ...routeConfig } = route;
145
+ const { price, accepts, onProtectedRequest, billable, ...routeConfig } = route;
138
146
  if (accepts === void 0 && price === void 0) {
139
147
  throw new Error("paid() requires a price or an accepts configuration");
140
148
  }
@@ -208,6 +216,18 @@ function createX402(options) {
208
216
  });
209
217
  return;
210
218
  }
219
+ if (billable && !await isBillable(billable, reqCtx, context.path)) {
220
+ if (beforeHandlerSettlement) {
221
+ logger?.error("x402 billable ignored: flow settles before the handler", {
222
+ path: context.path
223
+ });
224
+ } else {
225
+ await cancellationDispatcher.cancel({ reason: "after_verify_aborted" });
226
+ count("PaymentNotBillable");
227
+ logger?.warn("x402 payment not billed: route declined", { path: context.path });
228
+ return;
229
+ }
230
+ }
211
231
  const settlement = await httpServer.processSettlement(
212
232
  paymentPayload,
213
233
  paymentRequirements,
package/dist/stripe.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-FLK2T35N.js";
4
4
  import {
5
5
  createX402
6
- } from "./chunk-D46V2DLG.js";
6
+ } from "./chunk-4ZP7AJJC.js";
7
7
 
8
8
  // src/stripe.ts
9
9
  import Stripe from "stripe";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "powertools-x402",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "x402 payment middleware for AWS Lambda Powertools Event Handler",
5
5
  "license": "MIT",
6
6
  "author": "Allen Helton <allenheltondev@gmail.com>",