Skip to content

TypeScript middleware omits PAYMENT-RESPONSE header on settlement failure #1127

@ryanRfox

Description

@ryanRfox

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions