Agent payments
Let an AI agent pay for a ZyloPay card holder with an agent key, and accept those payments as a seller.
A ZyloPay card holder can connect an AI agent (a script, an assistant, a service acting for them) to one of their passes. The agent gets an agent key. The key cannot move money by itself. For each purchase, the agent asks ZyloPay for a single-use payment token and hands it to the seller. The seller confirms a payment intent with the token from its server, and ZyloPay settles it like a tap at a terminal, so every check of a card payment applies.
This guide is for two audiences:
- Agent builders: you build an agent, or a platform that runs agents, that pays for a ZyloPay user.
- Sellers: you sell an API, data or content to agents and want to accept ZyloPay.
sequenceDiagram
participant A as Agent
participant S as Seller
participant Z as ZyloPay
participant P as Card holder's phone
A->>S: Request a paid resource
S-->>A: 402 with price and merchant ID
A->>Z: Mint a payment token (agent key)
Z-->>A: zpt_ token, 2 minutes, this merchant, at most this amount
A->>S: Retry with the token
S->>Z: Create a payment intent, confirm it with the token (secret API key)
opt Held for approval
Z->>P: Approve this payment?
P-->>Z: Approve
end
Z-->>S: Intent succeeded, settled on Monad
S-->>A: 200 with the resource
What an agent key is
- The card holder creates it in the ZyloPay payer app (pay.zylopay.com), under AI agents. They name the agent, pick a pass and set its limits.
- Bound to one pass and one network. Mainnet keys start with
zpa_live_, testnet keys withzpa_test_. A key only ever pays from its own pass, on its own network. - Shown once. The app shows the full key one time. ZyloPay stores only a hash of it and can never show it again. A lost key is switched off and replaced.
- Up to 5 active keys per pass.
- Mints tokens, nothing else. The key cannot settle a payment, read the pass or act for the card holder in any other way.
The card holder can switch a key off at any time in the app. It stops at once: the next mint is refused, and tokens it minted but nobody used stop working.
Limits
Every payment passes all of these. The tighter one wins.
| Limit | Set by | What happens |
|---|---|---|
| Per-payment limit | Card holder, per agent | A mint above it is refused with AGENT_AMOUNT_TOO_HIGH. The token also carries the amount, so a seller cannot charge more. |
| Monthly budget | Card holder, per agent | Counts settled and in-flight payments in the current UTC calendar month. A payment that would go over is refused: at mint with AGENT_MONTHLY_LIMIT, at settlement with amount_exceeds_approval on the seller's payment intent. The budget resets on the 1st at 00:00 UTC. |
| Ask me every time | Card holder, per agent | Every payment by this agent waits for the card holder's approval on their phone (reason AGENT_APPROVAL_REQUIRED). |
| Expiry | Card holder, per agent | 30, 90 or 365 days, or never (default 90). After it, the key is refused with AGENT_KEY_INVALID. |
| The pass's spending approval | Card holder, when they create the pass | The on-chain SpendingApproval caps each payment. A mint above it is refused with AGENT_AMOUNT_TOO_HIGH. |
| Payer confirmation | ZyloPay risk rules | Some payments are held until the card holder approves them: above the network's confirmation threshold, the first payment to a merchant, and others. See Payer confirmation. |
A frozen account, a frozen or revoked pass, an expired approval and a restricted account stop the agent too. Nothing the agent sends can raise a limit.
Mint a payment token
Mint one token per purchase, right before you pay. Send the agent key in x-agent-key and the key's network in X-Network.
curl -X POST https://zylopay-api.fly.dev/api/agent/payment-tokens \
-H "x-agent-key: $ZYLOPAY_AGENT_KEY" \
-H "X-Network: testnet" \
-H "Content-Type: application/json" \
-d '{"merchantId": 1042, "amount": "2500000"}'| Field | Type | Meaning |
|---|---|---|
merchantId | integer | The seller's on-chain merchant ID on this network (1000 or more). Sellers publish it, for example in extra.merchantId of an x402 answer. |
amount | string | The most the token may pay, in USDC atoms (6 decimals): 2500000 is $2.50. See Amounts. |
X-Network is mainnet or testnet and must match the key: a zpa_test_ key only works with testnet. A mismatch is refused like an unknown key.
{
"success": true,
"data": {
"paymentToken": "zpt_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"expiresAt": "2026-09-28T14:05:31.000Z",
"merchantId": 1042,
"maxAmount": "2500000",
"network": "testnet"
}
}The token:
- is single-use: one settlement, then it is spent;
- is valid for 2 minutes (
expiresAt); - pays only this merchant, on this network, at most
maxAmount. Presented anywhere else it is refused (credential_invalidon the seller's payment intent), and nothing is charged.
Minting charges nothing. A token nobody settles simply expires. The route allows 60 mints per minute per key.
Mint errors
Errors use the ZyloPay envelope: { "success": false, "error": "…", "errorCode": "…" }. None of them is fixed by sending the same request again.
| Status | errorCode | Meaning | What to do |
|---|---|---|---|
| 403 | AGENT_KEY_INVALID | The key is unknown, switched off or expired, or X-Network does not match it. | Stop paying. Ask the card holder to check the agent in the app. |
| 400 | AGENT_AMOUNT_TOO_HIGH | The amount is above the agent's per-payment limit or the pass's spending approval. | Do not split the purchase to get around it. Ask the card holder. |
| 403 | AGENT_MONTHLY_LIMIT | The payment would go over the agent's monthly budget. | Wait for next month, or ask the card holder to raise the budget. |
| 403 | PAYER_FROZEN | The card holder's ZyloPay account is frozen. | Stop paying. |
| 403 | ACCOUNT_RESTRICTED | ZyloPay cannot accept payments from this account. | Stop paying. ZyloPay does not give the reason. |
| 403 | PASS_REVOKED, PASS_FROZEN | The pass can no longer pay. | Stop. The card holder connects the agent to another pass. |
| 403 | AUTHORIZATION_REQUIRED, AUTHORIZATION_EXPIRED | The pass's spending approval is missing or expired. | Ask the card holder to renew the pass's approval in the app. |
| 400 | — | The body failed validation (merchantId below 1000, amount not a positive integer). | Fix the request. |
| 429 | RATE_LIMITED | More than 60 mints in a minute for this key. | Wait for Retry-After, then mint again. |
Pay with x402
x402 uses HTTP 402 to ask for payment. ZyloPay payments use the scheme zylopay. The payload is a ZyloPay payment token, not a signed transfer, so the agent never holds a wallet key and never pays gas.
1. The seller asks for payment. A request without payment gets HTTP 402:
{
"x402Version": 1,
"error": "X-PAYMENT header is required",
"accepts": [
{
"scheme": "zylopay",
"network": "monad-testnet",
"maxAmountRequired": "2500000",
"resource": "/premium/report?topic=monad",
"description": "Deep report",
"mimeType": "application/json",
"payTo": "0x81616A7e1b92F9FDc6B4Ae1bD8905084fA6bcc67",
"maxTimeoutSeconds": 120,
"asset": "0x534b2f3A21130d7a60830c2Df862319e593943A3",
"extra": { "merchantId": 1042 }
}
]
}network is monad-testnet for testnet and monad for mainnet. payTo is the ZyloPay settlement contract and asset is USDC on that network. The merchant is extra.merchantId.
2. The agent mints a token for extra.merchantId and maxAmountRequired, on the matching X-Network. Check the price against your own budget first.
3. The agent retries with the token in X-PAYMENT, base64-encoded JSON:
{
"x402Version": 1,
"scheme": "zylopay",
"network": "monad-testnet",
"payload": { "credential": "zpt_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }
}const header = Buffer.from(
JSON.stringify({ x402Version: 1, scheme: 'zylopay', network: 'monad-testnet', payload: { credential: paymentToken } }),
).toString('base64');
const res = await fetch(url, { headers: { 'X-PAYMENT': header } });4. The seller confirms a payment intent with payload.credential as agent_token, then answers with the resource. See Accept agent payments as a seller.
The same token can also be handed to a seller any other way, for example in a paid tool call. What matters is that the seller confirms it once.
Accept agent payments as a seller
A seller accepts agent tokens on its own server, with its secret API key and the public API. Create a payment intent for the purchase, then confirm it with the agent's token. ZyloPay settles it at the intent's terminal like a tap, with every check of a card payment.
You need:
- a merchant on the network and its on-chain merchant ID (merchant dashboard). Agents need the ID to mint. Mainnet needs an approved business verification;
- a secret API key with
payments:write(zp_test_sk_…for testnet,zp_live_sk_…for mainnet). Keep it on your server. See Authentication; - a terminal for online sales on that network, for example named "Online / AI agents" (dashboard, Settings → POS Terminals → Add Terminal). Do not set up a POS device with its key. Its payments count toward that terminal's daily cap and show under its name.
Use a terminal no POS device uses
A POS device picks up the intents created for its terminal and shows them at the counter. While a device is presenting an intent, confirm answers 409 payment_intent_unexpected_state. Create agent intents only on your online terminal.
1. Create a payment intent when the agent's token arrives. The token is valid for 2 minutes, so do not create intents ahead of time. Idempotency-Key is required.
2. Confirm it once with the token, passed on unchanged. Idempotency-Key is required here too. The intent's amount must fit the token: same merchant, same network, at most its maxAmount.
import ZyloPay from '@zylopay/node';
const zp = new ZyloPay(process.env.ZYLOPAY_SECRET_KEY!);
// purchaseId: your own ID for this sale. agentToken: the agent's zpt_ token, unchanged.
async function chargeAgent(purchaseId: string, agentToken: string) {
const intent = await zp.paymentIntents.create(
{ amount: 2500000, terminal: 'tml_ZP-7730-Q', description: 'Deep report' }, // 2.50 USDC
{ idempotencyKey: `intent-${purchaseId}` },
);
return zp.paymentIntents.confirm(
intent.id,
{ agent_token: agentToken },
{ idempotencyKey: `confirm-${purchaseId}` },
);
}The API key chooses the network, so no X-Network header is needed. Give the confirm call up to 60 seconds: ZyloPay answers once the payment is on-chain. If the call times out, or answers 503 request_timeout, send it again with the same Idempotency-Key: it never runs twice, and you get its real outcome. See Idempotency.
3. Follow anything that is not final with the payment_intent.* webhooks, or GET /v1/payment_intents/{id}. Never confirm the same intent again to find out what happened.
Confirm accepts only an AI agent's token. A pass's NFC ID or wallet token is refused with 400 parameter_invalid (param: "agent_token"): those credentials are read by a terminal and never reach your servers. Confirm also refuses an intent that is not requires_payment_method (409 payment_intent_unexpected_state) and an intent whose terminal was deactivated (409 terminal_inactive). Reference: Confirm a payment intent.
Outcomes
The confirm answer is the payment intent. A decline is not an HTTP error: it is on the intent.
| Intent status | Meaning | Seller does | Agent does |
|---|---|---|---|
succeeded | Settled. USDC moved on Monad. | Deliver, for example 200 with an X-PAYMENT-RESPONSE receipt. | Use the result. Record the transaction. |
processing | Sent, outcome not confirmed yet. ZyloPay resolves it. | Answer 202 with a way to poll. Wait for payment_intent.succeeded, or retrieve the intent until it is final. | Poll. Never pay again for the same purchase. |
requires_action | Held for the card holder's approval on their phone. next_action.reason_code says why (AGENT_APPROVAL_REQUIRED when the agent is set to ask every time), next_action.expires_at when it expires. | Answer 202 with a way to poll. Wait for payment_intent.succeeded or payment_intent.canceled. An approved payment settles with no further call. | Poll. Never pay again for the same purchase. |
requires_payment_method with last_payment_error | Declined. Nothing was charged. | Answer 402 and say it is final. | Report it. Do not retry the same payment. |
An agent payment gives the card holder 5 minutes to approve (60 seconds at a POS). If they decline or do not answer, the intent becomes canceled with cancellation_reason step_up_denied or step_up_expired, and nothing is charged. The agent may start a new purchase with a new token after that, never before. See Payer confirmation.
last_payment_error.code on a declined agent payment:
| Code | Meaning |
|---|---|
credential_invalid | The token is spent or expired, was minted for another merchant, network or a smaller amount, or the agent was switched off before the payment went through. |
amount_exceeds_approval | The payment would go over the agent's monthly budget, or above the pass's spending approval. |
payer_frozen, account_restricted | The card holder's account cannot pay. ZyloPay does not give the reason. |
pass_revoked, pass_frozen | The pass can no longer pay. |
authorization_required, authorization_expired | The pass's spending approval is missing or expired. |
terminal_daily_limit | Your online terminal reached its daily volume cap. |
risk_declined | ZyloPay's risk rules declined the payment. |
A decline also sends payment_intent.payment_failed. Every code is listed in payment decline codes.
Security
- Treat the agent key as a secret. Keep it in a secrets manager or the agent platform's secret store, and read it only in the code that calls ZyloPay.
- Never put the key in a prompt, a model's context, a tool result, logs or client code. A model does not need it: give the model a "pay" tool and keep the key behind it.
- One key per agent. The card holder then sees and switches off each agent on its own.
- Switch it off on suspicion. In the payer app, AI agents → Switch off. It stops at once. Then create a new key: keys are never shown again.
- Tokens are short-lived by design. Mint right before you pay. Do not cache tokens or send them to anyone but the seller.
- Sellers: keep the secret API key on your server, confirm each token once with a fixed
Idempotency-Key, and never confirm again to follow a payment. Do not log tokens. See Security best practices.
Test on testnet
Testnet is the Monad testnet with test USDC. It runs the same checks as mainnet. See Testing your integration.
- In the payer app, switch the network to testnet in Settings.
- Get testnet USDC from faucet.circle.com: choose Monad Testnet. Get testnet MON from faucet.monad.xyz. MON is needed only for the one-time USDC approval in the app; agents and sellers never pay gas.
- Create a pass, then AI agents → Connect an agent on it. Store the
zpa_test_key. - As a seller, create a testnet merchant, a test API key with
payments:writeand a testnet terminal for online sales. No POS device and no business verification are needed on testnet. - Pay once from the agent and approve on the phone: the first payment to a merchant asks for approval.
Test the refusals too: an amount above the per-payment limit, a key you switched off, a token confirmed twice and a token presented to another merchant (credential_invalid).