X402 v2 payments

ICPay supports X402 v2, a “Payment Required” pattern for card/onramp-friendly flows. Instead of sending tokens directly from the user’s wallet to a contract, the wallet signs an authorization payload which is sent to ICPay’s facilitator and settlement endpoints. ICPay verifies and settles the payment, then returns the intent/payment status.

Summary of X402 v2 in ICPay

  • Server produces Accepts: The API returns one or more payment “acceptance” records describing what to sign (scheme/network/amount/recipient). EVM acceptances carry EIP‑3009 metadata; Solana acceptances carry message/transaction hints.
  • Client signs authorization: The SDK builds the correct authorization for the network:
    • EVM: EIP‑712 typed data (EIP‑3009 Permit or TransferWithAuthorization).
    • Solana: Signs an X402 v2 message or a prebuilt transaction (as required).
  • Settlement: The SDK sends a settle request with the X402 header to ICPay’s facilitator; ICPay validates and processes payment, then your app waits until a terminal status.
  • Facilitator: ICPay operates its own X402 facilitator endpoints for EVM and Solana so you don’t need to run custom infra.

High-level flow

  1. App requests an X402 intent using the SDK.
  2. API may return:
    • accepts[] → proceed with X402 flow (sign and settle).
    • A fallback recommendation → SDK falls back to the regular flow automatically.
  3. SDK builds EIP-712 typed data, requests signature from the user’s EVM wallet (eth_signTypedData_v4).
  4. SDK constructs an X402 header (base64 JSON) and calls the settle endpoint.
  5. SDK emits events and awaits a terminal status (completed/failed/mismatched/etc.).

Acceptance object (server response)

An X402 acceptance describes the exact authorization you must sign:

type X402Acceptance = {
  scheme: string            // usually 'exact'
  network: string           // EVM: chain ID (decimal string: '84532'); Solana: 'solana:mainnet' | 'solana:devnet'
  maxAmountRequired: string // amount in smallest unit (wei-like)
  resource?: string | null  // optional
  description?: string | null
  mimeType?: string | null
  payTo: string             // EVM: recipient/verifying contract; Solana: program address or destination
  maxTimeoutSeconds?: number
  asset?: string            // token address (may match payTo)
  extra?: {
    intentId?: string
    provider?: string
    ledgerId?: string
    facilitatorUrl?: string | null // ICPay facilitator URL used by the SDK
    name?: string                // token name for domain
    eip3009Version?: string      // e.g. '1'
    primaryType?: 'Permit' | 'TransferWithAuthorization' // optional hint
  }
}

Signing (EVM EIP-712 and Solana)

EVM:

The SDK constructs an EIP-712 domain and message from the acceptance, then asks the wallet to sign:

  • Domain:
    • name: extra.name
    • version: extra.eip3009Version
    • chainId: decimal network from the acceptance
    • verifyingContract: asset or payTo
  • Primary type:
    • If extra.primaryType === 'Permit', the SDK builds a Permit message.
    • Otherwise it builds a TransferWithAuthorization message.
  • Message fields:
    • TransferWithAuthorization: { from, to, value, validAfter, validBefore, nonce }
    • Permit: { owner, spender, value, nonce, deadline }
  • Nonce and validity:
    • nonce: 32-byte random value (hex).
    • validAfter: now - 86400s; validBefore/deadline: now + maxTimeoutSeconds (default ~300s).

The signature is collected using eth_signTypedData_v4.

Solana:

  • The SDK includes the payer’s publicKey when initiating the X402 intent so the server can build a signable payload.
  • Depending on the acceptance, the SDK either:
    • Requests a signMessage signature over the X402 v2 message with domain prefix ICPAY_X402_V2:; or
    • Requests a transaction signature over a prebuilt base64 transaction that includes the necessary program instructions.
  • The SDK then settles via ICPay’s Solana facilitator, which can relay the transaction on your behalf when required.

X402 header format

After signing, the SDK builds a base64-encoded JSON header that looks like:

{
  "x402Version": 1,
  "scheme": "exact",
  "network": "84532",
  "payload": {
    "authorization": {
      "from": "0xUserAddr",
      "to": "0xPayTo",
      "value": "1000000",
      "validAfter": "1690000000",
      "validBefore": "1690000300",
      "nonce": "0x...32-bytes..."
    },
    "signature": "0x...signature..."
  }
}

The SDK then base64-encodes this JSON and sends it as paymentHeader to the settle endpoint.

Settlement request

The SDK calls ICPay’s settle endpoint:

await publicApiClient.post('/sdk/public/payments/x402/settle', {
  paymentIntentId,         // provided by the API during X402 initiation
  paymentHeader,           // base64 string described above
  paymentRequirements: acceptance, // echo of the acceptance object
})

