Agentic X402 up-to payments
X402 v2 supports up-to schemes: the wallet signs an authorization for a maximum amount, and your service decides the final cost after the work is done. ICPay exposes this via x402Upto and the upto scheme so you can build agentic, usage-based, or long-running flows.
Overview
Typical use cases:
- AI / LLM calls where token usage is unknown up front (only a maximum budget is known).
- Long-running jobs where you want to cap the user’s exposure but bill only what they actually used.
- Agentic workflows where an agent is authorized to spend “up to X” for a task.
You get:
- Cap for the user: wallet signs a maximum amount.
- Flexibility for the service: backend chooses the final settled amount.
- Separation of concerns: frontend handles X402 authorization; backend handles settlement with secret key.
Flow overview
End-to-end flow:
- User requests a service. Your app decides a maximum price (cap).
- Create X402 up-to intent via SDK or widget:
- Intent is stored with
x402_upto = true. - ICPay returns an X402 acceptance with
scheme: 'upto'andmaxAmountRequired(cap).
- Intent is stored with
- User signs X402 v2 authorization:
- EVM: EIP-712 typed data (EIP‑3009) for
maxAmountRequired. - ICPay’s backend verifies the authorization.
3b. EVM up-to confirm (sync point): the widget or your app calls
POST /sdk/public/payments/intents/x402/upto/confirm(publishable key). ICPay persists the signed header and emits thex402_upto_authorization_receivedwebhook — subscribe to this so your server stays in lockstep with the client (especially when usingx402UptoSkipSettlementWaitand not polling forcompletedin the browser). Details: Webhook: x402_upto_authorization_received.
- EVM: EIP-712 typed data (EIP‑3009) for
- Your service starts work once the authorization is valid (ideally after you receive the webhook or your own callback from
onX402UptoIntent). - Service finishes and computes
settledAmount(in smallest unit), satisfying:0 < settledAmount <= maxAmountRequired.
- Backend settles:
- Uses
secretKey+protected.settleX402Uptoto finalize the payment withsettledAmount.
- Uses
- ICPay settles on-chain using the signed authorization, enforces the cap, and records
settledAmounton the intent. - You notify the user and/or client via webhooks, polling, or your own messaging.
Using icpay-sdk only
1. Frontend: create X402 up-to intent and authorization
On the frontend (publishable key), call createPaymentX402Usd with x402Upto: true:
Create X402 up-to intent (frontend)
import { Icpay } from '@ic-pay/icpay-sdk'
const icpay = new Icpay({
publishableKey: process.env.NEXT_PUBLIC_ICPAY_PK!,
enableEvents: true,
debug: true,
})
// User approves up to $25 for this job
const maxUsd = 25
const x402Init = await icpay.createPaymentX402Usd({
usdAmount: maxUsd,
tokenShortcode: 'base_usdc',
metadata: { orderId: 'job-123', context: 'agentic-x402' },
x402Upto: true,
})
// For up-to EVM flows, the SDK:
// - creates a payment intent with x402_upto = true
// - receives X402 accepts[] with scheme: 'upto' and maxAmountRequired
// - builds and signs an X402 v2 header (EIP‑712/EIP‑3009) for maxAmountRequired
// - returns a deferred object instead of auto-settling:
// {
// paymentIntentId,
// payment: {
// x402Version,
// paymentIntentId,
// accepts: [...],
// paymentHeader, // base64-encoded signed X402 header
// paymentRequirements, // the exact requirement used for signing
// },
// status: 'pending',
// ...
// }
What happens:
- ICPay creates a payment intent with
x402_upto = true. - API responds with HTTP 402 +
accepts[](scheme: 'upto'). - SDK builds and signs the X402 v2 authorization for
maxAmountRequiredand encodes it aspaymentHeader(base64). - SDK returns a deferred object that includes
paymentIntentId,payment.accepts,payment.paymentHeader, andpayment.paymentRequirementswithout calling the public settle endpoint.
You should pass both the paymentIntentId and the paymentHeader (and optionally paymentRequirements) to your backend. The backend will later send the header to ICPay when calling the secret-key settle endpoint.
X402 up-to header structure (EVM)
For EVM, paymentHeader is a base64-encoded JSON header that includes an EIP‑712 typed-data payload (EIP‑3009 style). Inside the decoded JSON:
payload.authorization.maxAmount— the cap in token smallest units.payload.authorization.validBefore— Unix timestamp (seconds) when the authorization expires.payload.authorization.nonce— unique nonce for replay protection.- Other fields describe the token, spender, and chain.
ICPay stores this header on the payment intent when you call the secret-key settle endpoint and uses it to:
- Validate signature and constraints.
- Enforce
settledAmount <= maxAmountRequired. - Ensure the authorization is still valid (
validBeforenot passed) at settlement time.
2. Backend: run the job and settle later
On the backend (Node, server-side only), configure secretKey:
Backend: settle X402 up-to intent
import { Icpay } from '@ic-pay/icpay-sdk'
const icpayBackend = new Icpay({
secretKey: process.env.ICPAY_SECRET_KEY!,
apiUrl: process.env.ICPAY_API_URL,
})
export async function runJobAndSettle(paymentIntentId: string, usageUsd: number) {
// 1) Look up the signed X402 header your frontend sent you when the up-to intent was created
const paymentHeader = await loadPaymentHeaderForIntent(paymentIntentId) // app-specific storage
// 2) Commit settlement (ICPay converts USD to token units and enforces cap, using the stored header)
const result = await icpayBackend.protected.settleX402Upto({
paymentIntentId,
settledAmountUsd: usageUsd,
// If you proxy the header through your backend, include it here so icpay-api can persist it
// paymentHeader,
})
if (!result.ok) {
throw new Error(`X402 upto settlement failed: ${result.error || 'unknown error'}`)
}
// 3) Optionally look up the payment and notify your app
const paymentAgg = await icpayBackend.protected.getPaymentById(paymentIntentId)
// paymentAgg.payment / paymentAgg.intent contain final values
}
The protected.settleX402Upto method:
- Is available only on the secret key SDK (
icpayBackend.protected). - Calls
POST /sdk/payments/x402/upto/settleon icpay-api. - Enforces:
- Intent belongs to your account.
- Intent has
x402_upto = true. 0 < settledAmount <= maxAmountRequired.
Using icpay-widget
When you want a drop-in UI but still need agentic/usage-based billing, use icpay-pay-button with x402Upto and onX402UptoIntent.
1. Configure the pay button
React: icpay-pay-button with X402 up-to
'use client'
import { IcpayPayButton, IcpaySuccess } from '@ic-pay/icpay-widget/react'
export default function Page() {
const config = {
publishableKey: process.env.NEXT_PUBLIC_ICPAY_PK,
amountUsd: 25, // max budget (cap)
x402Upto: true,
metadata: {
orderId: 'job-123',
icpay: { icpay_context: 'agentic-x402' },
},
onX402UptoIntent: async ({ paymentIntentId, amountUsd, accepts, paymentHeader, paymentRequirements }) => {
console.log('X402 up-to intent created', { paymentIntentId, amountUsd, accepts, paymentHeader })
// Call your backend to start work and pass paymentIntentId and the signed header
await fetch('/api/start-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentIntentId,
maxUsd: amountUsd,
paymentHeader, // base64 X402 header (optional but recommended)
paymentRequirements, // exact requirement used for signing (optional)
}),
})
},
}
return (
<IcpayPayButton
config={config}
onSuccess={(detail: IcpaySuccess) => console.log('Job paid', detail)}
/>
)
}
Under the hood:
- Widget calls
createPaymentX402Usd({ x402Upto: true, ... }). - SDK performs X402 authorization but does not call the public settle endpoint for up-to.
- For EVM up-to, the widget persists the signed header via
POST /sdk/public/payments/intents/x402/upto/confirm(publishable key), then continues (see Progress UI & optional skip settlement wait). - Widget calls
onX402UptoIntentwith:paymentIntentIdamountUsd(cap)metadataaccepts[](X402 requirements)paymentHeader,paymentRequirementswhen present (EVM)
- By default, the widget polls the intent via
GET /sdk/public/payments/intents/:iduntil it is terminal:- When your backend calls
protected.settleX402Uptoand ICPay marks the intentcompleted, the widget updates UI, emitsicpay-sdk-transaction-completed, and callsonSuccess.
- When your backend calls
- If
x402UptoSkipSettlementWait: true, the widget does not poll after confirm; see the section below. - In parallel, when up-to confirm succeeds, ICPay emits the
x402_upto_authorization_receivedwebhook so your server can sync (not only the browser callback). See Webhook: x402_upto_authorization_received.
2. Backend: same settleX402Upto call
Your backend implementation is identical to the SDK-only case: it receives paymentIntentId from onX402UptoIntent and later calls protected.settleX402Upto with the computed settledAmount.
Progress UI & optional skip settlement wait
When progressBar.enabled is not false (default on), icpay-pay-button drives icpay-progress-bar with step labels tailored to X402 up-to (similar in spirit to the Stripe checkout steps):
- Wallet ready — wallet connected for the flow.
- Sign authorization — user signs the X402 v2 header (EIP‑712 / cap).
- Submit authorization — EVM: signed header is sent to ICPay via up-to confirm; progress advances when the SDK returns and confirm succeeds.
- Settlement — waiting for merchant secret-key settlement and on-chain finalization.
Default: wait for settlement (poll)
With x402UptoSkipSettlementWait omitted or false:
- After confirm, the progress bar moves Settlement to loading and the widget polls the public payment intent until status is terminal (
completed, failed, etc.). - When the intent completes,
icpay-sdk-transaction-completedfires (window + SDK). The progress bar shows Payment Complete! and usespaymentIntent.amountUsdfrom the API when present so the success line reflects the settled fiat amount (e.g. $0.05 charged) rather than only the widget’s max cap (e.g. $0.10). ICPay enrichesamountUsdon the intent DTO after settlement metadata exists.
Optional: skip long poll (x402UptoSkipSettlementWait: true)
On-chain settlement can take minutes to hours. If you do not want the user to wait on a spinner until the intent is completed:
- Set
x402UptoSkipSettlementWait: trueon the pay button config (withx402Upto: true). - After up-to confirm succeeds, the widget:
- Dispatches a
CustomEventonwindow:icpay-x402-upto-submittedwithdetail: { paymentIntentId, amountUsdMax }(the cap in USD). - Sets the pay button to succeeded (if you use
disableAfterSuccess, the button can show “Paid”) and callsonSuccesswith{ id: 0, status: 'authorized_pending_settlement', paymentIntentId }— use this to acknowledge “authorization stored” while settlement is still pending. - The progress UI shows a green banner (“You’re done on your side…”) and keeps the last step (Settlement) in a pending state with dashed styling so it is obvious settlement is not finished yet. The user can Close the modal.
- Dispatches a
icpay-sdk-transaction-completedis not emitted from the widget for this path until something else completes the payment (there is no poll). Fulfillment when the intent eventually completes should rely on webhooks (especiallypayment.completed), server polling, or the user returning to a status page. For the moment the header is stored, rely onx402_upto_authorization_received— see Webhook: x402_upto_authorization_received and the Webhooks reference.
Example combining up-to, skip wait, and onX402UptoIntent:
React: up-to + skip settlement wait + progress
'use client'
import { IcpayPayButton, IcpaySuccess } from '@ic-pay/icpay-widget/react'
export default function AgentPay() {
const config = {
publishableKey: process.env.NEXT_PUBLIC_ICPAY_PK!,
amountUsd: 10, // max cap shown to user
x402Upto: true,
x402UptoSkipSettlementWait: true,
progressBar: { enabled: true },
metadata: { jobId: 'agent-42' },
onX402UptoIntent: async ({
paymentIntentId,
amountUsd,
paymentHeader,
paymentRequirements,
accepts,
}) => {
await fetch('/api/jobs/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentIntentId,
maxUsd: amountUsd,
paymentHeader,
paymentRequirements,
accepts,
}),
})
},
}
return (
<IcpayPayButton
config={config}
onSuccess={(d: IcpaySuccess) => {
if (d.status === 'authorized_pending_settlement') {
console.log('Authorization saved; settle later', d.paymentIntentId)
} else {
console.log('Payment completed', d)
}
}}
/>
)
}
For integrators building custom UI, you can listen for the same milestone:
window.addEventListener('icpay-x402-upto-submitted', (e: Event) => {
const { paymentIntentId, amountUsdMax } = (e as CustomEvent).detail || {}
// Optional: show your own “submitted, pending settlement” state
})
The internal <icpay-progress-bar> receives .x402UptoSkipSettlementWait from the pay button config so it can branch between “poll until completed” and “banner + pending last step”.
Webhook: x402_upto_authorization_received
ICPay delivers a dedicated webhook when an X402 up-to authorization has been saved on the payment intent after successful up-to confirm (EVM, publishable key).
Event type | x402_upto_authorization_received |
| When | Immediately after POST /sdk/public/payments/intents/x402/upto/confirm succeeds and the intent (metadata / header storage) is updated. |
| Not | On-chain settlement — that comes later via protected.settleX402Upto and is reflected in payment.completed (and related payment events). |
Why this is the key sync mechanism
- The browser may fire
icpay-x402-upto-submittedfor your SPA, but your backend should not rely only on the client. The webhook is the authoritative server notification that ICPay has the signed header and intent id, so you can safely enqueue work, tiepaymentIntentIdto a user session, or wait for settlement in a worker. - With
x402UptoSkipSettlementWait: true, the widget does not poll untilcompleted, sopayment.completedmay arrive much later. Handlingx402_upto_authorization_receivedlets you transition internal state to “authorized, pending settlement” in parallel with the widget’s “authorization saved” UI. - Combine with
onX402UptoIntentfor defense in depth: callback when the tab has confirmed; webhook when ICPay has persisted.
Subscribe in icpay.org → Settings → Webhook Endpoints (same HMAC verification as other events). Full payload field list: Webhooks → x402_upto_authorization_received.
Config reference (SDK & widget)
SDK: createPaymentX402Usd (frontend)
New field on CreatePaymentUsdRequest:
export interface CreatePaymentUsdRequest {
usdAmount: string | number;
...
/** When true, create an x402 intent that uses the 'upto' scheme instead of 'exact'. */
x402Upto?: boolean;
}
Usage:
x402Upto: true→ ICPay:- Marks the payment intent with
x402_upto = true. - Emits X402 v2 acceptance with
scheme: 'upto'. - SDK builds and signs an X402 v2 header and exposes it as
payment.paymentHeaderandpayment.paymentRequirementson thecreatePaymentX402Usdreturn value for up-to flows. - SDK does not auto-settle via
/sdk/public/payments/x402/settle; settlement must be driven by your backend viaprotected.settleX402Upto, optionally passing the header.
- Marks the payment intent with
SDK: protected API (backend)
New protected method:
icpayBackend.protected.settleX402Upto({
paymentIntentId: string;
settledAmountUsd: string | number; // USD, ICPay converts to token units and enforces <= maxAmountRequired
})
Requires:
secretKeyconfigured inIcpayConfig.- Intent must belong to the authenticated account and have
x402_upto = true.
Widget: pay button config
Relevant fields on PayButtonConfig:
export type PayButtonConfig = CommonConfig & {
amountUsd?: number;
buttonLabel?: string;
onSuccess?: (tx: {
id: number;
status: string;
paymentIntentId?: string;
paymentIntent?: Record<string, unknown>;
}) => void;
/** Use X402 `upto` scheme (capped authorization; settle later with secret key). */
x402Upto?: boolean;
/** Optional explicit scheme; defaults from `x402Upto`. */
x402Scheme?: 'exact' | 'upto';
/**
* When true with `x402Upto` (EVM): after up-to confirm, do not poll until `completed`.
* Progress shows “authorization saved” + pending settlement step; `onSuccess` with
* `status: 'authorized_pending_settlement'`. See [Progress UI & optional skip settlement wait](#progress-skip).
*/
x402UptoSkipSettlementWait?: boolean;
onX402UptoIntent?: (info: {
paymentIntentId: string;
amountUsd: number;
metadata?: Record<string, any>;
accepts: any[];
paymentHeader?: string;
paymentRequirements?: any;
}) => void | Promise<void>;
};
Behavior:
- When
x402Uptoistrueand the selected token supports X402:- Widget initiates X402 up-to intent and authorization via SDK.
- After EVM confirm, runs
onX402UptoIntent, then either polls until terminal or stops ifx402UptoSkipSettlementWaitistrue(see above). - On full completion (poll path or other flows), fires
onSuccess/icpay-sdk-transaction-completedwith enriched intent when applicable.
This combination—x402Upto + optional x402UptoSkipSettlementWait + onX402UptoIntent + protected.settleX402Upto—is the recommended pattern for agentic, usage-based X402 payments in ICPay.