PayAI Hosted Checkout

Developer docs

Stripe Checkout for stablecoins. Create a checkout session and send the buyer its url. People pay on a hosted page. AI agents pay the same url with x402. The money goes straight to your wallet.

llms.txtOpenAPI 3.1MCP serverAPI keys

Quick start

You need a checkout key and a payout wallet. Amounts are integers in US cents: 2500 is $25.00. The buyer pays 1 USDC, USDT or USDG per US dollar.

Before you start, set a payout wallet in Settings. Payments go to this wallet. You can use only a wallet that you signed with.

Each payment uses settle credits from your PayAI account. If the account has no credits, buyers cannot pay. Buy credits at https://staging.merchant.payai.network/dashboard/buy-credits.

  1. Get a key. Open Developers and create a key. A test key starts with payai_ck_test_. A live key starts with payai_ck_live_. An agent can get a key without a browser: see Agent sign-up.
  2. Create a checkout session.
    Form encoding (the same as Stripe)
    curl https://staging.checkout.payai.network/v1/checkout/sessions \
      -u payai_ck_test_YOUR_KEY: \
      -H "Idempotency-Key: order-1001" \
      -d "line_items[0][price_data][currency]=usd" \
      -d "line_items[0][price_data][unit_amount]=2500" \
      -d "line_items[0][price_data][product_data][name]=T-shirt" \
      -d "line_items[0][quantity]=1" \
      -d "client_reference_id=order-1001" \
      -d "success_url=https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}"
    JSON also works
    curl https://staging.checkout.payai.network/v1/checkout/sessions \
      -H "Authorization: Bearer payai_ck_test_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -d '{"line_items":[{"price_data":{"currency":"usd","unit_amount":2500,"product_data":{"name":"T-shirt"}},"quantity":1}]}'
    Response
    {
      "id": "cs_test_a1B2c3D4...",
      "object": "checkout.session",
      "amount_total": 2500,
      "currency": "usd",
      "status": "open",
      "payment_status": "unpaid",
      "url": "https://staging.checkout.payai.network/c/cs_test_a1B2c3D4...",
      "expires_at": 1792108800,
      "livemode": false,
      "payment": null
    }
  3. Send the url to the buyer. A person opens the url in a browser and pays from a wallet. An AI agent sends a request to the same url and pays with x402.

To confirm the payment, wait for the checkout.session.completed webhook. Or get the session until status is complete. Then fulfill the order.

The API uses the paths, parameters, objects and errors of Stripe Checkout Sessions. Code that works with Stripe Checkout needs a different host and key.

API

  • Base URL: https://staging.checkout.payai.network
  • Authentication: Authorization: Bearer payai_ck_.... curl -u payai_ck_...: also works.
  • Request bodies: JSON, or Stripe form encoding (line_items[0][price_data][unit_amount]=2500).
  • Send an Idempotency-Key header when you create a session. A retry with the same key and body returns the first session.
  • Each response has a Request-Id header. A key can make 100 requests per second.
  • The full schema is in openapi.json.
MethodPathDescription
POST/v1/checkout/sessionsCreate a checkout session. Send the returned url to the buyer.
GET/v1/checkout/sessionsList checkout sessions, newest first. Query: limit (1 to 100, default 10), starting_after, ending_before, status (open, complete or expired).
GET/v1/checkout/sessions/{id}Get one checkout session. Query: expand[]=line_items.
POST/v1/checkout/sessions/{id}/expireExpire an open session. Nobody can pay it after this.
GET/v1/checkout/sessions/{id}/line_itemsList the line items of a session (one page).
GET/v1/paymentsPayAI extension. List succeeded payments, newest first. Query: limit, starting_after.
POST/v1/webhook_endpointsCreate a webhook endpoint. Body: url, enabled_events[], description. The response has the signing secret (whsec_...) one time only.
GET/v1/webhook_endpointsList webhook endpoints.
GET/v1/webhook_endpoints/{id}Get one webhook endpoint.
DELETE/v1/webhook_endpoints/{id}Delete a webhook endpoint.
POST/v1/keysAgent sign-up. Exchange a facilitator JWT for a checkout key. Body: livemode (default true), name. Auth: Facilitator JWT.
GET/c/{id}The session url. Browsers get the payment page. Other clients get x402: HTTP 402 with PAYMENT-REQUIRED. Auth: none, pay with x402.

