API reference

Programmatic cycle purchases.

Three payment rails: POST /v1/checkout (Stripe / card), POST /v1/x402/topup (USDC on Base via x402), and POST /v1/ckusdc/topup (ck-USDC on ICP via ICRC-2). All converge on the same canister method that mints cycles via the CMC. Authenticated endpoints require an Internet Identity delegation chain serialised as Authorization: Bearer <base64(delegation_chain_json)>. The server cryptographically verifies every hop in the chain server-side; bare-principal tokens are rejected. See Authentication below for the exact token format and a working snippet. The /v1/rate endpoint is public and requires no auth.

API base URL: https://backend-production-9bca7.up.railway.app. All examples on this page hit that host. The asset canister at https://www.cyclepay.org (and its own URL, https://4w3yu-fyaaa-aaaac-beeoa-cai.icp0.io) serves only the static frontend (this site). It does not proxy /api or /v1 routes: a /v1/* request there returns this site's HTML app shell, not JSON. An HTML response means you called the wrong host. On the API host, an unknown route answers 404 {"error":"Not found"}.

Authentication

Bearer = base64-encoded II delegation chain.

Authenticated endpoints expect Authorization: Bearer <base64(delegation_chain_json)>. The server cryptographically verifies every hop in the chain (Ed25519 / secp256k1 / canister signatures) against the previous signer's DER public key, enforces optional targets scope, and only then derives the principal from the root key. A bare principal sent as the token is rejected with 401 auth_invalid. A delegation from a read-only Internet Identity session (one that narrows its permissions) is refused with 401 "Read-only Internet Identity sessions cannot use this API" (auth_invalid on the x402 and ck-USDC routes). A token over 8 KiB is refused on every route with 400 token_too_large, before it is decoded.

Browser: produce the token from an AuthClient
import { AuthClient } from '@icp-sdk/auth/client';

const authClient = new AuthClient(); // mainnet Internet Identity (id.ai)
await authClient.signIn({
  maxTimeToLive: BigInt(7 * 24 * 60 * 60 * 1_000_000_000), // 7-day session
});

// The delegation lasts five minutes and is replaced as it ages,
// so build the token right before each request.
async function bearerToken() {
  const identity = await authClient.getIdentity();
  await identity.refresh(); // mints a new delegation if this one is about to lapse
  return btoa(JSON.stringify(identity.getDelegation().toJSON()));
}

await fetch(`${API_BASE}/v1/topup`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${await bearerToken()}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ destination_type: 'canister', canister_id: '<your-canister-id>', amount_usd: 25 }),
});

Browsers can call the API only from the CyclePay site itself. The server's CORS policy (server/lib/cors-origin.ts) admits https://www.cyclepay.org (and https://cyclepay.org), https:// origins of canister 4w3yu-fyaaa-aaaac-beeoa-cai on icp0.io, ic0.app and icp.net (and their raw. forms), https://cycles.internetcomputer.org, the API host itself, the server's FRONTEND_URL, and http://localhost:* outside production. It allows the Content-Type, Authorization, PAYMENT-SIGNATURE and X-PAYMENT request headers and exposes PAYMENT-REQUIRED, PAYMENT-RESPONSE and X-PAYMENT-RESPONSE (not Retry-After, so browser code cannot read it). Other websites' pages cannot send these requests. Internet Identity also gives each origin its own principal: a delegation minted on another site belongs to a different principal, and CyclePay shows that principal none of your orders, subscriptions or balances.

From a terminal, icp identity link web cyclepay --app 4w3yu-fyaaa-aaaac-beeoa-cai.icp0.io signs you in as the same principal (see the CLI Quick Start), but no released icp-cli command buys from CyclePay yet, so finish purchases on this site. For API calls, use a browser sign-in token.

POST /v1/checkout Bearer token

Create a Stripe Checkout session for a cycle purchase. Returns a URL to Stripe's hosted payment page. Two destinations: destination_type: "canister" (default) deposits cycles directly to a target canister via the CMC; destination_type: "ledger" credits a Cycles Ledger account, yours or the one named by beneficiary_principal, with the minted cycles minus 0.0002T in Cycles Ledger fees.

