Developer platform
MainnetThe X1ID developer platform is a hosted JSON API under https://api.x1id.io/v1. It packages the things you'd otherwise build yourself on top of the @x1id/resolve SDK — sign-in, access gating, agent discovery, cross-namespace resolution — behind one key.
Two surfaces, one host. The Resolve API (api.x1id.io/resolve/…) is public and keyless — resolution is a read anyone can do. Everything on this page lives under the /v1 prefix and is keyed: a key identifies which app is calling, so the platform can rate-limit, meter, and (for Connect) bind a session to your app.
Base URL and authentication
https://api.x1id.io/v1Send your key in the X-API-Key header on every /v1 request:
curl https://api.x1id.io/v1/resolve/x1 \
-H "X-API-Key: $X1ID_API_KEY"API keys are server-only. Never put an X1ID API key in browser code, a mobile bundle, or anything a user can download. Call /v1 from your own backend and attach X-API-Key there. For browser sign-in, proxy the /v1/auth/* calls through your backend, as shown in Sign in with X1ID.
Every non-2xx response is a uniform { code, message } body, so you can branch on code instead of parsing prose:
{ "code": "API_KEY_REQUIRED", "message": "provide an X-API-Key header. Request a key at https://developers.x1id.io (or contact [email protected])." }API_KEY_REQUIRED is what a keyless call to a metered endpoint gets where x402 is not enabled. Where x402 is enabled, that same keyless call instead returns HTTP 402 Payment Required (describing what to pay), not API_KEY_REQUIRED — the pay-per-call path, no key needed.
The one exception to { code, message }: the 402 challenge body is an x402 payment-requirements object — { x402Version, accepts: [ … ] }, where each accepts[] entry carries the scheme, the CAIP-2 network (on X1 mainnet, solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT), asset, payTo, amount (smallest units, as a decimal string), and maxTimeoutSeconds; the X1-native entry also carries extra (with X-Payer: feePayer, the request-bound reference and requestHash). A USDC-via-CDP entry, where offered, uses the x402 v1 field name maxAmountRequired instead of amount. It is the x402 wire format, not an error envelope — detect a 402 by its status, then read accepts[], not a code.
Get a key
Keys are self-serve from the Developer Portal at developers.x1id.io. The portal signs you in with X1ID Connect — your wallet is the identity, no password — and then lets you create, list, and revoke keys for your account, and watch your usage.
A key's secret is shown once, at creation. Store it somewhere safe the moment it's revealed; the portal keeps only a hash and can't show it again. Lost a secret? Revoke the key and create a new one.
The portal is backed by four endpoints, all authenticated by your Connect bearer session (not an X-API-Key):
| Endpoint | Does |
|---|---|
POST /v1/keys | Create a key — the one-time secret is in the response. |
GET /v1/keys | List your keys, each with its tier and recent usage. |
DELETE /v1/keys/:id | Revoke one of your keys. |
GET /v1/usage | Your request counts, tier, and current limits. |
Tiers and rate limits
Every key carries a tier. Both windows are per key, shared across every /v1 endpoint the key calls.
| Tier | Burst | Daily | For |
|---|---|---|---|
| free | 300 / min | 15,000 / day | Evaluating and building. Self-serve. |
| partner | 1,000 / min | 100,000 / day | Production integrations. Issued after an agreement — contact [email protected]. |
Each response carries your live budget in headers:
X-RateLimit-Limit 300
X-RateLimit-Remaining 299
X-RateLimit-Daily-Limit 15000
X-RateLimit-Daily-Remaining 14999Over the limit returns 429 { "code": "RATE_LIMITED", … }. A keyed request that's over its window is not silently charged per call and does not fall through to x402: it gets the 429 until the window resets. x402 is a separate path for requests that send no key (see below), not an overflow for a key that hit its limit. To get more headroom, raise your tier.
Pay-per-call with x402
You don't strictly need a key. Where it's enabled, the metered read endpoints also accept an x402 payment: call with no key, get a 402 Payment Required describing what to pay, pay on-chain, sign the challenge's redeemMessage with the wallet that paid, and retry with a PAYMENT-SIGNATURE header carrying both signatures (the v1 name X-PAYMENT is still accepted). On success the response carries a PAYMENT-RESPONSE header (and the v1 X-PAYMENT-RESPONSE) confirming settlement. Send X-Payer with your wallet address on both requests: it is required for the X1-native rail (see below).
The order a request is tried is fixed: a valid X-API-Key is served (or rate-limited with 429); a key that's present but invalid gets 401 INVALID_KEY; only a request with no key reaches x402. The Connect /v1/auth/* endpoints and Gate policy management are keyed-only and not x402-metered.
This is the no-signup, no-contract path — ideal for autonomous agents. The X1-native rail is self-sponsored XNT on X1 (the buyer pays their own gas); a USDC-via-CDP rail can also be offered. Default prices are a fraction of a cent per call (resolve/reverse cheaper than verify/gate) and are tunable per deployment.
Breaking (2026-10-11): redemption now needs the payer's signature. api.x1id.io refuses a payment header without payload.payerSig (REDEEM_SIGNATURE_REQUIRED), so a client that only sends payload.signature stops working. Add the redeem signature step, and upgrade to @x1id/resolve 0.22.0 for x402RedeemMessage. The request-bound reference is the only payment shape: a payment transaction with any other instruction, including a Memo, is refused.
x402 is only live on a deployment that has a pay-to address configured. Check GET /v1/health — its x402 field tells you whether pay-per-call is accepted, and rails lists which payment rails are on.
x402 headers (v2 names)
api.x1id.io speaks the x402 v2 HTTP header names and still accepts the v1 ones:
| Direction | v2 header (preferred) | v1 header (still accepted) | Contents |
|---|---|---|---|
| Request: your payment | PAYMENT-SIGNATURE | X-PAYMENT | base64 JSON payment payload. On the X1-native rail: { x402Version, scheme: "exact", network, payload: { signature, payerSig } } (the v2 accepted: { scheme, network } form works too) |
Response: the 402 challenge | PAYMENT-REQUIRED | (body only) | base64 of the same { x402Version, accepts: [...] } object that is also the 402 body |
Response: settled 200 | PAYMENT-RESPONSE | X-PAYMENT-RESPONSE | base64 settlement receipt (both headers carry the same value) |
| Request: who is paying | X-Payer (or ?payer=) | your wallet's base58 address. See below |
Send one payment header. If PAYMENT-SIGNATURE and X-PAYMENT are both present and differ, the request is rejected.
Bind your payer with X-Payer on the first request
X-Payer is required. Send X-Payer: <your wallet> (or ?payer=) on the request that gets the 402. Only then does the X1-native challenge carry a request-bound reference (extra.reference). Without it the 402 has "code": "PAYER_REQUIRED" and no reference.
With X-Payer, the X1-native accepts[] entry's extra holds:
feePayer: your wallet. You pay your own fee; there is no sponsor.reference: a base58 address you add to your payment (see below). It binds the payment to this exact request (method, path, query and body), to the challenge, and to your wallet.requestHash: hex SHA-256 of the canonical request the reference was issued for. Optional to check.redeemMessage: the text your wallet signs to redeem the payment (see below). It isx402RedeemMessage(reference).
A live challenge, requested with X-Payer:
{
"x402Version": 2,
"accepts": [{
"scheme": "exact",
"network": "solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT",
"amount": "100000",
"asset": "So11111111111111111111111111111111111111112",
"payTo": "E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6",
"maxTimeoutSeconds": 60,
"extra": {
"feePayer": "H8FsKYhMyCN9brknY7tL4JXRbKZEjVh7XgrVmWcX1HiA",
"reference": "S7k9Y5brdu1MBfV8WykKzu8KBkFi97ri5JUp5H4cTTC",
"requestHash": "e96b43f9ae0ea38a788a748548527d328a67cb16271f64a99eb97e630f4a82bc",
"redeemMessage": "x1id-x402-redeem:v1|api.x1id.io/dev-api\nreference:S7k9Y5brdu1MBfV8WykKzu8KBkFi97ri5JUp5H4cTTC"
}
}]
}Redemption is bound to the wallet you named: only a payment whose TransferChecked authority is that wallet, which that wallet signs, and whose redemption carries that wallet's signature over redeemMessage, can redeem it. A later X-Payer cannot re-point it. A malformed X-Payer (not a base58 address) is silently ignored, as if you had not sent it, so double-check the value if you see PAYER_REQUIRED.
Retry the identical request. A payment for GET /v1/resolve/alice cannot be redeemed on /v1/resolve/bob, and a POST /v1/gate/check payment only works with the same body bytes. Path case matters: @Alice and @alice are different requests.
Always set a compute-unit limit
X1 charges the transaction fee by the compute-unit limit you request, not the compute units you use: about 10 lamports per requested CU plus a small base fee. A transaction with no SetComputeUnitLimit defaults to 200,000 CU per instruction, at least 0.002 XNT, which is many times the price of the call. Always set the limit explicitly, and size it to what your transaction actually consumes.
What a payment really costs, measured on a live X1 mainnet payment (2026-10-11, wrapped-native asset):
| Payment transaction | CU consumed | Limit requested | Fee (lamports) |
|---|---|---|---|
CU limit + CU price + TransferChecked + reference account | 6,719 | 7,719 | 80,191 |
The reference account adds about 180 CU, which keeps the network fee below the 0.0001 XNT resolve price.
Simulate, then size the limit to what was consumed plus about 1,000 CU of headroom (about 7,700 for the reference shape). Do not guess low: a limit below what the transaction consumes makes it fail on chain, and you are still charged the fee. Keep it at or under the server's cap of 40,000 CU.
The payment transaction
The X1-native rail verifies a fixed shape:
ComputeBudget::SetComputeUnitLimit: sized from simulation. It must be first, and anything above 40,000 is rejected (compute-limit-too-high).ComputeBudget::SetComputeUnitPrice: a small price (x1id's builders use 1 micro-lamport/CU; the cap is 5,000,000).- One SPL
TransferCheckedof at least the quotedamountof the quotedassettopayTo's associated token account, signed by your wallet, withextra.referenceappended as one extra account, read-only and not a signer. SPL Token ignores extra accounts on a single-owner transfer. Nobody signs with the reference; it is a hash, not a key.
Explorers may mislabel the reference. Because the TransferChecked carries one account more than a plain transfer, explorers and jsonParsed RPC output can decode it as a multisig transfer: the reference shows up as a multisig "signer" and your wallet as multisigAuthority. Neither is what happens on-chain. The reference is a read-only, non-signer account (the standard Solana Pay reference pattern), and the transfer authority is your ordinary wallet, approved by its normal transaction signature. A mainnet payment in this shape, for comparison: 2UUMMij6…9UdRo.
Allowed instructions. Only ComputeBudget and SPL Token instructions are allowed (a Memo instruction gets PAYMENT_LAYOUT_INVALID), exactly one transfer may pay payTo, the reference must appear exactly once and only in the transfer, and Address Lookup Tables are refused. A multisig token authority is not supported.
The amount is checked twice: the TransferChecked amount, and the balance actually credited to payTo's token account, which must also cover the price. A Token-2022 mint whose transfer fee leaves payTo short is refused, and so is a Token-2022 payment whose credited amount the RPC can't report.
Wrap native XNT in a separate, earlier transaction. The default asset is the wrapped-native mint (So11111111111111111111111111111111111111112). It has 9 decimals, like XNT. If your wrapped balance is short, top it up first (create the ATA if needed, transfer XNT into it, SyncNative) and wait for it to confirm. Those instructions cannot go in the payment transaction: instruction 0 must be the compute-unit limit, and the wrap has to happen before the transfer.
import {
ComputeBudgetProgram, Connection, PublicKey, TransactionMessage, VersionedTransaction,
} from "@solana/web3.js";
import { createTransferCheckedInstruction, getAssociatedTokenAddressSync } from "@solana/spl-token";
import { encodeBase58, parseX402Response, x402RedeemMessage } from "@x1id/resolve"; // 0.22.0+
const API = "https://api.x1id.io/v1/resolve/alice";
const connection = new Connection("https://rpc.mainnet.x1.xyz");
const payer = wallet.publicKey; // your wallet adapter / keypair
// 1. Ask for the challenge. X-Payer is required to get a reference.
const challenge = await fetch(API, { headers: { "X-Payer": payer.toBase58() } });
const parsed = parseX402Response(await challenge.json()); // status 402
const X1_MAINNET = "solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT"; // match exactly: a CDP rail is also "solana:..."
const req = parsed?.accepts.find((a) => a.network === X1_MAINNET);
if (!req?.extra.reference) throw new Error("no reference: send X-Payer"); // PAYER_REQUIRED
const mint = new PublicKey(req.asset);
// 2. Build the payment: [CU limit, CU price, TransferChecked + reference].
// (Wrap XNT -> wSOL in an EARLIER transaction if your balance is short.)
const transfer = createTransferCheckedInstruction(
getAssociatedTokenAddressSync(mint, payer),
mint,
getAssociatedTokenAddressSync(mint, new PublicKey(req.payTo), true), // payTo may be a PDA
payer,
BigInt(req.amount),
9, // wrapped-native XNT has 9 decimals (read the mint for any other asset)
);
transfer.keys.push({ pubkey: new PublicKey(req.extra.reference), isSigner: false, isWritable: false });
const build = (units: number) => [
ComputeBudgetProgram.setComputeUnitLimit({ units }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }),
transfer,
];
const { blockhash } = await connection.getLatestBlockhash("finalized");
const compile = (units: number) => new VersionedTransaction(
new TransactionMessage({ payerKey: payer, recentBlockhash: blockhash, instructions: build(units) }).compileToV0Message(),
);
// Simulate at the 40,000 cap, then request what it used + ~1,000 headroom
// (~7,700). Too low = failed tx, fee still charged.
const sim = await connection.simulateTransaction(compile(40_000), { sigVerify: false, replaceRecentBlockhash: true });
if (sim.value.err) throw new Error(`payment would fail: ${JSON.stringify(sim.value.err)}`);
const tx = compile(Math.min(40_000, sim.value.unitsConsumed! + 1_000));
const signature = await connection.sendTransaction(await wallet.signTransaction(tx));
await connection.confirmTransaction(signature, "finalized");
// 3. Sign the redemption with the SAME wallet that paid. The 402 carries the
// message verbatim; the SDK rebuilds it so you can check it first.
const redeemMessage = x402RedeemMessage(req.extra.reference);
if (req.extra.redeemMessage !== redeemMessage) throw new Error("unexpected redeemMessage");
const payerSig = encodeBase58(await wallet.signMessage(new TextEncoder().encode(redeemMessage)));
// 4. Claim: the IDENTICAL request, both signatures in PAYMENT-SIGNATURE.
// ({ reference: req.extra.reference, payerSig } instead of
// { signature, payerSig } also works: the server finds the payment by its reference.)
const payment = btoa(JSON.stringify({
x402Version: 2, scheme: "exact", network: req.network, payload: { signature, payerSig },
}));
const res = await fetch(API, { headers: { "X-Payer": payer.toBase58(), "PAYMENT-SIGNATURE": payment } });
// 200 + PAYMENT-RESPONSE header on successThe server only accepts a finalized payment, so wait for finality before you claim. A 402 with PAYMENT_NOT_FINALIZED means retry the same payment header shortly. With { reference }, the server checks every finalized transaction that lists your reference and skips any that only mention it without being a valid payment, so another transaction naming your reference can't block you; if none is valid yet you get the same retryable PAYMENT_NOT_FINALIZED.
Sign the redemption with the paying wallet
Your payment is public once it lands, signature and all. So the claim proves the caller is the payer: payload.payerSig is the base58 ed25519 signature, by the wallet bound with X-Payer, over extra.redeemMessage:
redeemMessage = "x1id-x402-redeem:v1|api.x1id.io/dev-api" "\n" "reference:" reference- Sign it with your wallet's
signMessage. A raw signature over the UTF-8 bytes and one over a wallet's Solana off-chain message envelope (SIMD-0048) are both accepted. @x1id/resolve0.22.0 exportsx402RedeemMessage(reference)and the domain stringX402_REDEEM_DOMAIN, so you can rebuild the message and compare it with the one in the402.- The reference is single-use and already bound to the request and the payer, so a
payerSigcan't be reused on another challenge. - A missing
payerSiggetsREDEEM_SIGNATURE_REQUIRED, and one that doesn't verify getsREDEEM_SIGNATURE_INVALID. Neither consumes the payment: sign and resend the same request with the same payment.
How the reference is derived
You do not need to compute it, since the 402 gives it to you. To check it, or to confirm you are paying for the request you will send, the @x1id/resolve SDK exports canonicalX402Request, x402RequestHash and deriveX402Reference:
canonical = METHOD "\n" path "\n" query "\n" hex(SHA-256(body))
requestHash = hex(SHA-256(canonical))
reference = base58(SHA-256("x1id-x402-ref:v1|api.x1id.io/dev-api" "\n" canonical "\n" nonce "\n" payer))- METHOD is uppercased. Scheme, host and fragment are left out.
- path: each
/-separated segment is percent-decoded, then re-encoded per RFC 3986 (letters, digits and- . _ ~stay literal; every other byte becomes%XX, uppercase). Trailing slashes are kept and case is preserved. - query: split on
&,+means a space, each key and value decoded and re-encoded, pairs sorted by key then value, duplicates kept, a barekbecomesk=. - body: the exact bytes sent. GET, HEAD and empty bodies use SHA-256 of the empty string (
e3b0c442…7852b855). - nonce is the challenge's server-side nonce. It is not sent to you: the server computes the reference and puts it in
extra.reference.
Worked example:
| Request | GET https://api.x1id.io/v1/resolve/@Alice?fresh=1&chain=x1 |
| canonical | GET\n/v1/resolve/%40Alice\nchain=x1&fresh=1\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
| requestHash | 9a408b21042932cda138cd88c8421e27cc23b699ab6780084dfa22269edfb6ce |
| nonce | 00112233445566778899aabbccddeeff |
| payer | H8FsKYhMyCN9brknY7tL4JXRbKZEjVh7XgrVmWcX1HiA |
| reference | GxfSof9wYBDnnLUXR3ZqePRUoNN2etHiJYkkaUShVseQ |
x402 error codes
A rejected payment gets another 402 carrying a fresh challenge, with the rejection's code in both error and code and an explanation in message, so you can branch on error and pay again from the same response:
code | Meaning |
|---|---|
PAYER_REQUIRED | The challenge was requested without X-Payer, so it has no reference. |
PAYMENT_PAYLOAD_INVALID | The payment header is not base64 JSON of an x402 payment payload with payload.signature or payload.reference. |
REDEEM_SIGNATURE_REQUIRED | No payload.payerSig. Sign extra.redeemMessage with the paying wallet and resend; the payment was not consumed. |
REDEEM_SIGNATURE_INVALID | payload.payerSig is not the paying wallet's signature over extra.redeemMessage. The payment was not consumed. |
PAYMENT_NOT_FINALIZED | The payment is not finalized yet. Retry the identical request with the same payment header shortly. |
PAYMENT_ALREADY_USED | This payment already bought a successful response. A replay of the same signature gets this code. Pay again against a fresh challenge. |
REFERENCE_MISSING | No issued reference in the TransferChecked, or it is writable, a signer, repeated, or also in another instruction. |
REFERENCE_MISMATCH | The request changed (method, path, query, body or endpoint), or the payment carries two references. |
REFERENCE_REUSED | A different payment carries a reference that has already paid for a successful call. Request a new challenge. |
REFERENCE_EXPIRED | Past the 600-second redeem window. Request a new challenge. |
MULTISIG_AUTHORITY_UNSUPPORTED | The transfer authority is a multisig. Pay from a single-signer wallet. |
PAYMENT_LAYOUT_INVALID | The transaction has an instruction outside the fixed shape (for example a Memo), a second transfer, or compute-budget values out of bounds. |
The error reference lists every code the gateway returns, with its HTTP status and what to do.
A call whose handler fails (for example a 404) does not consume your payment, so you can retry the same request with the same payment.
Health
Never gated. Reports liveness, whether x402 is enabled, and which RPC host and program the deployment reads.
curl https://api.x1id.io/v1/health
# → { "status": "ok", "service": "x1id-dev-api", "x402": true,
# "rails": ["x1-native"], "rpc": "rpc.mainnet.x1.xyz",
# "program": "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P" }The platform endpoints
Sign in with X1ID (Connect)
Challenge → sign → session. Prove a user controls their @handle or wallet.
Gate
Access control by on-chain attestation — verified-only allowlists behind one call.
Agents
Resolve an agent @handle to its validated manifest, endpoints, and pay-to.
Universal resolver
Resolve X1ID, .sol, and .eth names through one endpoint.
Events & webhooks
The identity-event stream and the signed-webhook delivery contract.
Resolve API (public)
The keyless resolution endpoint — no /v1, no key.
