Pay with USDC on Base via the x402
v2 payment protocol over its standard HTTP transport, so standard x402 v2 clients work with it
(verified with the client in @x402/core). The first POST without a payment returns 402 Payment Required with the
payment requirements (asset, network, amount, payTo) base64-encoded in the
PAYMENT-REQUIRED header, and the same JSON in the body. Sign an EIP-3009
transferWithAuthorization for that amount, send it base64-encoded in the
PAYMENT-SIGNATURE header (the legacy X-PAYMENT header is still
accepted), and repost. The server verifies and settles via the facilitator, then delivers the
cycles via the canister; the settlement receipt comes back in the PAYMENT-RESPONSE
header (and X-PAYMENT-RESPONSE for a legacy request). x402 clients cap each payment
at $1 by default: to pay more than $1, raise your client's
spendControls.maxAmountPerPayment (for example to "$200"). Only the exact scheme with an EIP-3009
authorization is accepted (not a Permit2 payload), and the paid repost must carry the same JSON
body. An II Bearer
token is optional for canister destinations (send one to tie the order to your principal so it
shows in /v1/history) and required for ledger destinations, which credit that
principal (401 auth_required without one). An Authorization header
that is not a valid II delegation is refused with 401 auth_invalid.
If the ICP has already moved and delivery is still finishing, the response is 202
with { success: true, status: "delivering", code: "delivery_in_progress", order_id, message }:
the cycles arrive automatically, or the payment is refunded if delivery cannot complete. Do not pay
again; retrying the same request is safe. A retry of a payment the server already has (the same
authorization, however encoded) answers with that payment's status without re-verifying it and
does not count against the rate limit, but only for its own principal: a ledger payment needs
that principal's Bearer token, and a caller signed in as another principal gets
403 forbidden. A resumed payment always goes to its stored destination;
the retry's body cannot change it. One exception to the no-re-verify rule: a retry that takes
over a payment still pending (not settled yet) after its first request went quiet
re-runs /verify and counts against the rate limit like a new payment, before it
settles; a takeover happens only after the first request has been quiet for 5 minutes. A takeover of
a verified or fulfilling payment reads the canister journal before it
delivers.
Request body
- amount_usd_cents
- integer required 100 to 20000 ($1 to $200 per order); larger amounts are rejected with 400
- destination_type
- "canister" | "ledger" default: "canister"; "ledger" credits the signed-in principal's Cycles Ledger account (no beneficiary_principal)
- canister_id
- string required for "canister"; omit for "ledger"
Response
- success
- boolean
- order_id
- string
- cycles
- string human-formatted with up to 4 decimals, e.g. "7.3659T"
- cycles_raw
- string raw cycle count, decimal digits
- block_index
- string?
- destination_type
- "canister" | "ledger"
- canister_id
- string? present only for canister orders
Discover payment requirements (no payment header)
POST /v1/x402/topup
Content-Type: application/json
{
"amount_usd_cents": 1000,
"destination_type": "canister",
"canister_id": "<your-canister-id>"
}
# 402 Payment Required
# PAYMENT-REQUIRED: base64 of the JSON below (also the body)
{
"x402Version": 2,
"error": "PAYMENT-SIGNATURE header required",
"resource": {
"url": "https://backend-production-9bca7.up.railway.app/v1/x402/topup",
"description": "ICP cycles top-up, paid in USDC on Base",
"mimeType": "application/json"
},
"accepts": [{
"scheme": "exact",
"network": "eip155:8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "10000000",
"payTo": "0x206389Da055eC021d003326bA2F23DA39bad2869",
"maxTimeoutSeconds": 300,
"extra": { "name": "USD Coin", "version": "2" }
}]
}
Pay: repost the same body with PAYMENT-SIGNATURE
# Sign EIP-712 typed data TransferWithAuthorization
# (EIP-3009) with domain:
# name "USD Coin", version "2", chainId 8453,
# verifyingContract = asset from the 402.
# PAYMENT-SIGNATURE = base64(JSON.stringify(payment)), where
# payment is the x402 v2 payload:
{
"x402Version": 2,
"accepted": <accepts[0] from the 402>,
"payload": {
"signature": "0x...",
"authorization": {
"from": "0x<payer>",
"to": "<payTo>",
"value": "10000000",
"validAfter": "0",
"validBefore": "<unix seconds: now + maxTimeoutSeconds>",
"nonce": "0x<32 random bytes>"
}
}
}
# Response: 200 (or 202 while delivery finishes), with
# PAYMENT-RESPONSE: base64 of
# { "success": true, "transaction": "0x<Base tx>",
# "network": "eip155:8453", "payer": "0x<payer>" }
# A declined settle: 402 settle_failed, with
# PAYMENT-RESPONSE { "success": false, "errorReason" }.
# A payment the facilitator rejects: 402
# payment_invalid, with PAYMENT-REQUIRED again and
# the reason in its "error".
# Legacy: X-PAYMENT carries the same, or the v1
# envelope CyclePay's own buy flow sends:
{
"x402Version": 1,
"scheme": "exact",
"network": "base",
"payload": {
"signature": "0x...",
"authorization": {
"from": "0x<payer>",
"to": "<payTo>",
"value": "10000000",
"validAfter": "0",
"validBefore": "<unix seconds: now + maxTimeoutSeconds>",
"nonce": "0x<32 random bytes>"
}
}
}