Create parameters

ParameterDescription
line_itemsRequired. 1 to 100 items.
line_items[].price_data.currencyusd. Only usd is available.
line_items[].price_data.unit_amountPrice of one unit in USD cents (integer). 2500 is $25.00. You can send unit_amount_decimal (whole cents, as a string) instead.
line_items[].price_data.product_data.nameRequired. Product name, up to 250 characters.
line_items[].price_data.product_data.descriptionOptional. Up to 500 characters.
line_items[].price_data.product_data.images[]Optional. Up to 8 https URLs.
line_items[].quantityOptional. 1 to 999999. Default 1.
modeOptional. payment is the default and the only value.
success_urlOptional. The page for the buyer after payment. {CHECKOUT_SESSION_ID} becomes the session id.
cancel_urlOptional. The page for a buyer who cancels.
client_reference_idOptional. Your order id, up to 200 characters.
customer_emailOptional. The buyer email, for your records.
metadataOptional. Up to 50 keys. Keys up to 40 characters. Values up to 500 characters.
expires_atOptional. Unix time in seconds, 30 minutes to 24 hours from now. Default: 24 hours.
payment_method_typesOptional. Only ["crypto"].
expand[]Optional. line_items adds the items to the response.

The total must be from 1 cent to 99,999,999 cents.

Session status

statusMeaning
openWaiting for payment. The url works.
processingA buyer signed a payment and the network is confirming it. Check again in a few seconds.
completePaid. payment_status is paid, and payment has the token, network and transaction.
expiredNobody can pay it. The url is null.

A paid session has a payment object (a PayAI extension). It shows the token, the network and the transaction. GET /v1/payments lists the same objects.

payment
"payment": {
  "id": "pay_...",
  "object": "payment",
  "status": "succeeded",
  "amount": 2500,
  "currency": "usd",
  "token": "USDC",
  "token_amount": "25000000",
  "token_decimals": 6,
  "network": "eip155:8453",
  "network_name": "Base",
  "payer": "0xBuyer...",
  "pay_to": "0xYourWallet...",
  "channel": "x402",
  "transaction": "0xabc...",
  "transaction_url": "https://basescan.org/tx/0xabc..."
}

Pagination

Lists return { "object": "list", "data": [...], "has_more": true }, newest first. For the next page, send starting_after with the id of the last object.

Webhooks

PayAI sends an event to your server when a session is paid (checkout.session.completed) or expires (checkout.session.expired).

  1. Create an endpoint. Store the secret (whsec_...) from the response. You cannot see it again.
    curl https://staging.checkout.payai.network/v1/webhook_endpoints \
      -u payai_ck_test_YOUR_KEY: \
      -d "url=https://example.com/payai/webhook" \
      -d "enabled_events[]=checkout.session.completed" \
      -d "enabled_events[]=checkout.session.expired"
  2. PayAI sends a POST with a JSON event to your URL. data.object is the checkout session.
    Event
    {
      "id": "evt_9xQ2mT7kLp3RzW8vB1nC4dF6",
      "object": "event",
      "api_version": "2026-10-15",
      "created": 1792022400,
      "livemode": false,
      "type": "checkout.session.completed",
      "data": {
        "object": {
          "id": "cs_test_a1B2c3D4...",
          "object": "checkout.session",
          "amount_total": 2500,
          "status": "complete",
          "payment_status": "paid",
          "client_reference_id": "order-1001",
          "metadata": {},
          "url": null,
          "payment": { "token": "USDC", "network": "eip155:84532", "transaction": "0xabc...", ... }
        }
      }
    }
  3. Verify the signature (see below). Reject the event if the signature is not correct.
  4. Answer with a 2xx status quickly. Do slow work after you answer.