Request body
destination_type
"canister" | "ledger" default: "canister"
canister_id
string required for "canister"; omit for "ledger"
beneficiary_principal
string "ledger" only: the principal whose Cycles Ledger account is credited (default: you). Card orders only.
amount_usd
number required 1 to 200 USD, at most 2 decimals
confirm_system_canister
boolean optional; see destination checks
project_id
string optional: a project you are a member of (any role); the order belongs to it and its members see it. Otherwise 403 {"error":"Not a member of this project"}, before any payment.
Response
order_id
string
checkout_url
string
session_id
string
view_token
string reads GET /v1/order-status/:id without a sign-in; also in the Stripe success URL
amount_usd
number
stripe_fee_usd
number 2.9% of amount_usd rounded half up to the cent, plus $0.30
net_converted_usd
number amount_usd minus stripe_fee_usd: what the cycles are minted for, the same at delivery
estimated_cycles
string
xdr_rate
number
GET /v1/guest-checkout none

Whether card checkout without a sign-in is on: 200 while it is, 404 while it is off and while the rail switches refuse card orders (maintenance, or card payments moved), when the POST would refuse it too. The Buy page offers it to signed-out buyers only when this answers.

Response
available
true
min_usd
number the smallest order without a sign-in (5)
max_usd
number the per-order cap without a sign-in (default 100)
POST /v1/guest-checkout none

