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.
- Get a key. Open Developers and create a key. A test key starts with
payai_ck_test_. A live key starts withpayai_ck_live_. An agent can get a key without a browser: see Agent sign-up. - 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 } - 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-Keyheader when you create a session. A retry with the same key and body returns the first session. - Each response has a
Request-Idheader. A key can make 100 requests per second. - The full schema is in openapi.json.
| Method | Path | Description |
|---|---|---|
POST | /v1/checkout/sessions | Create a checkout session. Send the returned url to the buyer. |
GET | /v1/checkout/sessions | List 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}/expire | Expire an open session. Nobody can pay it after this. |
GET | /v1/checkout/sessions/{id}/line_items | List the line items of a session (one page). |
GET | /v1/payments | PayAI extension. List succeeded payments, newest first. Query: limit, starting_after. |
POST | /v1/webhook_endpoints | Create a webhook endpoint. Body: url, enabled_events[], description. The response has the signing secret (whsec_...) one time only. |
GET | /v1/webhook_endpoints | List webhook endpoints. |
GET | /v1/webhook_endpoints/{id} | Get one webhook endpoint. |
DELETE | /v1/webhook_endpoints/{id} | Delete a webhook endpoint. |
POST | /v1/keys | Agent 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
| Parameter | Description |
|---|---|
line_items | Required. 1 to 100 items. |
line_items[].price_data.currency | usd. Only usd is available. |
line_items[].price_data.unit_amount | Price 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.name | Required. Product name, up to 250 characters. |
line_items[].price_data.product_data.description | Optional. Up to 500 characters. |
line_items[].price_data.product_data.images[] | Optional. Up to 8 https URLs. |
line_items[].quantity | Optional. 1 to 999999. Default 1. |
mode | Optional. payment is the default and the only value. |
success_url | Optional. The page for the buyer after payment. {CHECKOUT_SESSION_ID} becomes the session id. |
cancel_url | Optional. The page for a buyer who cancels. |
client_reference_id | Optional. Your order id, up to 200 characters. |
customer_email | Optional. The buyer email, for your records. |
metadata | Optional. Up to 50 keys. Keys up to 40 characters. Values up to 500 characters. |
expires_at | Optional. Unix time in seconds, 30 minutes to 24 hours from now. Default: 24 hours. |
payment_method_types | Optional. 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
| status | Meaning |
|---|---|
open | Waiting for payment. The url works. |
processing | A buyer signed a payment and the network is confirming it. Check again in a few seconds. |
complete | Paid. payment_status is paid, and payment has the token, network and transaction. |
expired | Nobody 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": {
"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).
- 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" - PayAI sends a POST with a JSON event to your URL.
data.objectis 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...", ... } } } } - Verify the signature (see below). Reject the event if the signature is not correct.
- 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>.
- Read the raw request body. Do not parse it before you verify it.
- Get
tand eachv1from the header. - Compute HMAC-SHA256 of
<t>.<raw body>. Use thewhsec_secret as the key. Encode the result as hex. - Compare your result with each
v1. Use a constant-time comparison. - Reject the event if
tis more than 5 minutes from your clock.
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);
});
}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.idand 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.expiredor*.
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.
{
"mcpServers": {
"payai-checkout": {
"url": "https://staging.checkout.payai.network/mcp",
"headers": { "Authorization": "Bearer payai_ck_live_YOUR_KEY" }
}
}
}claude mcp add --transport http payai-checkout https://staging.checkout.payai.network/mcp \
--header "Authorization: Bearer payai_ck_live_YOUR_KEY"| Tool | What it does |
|---|---|
create_checkout_session | Make a payment link. Amounts are USD cents. Returns the session with its url. |
get_checkout_session | Get a session by id. Use it to see if the buyer paid (status complete). |
list_checkout_sessions | List sessions, newest first. Filter by status. |
expire_checkout_session | Cancel an open payment link. |
list_payments | List 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.
- Send
GETto the url withoutAccept: text/html. The answer is HTTP 402. ThePAYMENT-REQUIREDheader has base64 JSON withaccepts: one entry per token and network. The body has the same JSON. - Choose an entry on a network where your wallet has the token.
amountis in the smallest unit of the token (6 decimals:25000000is $25.00). - Sign the payment for that entry with your wallet.
- Send the request again with the
PAYMENT-SIGNATUREheader (base64 JSON of the payment payload).X-PAYMENTalso works.
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"}}, ...]}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 }| Status | Meaning |
|---|---|
200 | Paid. The PAYMENT-RESPONSE header has { success, transaction, network, payer }. A GET without a payment header also answers 200 after payment. |
202 | The network has not confirmed the payment yet. Send GET without a payment header every 5 seconds until you get 200. |
400 | The payment header is not base64 JSON of an x402 payment payload. |
402 | Payment 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. |
404 | No session has this id. |
409 | Another payment for this session is in progress, the session is already paid, or the signature was used before. |
410 | The session expired. Ask the merchant for a new link. |
429 | Too many requests. Wait for the Retry-After seconds. |
503 | The 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.
- Get a PayAI facilitator key (
payai_sk_...). Create one in the merchant portal, or pay for one with x402 atPOST https://staging.merchant.payai.network/api/v1/keys/vend. The wallet that pays for a vended key becomes your payout wallet. - Sign a short JWT with the facilitator key: EdDSA,
kidandsubset to the key id,issset topayai-merchant, and a lifetime of 10 minutes or less. See facilitator authentication. - Send
POST /v1/keyswithAuthorization: Bearer <JWT>. The body can setlivemode(defaulttrue) andname. - Store
secretfrom the response. You cannot see it again.
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')}`;
}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"
}
}| type | HTTP status |
|---|---|
invalid_request_error | 400, 404 or 429 |
authentication_error | 401 |
idempotency_error | 400 |
api_error | 500 |
| code | What to do |
|---|---|
parameter_missing | A required parameter is missing. param names it. |
parameter_invalid, parameter_invalid_integer, parameter_invalid_string | A parameter has a bad value. param names it. |
parameter_unknown | The API does not know this parameter. |
amount_invalid | The total must be 1 to 99999999 cents. |
currency_unsupported | Use usd. |
url_invalid, email_invalid | Fix the URL or the email address. |
payout_wallet_missing | Set a payout wallet in the dashboard settings before you create sessions. |
resource_missing | No object has this id in this mode (HTTP 404). |
session_not_open | You can expire only open sessions. |
idempotency_key_in_use | You used this Idempotency-Key with different parameters. Use a new key. |
api_key_invalid | The key is missing, wrong or revoked (HTTP 401). |
rate_limited | Too many requests (HTTP 429). Wait and try again. |
internal_error | A 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.
| Network | Mode | Tokens | CAIP-2 id |
|---|---|---|---|
| Base | Live | USDC | eip155:8453 |
| Arbitrum | Live | USDC, USDT (USDT0), USDG (when enabled) | eip155:42161 |
| Polygon | Live | USDC | eip155:137 |
| Avalanche | Live | USDC | eip155:43114 |
| Sei | Live | USDC, USDT (USDT0) | eip155:1329 |
| X Layer | Live | USDT (USDT0), USDG (when enabled) | eip155:196 |
| Solana | Live | USDC, USDT, USDG (when enabled) | solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp |
| Base Sepolia | Test | USDC | eip155:84532 |
| Arbitrum Sepolia | Test | USDC | eip155:421614 |
| Solana Devnet | Test | USDC | solana: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.