ICPay verifies the signature and authorizes the payment. The response contains status info (e.g., succeeded / failed) and may include a txHash. The SDK then proceeds to await a terminal status via notify/long-polling if needed.

Facilitator endpoints (ICPay)

Merchants integrate via the ICPay SDK, which calls ICPay’s facilitator behind the scenes. For completeness, the relevant HTTPS endpoints are:

  • Verify (performed within settle; no separate public endpoint required)
    • Verification runs server-side as part of settlement. The request is rejected with a failure status if verification fails (e.g., signature invalid, deadlines invalid, recipient/asset mismatch, insufficient funds).
  • Settle (public; merchant-authenticated via publishable key)
    • POST https://api.icpay.org/sdk/public/payments/x402/settle
    • Body:
      {
        "paymentIntentId": "pi_123",
        "paymentHeader": "<base64-encoded x402 header JSON>",
        "paymentRequirements": { /* echo of the acceptance object used for signing */ }
      }
      
    • Returns (simplified):
      {
        "status": "succeeded | failed | processing | completed | mismatched",
        "paymentIntent": { /* intent DTO */ },
        "payment": { /* latest payment DTO or null */ },
        "transactionId": "0x... | <baseSignature> | null",
        "canisterTxId": 1234,
        "externalCostAmount": "0"
      }
      
  • Optional helper endpoints (used by the SDK on certain tokens)
    • POST https://api.icpay.org/sdk/public/payments/x402/prepare (prebuild Solana transactions)
    • POST https://api.icpay.org/sdk/public/payments/x402/signable (prebuild Solana signable message)
    • POST https://api.icpay.org/sdk/public/payments/x402/relay (relay signed Solana transactions)

How merchants use it:

  • Your backend issues an intent via the SDK.
  • If the API responds with HTTP 402 and accepts[], the SDK guides the wallet to sign and then calls ICPay settle. No additional merchant infra is required.

HTTP 402 response shape (ICPay)

When X402 is available for the selected token/chain, ICPay responds with HTTP 402 and a JSON body like:

{
  "x402Version": 2,
  "paymentIntentId": "pi_123",
  "rpcChainId": "8453",
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:8453",
      "maxAmountRequired": "1000000",
      "resource": "/sdk/public/payments/intents/pi_123",
      "description": "ICPay payment",
      "mimeType": "application/json",
      "payTo": "0x6c6551d0CC6315D0Be562c5b855B667eE32a5298",
      "maxTimeoutSeconds": 300,
      "asset": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      "extra": {
        "intentId": "pi_123",
        "provider": "icpay",
        "ledgerId": "token_abc",
        "facilitatorUrl": "https://api.icpay.org",
        "name": "USD Coin",
        "eip3009Version": "2",
        "rpcChainId": "8453",
        "rpcUrlPublic": "https://mainnet.base.org"
      }
    }
  ],
  "error": ""
}

Notes:

  • accepts[] entries map one-to-one to a specific token/chain configuration. The SDK uses this to build the EIP‑712 message (EVM) or signable bytes/transaction (Solana).
  • maxAmountRequired is in the token’s smallest unit.

Facilitator

ICPay operates its own X402 facilitator for EVM and Solana:

  • EVM: Verifies EIP‑712 authorization and performs settlement against the configured contracts.
  • Solana: Verifies the X402 v2 message or relays the signed transaction to the network using a platform relayer when necessary.

These endpoints are used automatically by the SDK; no additional configuration is required beyond having an EVM/Solana wallet provider available.

Up-to (scalable amount) flows

X402 v2 supports “up-to” payments where the wallet signs a maximum amount, but the final price is only known after the service completes its work. ICPay exposes this as the upto scheme with x402 up-to intents.

  • Intent flag: ICPay marks these as x402_upto = true on the payment intent.
  • Authorization: The wallet still signs an authorization for maxAmountRequired (cap).
  • Settlement: The business backend later calls a secret-key endpoint with the final amount in USD (settledAmountUsd); icpay-api converts to token units and must satisfy 0 < settledAmount <= maxAmountRequired.
  • On-chain (EVM): PaymentProcessor v1.3.0+ exposes payWithSignatureUpto: one EIP-3009 pull for the signed max, splits/platform fee are computed on the settled amount, and the unused cap is refunded to the payer in the same transaction. Upgrade deployed PaymentProcessor implementations before relying on up-to settlement in production.
  • Widget UX: icpay-pay-button with x402Upto: true drives tailored progress steps and, after EVM up-to confirm, either polls the intent until completed or, with x402UptoSkipSettlementWait: true, lets the user dismiss while settlement may still be pending. See Agentic X402 up-to payments for behavior, icpay-x402-upto-submitted, and success amounts from paymentIntent.amountUsd.
  • Webhooks: when the client calls POST /sdk/public/payments/intents/x402/upto/confirm, ICPay emits x402_upto_authorization_received so your backend can sync before payment.completed. Essential for skip-poll flows. See Webhooks and Agentic X402 → Webhook.