Verify the signature

The PayAI-Signature header has the format t=<unix seconds>,v1=<hex>.

  1. Read the raw request body. Do not parse it before you verify it.
  2. Get t and each v1 from the header.
  3. Compute HMAC-SHA256 of <t>.<raw body>. Use the whsec_ secret as the key. Encode the result as hex.
  4. Compare your result with each v1. Use a constant-time comparison.
  5. Reject the event if t is more than 5 minutes from your clock.
Node.js
import crypto from 'node:crypto';

// rawBody: the request body as a string, exactly as you received it.
// header: the value of the PayAI-Signature header.
// secret: the whsec_... secret from POST /v1/webhook_endpoints.
function verifyPayAISignature(rawBody, header, secret, toleranceSeconds = 300) {
  const pairs = header.split(',').map((part) => part.split('='));
  const timestamp = Number(pairs.find(([key]) => key === 't')?.[1]);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  return pairs
    .filter(([key]) => key === 'v1')
    .some(([, value]) => {
      const given = Buffer.from(value ?? '', 'hex');
      return given.length === expected.length && crypto.timingSafeEqual(given, expected);
    });
}
Express
import express from 'express';

const app = express();

// Use the raw body. A parsed and re-serialized body has a different signature.
app.post('/payai/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const header = req.get('PayAI-Signature') ?? '';
  if (!verifyPayAISignature(rawBody, header, process.env.PAYAI_WEBHOOK_SECRET)) {
    return res.status(400).send('Bad signature');
  }
  const event = JSON.parse(rawBody);
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    // Fulfill the order for session.client_reference_id.
    // Save event.id. Skip events that you already processed.
  }
  res.sendStatus(200);
});

Stripe's libraries can also verify the signature, because the format is the same. Give them the PayAI-Signature value where they expect Stripe-Signature. For example, in Node.js: stripe.webhooks.constructEvent(rawBody, signature, secret).

Retries

  • If your endpoint does not answer 2xx in 10 seconds, PayAI tries again with backoff (1 minute, 5 minutes, 30 minutes, then up to 24 hours) for about 3 days. Redirects count as failures.
  • An event can arrive more than once. Save event.id and skip events that you already processed.
  • Events can arrive out of order. Get the session from the API if the order is important.
  • Live endpoints must use https on a public host. Test endpoints can also use http. Each mode can have 16 endpoints. Event types: checkout.session.completed, checkout.session.expired or *.

AI agents

AI agents can sell and buy. A merchant agent creates sessions through the MCP server or the API. A buyer agent pays a session url with x402. An agent can also get its own key. Agents can read llms.txt and openapi.json.

Merchant agents: MCP server

The MCP server is at https://staging.checkout.payai.network/mcp. It uses Streamable HTTP and is stateless. Send your checkout key on every request. A test key makes test sessions only.

MCP client configuration
{
  "mcpServers": {
    "payai-checkout": {
      "url": "https://staging.checkout.payai.network/mcp",
      "headers": { "Authorization": "Bearer payai_ck_live_YOUR_KEY" }
    }
  }
}
Claude Code
claude mcp add --transport http payai-checkout https://staging.checkout.payai.network/mcp \
  --header "Authorization: Bearer payai_ck_live_YOUR_KEY"
ToolWhat it does
create_checkout_sessionMake a payment link. Amounts are USD cents. Returns the session with its url.
get_checkout_sessionGet a session by id. Use it to see if the buyer paid (status complete).
list_checkout_sessionsList sessions, newest first. Filter by status.
expire_checkout_sessionCancel an open payment link.
list_paymentsList succeeded payments with token, network and transaction.

Tool amounts are integers in US cents. Tool errors come back as results with isError: true and a message that says what to change.

Buyer agents: x402