Card checkout without an Internet Identity sign-in, for any canister or for the Cycles Ledger account named by beneficiary_principal, for $5 to $100 per order (the server's GUEST_MAX_ORDER_USD, default 100; signed-in orders take $1 to $200). While it is off (GET /v1/guest-checkout answers 404) this route answers 404 too. No Stripe customer is created, the card is not saved, Stripe attempts 3-D Secure on every card that supports it, and an unpaid session expires after 31 minutes. The destination checks of /v1/checkout apply (400 INVALID_CANISTER_ID for a typo), and so do the rail switches (503 maintenance, 410 card_rail_moved). A guest order belongs to no project: a project_id answers 400 PROJECT_NEEDS_SIGN_IN. The order is in no one's history: read its status with the returned view_token through GET /v1/order-status/:id, as for any card order. Sign in and use /v1/checkout for smaller or larger orders, purchase history and auto-refill.

Limits: 5 sessions per IP per hour (an IPv6 client per /64) and 60 in total per minute, paid or not. At most 10 guest orders are delivered per destination per day, 100 in total per hour and $1,000 in total per day, counted by when they were paid and checked when the payment arrives: an order paid over a cap is refunded in full and nothing is delivered. Other buyers' unpaid sessions never count against a destination; they share only the per-minute ceiling, which answers limit: "sessions" (sign in to pay at once, or try again in a minute). Over a limit at creation: 429 with code: "GUEST_RATE_LIMITED", limit ("ip", "sessions", "destination", "global" or "daily_dollars") and a Retry-After header.

Request body
destination_type
"canister" | "ledger" default: "canister"
canister_id
string required for "canister"; omit for "ledger"
beneficiary_principal
string required for "ledger" (400 BENEFICIARY_REQUIRED without it): the principal whose Cycles Ledger account is credited
amount_usd
number required 5 to 100 USD, at most 2 decimals. $1 to $5 answers 400 GUEST_MINIMUM_NOT_MET with min_guest_usd; over the guest cap up to $200, 400 GUEST_LIMIT_EXCEEDED with max_guest_usd; under $1, over $200 or not a number, 400 with no code ("Amount must be a number between $5 and $100")
confirm_system_canister
boolean optional; see destination checks
Response
order_id
string
checkout_url
string
session_id
string
view_token
string reads GET /v1/order-status/:id; also in the Stripe success URL. Keep it: it is not shown again.
amount_usd, stripe_fee_usd, net_converted_usd
number
estimated_cycles
string
xdr_rate
number
min_guest_usd
number the smallest order without a sign-in (5)
max_guest_usd
number the per-order cap without a sign-in
expires_at
string ISO 8601; the checkout URL stops working then if unpaid
GET /v1/rate none (public)

Current ICP/XDR rate, ICP/USD rate, and cycles per USD. ICP/XDR sourced from the Cycles Minting Canister; ICP/USD from CoinGecko spot price. xdr_usd is the CyclePay canister's own XDR/USD rate (refreshed from the Exchange Rate Canister every 6 hours) when it is fresh, so quotes match what the canister charges; otherwise it is derived from the CMC and CoinGecko rates. Cached for 5 minutes. No authentication required.

Response
xdr_usd
number
icp_xdr
number
icp_usd
number
cycles_per_usd
string
timestamp
string
source
string
certVerified
boolean true when the CMC response certificate verified
xdr_usd_source
"canister-xrc" | "derived" where xdr_usd came from
GET /v1/order-status/:id?token=<view_token> view token (no sign-in)

A card order's status for whoever holds its view_token (returned by /v1/checkout, /v1/topup and /v1/guest-checkout, and carried in the Stripe success URL), with no sign-in: what the page Stripe returns to shows on a phone that paid by scanning the checkout QR code. A wrong or missing token, an order without one and an unknown id all answer 404. Nothing about the buyer or the payment is returned.

Response
status
string pending, fulfilling, completed, failed, refund_due (a refund is owed and on its way), refunding, refunded or disputed
amount_usd
number
destination
{ type: 'canister' | 'ledger', id } id shortened, e.g. "qhbym-qaaaa-...-cai"
cycles_deposited
string | null e.g. "3.35T" once delivered
detail
string | null why a held or failed order is not delivered: delivery_pending, delivery_unconfirmed, needs_review, refund_retrying, not_paid, amount_mismatch, guest_cap_exceeded (a guest order paid over a guest cap: nothing is delivered and the card is refunded)
created_at, updated_at
string
GET /v1/order/:id Bearer token

Check the status of a specific order. Returns both the local server state and the authoritative canister state for reconciliation. Only accessible by the principal that created the order.

Response
order_id
string
local
object | null Stripe orders only (null for x402 and ck-USDC orders): status, amount_usd (a number, or a decimal string such as "25.00" once read back from the database), destination_type, canister_id, beneficiary_principal (the credited account for ledger orders, else null), cycles_deposited, error, detail (the same code as GET /v1/order-status/:id), created_at, updated_at
canister
object | null on-chain order state from the backend canister
GET /v1/history Bearer token

Purchase history for the authenticated principal across all three rails, newest first, at most 100 rows. x402 purchases made without II auth are not listed.

Response { orders: [...] }
id
string Stripe: the order id. x402: SHA-256 hex of the payment header the payment first came in. ck-USDC: the ckusdc_ idempotency id. The crypto-rail ids differ from the order_id returned at purchase.
source
"stripe" | "x402" | "ckusdc"
status
string
amount_usd_cents
integer
destination
{ type: 'canister', canister_id } | { type: 'ledger', beneficiary_principal }
amount_refunded_cents
integer card orders: cents refunded so far (a partial refund keeps the status); 0 on the crypto rails, whose refunds are whole
error
string | null card orders: why it failed or what it waits on, e.g. "Checkout session expired"; null on the crypto rails
created_at
string Postgres timestamp text such as "2026-10-06 20:54:00.123+00", not ISO 8601
GET /v1/console/stats Bearer token

Card-order totals for the authenticated principal. GET /v1/projects/:id/stats (members only, 403 otherwise) returns the same fields over the project's orders and subscriptions, whoever made them, plus your own that belong to no project; month_spent_toward_cap_usd stays your own, across every project.

Response
subscription_count
integer
total_orders
integer
completed_orders
integer
total_spent_usd
number delivered card orders, net of partial refunds
month_delivered_usd
number delivered card orders this month, net of partial refunds
month_spent_usd
number the same as month_delivered_usd, kept for older clients
month_spent_toward_cap_usd
number what each subscription's max_monthly_usd is checked against: every charged, unrefunded card order this month, delivered or not, in any project
GET /v1/ledger-balance Bearer token

Current Cycles Ledger balance for the authenticated principal. Reads icrc1_balance_of on the Cycles Ledger canister (um5iw-rqaaa-aaaaq-qaaba-cai) and returns both the raw-cycles value and a trillions-denominated convenience field.

Response
principal
string
balance
string raw cycles, decimal string (no units)
balance_t
number balance in trillions of cycles
ledger_canister
string
timestamp
string
POST /v1/subscribe Bearer token

Register an auto-refill configuration for a canister, or for your own Cycles Ledger account (destination_type: "ledger"; each refill delivers 0.0002T less than it mints, and the charge covers that). Stores the threshold, target balance, and spending cap. A server-side worker (server/workers/refill.ts) scans active subscriptions every 30 minutes (configurable via REFILL_INTERVAL_MS); when the balance falls below threshold_cycles, it creates an off-session Stripe payment intent against the saved card and tops the balance up to refill_to_cycles. Idempotency keys are deterministic per (subscription, hour-bucket) so retries are safe. The notify_webhook URL, if set, is invoked after each refill. A canister subscription works only once CyclePay's backend canister (2bwus-iiaaa-aaaac-bee3a-cai) is a controller of the canister: the worker reads the balance through it and skips the canister otherwise.

Limits: at most 10 subscriptions per principal (400 after that). One subscription per (principal, canister) and one ledger subscription per principal; a duplicate returns 409. CyclePay's own canisters, the CMC, the ICP Ledger, Internet Identity, and aaaaa-aa return 403 with error_code: "CANISTER_DENIED".

Request body
destination_type
"canister" | "ledger" default: "canister"
canister_id
string required for "canister"; omit for "ledger"
threshold_cycles
string required positive integer string, less than refill_to_cycles
refill_to_cycles
string required positive integer string
max_monthly_usd
number required 1 to 150, at most 2 decimals
notify_webhook
string public HTTPS URL
project_id
string a project you are a member of (403 otherwise)
Response
id
string
destination_type
"canister" | "ledger"
canister_id
string | null null for ledger
threshold_cycles
string
refill_to_cycles
string
max_monthly_usd
number
notify_webhook
string | null
project_id
string | null
status
"active"
DELETE /v1/subscribe/:id Bearer token

Remove an auto-refill configuration. Deletes the stored subscription record.

Response
id
string
status
"deleted"
POST /v1/topup Bearer token

Agent-friendly alias for /v1/checkout. Both endpoints create a Stripe Checkout session and return a payment URL with the same request and response shape. Use /v1/checkout from the website's buy flow and /v1/topup from AI agents, CLI tools, and scripts, so call-site intent is obvious in logs and traces. Set destination_type: "ledger" to credit a Cycles Ledger account (yours, or beneficiary_principal's) instead of a canister.

