Skip to content

feat(x402): Add the x402 credit purchases endpoint - #6648

Merged
osmarluz merged 7 commits into
mainfrom
feat/x402/credit-purchases
Oct 15, 2026
Merged

osmarluz merged 7 commits into
mainfrom
feat/x402/credit-purchases

Conversation

@osmarluz

@osmarluz osmarluz commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Context

E8 is how the lago-x402 middleware turns an agent's signed payment into credits: Lago settles the payment itself, then grants. This completes the endpoint on top of its refusals.

Description

  • X402::CreditPurchases::PurchaseService looks the payment digest up first and answers a replay from its row; otherwise it runs the refusals, /verify, the pending row and /settle, flips a settled row alone, then grants.
  • An unconfirmed settle answers pending with reconcile_after. Any grant failure after the settle answers credit_grant_failed, so the middleware knows the money moved, and the settled row is what a replay grants from.
  • POST /api/v1/x402/credit_purchases renders the settlement under x402_settlement. It reads the payment from the raw body, because parameter parsing drops null elements from arrays and the payment must reach the facilitator as the agent signed it.

@osmarluz
osmarluz added this pull request to stack #6649 October 12, 2026 17:46
@osmarluz
osmarluz force-pushed the feat/x402/credit-purchases branch from 3af148e to 2667e81 Compare October 13, 2026 07:42
@osmarluz
osmarluz marked this pull request as ready for review October 13, 2026 07:42
@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — the new purchase path can settle and grant credits against targets that the gate path would reject.

  • Enforce the gate's plan eligibility at purchase time and cover it in PurchaseService specs. CreditPurchases::ValidateService checks only that plan_code exists, so this endpoint accepts non-USD or non-usage-only plans even though GateChecks::RouteCheckService rejects them; plan_code is not bound by the signed payment.
  • Reject an existing target wallet whose currency is not USD and add regression coverage. The validator checks only x402_enabled, so a valid EUR x402 wallet receives USDC settled_amount_cents as EUR cents, while newly created x402 wallets are correctly restricted to USD.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — pending purchases have no completion path. A facilitator timeout or settlement_pending response creates a pending settlement, but no service/job consumes pending_reconciliation; retries only return that same row, while the unique pending-payer constraint blocks every later purchase. Add reconciliation that settles and grants (or fails/unblocks) these rows, with coverage for the timeout/pending lifecycle.

@osmarluz
osmarluz force-pushed the feat/x402/credit-purchases branch from 78ab7ba to 40b2dbb Compare October 13, 2026 09:09
@osmarluz

osmarluz commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Regarding the automated pre-review:

  1. Taken in feat(x402): Refuse invalid x402 credit purchases #6645 (3a5b076c3): E8 now refuses plan_currency_not_supported and plan_not_usage_only before /verify, with E7's own rules, which X402::PlanEligibility now holds for both.
  2. Declined: only E8's own grant sets x402_enabled today, from a wallet shape it requires to be USD. The x402 wallet toggle (E10) will refuse a non-USD wallet, and the review of feat(x402): Add the x402 gate check #6603 kept that as the single guard.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — ambiguous settlements can remain pending forever, potentially taking funds without issuing credits.

  • PurchaseService#settle returns every non-settled outcome as pending, and #answer_replay only re-renders that row. Nothing consumes X402::Settlement.pending_reconciliation or uses the chain readers to advance it. Add and schedule reconciliation that marks pending rows settled or failed and invokes GrantService after a confirmed settlement, with coverage from timeout/settlement_pending through credit grant.

@osmarluz

Copy link
Copy Markdown
Contributor Author

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — ambiguous settlements can remain pending forever, potentially taking funds without issuing credits.

  • PurchaseService#settle returns every non-settled outcome as pending, and #answer_replay only re-renders that row. Nothing consumes X402::Settlement.pending_reconciliation or uses the chain readers to advance it. Add and schedule reconciliation that marks pending rows settled or failed and invokes GrantService after a confirmed settlement, with coverage from timeout/settlement_pending through credit grant.

This is handled on #6651

@osmarluz
osmarluz requested a review from mikeh-lago October 13, 2026 09:39
Base automatically changed from feat/x402/credit-purchase-refusals to main October 15, 2026 13:50
## Context

E8's refusals, the settle pipeline and the grant exist, but nothing runs them in order for a credit purchase.

## Description

`X402::CreditPurchases::PurchaseService` computes the payment digest, then runs the refusals that need no payer, `/verify`, the refusals that need the verified payer, the `pending` row and `/settle`. A settled answer is flipped alone and then granted; any other answer leaves the row `pending` for reconciliation. A grant that fails after the settle answers `credit_grant_failed` on `base`, whatever stopped it, so the caller knows the money moved; the settled row is what a replay grants from.
## Context

The middleware resends a payment when E8's answer is lost or late, so a resent payment must never reach the facilitator again, and must not be refused by settings that changed since.

## Description

The payment digest is looked up first, among the organization's pending and settled rows, before the connection and every refusal. A granted row answers as it is, a settled row without a grant is granted from its stored settings, a pending row answers pending, and an invoice payment is refused as `recorded_as_invoice_payment`. An insert that loses a race on the digest or the payer's pending purchase looks the digest up again, so a payment sent twice at once answers from the row that won. Replays are logged with whether their settings differ from the row's.
## Context

The lago-x402 middleware needs an endpoint to hand over the payment an agent signed, and to learn whether credits were granted or the payment is still settling.

## Description

`POST /api/v1/x402/credit_purchases` runs under the `x402` API-key scope, with the body under a `credit_purchase` root key. `payment` and `payment_requirements` are read from the raw JSON body, because parameter parsing drops `null` elements from arrays and they must reach the facilitator as the agent signed them. `V1::X402::SettlementSerializer` renders the settlement under `x402_settlement`: the grant once settled, `reconcile_after` while pending, and whether the answer is a replay.
## Context

A credit purchase answers `credit_grant_failed` whenever the grant fails after the money moved, so the middleware knows to serve the agent and resend the same payment. An exception raised inside the grant, rather than a failed result, escaped as a 500, which looks like nothing happened.

## Description

An exception raised by the grant after the settle is reported to Sentry and answered as `credit_grant_failed`, like a failed grant; the settled row is still what a replay grants from.
## Context

The credit purchases endpoint reads the signed payment from the raw JSON body. A body that isn't JSON, or one without the `credit_purchase` root, raised a 500.

## Description

When the raw body can't supply the payment, the request carries none, and the purchase is refused as `invalid_payment` like any other request without one.
## Context

A `credit_purchase` sent as a string, a number or an array made strong parameters raise, so the endpoint answered a 500 instead of refusing the request.

## Description

The root is read with `params.expect`, which refuses a missing or non-object root with a 400. A form-encoded body, whose raw body isn't JSON, is covered too: it carries no payment, so it is refused as `invalid_payment`.
## Context

The purchase specs did not cover a grant failing on a replay, a subscription id held by another customer, or a payer at the wallet cap.

## Description

Add those cases: the last two are refused before `/settle`, and a replay whose grant fails answers `credit_grant_failed`, as a live purchase does.
@osmarluz
osmarluz force-pushed the feat/x402/credit-purchases branch from 40b2dbb to ceeec5e Compare October 15, 2026 13:50
@osmarluz
osmarluz merged commit 155beb9 into main Oct 15, 2026
12 checks passed
@osmarluz
osmarluz deleted the feat/x402/credit-purchases branch October 15, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants