Checkout widget
MainnetAccept X1ID payments on any website with one script tag. Your server creates an on-chain invoice, your page opens checkout for it, the payer pays from their own wallet in an X1ID popup, and your server confirms the payment by reading the invoice back from the chain.
Verify every payment on your server, by reading the invoice from the chain. The widget tells your page how checkout ended, and anyone who controls a browser can fake that message. Ship goods only after your server calls verifyInvoicePaid(...) and gets paid: true.
How it works
The trust sits on chain, not in the widget:
- Your server creates an invoice on chain: payee (your wallet), exact amount, token, optional expiry. Sign it with your own wallet, or with a merchant-delegate key that can create invoices but can never receive or move funds.
- Your page opens checkout for that invoice. The widget opens
app.x1id.io/invoice?address=<invoice>&embed=1in a popup. The payer sees the on-chain terms and pays from their own wallet. The X1ID registry program moves the funds payer → payee and marks the invoice Paid in the same instruction. - The popup reports back to your page with
postMessage:{ invoice, signature, status }. - Your server checks the chain with
verifyInvoicePaidand fulfils the order.
your server your page X1ID popup (app.x1id.io) X1 chain
─────────── ───────── ──────────────────────── ────────
create_invoice ─────────────────────────────────────────────────────────────────────────▶ Invoice (Open)
{ invoice } ──────────▶ X1IDCheckout.pay(invoice) ──────▶ /invoice?address=…&embed=1
payer reviews + signs ─────────▶ pay_invoice → Paid
◀── { status: "paid", … } ─────── reads Invoice back: Paid
verifyInvoicePaid ◀──── POST /orders/:id/check
getAccountInfo ───────────────────────────────────────────────────────────────────────▶ Paid ✓
fulfil the orderWhy a popup and not an iframe? Wallet extensions work reliably in a top-level window, and a page that can be framed can be clickjacked. app.x1id.io sends frame-ancestors 'none' and X-Frame-Options: DENY; there is no iframe mode.
The protocol fee is 0.25 % (25 bps), taken from the amount: the payer pays exactly the invoice amount, and you receive amount − fee.
You need @x1id/resolve 0.27.0 or later on your server:
npm install @x1id/[email protected] @solana/web3.js1. One-time setup: a merchant key
Your server needs a key to sign create_invoice. Don't put your payee wallet on a web server. Instead, authorize a merchant delegate once, from your payee wallet:
import { PublicKey } from "@solana/web3.js";
import { buildCreateMerchantAuthorityIx, findMerchantAuthorityPda, DEFAULT_HANDLE_PROGRAM } from "@x1id/resolve";
const find = (seeds, programId) => PublicKey.findProgramAddressSync(seeds, new PublicKey(programId))[0].toBase58();
// Signed by the PAYEE wallet (e.g. in a wallet app), once per delegate key.
const ix = buildCreateMerchantAuthorityIx({
programId: DEFAULT_HANDLE_PROGRAM,
payee: PAYEE, // your wallet: where funds go
delegate: SERVER_KEY_PUBKEY,
merchantAuthority: await findMerchantAuthorityPda(find, DEFAULT_HANDLE_PROGRAM, PAYEE, SERVER_KEY_PUBKEY),
});A delegate can create and cancel invoices whose payee is fixed to your wallet. If the key leaks, an attacker can create invoices that pay you; it cannot receive or move funds. Revoke it with buildRevokeMerchantAuthorityIx.
2. Create the invoice on your server
import { Connection, Keypair, PublicKey, Transaction, TransactionInstruction, ComputeBudgetProgram } from "@solana/web3.js";
import {
DEFAULT_HANDLE_PROGRAM as PROGRAM,
buildCreateInvoiceIx, findInvoicePda, findMerchantAuthorityPda,
newInvoiceId, invoiceIdToHex, invoiceMemoHash, INVOICE_NATIVE_MINT,
} from "@x1id/resolve";
const RPC = "https://rpc.mainnet.x1.xyz";
const connection = new Connection(RPC, "confirmed");
const serverKey = Keypair.fromSecretKey(/* from your secret store */);
const PAYEE = "YourPayeeWallet…";
const find = (seeds, programId) => PublicKey.findProgramAddressSync(seeds, new PublicKey(programId))[0].toBase58();
const toWeb3 = (ix) => new TransactionInstruction({
programId: new PublicKey(ix.programId),
keys: ix.keys.map((k) => ({ pubkey: new PublicKey(k.pubkey), isSigner: k.isSigner, isWritable: k.isWritable })),
data: Buffer.from(ix.data),
});
export async function createInvoiceForOrder(order) {
const id = newInvoiceId(); // 16 random bytes
const invoice = await findInvoicePda(find, PROGRAM, PAYEE, id);
const ix = buildCreateInvoiceIx({
programId: PROGRAM,
creator: serverKey.publicKey.toBase58(), // the delegate signs
payee: PAYEE, // funds always go here
merchantAuthority: await findMerchantAuthorityPda(find, PROGRAM, PAYEE, serverKey.publicKey.toBase58()),
config: find([Buffer.from("config")], PROGRAM),
invoice,
id,
mint: INVOICE_NATIVE_MINT, // or an SPL mint (e.g. USDC)
amount: order.amountBaseUnits, // bigint, base units (XNT: 9 decimals)
expiresAt: BigInt(Math.floor(Date.now() / 1000) + 30 * 60), // 30 min
memoHash: invoiceMemoHash(`order ${order.id}`),
});
const tx = new Transaction().add(ComputeBudgetProgram.setComputeUnitLimit({ units: 80_000 }), toWeb3(ix));
await connection.sendTransaction(tx, [serverKey]); // then confirm as usual
// STORE these with the order. They, not anything from the browser, are what you verify against.
await db.orders.update(order.id, {
invoice, invoiceId: invoiceIdToHex(id), payee: PAYEE,
mint: INVOICE_NATIVE_MINT, amount: order.amountBaseUnits.toString(),
});
return invoice;
}The server key pays the transaction fee and the invoice's rent (refunded when the invoice is closed). Pass rentPayer to have another key fund the rent.
3. Add checkout to your page
The widget is a single dependency-free script, about 9 KB and not minified:
https://app.x1id.io/widget/checkout-v1.js<script src="https://app.x1id.io/widget/checkout-v1.js" defer></script>
<button id="pay" data-x1id-invoice="INVOICE_ADDRESS_FROM_YOUR_SERVER">Pay with X1ID</button>
<script>
document.getElementById("pay").addEventListener("x1id:checkout", async (e) => {
// e.detail = { invoice, signature, status: "paid" | "cancelled" | "closed" }
// Whatever the status, ask YOUR server (a "closed" popup may still have paid).
const r = await fetch(`/orders/${ORDER_ID}/check`, { method: "POST" }).then((r) => r.json());
if (r.paid) location.href = `/orders/${ORDER_ID}/thanks`;
});
document.getElementById("pay").addEventListener("x1id:checkout-error", (e) => {
// Popup blocked, or a bad invoice address.
console.warn(e.detail.error);
});
</script>Any element with data-x1id-invoice opens checkout on click, including elements added later. data-x1id-app="https://…" on the element, or data-app on the script tag, points at a different X1ID deployment (for example testnet).
import { useState } from "react";
// Load https://app.x1id.io/widget/checkout-v1.js once (e.g. in your <head>).
export function PayButton({ orderId, invoice }) {
const [state, setState] = useState("idle");
async function onClick() {
setState("open");
try {
// Must run straight from the click, or the browser blocks the popup.
const result = await window.X1IDCheckout.pay(invoice);
// result.status is a hint only: always ask your server.
const r = await fetch(`/orders/${orderId}/check`, { method: "POST" }).then((r) => r.json());
setState(r.paid ? "paid" : "idle");
} catch (e) {
setState("idle"); // CheckoutPopupBlockedError: ask the user to allow popups
}
}
return (
<button onClick={onClick} disabled={state === "open"}>
{state === "paid" ? "Paid" : "Pay with X1ID"}
</button>
);
}X1IDCheckout.open(invoice) returns { sid, result, abort() } if you need to cancel from your side. With a bundler, the same controller ships in the SDK: import { openCheckout } from "@x1id/resolve" and call openCheckout(window, { invoice }).
Results
status | Meaning | What to do |
|---|---|---|
paid | The popup read the Invoice account back as Paid. signature is the payment transaction when known. | Verify on your server, then fulfil. |
cancelled | The payer clicked Return without paying. | Verify anyway (it's cheap), then offer to retry. |
closed | The popup closed without a result. The payer may still have paid (e.g. closed it before confirmation). | Verify on your server. Poll for a short while if you expect a payment. |
4. Verify the payment on your server
import express from "express";
import { verifyInvoicePaid } from "@x1id/resolve";
const app = express();
app.post("/orders/:id/check", async (req, res) => {
const order = await db.orders.get(req.params.id); // terms YOU stored in step 2
if (!order) return res.sendStatus(404);
if (order.fulfilled) return res.json({ paid: true });
const r = await verifyInvoicePaid("https://rpc.mainnet.x1.xyz", order.invoice, {
payee: order.payee,
amount: BigInt(order.amount), // exact base units
mint: order.mint, // omit for native XNT
id: order.invoiceId, // binds this check to this one invoice
// commitment: "finalized", // for high-value goods
});
if (r.paid) {
// Idempotent: fulfil once, even if this endpoint is hit twice.
await db.orders.markFulfilledOnce(order.id, { paidBy: r.paidBy, paidAt: Number(r.paidAt) });
return res.json({ paid: true });
}
res.json({ paid: false, reason: r.reason }); // e.g. "not-paid" (yet)
});verifyInvoicePaid(rpc, invoice, { payee, amount, mint?, id?, programId?, commitment? }) does one getAccountInfo and returns paid: true only when all of these hold:
- the account is owned by the X1ID registry program (default
8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P); - it decodes as an Invoice;
- its payee, token and amount equal what you expect, and its id does too when you pass one;
- its status is Paid.
Otherwise it returns paid: false with a reason: not-found, wrong-program, not-invoice, wrong-payee, wrong-amount, wrong-mint, wrong-id, or not-paid (with the current status: open or cancelled). It throws on RPC errors and bad arguments, so a network failure can never read as paid. rpc is a URL or an RpcFn.
A paid invoice can later be closed by its payee or rent payer to reclaim rent. After that the account is gone and verification returns not-found, so record fulfilment in your database the first time you see paid: true.
Prefer push to polling? Register a Pay webhook for invoice.paid on your payee wallet or @name, and run the same verifyInvoicePaid check when it arrives.
Security notes
-
Never fulfil from the browser result. The
x1id:checkoutevent,X1IDCheckout.pay()'s result, a redirect, or anything else from the client is only a hint to call your server. Your server callsverifyInvoicePaid. -
Verify against your stored terms. Take the invoice address, payee, amount, mint and id from your database, never from the request, and pass
id. Otherwise someone could present a different invoice that was paid, such as a cheaper one or one already used for another order. -
Fulfil idempotently, once per order.
-
Use
finalizedfor high-value goods.confirmed(the default) is supermajority-voted and fast;finalizedtakes a few seconds more but cannot be rolled back. -
Expire invoices. Set
expiresAtso a stale checkout can't be paid hours later at an old price. The program refuses payment after expiry. -
Pinned terms. The payer's wallet signs
pay_invoicewith the exact amount and token they were shown. If the invoice is replaced with different terms after they review it, the program refuses (InvoiceTermsChanged). The popup also re-reads the invoice right before signing. -
Origin checks, both ways. The popup posts only to the window that opened it, with
targetOriginpinned to your page's origin, so no other site can read the result. Your page accepts a message only from that popup window, from exactlyhttps://app.x1id.io, carrying the 128-bit session id of this checkout and the invoice you asked for. Malformed or unexpected messages are dropped. -
HTTPS only. Checkout refuses to start from a non-
https:page (http://localhostis allowed for development), because results could not be delivered to it safely. -
No iframe, no clickjacking. app.x1id.io cannot be framed (
frame-ancestors 'none',X-Frame-Options: DENY). -
Pin the script if you like.
/widget/checkout-v1.jsis served with CORS, so you can add Subresource Integrity, or self-host the file:<script src="https://app.x1id.io/widget/checkout-v1.js" integrity="sha384-…" crossorigin="anonymous" defer></script>Compute the hash with:
curl -s https://app.x1id.io/widget/checkout-v1.js | openssl dgst -sha384 -binary | openssl base64 -A -
The merchant key can't steal. A merchant-delegate key only creates and cancels invoices that pay your wallet.
Protocol reference (v1)
You only need this if you build your own client instead of using the script or openCheckout.
Popup URL:
https://app.x1id.io/invoice?address=<invoice>&embed=1&origin=<your page's origin>&sid=<32 hex>Every message is { protocol: "x1id-checkout", version: 1, type, sid, … } with exactly these fields:
type | Direction | Extra fields |
|---|---|---|
ready | popup → page, after the invoice is read | invoice |
ack | page → popup, reply to ready. The popup then shows "Checkout for <your host>" | none |
result | popup → page, once | invoice, status (paid | cancelled), signature (base58 or null; always null for cancelled) |
closed is never sent: the page reports it itself when the popup closes without a result.
The popup sends paid only after the Invoice account reads back as Paid, not when the wallet returns a signature. After a payment is submitted, Return never reports cancelled. If the popup closes before confirmation, your page gets closed.
checkout-v1.js is versioned: a breaking protocol change ships as checkout-v2.js next to it, and v1 keeps working.