Request body
destination_type
"canister" | "ledger" default: "canister"
canister_id
string required for "canister"; omit for "ledger"
beneficiary_principal
string "ledger" only: the principal whose Cycles Ledger account is credited (default: you). Card orders only.
amount_usd
number required 1 to 200 USD, at most 2 decimals
confirm_system_canister
boolean optional; see destination checks
project_id
string optional: a project you are a member of (any role); the order belongs to it and its members see it. Otherwise 403 {"error":"Not a member of this project"}, before any payment.
Response
checkout_url
string
session_id
string
order_id
string
view_token
string reads GET /v1/order-status/:id without a sign-in; also in the Stripe success URL
amount_usd
number
stripe_fee_usd
number 2.9% of amount_usd rounded half up to the cent, plus $0.30
net_converted_usd
number amount_usd minus stripe_fee_usd: what the cycles are minted for, the same at delivery
estimated_cycles
string
xdr_rate
number
GET /v1/subscriptions Bearer token

List all auto-refill subscriptions for the authenticated principal. Each one has exactly the fields below.

Response { subscriptions: [...] }
id
string
principal
string
destination_type
"canister" | "ledger"
canister_id
string | null null for ledger
threshold_cycles
string
refill_to_cycles
string
max_monthly_usd
string decimal, e.g. "100.00"
notify_webhook
string | null
project_id
string | null
created_at
string
last_refill_at
string | null
refill_in_progress
boolean
action_needed
"authentication_required" | null what you must do before auto-refill can charge again: "authentication_required" means the saved card needs authenticating; null when nothing
PUT /v1/subscribe/:id Bearer token

Update an existing auto-refill subscription. Only the principal that created the subscription can modify it.

Request body
threshold_cycles
string
refill_to_cycles
string
max_monthly_usd
number
notify_webhook
string
Response
id
string
principal
string
destination_type
"canister" | "ledger"
canister_id
string | null
threshold_cycles
string
refill_to_cycles
string
max_monthly_usd
string decimal, e.g. "100.00"
notify_webhook
string | null
project_id
string | null
created_at
string
last_refill_at
string | null
refill_in_progress
boolean
action_needed
"authentication_required" | null what you must do before auto-refill can charge again: "authentication_required" means the saved card needs authenticating; null when nothing
POST /v1/x402/topup x402 (USDC on Base)

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"
confirm_system_canister
boolean optional; see destination checks
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>"
    }
  }
}
POST /v1/ckusdc/topup Bearer token

Pay with ck-USDC on ICP. Signed in with Internet Identity, the customer signs an ICRC-2 approve from that same II principal (Plug, OISY, NNS dapp and Stoic are not supported today) granting the CyclePay hot wallet principal (nsp5q-aelxk-xax2u-3sqxb-xeod6-fduhf-zmoir-qxpp3-xusb4-nez2q-5ae) an allowance of amount + fee on the ck-USDC ledger (xevnm-gaaaa-aaaar-qafnq-cai; fee 10_000 atoms = 0.01 ck-USDC). Then POST here with the II Bearer token. The server pulls funds via icrc2_transfer_from on the ck-USDC ledger, then fulfills cycles through the same canister path used by Stripe and x402. Synchronous: cycles land in the response, with no checkout URL. ck-USDC is auto-rebalanced to ICP via ICPSwap, so this rail closes the treasury loop without any operator action.

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.