New endpoints and flags

  • Intent creation (public SDK, publishable key)

    • SDK method: createPaymentX402Usd({ usdAmount, tokenShortcode, x402Upto: true, ... })
    • Widget prop (pay button): x402Upto: true
    • API: /sdk/public/payments/intents/x402 (unchanged URL, intent stored with x402_upto = true).
    • X402 acceptance:
      • scheme: 'upto'
      • maxAmountRequired: cap in smallest unit.
  • Public settle (publishable key)

    • URL: POST /sdk/public/payments/x402/settle
    • Behavior for up-to:
      • If the intent is x402_upto = true, this endpoint now returns:
        • { status: 'failed', error: 'x402_upto_settlement_requires_secret_key' }
      • This prevents client-side wallets from unilaterally settling up-to flows.
  • Secret-key settle for up-to (backend only)

    • URL (icpay-api, secret key): POST /sdk/payments/x402/upto/settle
      • Guarded by SecretKeyAuthGuard, requires secretKey and x-account-id.
      • Body: paymentIntentId, settledAmountUsd (USD), optional paymentHeader (base64) if not already stored on the intent.
      • Constraints:
        • Intent must belong to the authenticated account.
        • Intent must have x402_upto = true.
        • Converted token settledAmount must satisfy 0 < settledAmount <= maxAmountRequired (signed cap).
      • Calls icpay-services to invoke payWithSignatureUpto on PaymentProcessor (pull max, finalize on settled amount, refund excess).
  • icpay-services internal settlement (called by icpay-api)

    • URL: POST /evm/settlement/x402-upto
    • Auth: internal API key between icpay-api and icpay-services (Bearer).
    • Role: enforce cap, load stored X402 header (authorization + signature), call payWithSignatureUpto on-chain, then persist the settled amount on the intent.

Use case: metered AI API or long-running job

Scenario: You run an AI-enhanced service where:

  • The user triggers a job (e.g., “summarize 500 documents”).
  • You only know the upper bound price up front (e.g., $25 max), based on token or compute limits.
  • Actual cost might be anywhere from $5 to $25 depending on usage.

With X402 up-to in ICPay:

  1. Frontend / widget (user starts the job)

    • Your frontend uses the ICPay widget pay button with x402Upto: true:
    <icpay-pay-button
      publishableKey={process.env.NEXT_PUBLIC_ICPAY_PK}
      amountUsd={25}
      fiat_currency="USD"
      x402Upto={true}
      metadata={{
        orderId: 'job-123',
        icpay: {
          icpay_context: 'ai-job:x402',
        },
      }}
    />
    
    • The widget:
      • Calls createPaymentX402Usd with x402Upto: true.
      • Gets an X402 v2 accepts[] response with scheme: 'upto', maxAmountRequired corresponding to $25.
      • Guides the wallet to sign the X402 authorization.
  2. SDK (client-side X402 flow)

    If you use the SDK directly instead of the widget:

    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 x402Init = await icpay.createPaymentX402Usd({
      usdAmount: 25,
      tokenShortcode: 'base_usdc',
      metadata: { orderId: 'job-123' },
      x402Upto: true,
    });
    
    • ICPay creates a payment intent flagged as x402_upto = true and returns an HTTP 402 with accepts[] (scheme: 'upto').
    • The SDK:
      • Builds the EIP‑712 payload (value = maxAmountRequired).
      • Requests the wallet signature.
      • Verifies/settles X402 authorization server-side.
    • The job is now authorized up to the cap, but not yet finally billed.
  3. Backend service work

    • Your backend sees the X402 authorization is valid (e.g., via webhook or polling) and starts the long-running job.
    • When the job finishes, your backend calculates the actual cost (for example, $9.75900000 units in smallest token unit).
  4. Backend settlement with secret key

    On your server, using secret key + account id, you call the new SDK protected method:

    import { Icpay } from '@ic-pay/icpay-sdk';
    
    const icpayBackend = new Icpay({
      secretKey: process.env.ICPAY_SECRET_KEY!,
      apiUrl: process.env.ICPAY_API_URL,      // e.g. https://api.icpay.org
      enableEvents: false,
    });
    
    // Example: usage cost = 900000 token units (<= maxAmountRequired)
    await icpayBackend.protected.settleX402Upto({
      paymentIntentId: 'pi_123',
      settledAmount: '900000',
    });
    
    • This hits POST /sdk/payments/x402/upto/settle:
      • Confirms the intent is x402_upto and belongs to your account.
      • Enforces business rule settledAmount <= maxAmountRequired.
    • icpay-api then calls icpay-services /evm/settlement/x402-upto to perform on-chain settlement.
  5. Notifications

    • Once settlement is done, ICPay updates the payment/intent status.
    • You can:
      • Use webhooks (existing ICPay webhooks) to be notified when the payment is completed.
      • Or use icpayBackend.protected.getPaymentById / getPaymentHistory to reconcile.

