Docs
Charge agents per request
Put a price on a route. A request without payment is told the price; an agent pays and asks again; your code runs, and the money goes from the agent straight to you.
- Nothing else to install
- Express, Next.js, Node
- Both versions of x402
Quickstart
Four steps, and the last one is watching it work.
Install
Terminal npm install @lotvs/paySay where the money goes
Payments arrive in USDC, a digital dollar, at a Solana address you control. Any wallet works, and so does the address of your Lotvs account.
.env # The Solana address that gets paid LOTVS_PAY_TO=YourSolanaAddressThe address must already hold USDC, or have held it once. A payment cannot open the account it lands in. Send yourself a dollar of USDC first and this is taken care of.
Put a price on a route
One line, in front of the code you already have.
server.js import express from "express"; import { lotvsPay } from "@lotvs/pay"; const app = express(); app.get("/report", lotvsPay({ price: "0.10" }), (req, res) => { res.json({ report: "Markets were open today." }); }); app.listen(3000);Watch it answer
Ask without paying and you get the price back. This site runs a paid route of its own; the first two steps below are real, sent from your browser.
pay.lotvs.ai/api/demo/fortune$0.01 a request RequestReady GET /api/demo/fortune HTTP/1.1 Host: pay.lotvs.aiNo payment, no key, no account. This is what any agent sends the first time it meets a paid route. Send it from your browser and see what comes back.
When the buyer is charged
There are two orders to do things in. The first is the default, and it is the one to keep unless your code is slow.
Charge after success
Default- Check the payment
- Run your code
- Collect the money
- Answer
A request that fails costs the buyer nothing: any answer with a status of 400 or above is not charged, and the same payment can be sent again. Your code has about a minute, which is how long a signed payment lasts.
Collect first
flow: "upfront"- Collect the money
- Run your code
- Answer
For anything that streams its answer or takes more than a few seconds. If your code fails after the money has moved, the refund is yours to make, and onFailure tells you when.
app.get("/research", lotvsPay({ price: "1.00", flow: "upfront" }), async (req, res) => {
for await (const chunk of longRunningWork()) res.write(chunk);
res.end();
});Receipts and your ledger
Inside a paid handler, paymentOf gives you the payment that came with the request.
import { lotvsPay, paymentOf } from "@lotvs/pay";
app.get("/report", lotvsPay({ price: "0.10" }), (req, res) => {
const payment = paymentOf(req);
res.json({ report, for: payment.payer });
});To keep a record, take every receipt as it happens. A receipt is a plain object, ready to store as it is.
lotvsPay({
price: "0.10",
onPayment: async (receipt) => {
await db.payments.insert(receipt);
},
onFailure: (failure) => {
log.warn(failure.stage, failure.reason, failure.charged);
},
});- transaction
- The payment's id. Public and permanent: anyone can look it up.
- payer
- The buyer's address.
- amount
- What was paid, as a decimal: "0.1". amountAtomic has it in the token's smallest unit.
- resource
- The route that was bought. method has the verb.
- settledAt
- When the money moved.
- test
- True on the test network, where no real money moved.
A failure says whether the buyer's money moved: charged is "no", "yes" or "unknown". Unknown means the payment was sent and could not be confirmed. Look the transaction up before doing anything else.
Prices per request
A price can be worked out from what is being asked for, and a payment can carry a reference of yours, such as an order number.
lotvsPay({
// The same request must always get the same price
price: (request) => (request.url.endsWith("/full") ? "2.50" : "0.10"),
// Written into the payment itself, to match against your own records
memo: (request) => "order-" + new URL(request.url).pathname,
});Test mode
One setting moves everything to Solana's test network, where the dollars are not real. Prices, payments and receipts behave the same, and payment.test is true.
LOTVS_PAY_NETWORK=solana-devnetMore than one server
A payment is remembered for two minutes so that it cannot buy two answers. By default that memory lives in each process. Behind a load balancer, or on serverless, give your servers one memory to share.
const replayStore = {
claim: async (key, ttlSeconds) => (await redis.set(key, "1", "NX", "EX", ttlSeconds)) === "OK",
release: async (key) => void (await redis.del(key)),
};
lotvsPay({ price: "0.10", replayStore });Options
A mistake in these stops your server from starting, with a message that says which one. The exception is a missing address: the route then answers 503 until it has one.
| Option | Default | What it does |
|---|---|---|
| price | required | What one request costs, in dollars: "0.10". Or a function of the request. |
| payTo | LOTVS_PAY_TO | The Solana address that gets paid. |
| network | "solana" | "solana" for real money, "solana-devnet" for none. Also read from LOTVS_PAY_NETWORK. |
| description | none | Shown to the buyer's agent before it pays. Worth writing well: it is your shop window. |
| flow | "authorization" | "authorization" collects after your code succeeds. "upfront" collects first. |
| memo | none | A reference written into the payment, up to 256 bytes. A string, or a function of the request. |
| onPayment | none | Called once for every payment collected, with the receipt. |
| onFailure | none | Called when a payment was refused, failed, or could not be confirmed. |
| pendingSettlement | "withhold" | What to do when a payment was sent but is not confirmed yet: "withhold" the response or "deliver" it. |
| facilitators | public ones | Who checks and settles payments. The defaults need no account. |
| replayStore | in memory | Remembers payments already used. Share one if you run several servers. |
| legacy | true | Also accept clients on the first version of x402. |
| baseUrl | from the request | Your public address, when it cannot be worked out from the request: "https://api.example.com". |
What a buyer is told
Every answer that is not your own, and what it means. The body is JSON, with the reason in error.
| Status | error | When |
|---|---|---|
| 402 | the reason, in words | No payment came with the request, or the one that came was refused, failed or already used. The answer carries the terms, so the buyer can pay and ask again. |
| 402 | settlement_pending | The payment went out but is not confirmed. The buyer is told the transaction and told not to pay twice. |
| 400 | invalid_payment | The payment header could not be read at all. |
| 503 | payments_unavailable | The payment could not be checked just now. Nothing was charged, and trying again is safe. |
| 503 | payments_not_configured | The route has no address to be paid to yet. It refuses rather than work for free. |
Questions
Where is my money?
At the address you set, by the time the buyer reads your answer. Lotvs Pay never holds it. To move it to a bank account, send it on from a Lotvs account or any service that takes USDC.
Which agents can pay?
Any client that speaks x402 and holds USDC on Solana, including an agent with a Lotvs account. x402 is an open standard, and both of its versions are answered.
What does the buyer pay on top?
Nothing. They pay the price you set. The fee for moving the money is covered for them.
Can I refund a payment?
Yes, by sending the amount back to the payer on the receipt. A refund is an ordinary transfer from your wallet.
What if my code fails after the buyer paid?
Under the default it cannot happen: the money is collected only after your code succeeds. With flow set to "upfront" it can, and onFailure tells you, with the payment's id, so you can put it right.