Request body
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"
amount_usdc_atomic
string | number required 6-decimal ck-USDC atoms (10_000_000 = 10.00 USDC). $1 to $200 per order (1_000_000 to 200_000_000); larger amounts are rejected with 400. Must be a multiple of 10_000 atoms ($0.01); other amounts are rejected with 400.
request_id
string UUID (8-4-4-4-12 hex digits), required for safe retries: send a fresh one per purchase and the same one on every retry of it. Without it the server keys the payment on principal, amount, destination and a 10-second window, so a retry after 10 seconds counts as a new purchase and a second identical purchase inside the window as the same one. A request_id that is not a UUID is rejected with 400.
confirm_system_canister
boolean optional; see destination checks
project_id
string optional: a project you are a member of (any role); the payment belongs to it. Otherwise 403 not_project_member, before any pull.
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 ck-USDC ICRC-2 transfer_from block index
destination_type
"canister" | "ledger"
canister_id
string? present only for canister orders
ck-USDC top-up (after the user signs the ICRC-2 approve)
POST /v1/ckusdc/topup
Authorization: Bearer <base64(delegation_chain_json)>
Content-Type: application/json
{
  "destination_type": "canister",
  "canister_id": "<your-canister-id>",
  "amount_usdc_atomic": "10000000",
  "request_id": "<fresh UUID per purchase>"
}
# 10_000_000 atoms = 10.00 USDC. At xdr_usd 1.3576
# (the rate example below), $10 buys about 7.3659T.

# Response (200):
{
  "success": true,
  "order_id": "ord_...",
  "cycles": "7.3659T",
  "cycles_raw": "7365939893930",
  "block_index": "<icrc2 block>",
  "destination_type": "canister",
  "canister_id": "<your-canister-id>"
}
GET /v1/ckusdc/balance Bearer token

Current ck-USDC balance for the authenticated principal. Reads icrc1_balance_of on the ck-USDC ledger and returns both the raw atomic value and a USD-denominated convenience field. Useful for showing “you have $X” before the user picks an amount on the ck-USDC rail.

Response
success
boolean
principal
string
balance_atomic
string raw ck-USDC atoms (6 decimals), decimal string
balance_usd
string USD value of the balance (atomic / 1_000_000), formatted to 2 decimals
GET /health none (public)

Health check endpoint. Returns service status.

Errors and limits

Rate limits, destination checks, error codes.

Maintenance and moved rails

Payments already taken are still delivered or refunded, and order status reads keep working.

