aisoul.dev

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

terminal
pnpm add nuxt-openreceive pg

01Add the module

nuxt.config.ts
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

.env
# server only, never NUXT_PUBLIC_*
NUXT_OPENRECEIVE_NWC_URI=nostr+walletconnect://...   # receive-only code from your wallet
NUXT_OPENRECEIVE_DATABASE_URL=postgres://...         # or DATABASE_URL

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

server/openreceive.ts
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

app/pages/checkout/[reference].vue
<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.

.env
NUXT_OPENRECEIVE_LSC_URI_PRIMARY=lightning+swapconnect://...
  1. The checkout must live at a per-order URL the payer can come back to, like /checkout/:reference.
  2. authorize must still recognise the payer hours later: a deposit that arrives short or late becomes refund_required and is claimed on that second visit.

No login on your site? Recognise anonymous payers by a cookie set when they create the order:

server/utils/visitor.ts
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
}
server/openreceive.ts
// 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.

Amount

Supporters