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:

  1. User requests a service. Your app decides a maximum price (cap).
  2. Create X402 up-to intent via SDK or widget:
    • Intent is stored with x402_upto = true.
    • ICPay returns an X402 acceptance with scheme: 'upto' and maxAmountRequired (cap).
  3. 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 the x402_upto_authorization_received webhook — subscribe to this so your server stays in lockstep with the client (especially when using x402UptoSkipSettlementWait and not polling for completed in the browser). Details: Webhook: x402_upto_authorization_received.
  4. Your service starts work once the authorization is valid (ideally after you receive the webhook or your own callback from onX402UptoIntent).
  5. Service finishes and computes settledAmount (in smallest unit), satisfying:
    • 0 < settledAmount <= maxAmountRequired.
  6. Backend settles:
    • Uses secretKey + protected.settleX402Upto to finalize the payment with settledAmount.
  7. ICPay settles on-chain using the signed authorization, enforces the cap, and records settledAmount on the intent.
  8. 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 maxAmountRequired and encodes it as paymentHeader (base64).
  • SDK returns a deferred object that includes paymentIntentId, payment.accepts, payment.paymentHeader, and payment.paymentRequirements without 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 (validBefore not 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/settle on 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 onX402UptoIntent with:
    • paymentIntentId
    • amountUsd (cap)
    • metadata
    • accepts[] (X402 requirements)
    • paymentHeader, paymentRequirements when present (EVM)
  • By default, the widget polls the intent via GET /sdk/public/payments/intents/:id until it is terminal:
    • When your backend calls protected.settleX402Upto and ICPay marks the intent completed, the widget updates UI, emits icpay-sdk-transaction-completed, and calls onSuccess.
  • 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_received webhook 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):

  1. Wallet ready — wallet connected for the flow.
  2. Sign authorization — user signs the X402 v2 header (EIP‑712 / cap).
  3. Submit authorization — EVM: signed header is sent to ICPay via up-to confirm; progress advances when the SDK returns and confirm succeeds.
  4. 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-completed fires (window + SDK). The progress bar shows Payment Complete! and uses paymentIntent.amountUsd from 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 enriches amountUsd on 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: true on the pay button config (with x402Upto: true).
  • After up-to confirm succeeds, the widget:
    • Dispatches a CustomEvent on window: icpay-x402-upto-submitted with detail: { paymentIntentId, amountUsdMax } (the cap in USD).
    • Sets the pay button to succeeded (if you use disableAfterSuccess, the button can show “Paid”) and calls onSuccess with { 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.
  • icpay-sdk-transaction-completed is 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 (especially payment.completed), server polling, or the user returning to a status page. For the moment the header is stored, rely on x402_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 typex402_upto_authorization_received
WhenImmediately after POST /sdk/public/payments/intents/x402/upto/confirm succeeds and the intent (metadata / header storage) is updated.
NotOn-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-submitted for 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, tie paymentIntentId to a user session, or wait for settlement in a worker.
  • With x402UptoSkipSettlementWait: true, the widget does not poll until completed, so payment.completed may arrive much later. Handling x402_upto_authorization_received lets you transition internal state to “authorized, pending settlement” in parallel with the widget’s “authorization saved” UI.
  • Combine with onX402UptoIntent for 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.paymentHeader and payment.paymentRequirements on the createPaymentX402Usd return value for up-to flows.
    • SDK does not auto-settle via /sdk/public/payments/x402/settle; settlement must be driven by your backend via protected.settleX402Upto, optionally passing the header.

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:

  • secretKey configured in IcpayConfig.
  • 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 x402Upto is true and 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 if x402UptoSkipSettlementWait is true (see above).
    • On full completion (poll path or other flows), fires onSuccess / icpay-sdk-transaction-completed with enriched intent when applicable.

This combination—x402Upto + optional x402UptoSkipSettlementWait + onX402UptoIntent + protected.settleX402Upto—is the recommended pattern for agentic, usage-based X402 payments in ICPay.

Was this page helpful?