503 maintenance
a short maintenance window: new orders on every rail and console changes are paused, with Retry-After: 300. Console reads still work.
410 card_rail_moved
new card payments (/v1/checkout, /v1/topup, /v1/guest-checkout) moved on-chain; the body has buy_url and backend_canister_id.
410 ckusdc_rail_moved
/v1/ckusdc/topup moved to the backend canister; the body has buy_url and backend_canister_id.
410 console_moved
every console route (projects, teams, settings, subscriptions, history, stats), reads included, moved to the console canister; the body has console_url.
Rate limits
All routes
60 requests per minute per IP (/webhook/* excluded), then 429 {"error":"Too many requests"} with Retry-After set to the seconds until the window resets
/v1/x402/topup
10 top-ups per payer address per 24 hours, then 429 rate_limited with Retry-After set to the seconds until that payer's window frees up
/v1/guest-checkout
while on: 5 sessions per IP per hour and 60 in total per minute; 10 guest orders delivered per destination per day, 100 in total per hour and $1,000 in total per day (a payment over a cap is refunded); then 429 GUEST_RATE_LIMITED with Retry-After (see guest checkout)
Destination checks

Applies to every purchase route (/v1/checkout, /v1/topup, /v1/guest-checkout, /v1/x402/topup and /v1/ckusdc/topup); INVALID_CANISTER_ID also applies to /v1/subscribe. Nothing is charged when a check fails.

400 INVALID_CANISTER_ID
"That canister ID has a typo (bad checksum)": canister_id has the right shape, but its checksum is wrong.
400 BENEFICIARY_INVALID
beneficiary_principal is not a principal in canonical text form.
400 SYSTEM_CANISTER_WARNING
The canister is one of 9 known system canisters (NNS dapp, CMC, ICP Ledger, Internet Identity, Cycles Ledger, NNS Governance, NNS Root, CyclePay backend, CyclePay frontend). The response includes a system_canister object. Repost with "confirm_system_canister": true to proceed.
400 DESTINATION_BLOCKED
aaaaa-aa (the management canister) cannot receive cycles. A beneficiary_principal that is anonymous, a system canister or CyclePay's own canister gets the same code. No override.

The x402 and ck-USDC routes answer errors as { success: false, error, code?, support_ref? }. support_ref is the order id when one exists; quote it to support. Card, guest, console and subscription routes answer { error, code? }: code is set for destination checks, guest limits, the switches above, token_too_large and a bare principal sent as the token (401 auth_invalid); a subscription to a refused canister answers 403 with error_code: "CANISTER_DENIED". The server-wide 429 and 400 token_too_large run before any route, so they have no success field even on the crypto routes. The x402 402 body is the x402 PaymentRequired object. Every 429 carries a Retry-After header in seconds.

/v1/ckusdc/topup
202 delivery_in_progress
not an error: payment received and cycles still being delivered (success: true, status: "delivering"). They arrive automatically, or the payment is refunded. Do not pay again.
202 needs_review
not an error: payment received, and the order is held for a manual check (success: true, status: "under_review"); the cycles are delivered or the payment is refunded once an operator settles it. Do not pay again.
401 auth_required
no Bearer token
401 auth_invalid
token is not a valid II delegation chain
402 insufficient_balance
ck-USDC balance is below the amount
402 pull_refused
the ledger refused icrc2_transfer_from, usually a missing or too-small approve
403 not_project_member
project_id names a project you are not a member of
409 duplicate_payment
this purchase was already fulfilled
409 duplicate_payment_failed
an earlier attempt failed and needs manual reconciliation
409 payment_in_progress
another request is processing this payment right now (what a retry can hit while the first request is still running); wait a moment and retry the same request
409 payment_failed
delivery failed and the payment is being refunded
409 duplicate_payment_refunded
this payment was refunded, or its refund is in progress
503 canister_unavailable
the canister could not confirm the payment; retry shortly
500 / 503 db_error
the server could not record the payment; retry shortly
502 pull_transient_error
the pull call failed in transit; retrying the same request is safe
503 balance_read_failed
the balance read failed; try again
500 fulfill_error
cycles were not delivered; the payment is refunded automatically once the canister confirms the order failed
/v1/x402/topup
202 delivery_in_progress
not an error: payment received and cycles still being delivered (success: true, status: "delivering"). They arrive automatically, or the payment is refunded. Do not pay again.
202 needs_review
not an error: payment received, and the order is held for a manual check (success: true, status: "under_review"); the cycles are delivered or the payment is refunded once an operator settles it. Do not pay again.
400 payment_invalid
the payment header is not base64-encoded JSON, carries no EIP-3009 authorization, or PAYMENT-SIGNATURE and X-PAYMENT disagree
401 auth_required
ledger destination without an II Bearer token (also a retry of a ledger payment)
401 auth_invalid
an Authorization header that is not a valid II delegation
402 payment_invalid
the facilitator rejected the signed authorization; PAYMENT-REQUIRED comes back with the reason in error
402 settle_failed
the facilitator declined to settle on-chain; PAYMENT-RESPONSE carries success: false and errorReason
402 chain_verify_failed
the on-chain USDC transfer could not be confirmed
403 forbidden
a retry of a known payment signed in as a principal that did not make it
409 duplicate_payment, duplicate_payment_failed
this authorization was already processed (however encoded), or an earlier attempt with it failed
409 payment_in_progress
another request is processing this payment right now (what a retry can hit while the first request is still running); wait a moment and retry the same request
409 payment_failed
delivery failed and the payment is being refunded
409 duplicate_payment_refunded
this payment was refunded, or its refund is in progress
503 canister_unavailable
the canister could not confirm the payment; retry shortly
500 / 503 db_error
the server could not record the payment; retry shortly
502 settle_invalid
the facilitator's settle response had no on-chain transaction hash
429 rate_limited
10 top-ups per payer address per 24 hours
502 facilitator_error
the facilitator was unreachable; retry
503 config_error
the payment rail is not configured
500 fulfill_error
cycles were not delivered; the payment is refunded automatically once the canister confirms the order failed
Example requests

Curl the endpoints.

Create a checkout session
# TOKEN = the Bearer token from a browser sign-in, valid for up to five minutes.
# See the "Authentication" section above for how to build it.
curl -X POST \
  https://backend-production-9bca7.up.railway.app/v1/checkout \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "canister_id": "<your-canister-id>",
    "amount_usd": 25
  }'

# Response:
{
  "order_id": "ord_a1b2c3d4-...",
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
  "session_id": "cs_live_...",
  "view_token": "<22-character token for GET /v1/order-status>",
  "amount_usd": 25,
  "stripe_fee_usd": 1.03,
  "net_converted_usd": 23.97,
  "estimated_cycles": "17.66T",
  "xdr_rate": 1.3576
}

# Open checkout_url to complete payment.
# Cycles are deposited after Stripe confirms.
Get current rate
curl https://backend-production-9bca7.up.railway.app/v1/rate

# Response:
{
  "xdr_usd": 1.3576,
  "icp_xdr": 2.497,
  "icp_usd": 3.39,
  "cycles_per_usd": "0.74T",
  "timestamp": "2026-10-06T20:54:00.000Z",
  "source": "CMC + CoinGecko live",
  "certVerified": true,
  "xdr_usd_source": "canister-xrc"
}

# Public endpoint, no auth required.
# Cached for 5 minutes.
Set up auto-refill

Register a refill policy.

Register an auto-refill configuration for a canister. A server-side worker scans active subscriptions every 30 minutes; when the canister's balance falls below the threshold, it charges the saved card off-session and tops the canister back up to the target. Idempotency keys are deterministic per (subscription, hour-bucket) so retries are safe.

POST /v1/subscribe
# TOKEN = the Bearer token from a browser sign-in, valid for up to five minutes.
# See the "Authentication" section above for how to build it.
# threshold_cycles / refill_to_cycles are decimal strings of raw cycles.
# 5_000_000_000_000 = 5T cycles (the "T" suffix is human shorthand only:
# the API accepts digits, not "5T").
curl -X POST \
  https://backend-production-9bca7.up.railway.app/v1/subscribe \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "canister_id": "<your-canister-id>",
    "threshold_cycles": "5000000000000",
    "refill_to_cycles": "20000000000000",
    "max_monthly_usd": 100,
    "notify_webhook": "https://your-bot.com/webhook"
  }'

# Response:
{
  "id": "sub_7f3a2b...",
  "destination_type": "canister",
  "canister_id": "<your-canister-id>",
  "threshold_cycles": "5000000000000",
  "refill_to_cycles": "20000000000000",
  "max_monthly_usd": 100,
  "notify_webhook": "https://your-bot.com/webhook",
  "project_id": null,
  "status": "active"
}
For AI agents

Top up canisters, programmatically.

AI coding agents can use the REST API to request cycle top-ups. Two destinations are supported: a specific canister (destination_type: "canister", with a canister_id) or a Cycles Ledger account (destination_type: "ledger", the signed-in principal's or beneficiary_principal's). POST /v1/topup returns a Stripe checkout URL, a human completes payment, and cycles are delivered after the payment confirms.

Canister top-up
POST /v1/topup
Authorization: Bearer <base64(delegation_chain_json)>
{
  "destination_type": "canister",
  "canister_id": "<your-canister-id>",
  "amount_usd": 25
}
// Cycles land on <your-canister-id> via the CMC.
Cycles Ledger top-up
POST /v1/topup
Authorization: Bearer <base64(delegation_chain_json)>
{
  "destination_type": "ledger",
  "beneficiary_principal": "<principal>",
  "amount_usd": 25
}
// Cycles land on <principal>'s Cycles Ledger
// account, minus 0.0002T in ledger fees. Omit
// beneficiary_principal to credit yourself.
Terminal tools. A tool can send its user to /?dest=ledger&to=<principal>&usd=<1..200>&from=icp-cli on this site. The Buy page preselects that principal's Cycles Ledger account and the amount, and the user pays by card: without a sign-in while guest checkout is on and the amount is $5 to $100, otherwise after signing in with Internet Identity. /?dest=canister&to=<canister id>&usd=<1..200>&from=icp-cli does the same for a canister.
Note. Every /v1/topup purchase requires a human to complete the Stripe checkout. The agent receives a checkout URL and presents it to the user. After payment, cycles land on the target canister or Cycles Ledger account.
Treat the token like a password. The Bearer token is the delegation chain itself, so anyone holding a copy can call the API as you until it expires. See agent security for what a sign-in can and can’t do, and for guidance if you’re building your own agent flow.
01
Request

Agent calls the API

Give your agent instructions to call POST /v1/topup with a canister ID and USD amount. The API returns a Stripe checkout URL.

02
Payment

Human completes payment

The agent presents the checkout URL. You open it in a browser and pay with a credit card. Stripe processes the payment and fires a webhook to the backend.

03
Delivery

Cycles deposited via CMC

After payment confirms, the backend converts USD to ICP and sends it to the Cycles Minting Canister. Cycles land on the target canister or are credited to a Cycles Ledger account.

CLAUDE.md / agent config

Drop this into your agent's context.

CLAUDE.md
# Cycles Top-Up (www.cyclepay.org)
When you need ICP cycles, fetch the index first:
  https://www.cyclepay.org/llms.txt   (or https://4w3yu-fyaaa-aaaac-beeoa-cai.icp0.io/llms.txt)

# The frontend canister (4w3yu-...icp0.io) serves
# only static pages. The REST API lives on the
# Railway backend. Use the
# base URL below for every /v1/* call.
API base: https://backend-production-9bca7.up.railway.app

# Auth: an Internet Identity delegation from a browser
# sign-in on the CyclePay site, sent as the Bearer token:
# const identity = await authClient.getIdentity();
# await identity.refresh();
# token = btoa(JSON.stringify(identity.getDelegation().toJSON()))
# A token is valid for up to five minutes: build one
# per request.
# icp-cli sign-ins (icp identity link web) are not
# accepted by the API yet. Never print or log $TOKEN.
# Bare-principal Bearer tokens are rejected in production.

# Purchase cycles via Stripe (canister destination):
POST /v1/topup
Authorization: Bearer $TOKEN
{
  "destination_type": "canister",
  "canister_id": "...",
  "amount_usd": 10
}

# Or credit a Cycles Ledger account (minus 0.0002T
# in ledger fees); omit beneficiary_principal to
# credit the signed-in principal:
POST /v1/topup
Authorization: Bearer $TOKEN
{
  "destination_type": "ledger",
  "beneficiary_principal": "...",
  "amount_usd": 25
}

# Both return { "checkout_url": "..." }. Open it in a
# browser for the user to complete payment.

# Pay with USDC on Base (x402 v2):
# First POST returns 402 with payment requirements
# in the PAYMENT-REQUIRED header; sign EIP-3009
# transferWithAuthorization, repost with it in the
# PAYMENT-SIGNATURE header (legacy X-PAYMENT also
# works). Standard x402 v2 clients do this; to pay
# more than $1, raise the client's per-payment cap
# (spendControls.maxAmountPerPayment, $1 by default).
# See /api#endpoints for the full flow. Bearer auth is optional for canister
# destinations and required for ledger ones.
POST /v1/x402/topup
{
  "destination_type": "canister",
  "canister_id": "...",
  "amount_usd_cents": 1000
}

# Pay with ck-USDC on ICP:
# 1. Sign in with Internet Identity to get $TOKEN.
# 2. As that same II principal, sign an ICRC-2
#    `approve` on the ck-USDC ledger
#    (xevnm-gaaaa-aaaar-qafnq-cai) for
#    `amount + fee` (fee = 10_000 atoms), with
#    the CyclePay hot wallet as the spender:
#    nsp5q-aelxk-xax2u-3sqxb-xeod6-fduhf-zmoir-qxpp3-xusb4-nez2q-5ae
#    Plug, OISY, NNS dapp and Stoic are not
#    supported today: approve from the same II
#    principal that produced $TOKEN.
# 3. POST /v1/ckusdc/topup with $TOKEN. The server
#    pulls funds via icrc2_transfer_from and
#    delivers cycles via the same canister path
#    Stripe and x402 use. No checkout URL: cycles
#    land synchronously in the response.
POST /v1/ckusdc/topup
Authorization: Bearer $TOKEN
{
  "destination_type": "canister",
  "canister_id": "...",
  "amount_usdc_atomic": "10000000",
  "request_id": "<fresh UUID per purchase>"
}
# request_id: a fresh UUID per purchase, the same
# one on every retry (required for safe retries).
# amount_usdc_atomic is 6-decimal ck-USDC atoms, a
# multiple of 10_000 ($0.01); 10_000_000 = 10.00
# USDC. Every rail takes $1 to
# $200 per order. Returns { success, order_id,
# cycles, cycles_raw, block_index, destination_type,
# canister_id? }.

# GET /v1/ckusdc/balance returns the authed
# principal's ck-USDC balance, handy for showing
# "you have $X" before the user picks an amount.
Compatible with any agent that can make HTTP requests

One endpoint. Every harness.

Add the API endpoint and instructions to your agent's context file. The agent calls the REST API, and you complete payment in the browser.

Claude
via CLAUDE.md
ChatGPT
via system prompt
Cursor
via .cursor/rules
Devin
via knowledge base
Copilot
via instructions
Windsurf
via rules file
Claude Code
via CLAUDE.md
OpenCode
via config
Any CLI agent
via curl / HTTP
Custom scripts
via REST API
Get started

Try the API.

Authenticate with Internet Identity, then call the endpoints above from the browser, a script, or an AI agent.