// Nuxt module · open source · MIT
Bitcoin checkout for Nuxt, paid into your own wallet.
Lightning out of the box, plus USDT / USDC / ETH / SOL through a swap. No account, no API key, and no third party ever holds your funds. You write one host file; the module wires the rest.
pnpm add nuxt-openreceive pg01Add the module
export default defineNuxtConfig({
modules: ['nuxt-openreceive'],
openreceive: {
migrate: true, // create the payment tables on boot (or run the DDL in your migrations)
},
}) Prefer your own migrations? Leave migrate off and run openReceiveSchemaSql('postgres') (auto-imported on the server), or npx openreceive scaffold payments --orm drizzle for an ORM migration.
02Wallet and database
# server only, never NUXT_PUBLIC_*
NUXT_OPENRECEIVE_NWC_URI=nostr+walletconnect://... # receive-only code from your wallet
NUXT_OPENRECEIVE_DATABASE_URL=postgres://... # or DATABASE_URLGet a receive-only NWC code from Rizful (menu → NWC → Receive-only) or Alby Hub (Connections → Add Connection → Read Only), see the guide. OpenReceive refuses to boot with a code that can spend.
03The host file
Three hooks mapped onto your existing order and user tables.
export default defineOpenReceiveHost({
// The trusted price, from YOUR order row. Never from the request.
amountFor: async (reference) => {
const order = await findOrder(reference)
return order?.state === 'awaiting_payment'
? { currency: 'USD', value: order.total, description: order.title } // decimal string
: null // → 404
},
// Runs on every payment request. `resource.reference` is a claim, not proof.
authorize: async ({ event, resource }) => {
const { user } = await getUserSession(event) // your session: nuxt-auth-utils, Supabase, ...
const order = resource.reference ? await findOrder(resource.reference) : undefined
return Boolean(user && order?.userId === user.id)
},
// Once per order, INSIDE the settlement transaction. Write through `query`.
onPaid: async ({ reference, paidAt, query }) => {
await query(
`UPDATE orders SET state = 'paid', paid_at = to_timestamp($1)
WHERE id = $2 AND state = 'awaiting_payment'`,
[paidAt, reference],
)
},
})- reference is your order id. Create the order before checkout, keep its id across retries, never reuse it.
- onPaid is for database writes only and must be idempotent. Send emails and webhooks after the commit.
- Lightning payments can't be refunded by the merchant. The wallet code is receive-only.
04The checkout page
<script setup lang="ts">
const route = useRoute()
// your own route that 404s unless the visitor owns the order
const { data, refresh } = await useFetch(`/api/checkout/${route.params.reference}`)
const settledHere = ref(false)
</script>
<template>
<p>{{ data?.state === 'paid' ? 'Paid' : 'Awaiting payment' }}</p>
<!-- show it only while unpaid, but keep it mounted once it settles: it is the receipt -->
<OpenReceiveCheckout
v-if="data && (data.state !== 'paid' || settledHere)"
:reference="data.reference"
:resumable="true"
:onSettled="() => { settledHere = true; refresh() }"
/>
</template> Every @openreceive/vue prop passes through. Event handlers are props (:onSettled), not @settled.
05Turn on swaps USDT · USDC · ETH · SOL
Add a swap provider and the checkout offers stablecoins and altcoins next to Lightning. The deposit is swapped into a Lightning payment to the same wallet.
NUXT_OPENRECEIVE_LSC_URI_PRIMARY=lightning+swapconnect://...- The checkout must live at a per-order URL the payer can come back to, like
/checkout/:reference. authorizemust still recognise the payer hours later: a deposit that arrives short or late becomesrefund_requiredand is claimed on that second visit.
No login on your site? Recognise anonymous payers by a cookie set when they create the order:
import type { H3Event } from 'h3'
// Anonymous payers: a long-lived httpOnly cookie identifies who created an order,
// so only they can pay it, and come back to it for a swap refund.
export function useVisitor(event: H3Event): string {
let id = getCookie(event, 'visitor')
if (!id || !/^[0-9a-f-]{36}$/i.test(id)) {
id = crypto.randomUUID()
setCookie(event, 'visitor', id, {
httpOnly: true,
sameSite: 'lax',
secure: !false,
maxAge: 60 * 60 * 24 * 365,
path: '/',
})
}
return id
}// store useVisitor(event) on the order when you create it
authorize: async ({ event, resource }) => {
const order = resource.reference ? await findOrder(resource.reference) : undefined
return Boolean(order && order.visitor === getCookie(event, 'visitor'))
},Tested with real money: SOL sent from an exchange went through the swap and 3 000 sats landed in the receiving wallet. The refund path hasn't been tested with real funds yet. More in the swap refunds guide.
06Support the authors
This page runs on nuxt-openreceive. A tip goes straight to our wallet through the same checkout you just read about: Lightning, or USDT / USDC / ETH / SOL through a swap.