-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Problem
The v2 spec requires PAYMENT-RESPONSE on all settlement responses, success and failure alike (specs/transports-v2/http.md, lines 117-153). The three TS middleware implementations only set it on success. On failure, clients get a 402 with no header and getPaymentSettleResponse() throws "Payment response header not found" (x402HTTPClient.ts:143).
Spec reference
The spec shows PAYMENT-RESPONSE on both outcomes:
Success (line 117):
HTTP/1.1 200 OK
Content-Type: application/json
PAYMENT-RESPONSE: eyJzdWNjZXNzIjp0cnVlLCJ0cmFuc2FjdGlvbiI6IjB4MTIzNDU2...Failure (line 141):
HTTP/1.1 402 Payment Required
Content-Type: application/json
PAYMENT-RESPONSE: eyJzdWNjZXNzIjpmYWxzZSwiZXJyb3JSZWFzb24iOiJpbnN1ZmZp...Same header, different payload (success: true vs success: false).
Affected code
Each middleware sets the header on success but skips it on failure:
Express (typescript/packages/http/express/src/index.ts):
if (!settleResult.success) {
bufferedCalls = [];
res.status(402).json({ error: "Settlement failed", details: settleResult.errorReason });
return; // no header
}
// success path — header set
Object.entries(settleResult.headers).forEach(([key, value]) => {
res.setHeader(key, value);
});Hono (typescript/packages/http/hono/src/index.ts):
if (!settleResult.success) {
res = c.json({ error: "Settlement failed", details: settleResult.errorReason }, 402);
// no header
} else {
Object.entries(settleResult.headers).forEach(([key, value]) => {
res.headers.set(key, value);
});
}Next.js (typescript/packages/http/next/src/utils.ts):
if (!result.success) {
return new NextResponse(JSON.stringify({ error: "Settlement failed", details: result.errorReason }), {
status: 402, headers: { "Content-Type": "application/json" }, // no PAYMENT-RESPONSE
});
}
Object.entries(result.headers).forEach(([key, value]) => {
response.headers.set(key, value);
});Reproduction
Drop this into typescript/packages/http/express/src/index.test.ts on main. It passes, confirming the header is missing:
it("does NOT set PAYMENT-RESPONSE header when settlement fails", async () => {
setupMockHttpServer(
{
type: "payment-verified",
paymentPayload: mockPaymentPayload,
paymentRequirements: mockPaymentRequirements,
},
{ success: false, errorReason: "Insufficient funds" },
);
const middleware = paymentMiddleware(
mockRoutes,
{} as unknown as x402ResourceServer,
undefined,
undefined,
false,
);
const req = createMockRequest();
const res = createMockResponse();
const next = vi.fn(() => {
res.statusCode = 200;
res.end();
});
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(402);
// header is never set on failure
const setHeaderCalls = vi.mocked(res.setHeader).mock.calls;
const paymentResponseHeaders = setHeaderCalls.filter(
([key]) => key === "PAYMENT-RESPONSE",
);
expect(paymentResponseHeaders).toHaveLength(0);
});The mock type tells the same story — failure is { success: false; errorReason: string } with no headers field, while success is { success: true; headers: Record<string, string> }.
Impact
Without the header, clients can't tell "you need to pay" apart from "you paid but settlement failed," and can't read errorReason. Every example and the e2e client call getPaymentSettleResponse() without a try/catch, so a settlement failure crashes the client.
Scope
TS middleware only. Go/Gin has the same gap but is out of scope here.
Related issues
- Error in error response example #575 - Error in error response example
- Hard to understand what went wrong when something fails #959 - Hard to understand what went wrong when something fails
- Payload is verified successfully but fails settlement #961 - Payload is verified successfully but fails settlement
- Handling settlement failure #222 - Handling settlement failure