# Checkout widget
URL: /docs/x1id/checkout

> Accept X1ID payments on any website. Your server creates an on-chain invoice, a drop-in script opens a secure X1ID checkout popup, and your server confirms payment by reading the invoice from chain with verifyInvoicePaid.

# Checkout widget

<Pill tone="mainnet">Mainnet</Pill>

<LlmActions />

Accept 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.

<Callout type="warn">
  **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(...)`](#4-verify-the-payment-on-your-server) and gets `paid: true`.
</Callout>

## How it works

The trust sits on chain, not in the widget:

1. **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](#1-one-time-setup-a-merchant-key) that can create invoices but can never receive or move funds.
2. **Your page opens checkout** for that invoice. The widget opens `app.x1id.io/invoice?address=<invoice>&embed=1` in 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.
3. **The popup reports back** to your page with `postMessage`: `{ invoice, signature, status }`.
4. **Your server checks the chain** with `verifyInvoicePaid` and 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 order
```

<Callout type="info">
  **Why 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.
</Callout>

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`](https://www.npmjs.com/package/@x1id/resolve) **0.27.0** or later on your server:

```bash
npm install @x1id/resolve@0.27.0 @solana/web3.js
```

## 1. 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:

```js
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

```js title="invoice.js"
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
```

<Tabs items={['HTML', 'React']}>
  <Tab value="HTML">
    ```html
    <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).
  </Tab>

  <Tab value="React">
    ```jsx title="PayButton.jsx"
    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 })`.
  </Tab>
</Tabs>

### 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

```js title="server.js"
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`.

<Callout type="info">
  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`.
</Callout>

Prefer push to polling? Register a [Pay webhook](/docs/x1id/pay-webhooks) 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:checkout` event, `X1IDCheckout.pay()`'s result, a redirect, or anything else from the client is only a hint to call your server. Your server calls `verifyInvoicePaid`.
* **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 `finalized` for high-value goods.** `confirmed` (the default) is supermajority-voted and fast; `finalized` takes a few seconds more but cannot be rolled back.
* **Expire invoices.** Set `expiresAt` so 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_invoice` with 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 `targetOrigin` pinned to your page's origin, so no other site can read the result. Your page accepts a message only from that popup window, from exactly `https://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://localhost` is 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.js` is served with CORS, so you can add Subresource Integrity, or self-host the file:

  ```html
  <script src="https://app.x1id.io/widget/checkout-v1.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
  ```

  Compute the hash with:

  ```bash
  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.

## Next

<Cards>
  <Card href="/docs/x1id/pay-webhooks" icon="BellRing" title="Pay webhooks" description="Get a signed POST when an invoice is paid, instead of polling." />

  <Card href="/docs/x1id/get-paid" icon="QrCode" title="Get paid to your handle" description="QR codes and payment links for @you." />

  <Card href="/docs/x1id/sdk" icon="Code" title="SDK reference" description="The @x1id/resolve SDK that builds invoices and verifies them." />
</Cards>
