# Checkout (Beta)

:::caution
**Beta:** Voltage Checkout is currently in beta. Availability, supported payment methods, configuration, and interfaces may change. Confirm that the current beta fits your implementation before launching it in production.
:::

Voltage Checkout gives merchants a hosted Lightning payment page without requiring them to build and operate the payment interface themselves.

## See the Demo

If you want to see a demo of what to expect from the checkout, you can view a live example [here](https://voltage-raincheck.netlify.app/).

[![](/images/checkout-beta-1.png)](https://voltage-raincheck.netlify.app/)

[Raincheck Demo](https://voltage-raincheck.netlify.app/)

## How Checkout works

1. Your server creates a checkout session for an order.
2. Voltage returns a hosted checkout\_url, checkout session ID, and payment ID.
3. Your browser opens the returned URL: with the Voltage Checkout JavaScript SDK, as a full-page redirect, or in your own iframe. See [Choose how to open Checkout](#choose-how-to-open-checkout).
4. The customer pays the Lightning invoice.
5. The hosted page reports status to the browser.
6. Your server verifies the payment before fulfilling the order.

The browser experience helps the customer complete payment, but it is not the authoritative payment record. A customer can close a tab, lose network access, or modify browser code, so always verify payment from your server.

## Before you integrate

Make sure you have a Voltage wallet that you can use (Mutinynet for testing, or mainnet for real money). You can learn more about those topics in the [Wallet Setup Guide](/wallet-setup-guide) or [Wallet Management](/wallet-management) article.

## Configure browser origins

First, decide which browser origins you want to allow when using the Checkout in an iframe or SDK overlay. A full-page redirect does not embed Checkout, so it does not need an origin entry. For example, you could declare that you only want the Checkout to be shown on shop.example.com, as well as staging.shop.example.com for testing and localhost:5173 for local development. You can also allow your Checkout to appear on *any domain name*, though this is not recommended. If you choose "Deny", no site can embed Checkout in an iframe or SDK overlay for that environment. Deny does not disable full-page hosted checkout, and it does not revoke checkout links that have already been created.

To configure browser origins:

1. Sign in to Voltage.
2. Open the organization and environment.
3. Select **Manage Environment**.
4. Find **Checkout**.
5. Select an origin policy.
6. If you use an allowlist, enter one exact origin per line.
7. Save the origins.

![](/images/checkout-beta-2.png)

### Formatting the origin

An origin contains a scheme, hostname, and optional port. It does not contain a path, query, user information, or fragment.

Valid origins:

```text
https://shop.example.com
https://admin.example.com:8443
http://localhost:5173
http://127.0.0.1:5173
```

Invalid origins:

```text
shop.example.com
https://shop.example.com/checkout
https://shop.example.com?store=1
https://*.example.com
```

| Policy | Behavior | Recommended use |
| --- | --- | --- |
| deny\_all | Prevents iframe and SDK embedding. Full-page hosted checkout still works. | Environments that use only full-page redirects, or that should never be embedded. |
| allowlist | Permits only exact entries in allowed\_origins. | Live use and normal testing. |
| allow\_all | Permits any browser origin. | Short-lived troubleshooting only. |

localhost and 127.0.0.1 are different origins. Netlify, Vercel, and similar deploy previews also have origins that differ from the primary site. Add each origin intentionally, and review the list before launch. Browser-origin settings are specific to each environment. When switching environments, re-add your application’s exact origin in the new environment—for example, https\://voltage-raincheck.netlify.app. This is separate from the API key’s IP allowlist.

## Create a checkout session

Create a checkout session from your server. Never call this endpoint from browser code, because the request carries your API key.

```http
POST https://voltageapi.com/v1/organizations/{organization_id}/environments/{environment_id}/checkout/sessions
x-api-key: {api_key}
Content-Type: application/json
```

### Example request

This request creates a 10,000-satoshi Lightning (BOLT11) checkout with optional return URLs:

```bash
curl -X POST "https://voltageapi.com/v1/organizations/$VOLTAGE_ORGANIZATION_ID/environments/$VOLTAGE_ENVIRONMENT_ID/checkout/sessions" \
  -H "x-api-key: $VOLTAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "NEW_SESSION_UUID",
    "wallet_id": "YOUR_WALLET_ID",
    "payment_kind": "bolt11",
    "amount": {
      "currency": "btc",
      "amount": 10000000
    },
    "expires_at": "EXPIRES_AT",
    "description": "Order 1234",
    "metadata": {
      "order_id": "1234"
    },
    "redirect_urls": {
      "success": "https://shop.example.com/orders/1234/return?outcome=success",
      "failure": "https://shop.example.com/orders/1234/return?outcome=failure"
    }
  }'
```

Before you send it, replace:

- `NEW_SESSION_UUID` with a new UUID for this checkout attempt.
- `YOUR_WALLET_ID` with the ID of the wallet that receives the payment.
- `EXPIRES_AT` with a UTC timestamp in the near future, for example 15 minutes from now in `YYYY-MM-DDTHH:MM:SSZ` format.

The organization, environment, API key and wallet must all belong to the environment you intend to charge. Use a Mutinynet wallet environment during development, and switch to your live environment and mainnet wallet when you are ready to accept real payments.

`redirect_urls` is optional. Include it for a [full-page redirect](#redirect-to-the-hosted-page) or [your own iframe](#embed-the-hosted-page-in-your-own-iframe); see [Return URLs](#return-urls). The JavaScript SDK does not need it.

### Request fields

| Field | Description |
| --- | --- |
| `id` | **Required** · UUID. Merchant-generated session ID. It is also the idempotency key. |
| `wallet_id` | **Required** · UUID. Wallet that receives the payment. |
| `payment_kind` | **Required** · string. Use `bolt11` for the beta checkout flow. The API also accepts `onchain`, `bip21` and `taprootasset`. `onchain` and `bip21` must be enabled for your organization. |
| `amount` | **Conditional** · object. Fixed amount: `{ "currency": ..., "amount": ... }`. See [Amount encoding](#amount-encoding). Required for USD, `onchain` and `taprootasset`. Omit it only for an any-amount `bolt11` or `bip21` session, where the customer chooses the amount. Any-amount sessions require a BTC-backed wallet. |
| `expires_at` | **Required** · string. RFC 3339 UTC timestamp. Must be in the future and no more than 24 hours away. |
| `currency` | **Optional** · string. `btc`, `usd` or `asset:<group key>`. If you send `amount`, it must match `amount.currency`. When `amount` is omitted, it defaults to `btc`. |
| `payment_id` | **Optional** · UUID. Merchant-generated payment ID. Must be unique. Voltage generates one when omitted. Payment webhooks return it as `detail.data.id`. |
| `description` | **Optional** · string. Payment description. For `bolt11`, Voltage uses it as the invoice memo. |
| `payment_metadata` | **Optional** · object. String keys and string values copied onto the payment. Up to 50 entries, 256 characters per key and value. Voltage adds `checkout_session_id` itself, so you can't use that key. |
| `metadata` | **Optional** · object. Any JSON object, stored with the checkout session. It is not copied onto the payment. Defaults to `{}`. |
| `redirect_urls` | **Optional** · object. Where **Continue** sends the customer when the session ends. |
| `redirect_urls.success` | **Optional** · string. Absolute `http` or `https` URL for a completed payment. |
| `redirect_urls.failure` | **Optional** · string. Absolute `http` or `https` URL for a failed payment or expired session. |

If you send `redirect_urls`, include at least one of `success` or `failure`. Unknown fields inside `redirect_urls` are rejected.

The session `id` is the idempotency key. Resending an identical request with the same `id` returns the same session, so you can retry safely after a network error. Reusing the `id` with any field changed returns `409 Conflict`.

### Amount encoding

`amount.amount` is an integer in the currency's smallest unit:

| Currency | Unit | Example |
| --- | --- | --- |
| `btc` | Millisatoshis (1 satoshi = 1,000 msats) | 10,000 satoshis: `{ "currency": "btc", "amount": 10000000 }` |
| `usd` | Cents | $25.00: `{ "currency": "usd", "amount": 2500 }` |
| `asset:<group key>` | The asset's base units | `{ "currency": "asset:<66-character hex group key>", "amount": 100 }` |

A fixed BTC amount must be at least 1 satoshi (1,000 msats). Do not send decimal BTC values. USD amounts require a wallet with a USD-backed line of credit.

`bolt11`, `onchain` and `bip21` accept only `btc` or `usd` amounts. `taprootasset` requires a fixed, non-zero asset amount and does not work with a USD-backed wallet.

### Successful response

A successful request returns `201 Created`:

```json
{
  "id": "11111111-1111-4111-8111-111111111111",
  "payment_id": "22222222-2222-4222-8222-222222222222",
  "checkout_token": "vlt_co_REDACTED",
  "checkout_url": "https://app.voltage.cloud/cs/11111111-1111-4111-8111-111111111111#vlt_co_REDACTED",
  "expires_at": "2026-10-21T18:15:00Z",
  "origin_policy": "allowlist",
  "allowed_origins": ["https://shop.example.com"]
}
```

| Field | Description |
| --- | --- |
| `id` | Checkout session ID. Store it with your order. |
| `payment_id` | Payment ID. Store it with your order and use it to [verify payment](#verify-payment-on-your-server). |
| `checkout_token` | Bearer credential for the hosted page. It is already part of `checkout_url`. |
| `checkout_url` | Complete hosted checkout URL. Send this to the browser. |
| `expires_at` | When the session expires. |
| `origin_policy` | The environment's [browser-origin](#configure-browser-origins) policy: `deny_all`, `allowlist` or `allow_all`. |
| `allowed_origins` | The environment's allowed origins, used when the policy is `allowlist`. |

Use `checkout_url` exactly as returned. The URL fragment contains a bearer credential. Do not log it, send it to analytics or error tracking, include it in screenshots, or move it into a query parameter.

## Choose how to open Checkout

All three options use the same server-created session and the same `checkout_url`. They differ in where the hosted page runs and how the customer gets back to your site.

| Option | Where the hosted page runs | How the customer returns | Origin allowlist |
| --- | --- | --- | --- |
| [JavaScript SDK](#open-checkout-with-the-javascript-sdk) (recommended) | Overlay iframe created by the SDK | SDK callbacks (`onComplete`, `onClose`, …) | Required |
| [Full-page redirect](#redirect-to-the-hosted-page) | The whole browser tab | **Continue** button to your `redirect_urls` | Not used |
| [Your own iframe](#embed-the-hosted-page-in-your-own-iframe) | An iframe on your page | **Continue** loads your return page inside the iframe | Required |

The redirect and iframe options don't load `checkout.js`. They rely on return URLs, which you set when you create the session.

## Open Checkout with the JavaScript SDK

Load the Checkout SDK:

```html
<script src="https://app.voltage.cloud/checkout/v1/checkout.js"></script>
```

Your browser should call your own server endpoint, receive the complete checkout URL, and pass it to the SDK:

```js
async function startCheckout(productId) {
  const response = await fetch('/api/checkout', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ product_id: productId })
  });

  const checkout = await response.json();
  if (!response.ok) {
    throw new Error(checkout.error || 'Unable to create checkout session.');
  }

  let instance;
  instance = window.VoltageCheckout.open({
    checkoutUrl: checkout.checkout_url,
    theme: 'dark',
    dismissible: true,
    title: 'Pay Example Store',
    amountDisplay: {
      primary: 'requested_amount',
      secondary: 'auto',
      bitcoinUnit: 'sats',
      locale: 'en-US',
      currencyDisplay: 'code'
    },
    onComplete(payload) {
      // This is a UI signal. Verification still happens on the server.
      instance.close();
      showOrderPendingVerification(payload.session_id);
    },
    onExpired() {
      showCheckoutExpired();
    },
    onFailed() {
      showCheckoutFailed();
    },
    onCancel() {
      showCheckoutCancelled();
    },
    onClose() {
      restoreMerchantPage();
    }
  });
}
```

The SDK creates and sizes the iframe, validates message origins, locks page scrolling, restores focus, and removes the overlay when closed. If you cannot load the SDK, use a [full-page redirect](#redirect-to-the-hosted-page) or [your own iframe](#embed-the-hosted-page-in-your-own-iframe) instead.

### SDK options

| Option | Description |
| --- | --- |
| checkoutUrl | Complete checkout\_url returned by Voltage. Required. |
| theme | light or dark. |
| dismissible | When false, hides customer dismissal controls. Merchant code can still call close(). |
| title | Accessible iframe title. Describe the payment task. |
| amountDisplay.primary | amount, requested\_amount, or custom display text. |
| amountDisplay.secondary | auto, hidden, or custom display text. |
| amountDisplay.bitcoinUnit | auto, btc, or sats. |
| amountDisplay.locale | BCP 47 locale such as en-US. |
| amountDisplay.currencyDisplay | symbol or code. |
| onComplete | Checkout reported a completed payment. Verify it on your server. |
| onExpired | Session expired before completion. |
| onFailed | Checkout reported a failed payment. |
| onCancel | Checkout reported an explicit cancellation event. |
| onMessage | Receives every accepted Voltage checkout message. |
| onClose | Runs after the SDK removes the overlay. |

open() returns an object with:

- close() — removes the overlay programmatically.
- iframe — the iframe element created by the SDK.

Terminal callbacks do not automatically remove the overlay. This lets the hosted page display its completed, failed, or expired state. Call close() when your merchant experience should continue elsewhere.

An ordinary customer dismissal runs onClose; it does not imply payment failure or cancellation. Before creating another payment, check the original order on your server.

### Example completion payload

Terminal callback payloads can include the session and amount details:

```json
{
  "status": "completed",
  "session_id": "11111111-1111-4111-8111-111111111111",
  "amount": {
    "currency": "btc",
    "amount": 10000000,
    "unit": "msats"
  },
  "btc_amount": {
    "currency": "btc",
    "amount": 10000000,
    "unit": "msats"
  }
}
```

Use session\_id to update the customer interface, then verify the corresponding payment from trusted server code.

## Return URLs

Add `redirect_urls` to the [create checkout session request](#example-request) to give the hosted page somewhere to send the customer when checkout ends:

```json
{
  "redirect_urls": {
    "success": "https://shop.example.com/orders/1234/return?outcome=success",
    "failure": "https://shop.example.com/orders/1234/return?outcome=failure"
  }
}
```

| Field | Description |
| --- | --- |
| `redirect_urls.success` | Shown when the payment is `completed`. |
| `redirect_urls.failure` | Shown when the payment is `failed` or the session is `expired`. |

- Include at least one of `success` or `failure`. Each must be an absolute `http` or `https` URL. Other fields are rejected.
- There is no cancel URL. If the customer leaves before paying, they don't reach either URL.
- Checkout does not redirect automatically. When the session ends, the hosted page shows a **Continue** button that opens the matching URL. If no URL is set for that outcome, there's no **Continue** button.
- Return URLs are part of the session's immutable inputs. If you retry a session creation with the same `id`, send the same `redirect_urls`.
- Put your own order reference in the URL. Never put the checkout token, `checkout_url`, or an API key in it.

:::caution
Reaching a return URL is a navigation, not a payment confirmation. Anyone can open your success URL directly. Look up the order on your server and [verify the payment](#verify-payment-on-your-server) before you fulfill it.
:::

## Redirect to the hosted page

With a full-page redirect, the customer leaves your site to pay on the hosted page, then selects **Continue** to come back.

1. Your server creates the session with `redirect_urls`.
2. Your browser sends the customer to the complete `checkout_url`.
3. The customer pays on the hosted page.
4. When the session ends, the customer selects **Continue** and arrives at your return URL.
5. Your return page shows the order status that your server has verified.

Include `redirect_urls` in the [session request](#example-request), as shown in the example.

Then, in the browser, send the customer to the URL exactly as your server returned it:

```js
async function startCheckout(productId) {
  const response = await fetch('/api/checkout', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ product_id: productId })
  });
  const checkout = await response.json();
  if (!response.ok) {
    throw new Error(checkout.error || 'Unable to create checkout session.');
  }

  window.location.assign(checkout.checkout_url);
}
```

While the payment is in progress, the hosted page asks the browser to confirm before the customer leaves, but browsers may skip that prompt. If the browser has a previous page to go back to, the hosted page also shows a **Back** button. Your return page should handle a customer who comes back without paying. Show the order's current state from your server, and check the existing payment before you create another session.

## Embed the hosted page in your own iframe

You can show the hosted page in an iframe on your own page without the SDK. In this case, **Continue** loads your return URL inside the iframe, not in the whole tab. Your return page is responsible for telling your page that checkout is finished.

:::note
The SDK already handles frame sizing, message origins, focus, and scroll locking. Build your own iframe only when you can't load the SDK, and test it before launch.
:::

1. Add your page's exact origin to the environment's [origin allowlist](#configure-browser-origins). Without it, the browser won't show Checkout in your iframe.
2. Create the session with `redirect_urls` that point to a return page **on the same origin as the page that contains the iframe**.
3. Set the iframe `src` to the complete `checkout_url`.
4. When the session ends, the customer selects **Continue**. The iframe loads your return page.
5. Your return page sends a message to the parent page. The parent checks it, removes the iframe, and asks your server for the verified order status.

The page that contains the iframe:

```html
<iframe id="voltage-checkout" title="Pay with Voltage Checkout"
  allow="clipboard-write"></iframe>
<script>
  // checkout: the response from your server.
  // attemptId: a random ID that you also put in the return URLs.
  function openCheckout(checkout, attemptId) {
    const frame = document.getElementById('voltage-checkout');

    function onMessage(event) {
      if (event.origin !== window.location.origin) return;
      if (event.source !== frame.contentWindow) return;
      const data = event.data;
      if (data?.type !== 'shop.checkout-return' || data.attempt !== attemptId) return;

      window.removeEventListener('message', onMessage);
      frame.remove();
      showOrderStatusFromServer(); // Verify on the server before fulfillment.
    }

    window.addEventListener('message', onMessage);
    frame.src = checkout.checkout_url;
  }
</script>
```

Your return page, for example `https://shop.example.com/checkout-return?attempt=…&outcome=success`:

```html
<!doctype html>
<p>Returning to the store…</p>
<script>
  const params = new URLSearchParams(window.location.search);
  if (window.parent !== window) {
    const message = {
      type: 'shop.checkout-return',
      attempt: params.get('attempt'),
      outcome: params.get('outcome')
    };
    window.parent.postMessage(message, window.location.origin);
  }
</script>
```

- **Allow framing of the return page by your own origin only.** Use `X-Frame-Options: SAMEORIGIN` or `Content-Security-Policy: frame-ancestors 'self'`. A site-wide `DENY` blocks the return page from loading inside the iframe.
- **Check every message.** Accept a message only when its `origin` is your own origin, its `source` is the checkout iframe, and its `attempt` matches the checkout you opened.
- **Target your own origin** in `postMessage`, not `*`.
- **Treat `outcome` as a hint.** It decides what to show while your server checks the payment. It doesn't decide whether the order is paid.

For a working example, see the [Raincheck demo](https://voltage-raincheck.netlify.app/).

## Verify payment on your server

Store the checkout session ID and payment ID with your merchant order. Before fulfillment, either process a trusted Voltage webhook or retrieve the payment from the Payments API:

```http
GET https://voltageapi.com/v1/organizations/{organization_id}/environments/{environment_id}/payments/{payment_id}
```

Authenticate from your server. Fulfill only after the authoritative payment state is complete, and make fulfillment idempotent so retries or duplicate webhook delivery cannot produce a second order.

See Webhooks and the Voltage Payments API for the complete verification contract.

## Payment lifecycle

| Status | Meaning |
| --- | --- |
| generating | Voltage is creating the payment request. |
| receiving | The request is ready and Voltage is waiting for payment. |
| completed | Voltage completed the receive payment. |
| failed | Voltage could not complete the payment. |
| expired | The session reached expires\_at before completion. |

Browser dismissal is not a payment status. A customer may close or refresh the page while payment is still in flight.

Give customers a merchant-controlled order-status page. If a session is interrupted, verify the existing payment before creating another checkout. If you retry session creation after an uncertain network response, reuse the original session ID and immutable inputs first.

## Security checklist

- Keep the environment API key on your server.
- Never use a VITE\_, NEXT\_PUBLIC\_, or similar public prefix for the API key.
- Create checkout sessions through a merchant-controlled server endpoint.
- Use the complete returned checkout URL without reconstructing it.
- Treat the checkout token and checkout URL as bearer credentials.
- Redact tokens and URLs from logs, analytics, screenshots, and support tickets.
- Use an exact origin allowlist for every live browser origin.
- Treat browser callbacks as UI signals only.
- Verify payment on the server before fulfillment.
- Make order fulfillment idempotent.

## Troubleshooting

| Symptom | Likely cause | What to check |
| --- | --- | --- |
| 401 Unauthorized creating a session | Missing or invalid API key. | x-api-key, API environment, and secret loading. |
| 403 Forbidden creating a session | API key lacks access. | Organization, environment, and key permissions. |
| 409 Conflict creating a session | Session ID reused with different inputs. | Replay the original request or generate a new UUID. |
| Browser says the checkout refused to connect | Origin blocked by checkout framing policy. | Exact scheme, hostname, port, and environment settings. |
| Checkout says its token is missing | URL fragment was removed. | Pass the complete returned checkout\_url unchanged. |
| SDK does not load | Wrong script URL, CSP, network, or script failure. | SDK URL and browser console. |
| Checkout remains in generating | Payment creation or status updates are delayed. | Payment and session records in Voltage. |
| Checkout completes but merchant UI does not update | Callback or origin-message problem. | Parent origin, SDK version, callback handlers, and console. |
| Checkout returns a plain-text 5xx | Hosted checkout or upstream service is unavailable. | Record the time and IDs, avoid logging the token, and contact support. |

## Related guides

- [Wallet Setup Guide](/wallet-setup-guide)
- [Staging Environment](/staging-environment)
- [Payments Access](/payments-access)
- [Receiving](/receiving)
- [Webhooks](/webhooks)
- [Voltage Payments API](/voltage-payments-api)