The session url is also an x402 v2 resource. An agent can pay it without a browser and without PayAI code. The PayAI facilitator pays the gas, so the agent needs only the token. The url allows cross-origin requests (CORS), so an agent in a browser can also call it.

  1. Send GET to the url without Accept: text/html. The answer is HTTP 402. The PAYMENT-REQUIRED header has base64 JSON with accepts: one entry per token and network. The body has the same JSON.
  2. Choose an entry on a network where your wallet has the token. amount is in the smallest unit of the token (6 decimals: 25000000 is $25.00).
  3. Sign the payment for that entry with your wallet.
  4. Send the request again with the PAYMENT-SIGNATURE header (base64 JSON of the payment payload). X-PAYMENT also works.
The 402 answer
curl -i https://staging.checkout.payai.network/c/cs_live_...
# HTTP/2 402
# payment-required: eyJ4NDAyVmVyc2lvbiI6Miwi...   (base64 JSON, same as the body)
#
# {"x402Version":2,"error":"Payment required","resource":{"url":"https://staging.checkout.payai.network/c/cs_live_..."},
#  "accepts":[{"scheme":"exact","network":"eip155:8453","amount":"25000000",
#              "asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","payTo":"0xMerchant...",
#              "maxTimeoutSeconds":300,"extra":{"name":"USD Coin","version":"2"}}, ...]}
Pay with @x402/fetch (TypeScript)
import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from '@x402/fetch';
import { ExactEvmScheme } from '@x402/evm';
import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(process.env.BUYER_PRIVATE_KEY);
const payFetch = wrapFetchWithPaymentFromConfig(fetch, {
  // Pay on Base. Register the networks where your wallet has funds.
  schemes: [{ network: 'eip155:8453', client: new ExactEvmScheme(account) }],
});

const res = await payFetch('https://staging.checkout.payai.network/c/cs_live_...');
console.log(res.status, await res.json()); // 200: paid. 202: confirming.
const settlement = res.headers.get('PAYMENT-RESPONSE');
if (settlement) console.log(decodePaymentResponseHeader(settlement)); // { success, transaction, network, payer }
StatusMeaning
200Paid. The PAYMENT-RESPONSE header has { success, transaction, network, payer }. A GET without a payment header also answers 200 after payment.
202The network has not confirmed the payment yet. Send GET without a payment header every 5 seconds until you get 200.
400The payment header is not base64 JSON of an x402 payment payload.
402Payment required. After a signed payment, it means the payment failed and no money moved. The body has error, code and new accepts. You can sign again. If the message says that the merchant cannot take payments, do not try again.
404No session has this id.
409Another payment for this session is in progress, the session is already paid, or the signature was used before.
410The session expired. Ask the merchant for a new link.
429Too many requests. Wait for the Retry-After seconds.
503The checkout cannot take payments now, or the payment service is busy. No money moved. Try again later.

Other standard x402 clients work too, for example x402-axios and the Python x402 client.

Agent sign-up

An agent can get a checkout key with no person and no browser.

  1. Get a PayAI facilitator key (payai_sk_...). Create one in the merchant portal, or pay for one with x402 at POST https://staging.merchant.payai.network/api/v1/keys/vend. The wallet that pays for a vended key becomes your payout wallet.
  2. Sign a short JWT with the facilitator key: EdDSA, kid and sub set to the key id, iss set to payai-merchant, and a lifetime of 10 minutes or less. See facilitator authentication.
  3. Send POST /v1/keys with Authorization: Bearer <JWT>. The body can set livemode (default true) and name.
  4. Store secret from the response. You cannot see it again.
Sign the JWT (Node.js)
import crypto from 'node:crypto';