This pattern gives you:

  • Guardrails for the user: wallet signs an authorization capped at maxAmountRequired.
  • Flexibility for the service: backend chooses the final settledAmount based on real usage.
  • Security: final settlement for up-to flows is restricted to secret-key server calls, not client-side publishable-key flows.

SDK usage example

import { Icpay } from '@ic-pay/icpay-sdk'

// Ensure window.ethereum is available for EIP-712 signing
const icpay = new Icpay({
  publishableKey: process.env.NEXT_PUBLIC_ICPAY_PK!,
  enableEvents: true,
  debug: true,
  // Optionally pass your own provider; otherwise SDK uses window.ethereum
  // evmProvider: window.ethereum,
})

// Prefer tokenShortcode; server derives token/chain from shortcode
const result = await icpay.createPaymentX402Usd({
  usdAmount: 10,
  tokenShortcode: 'ic_icp',
  metadata: { orderId: 'A-1001' },
})

// Handle terminal response (completed / failed / pending -> continued waiting)
if (result.status === 'completed') {
  // success
}

What the SDK does for you:

  • Initiates the X402 intent (/sdk/public/payments/intents/x402).
  • If the server returns accepts[], it:
    • Ensures wallet is on the desired chain (best-effort).
    • Builds domain + message for EIP-712 (Permit or TransferWithAuthorization).
    • Requests signature (eth_signTypedData_v4) from the wallet.
    • Builds a base64 X402 header and calls ICPay settle.
    • Emits lifecycle events and awaits terminal status.
  • If the server suggests fallback or does not return accepts, it transparently falls back to createPaymentUsd.

Deprecated inputs (still supported)

You can still pass symbol, ledgerCanisterId, or chainId, but tokenShortcode is now the primary input for selecting a token. The server resolves token ledger and chain from the shortcode automatically.

Scope and networks

  • EVM and Solana are supported in X402 v2 flows (EIP‑712 on EVM; message/transaction signing on Solana).
  • For the Internet Computer (IC) network, the SDK uses native ICRC‑1 transfers (see createPayment / createPaymentUsd) and then notifies/awaits terminal status. X402 is not used on IC at this time.

USDC example (Base)

Use USD Coin on Base mainnet via tokenShortcode: 'base_usdc':

import { Icpay } from '@ic-pay/icpay-sdk'

const icpay = new Icpay({
  publishableKey: process.env.NEXT_PUBLIC_ICPAY_PK!
})

const res = await icpay.createPaymentX402Usd({
  usdAmount: 12.5,
  tokenShortcode: 'base_usdc',
  metadata: { orderId: 'ORDER-1002' },
})

if (res.status === 'completed' || res.status === 'succeeded') {
  // payment completed
} else if (res.status === 'failed') {
  // handle failure or fallback
}

Events and statuses

Enable with enableEvents: true. X402 flows emit the same lifecycle events as regular payments:

  • icpay-sdk-transaction-created
  • icpay-sdk-transaction-updated
  • icpay-sdk-transaction-completed
  • icpay-sdk-transaction-failed
  • icpay-sdk-transaction-mismatched

The settle response can be terminal or transitional. When non-terminal, the SDK continues notifying the API until a terminal state is observed.

Troubleshooting

  • No EVM provider: Ensure window.ethereum is present or pass evmProvider in Icpay config.
  • Wrong chain: The SDK will try to wallet_switchEthereumChain. Provide rpcUrlPublic and chainName in the intent (server side) if add-and-switch is needed.
  • Signature rejected: Users can cancel the signature request. Catch IcpayError and handle isUserCancelled().
  • Minimum limits: If settlement fails due to minimums (e.g., x402_minimum_amount_not_met), the SDK emits a failure and then falls back to the normal flow.
  • Server returns HTTP 402: The SDK extracts accepts/intentId from the response and begins settlement/wait accordingly.

Was this page helpful?