Seller guide: charge for your own API
You are the seller: you host an API and want buyers to pay per call. There are two ways to do it, a ready-made middleware or the full hand-rolled flow. Both do the same four things: advertise a price, verify the payment, settle it, and wait for confirmation before serving. If you would rather write no payment code at all, the gateway does all of it for you and lists your service in the marketplace.
Seller API keys
The middleware and the raw HTTP paths both need a seller API key, which authenticates your server to the facilitator. It is not a payment credential, and buyers never see one. (The gateway needs no key.)
What the key looks like
xdcai_live_<keyId>_<secret>
There is no separate access key and secret key. It is one bearer string, with the public keyId embedded in it. The facilitator stores only sha256(keyId || secret), so the secret cannot be recovered by anyone, including us.
| Part | Public | Used for |
|---|---|---|
keyId | Yes | Identifying the key in the dashboard, in logs, and when revoking |
secret | No | Proving the request is yours |
Create one
- Open the facilitator dashboard.
- Choose the address your API is paid at.
- Select New key, label it after the deployment that will use it, and create it.
- Copy the key from the dialog. It is shown once and there is no way to see it again.
Creating a key is not idempotent: pressing the button twice mints two keys. Any signed-in account can create keys for an address it owns, and only for an address it owns.
Set your environment
# .env on your server. Never in a browser bundle, never sent to buyers.
FACILITATOR_URL=https://public-facilitator.xdcai.tech
FACILITATOR_API_KEY=xdcai_live_...
SELLER_RECEIVER_ADDRESS=0xYourReceivingAddress
TOKEN_ASSET=0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1
TOKEN_NAME=USDC
TOKEN_VERSION=2
Prices are integer strings in the token's smallest unit. USDC has 6 decimals, so "10000" is 0.01 USDC. Do not send a float.
Keep it server-side
Anyone holding your key can ask the facilitator to settle payments on your account and can spend your credits. It grants no access to your funds and cannot move USDC out of your wallet, but treat it like a password.
- Never ship it to a browser, a mobile app, or any client-side bundle.
- Never give it to buyers. They do not need one.
- Use one key per deployment, so revoking staging does not break production.
- Keep it out of logs. Log the
keyIdif you need to correlate, never the whole string.
Rate limits
Limits are per key, not per buyer.
| Plan | Requests per minute | Burst |
|---|---|---|
free | 60 | 20 |
payg | 600 | 100 |
enterprise | 3000 | 500 |
Exceeding them returns 429 rate_limited. The limit that applies is the one on the key, not the verifyRpm and settleRpm shown on your plan.
Rotate and revoke
Rotate without downtime:
- Create the new key.
- Deploy it to the server.
- Confirm traffic is flowing.
- Revoke the old key.
Revoking takes effect immediately, and a revoked key returns 401 revoked_key. Revoking a key that is already revoked is safe and does nothing.
Pick your path
- Seller SDK
- Raw HTTP
@xdcai/x402-seller wraps the whole flow, the 402 response, verification, settlement and the confirmation poll, into one Express or Hono middleware. Published by the facilitator team (Apache-2.0, Node 20+). Buyers still stay accountless.
npm install @xdcai/x402-seller@1.0.0-rc.1
:::warning Pin the exact version
This is a release candidate. Both latest and next point at 1.0.0-rc.1 today, so pin the exact version and read the release notes before a major upgrade.
:::
:::note You do not write wallet-specific code You return the challenge, the buyer's SDK builds the payment, and the facilitator verifies it. Whether the buyer is an ordinary address or a deployed contract wallet is the facilitator's problem, not yours, and it needed no seller release. Paid content is released only after a confirmed settlement either way. :::
Express:
import express from "express";
import { paymentMiddleware, XDC_USDC } from "@xdcai/x402-seller";
const app = express();
app.use(paymentMiddleware({
facilitatorUrl: process.env.FACILITATOR_URL!,
facilitatorApiKey: process.env.FACILITATOR_API_KEY!, // server side only
seller: { receiver: process.env.SELLER_RECEIVER_ADDRESS! },
routes: {
"GET /api/data": {
price: "10000", // atomic USDC, so 0.01 USDC
asset: process.env.TOKEN_ASSET ?? XDC_USDC.asset,
network: XDC_USDC.network,
description: "Premium data endpoint",
tokenName: process.env.TOKEN_NAME ?? XDC_USDC.eip712.name,
tokenVersion: process.env.TOKEN_VERSION ?? XDC_USDC.eip712.version,
},
},
}));
app.get("/api/data", (_req, res) => {
res.json({ ok: true, data: "paid content" });
});
The route handler only runs after a payment settles on-chain; an unpaid request never reaches it.
Hono on Node uses the same config object:
import { Hono } from "hono";
import { honoPaymentMiddleware, XDC_USDC } from "@xdcai/x402-seller";
const app = new Hono();
app.use("*", honoPaymentMiddleware({ /* same config as Express */ }));
app.get("/api/data", (c) => c.json({ ok: true }));
Other exports: createSellerGuard (framework-agnostic), XDC_USDC, SellerSdkError, and the header constants. Subpath imports @xdcai/x402-seller/express and /hono are available too.
:::warning Name must be "USDC", not "USD Coin"
Using USD Coin for this contract produces a different EIP-712 domain separator, and the signed authorization reverts on settlement. GET /supported does not return the token address or the EIP-712 name and version, which is why the SDK ships them as the XDC_USDC constant. Use it rather than hardcoding.
:::
The SDK sorts failures into categories so you can tell a buyer problem from a seller problem, and buyer-visible bodies never contain your key. Subscribe to onEvent to log each one:
| Category | HTTP | What to do |
|---|---|---|
payment_required | 402 | Normal challenge, buyer pays and retries |
buyer_payment_invalid | 402 | Bad proof, do not blind-retry |
settlement_failed | 402 | Investigate, do not duplicate the payment |
seller_api_key_invalid | 503 | Rotate or fix your key |
seller_rate_limited | 503 | Back off |
seller_out_of_facilitator_credits | 503 | Top up your credits |
facilitator_unavailable | 503 | Retry with backoff |
misconfigured_sdk | 500 | Fix your config |
It is exact scheme only (scheme=exact, network eip155:50); batch and channel settlement are not in this version.
If you would rather not take a dependency, the flow is four steps and needs no chain library: the facilitator does the on-chain work. Examples are TypeScript with fetch, and work in JavaScript if you drop the types.
1. Answer 402 with your price. When a request arrives with no payment header, tell the caller what to pay.
import express from "express";
const app = express();
const requirements = {
scheme: "exact",
network: "eip155:50",
amount: "10000", // 0.01 USDC
payTo: process.env.SELLER_RECEIVER_ADDRESS!,
asset: process.env.TOKEN_ASSET!,
maxTimeoutSeconds: 300,
extra: { name: "USDC", version: "2", assetTransferMethod: "eip3009" },
};
app.get("/api/data", async (req, res) => {
const header = req.header("PAYMENT-SIGNATURE") ?? req.header("X-PAYMENT");
if (!header) {
return res.status(402).json({
x402Version: 2,
accepts: [requirements],
resource: { url: "https://your.api/api/data", mimeType: "application/json" },
});
}
// continues in step 2
});
PAYMENT-SIGNATURE is the x402 v2 header. X-PAYMENT is v1. Accept both so older clients keep working.
2. Verify, then settle. Both endpoints take the same body and the same Authorization header.
const auth = { Authorization: `Bearer ${process.env.FACILITATOR_API_KEY}` };
const json = { ...auth, "content-type": "application/json" };
const paymentPayload = JSON.parse(Buffer.from(header, "base64").toString());
const body = { x402Version: 2, paymentPayload, paymentRequirements: requirements };
// Verify is free and never touches your credits.
const verify = await fetch(`${process.env.FACILITATOR_URL}/verify`, {
method: "POST",
headers: json,
body: JSON.stringify(body),
}).then((r) => r.json());
if (!verify.isValid) {
return res.status(402).json({ error: "buyer_payment_invalid" });
}
const settle = await fetch(`${process.env.FACILITATOR_URL}/settle`, {
method: "POST",
headers: {
...json,
// The EIP-3009 nonce is unique per authorization and stable across retries,
// so a retry can never broadcast the same payment twice.
"Idempotency-Key": paymentPayload.payload.authorization.nonce,
},
body: JSON.stringify(body),
}).then((r) => r.json());
Idempotency-Key is required on /settle.
3. Wait for confirmation, then serve.
:::danger Settlement is asynchronous
/settle can return 202 settlement_queued (accepted, nothing broadcast yet) or settlement_pending (broadcast, not yet mined). Neither is a payment. Serve paid content only when the response has success: true and a transaction hash.
:::
let result = settle;
for (let i = 0; i < 10 && !result.success && result.settlementId; i++) {
await new Promise((r) => setTimeout(r, 1500));
result = await fetch(
`${process.env.FACILITATOR_URL}/settlements/${result.settlementId}`,
{ headers: auth },
).then((r) => r.json());
}
if (!result.success || !result.transaction) {
// Not necessarily a failure: it may still confirm. Reconcile by settlementId
// later rather than settling the same payment again.
return res.status(402).json({ error: "facilitator_settlement_issue" });
}
res.setHeader("PAYMENT-RESPONSE", Buffer.from(JSON.stringify(result)).toString("base64"));
res.json({ data: "your paid content" });
A settlement whose status is failed, expired, canceled or rejected is terminal. Anything else with a settlementId is still in flight.
Telling the two kinds of failure apart
Whichever path you use, a buyer whose payment was fine should never be told their payment failed.
| Response | What it means | What to tell the buyer |
|---|---|---|
401 invalid_key, revoked_key | Your key is wrong or revoked | Nothing. Fix your server |
403 insufficient_facilitator_credits | Your prepaid credits ran out | Nothing. Top up |
403 seller_plan_inactive | Your plan is suspended | Nothing. Check billing |
429 rate_limited | Your key's rate limit | Retry with backoff |
verify.isValid: false | The payment really is invalid | Ask them to pay again |
202 settlement_queued | Accepted, not yet paid | Keep waiting. Do not serve |