// keyId: the id of your PayAI facilitator key.
// secretKey: the private key, payai_sk_...
function facilitatorJwt(keyId, secretKey) {
  const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
  const now = Math.floor(Date.now() / 1000);
  const header = encode({ alg: 'EdDSA', typ: 'JWT', kid: keyId });
  const claims = encode({ sub: keyId, iss: 'payai-merchant', iat: now, exp: now + 120 });
  const privateKey = crypto.createPrivateKey({
    key: Buffer.from(secretKey.replace(/^payai_sk_/, ''), 'base64'),
    format: 'der',
    type: 'pkcs8',
  });
  const signature = crypto.sign(null, Buffer.from(`${header}.${claims}`), privateKey);
  return `${header}.${claims}.${signature.toString('base64url')}`;
}
Get the key
const res = await fetch('https://staging.checkout.payai.network/v1/keys', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${facilitatorJwt(KEY_ID, SECRET_KEY)}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ livemode: false, name: 'My agent' }),
});
const key = await res.json();
// key.secret is payai_ck_test_... Store it now. You cannot see it again.

Do not send the payai_sk_ key itself. Each account can create 10 keys per hour.

Test mode

  • A test key (payai_ck_test_...) makes test sessions (cs_test_...). Buyers pay them on testnets with test tokens that have no value.
  • Test sessions, payments, webhook endpoints and keys are separate from live mode.
  • Get test USDC from the Circle faucet. Test networks: Base Sepolia, Arbitrum Sepolia and Solana Devnet.
  • Test webhook endpoints can use http. Live endpoints must use https.
  • When you are ready, change the test key to a live key. Nothing else changes.

Errors

Errors use the Stripe shape. message says what to change. param names the parameter.

{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_missing",
    "message": "Missing required param: line_items[0][price_data][unit_amount] (USD cents).",
    "param": "line_items[0][price_data][unit_amount]",
    "doc_url": "https://staging.checkout.payai.network/docs#errors",
    "request_id": "req_8Hx2k9QmZr4aP1sT"
  }
}
typeHTTP status
invalid_request_error400, 404 or 429
authentication_error401
idempotency_error400
api_error500
codeWhat to do
parameter_missingA required parameter is missing. param names it.
parameter_invalid, parameter_invalid_integer, parameter_invalid_stringA parameter has a bad value. param names it.
parameter_unknownThe API does not know this parameter.
amount_invalidThe total must be 1 to 99999999 cents.
currency_unsupportedUse usd.
url_invalid, email_invalidFix the URL or the email address.
payout_wallet_missingSet a payout wallet in the dashboard settings before you create sessions.
resource_missingNo object has this id in this mode (HTTP 404).
session_not_openYou can expire only open sessions.
idempotency_key_in_useYou used this Idempotency-Key with different parameters. Use a new key.
api_key_invalidThe key is missing, wrong or revoked (HTTP 401).
rate_limitedToo many requests (HTTP 429). Wait and try again.
internal_errorA problem on our side (HTTP 500). Try again. Give us the request_id if it continues.

Tokens and networks

Buyers pay 1 token per US dollar. The PayAI facilitator pays the gas on every network, so buyers need no native coin.

NetworkModeTokensCAIP-2 id
BaseLiveUSDCeip155:8453
ArbitrumLiveUSDC, USDT (USDT0), USDG (when enabled)eip155:42161
PolygonLiveUSDCeip155:137
AvalancheLiveUSDCeip155:43114
SeiLiveUSDC, USDT (USDT0)eip155:1329
X LayerLiveUSDT (USDT0), USDG (when enabled)eip155:196
SolanaLiveUSDC, USDT, USDG (when enabled)solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp
Base SepoliaTestUSDCeip155:84532
Arbitrum SepoliaTestUSDCeip155:421614
Solana DevnetTestUSDCsolana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1
  • USDT on EVM networks is USDT0.
  • A checkout offers a network only when you have a payout wallet for it: an EVM wallet for the EVM networks and a Solana wallet for Solana.
  • On Solana, your payout wallet must have a token account for the token.

Non-custodial

  • PayAI never holds your funds. PayAI has no key that can move them.
  • The buyer signs a transfer straight to your payout wallet. The signature fixes the amount, the token and the address.
  • The PayAI facilitator only submits the signed transfer and pays the gas.
  • Each payment uses settle credits from your PayAI account, the same as any other facilitator settle. Without credits, buyers cannot pay.
  • Payouts go only to wallets that you proved you control with a signature.