Skip to content

Developer Guide

Checkout (Beta)

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

If you want to see a demo of what to expect from the checkout, you can view a live example here.

Raincheck Demo

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

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 or Wallet Management article.

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.

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

Valid origins:

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

Invalid origins:

shop.example.com
https://shop.example.com/checkout
https://shop.example.com?store=1
https://*.example.com
PolicyBehaviorRecommended use
deny_allPrevents iframe and SDK embedding. Full-page hosted checkout still works.Environments that use only full-page redirects, or that should never be embedded.
allowlistPermits only exact entries in allowed_origins.Live use and normal testing.
allow_allPermits 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 from your server. Never call this endpoint from browser code, because the request carries your API key.

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

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

Terminal window
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 or your own iframe; see Return URLs. The JavaScript SDK does not need it.

FieldDescription
idRequired · UUID. Merchant-generated session ID. It is also the idempotency key.
wallet_idRequired · UUID. Wallet that receives the payment.
payment_kindRequired · 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.
amountConditional · object. Fixed amount: { "currency": ..., "amount": ... }. See 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_atRequired · string. RFC 3339 UTC timestamp. Must be in the future and no more than 24 hours away.
currencyOptional · 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_idOptional · UUID. Merchant-generated payment ID. Must be unique. Voltage generates one when omitted. Payment webhooks return it as detail.data.id.
descriptionOptional · string. Payment description. For bolt11, Voltage uses it as the invoice memo.
payment_metadataOptional · 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.
metadataOptional · object. Any JSON object, stored with the checkout session. It is not copied onto the payment. Defaults to {}.
redirect_urlsOptional · object. Where Continue sends the customer when the session ends.
redirect_urls.successOptional · string. Absolute http or https URL for a completed payment.
redirect_urls.failureOptional · 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.amount is an integer in the currency’s smallest unit:

CurrencyUnitExample
btcMillisatoshis (1 satoshi = 1,000 msats)10,000 satoshis: { "currency": "btc", "amount": 10000000 }
usdCents$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.

A successful request returns 201 Created:

{
"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"]
}
FieldDescription
idCheckout session ID. Store it with your order.
payment_idPayment ID. Store it with your order and use it to verify payment.
checkout_tokenBearer credential for the hosted page. It is already part of checkout_url.
checkout_urlComplete hosted checkout URL. Send this to the browser.
expires_atWhen the session expires.
origin_policyThe environment’s browser-origin policy: deny_all, allowlist or allow_all.
allowed_originsThe 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.

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.

OptionWhere the hosted page runsHow the customer returnsOrigin allowlist
JavaScript SDK (recommended)Overlay iframe created by the SDKSDK callbacks (onComplete, onClose, …)Required
Full-page redirectThe whole browser tabContinue button to your redirect_urlsNot used
Your own iframeAn iframe on your pageContinue loads your return page inside the iframeRequired

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

Load the Checkout SDK:

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

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 or your own iframe instead.

OptionDescription
checkoutUrlComplete checkout_url returned by Voltage. Required.
themelight or dark.
dismissibleWhen false, hides customer dismissal controls. Merchant code can still call close().
titleAccessible iframe title. Describe the payment task.
amountDisplay.primaryamount, requested_amount, or custom display text.
amountDisplay.secondaryauto, hidden, or custom display text.
amountDisplay.bitcoinUnitauto, btc, or sats.
amountDisplay.localeBCP 47 locale such as en-US.
amountDisplay.currencyDisplaysymbol or code.
onCompleteCheckout reported a completed payment. Verify it on your server.
onExpiredSession expired before completion.
onFailedCheckout reported a failed payment.
onCancelCheckout reported an explicit cancellation event.
onMessageReceives every accepted Voltage checkout message.
onCloseRuns 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.

Terminal callback payloads can include the session and amount details:

{
"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.

Add redirect_urls to the create checkout session request to give the hosted page somewhere to send the customer when checkout ends:

{
"redirect_urls": {
"success": "https://shop.example.com/orders/1234/return?outcome=success",
"failure": "https://shop.example.com/orders/1234/return?outcome=failure"
}
}
FieldDescription
redirect_urls.successShown when the payment is completed.
redirect_urls.failureShown 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.

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, as shown in the example.

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

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.

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.

  1. Add your page’s exact origin to the environment’s origin allowlist. 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:

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

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

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:

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.

StatusMeaning
generatingVoltage is creating the payment request.
receivingThe request is ready and Voltage is waiting for payment.
completedVoltage completed the receive payment.
failedVoltage could not complete the payment.
expiredThe 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.

  • 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.
SymptomLikely causeWhat to check
401 Unauthorized creating a sessionMissing or invalid API key.x-api-key, API environment, and secret loading.
403 Forbidden creating a sessionAPI key lacks access.Organization, environment, and key permissions.
409 Conflict creating a sessionSession ID reused with different inputs.Replay the original request or generate a new UUID.
Browser says the checkout refused to connectOrigin blocked by checkout framing policy.Exact scheme, hostname, port, and environment settings.
Checkout says its token is missingURL fragment was removed.Pass the complete returned checkout_url unchanged.
SDK does not loadWrong script URL, CSP, network, or script failure.SDK URL and browser console.
Checkout remains in generatingPayment creation or status updates are delayed.Payment and session records in Voltage.
Checkout completes but merchant UI does not updateCallback or origin-message problem.Parent origin, SDK version, callback handlers, and console.
Checkout returns a plain-text 5xxHosted checkout or upstream service is unavailable.Record the time and IDs, avoid logging the token, and contact support.