Private, serverless Bitcoin payments for indie devs

Posted on: July 27, 2025

In this tutorial, I’ll demonstrate a way you can accept on-chain Bitcoin donations via your website in a permissionless, privacy-preserving way.

There’s no need to run your own server or Bitcoin node, nor will you need any specialist software or command line skills. The only requirements are some familiarity with how Bitcoin addresses are created (I’ll provide a brief summary and useful links) and the ability to create a serverless function for your website (e.g. Cloudflare Workers, Netlify / Vercel functions, etc).

Let’s go.

Contents

  1. Who’s this for?
  2. Prerequisites: what you’ll need
  3. Step-by-step setup
  4. Maintenance & enhancements
  5. Closing thoughts

1. Who’s this for?

The approach I’m going to show you today is a minimalist, free, DIY solution for technically-curious people who are new to payments or donations in Bitcoin. I’d say it might be useful if the following apply to you:

  1. You want to accept Bitcoin from strangers, with no middlemen or fees, in a fully-automated yet non-custodial way, while maintaining as much privacy as is practical (i.e. avoiding address reuse)
  2. Your incoming payments or donations are fairly infrequent and not business-critical, and you don’t yet need invoicing, point-of-sale, or CMS integrations
  3. You like getting your hands dirty with a learn-by-doing approach

What if this doesn’t apply to you? If your requirements are more advanced than this, then you might be better off using an established payment gateway.

Why not BTCPay Server?

BTCPay Server is an incredible piece of software. It’s a free, open-source, non-custodial payment gateway that allows individuals and businesses to accept Bitcoin with no third parties and without fees. It’s a powerful full-stack solution - ideal if you need point-of-sale or CMS integrations. But if you’re just looking to accept Bitcoin occasionally with strong privacy, it might be overkill.

My approach aims to be far leaner and scrappier, with no costs and low technical overheads. You can always upgrade to BTCPay Server in the future!

Why not just publish a static address?

This is a good question that everyone runs into at some point in their journey, so I’ll answer it here.

There’s no technical reason why you can’t reuse a Bitcoin address. But doing so can severely undermine your privacy and that of others. Satoshi Nakamoto even alludes to this in the original Bitcoin whitepaper, recommending that “a new key pair should be used for each transaction to keep them from being linked to a common owner.”

Reusing a Bitcoin address allows anyone to view the full transaction history associated with it. Your incoming payment amounts, frequency of payments, and even your future outgoing spends from that address are irreversibly public. It’s then trivial for blockchain analysis companies - or even motivated individuals - to link transactions to your real-world identity, potentially exposing your wider financial activity.

This is one of the reasons why silent payments (as described by BIP 352) are proving to be a popular proposal. Until they’re widely available, rotating addresses is a good solution.

2. Prerequisites: what you’ll need

Below is a fully-functional preview of what we’re going to build. Any Bitcoin sent to this particular address comes to me, and pays for coffee to fuel my future tutorials! You can ignore the Lightning tab - I’ve covered how to accept Lightning payments in a separate tutorial.

Loading...

Loading...

How does it work?

In a nutshell, this approach uses your public key (XPUB) to pre-generate a large pool of new public addresses. A simple serverless function then distributes them at random, while a client-side script persists one address per user and renders it in a QR Code.

For this tutorial, I will assume that you already have a self-custodial Bitcoin wallet. If you don’t have one, get one!

Beyond that, you will need:

  1. Your extended public key (XPUB, or more likely ZPUB or TPUB on a modern device)
  2. The ability to create a serverless function, available in the free tier of many popular hosting providers: Netlify, Vercel, Cloudflare.
  3. Sparrow wallet, another brilliant piece of free software, provides a convenient way to generate receiving address from your XPUB - without the command line - and monitor them using a watch-only setup.
  4. (Optional) Your own Bitcoin node, enabling you to check your balances in a trustless way (i.e. without exposing your IP address)

If any of the concepts or tools above are new to you, I recommend following the links I’ve provided and familiarizing yourself.

3. Step by step setup

3.1 Generate addresses

First, you need to generate at least 500 new public addresses for your wallet (the exact amount is up to you). I recommend using a dedicated account within your existing wallet for this purpose, because it will make it easier to check your balance later.

Account creation is easy with most popular wallet software (see steps for Trezor, Ledger). Create your account, and note down your XPUB and its derivation path (e.g. m/84'/0'/1').

Account creation in Trezor suite

What is an derivation path? A Hierarchical Deterministic (HD) wallet - first proposed in BIP32 - is a wallet which derives all its addresses and keys from a single source, namely the wallet seed. These are organised into a tree-like structure. A derivation path is effectively a series of instructions for navigating the tree and arriving at the extended public key (XPUB) for a specific account. The XPUB can then derive unique public addresses. As implied by the name, the process is deterministic, meaning the addresses generated will be the same regardless of which software you’re using. Handle your XPUB with caution - while it cannot be used to spend funds, it can reveal your transaction history and balance.

With your XPUB to hand, you’re ready to generate addresses. You could do this manually in your existing wallet software, or even on the command line using Node or Python, but Sparrow Wallet makes this process exceptionally easy.

Open Sparrow Wallet, then hit “Create new wallet” and give it a name (e.g. “donations”).

Creating a new wallet in Sparrow

You’ll then see the wallet configuration options.

Setting up a watch-only wallet in Sparrow.

Under “Keystores”, hit “xPub / Watch Only Wallet”.

What’s a watch-only wallet? It’s a wallet with no access to private keys or ability to spend funds. Think of it as a read-only view into a subset of your Bitcoin. For our purposes, Sparrow simply provides a convenient way to generate and monitor public addresses. These are not unique or tied to Sparrow - the funds you receive will be accessible and visible in any existing wallet software you use.

Copy your XPUB and derivation path into the appropriate fields. You can leave the other settings at their defaults.

Entering your XPUB into Sparrow for a watch-only wallet.

Next, hit “Advanced”.

Setting the gap limit in Sparrow wallet.

You can set the “Birth date” field to the present day. Assuming it’s a new account with zero funds, this will save Sparrow from scanning the entire blockchain for balances.

You should also set the “Gap Limit” to 500 (assuming this is how many addresses you want to generate). This setting also controls how far through the sequential address list Sparrow should look for transactions, which is important since you’ll be using your addresses in a random order. For example, you want Sparrow to check the 250th address even if the first 249 are empty.

Close the modal, then hit “Apply”. Sparrow will prompt you to choose a password to secure your watch-only wallet.

Your wallet is now ready! Head to the “Addresses” tab, and you’ll see a long list of ready-to-use public addresses. Click on the little blue arrow next to “Receive Addresses” to export a CSV file of addresses in bulk.

Generating receive addresses using Sparrow wallet.

You now have 500 fresh addresses on which to receive Bitcoin. These are uniquely associated with your wallet, and can be checked with any wallet software or blockchain explorer. To verify this, you can head back to whatever wallet software you used before Sparrow and generate a couple of new public addresses - these should match the first addresses in your CSV export.

3.2 Create a randomized address pool

Next we need a practical way to show 1 address per visitor.

Storing all addresses in your frontend code and using JavaScript to select one at random would completely defeat the purpose; all your addresses would be discoverable with minimal effort. Incidentally, including your XPUB anywhere in your frontend would be even worse, for the same reason.

I’ll show you how to host all 500 addresses server-side with a simple endpoint returning one address seemingly at random. We’ll provide some resistance to brute force attacks by choosing the address to serve to a given client based on a semi-stable fingerprint.

Is this overkill? It’s true your coins aren’t at risk - but your privacy is. Adding a few lines of code to obfuscate your address pool costs nothing and makes it considerably harder for a determined snooper to surveil your activity.

import type { Context } from "@netlify/functions";
import { addresses } from "./addresses.js";
import { createHash } from "crypto";

function hashToInt(input: string) {
  const hash = createHash("sha256").update(input).digest();
  return hash.readUInt32BE(0);
}

export default async (req: Request, context: Context) => {
  try {
    const unusedAddresses = addresses.filter(addr => !addr.used);
    if (unusedAddresses.length < 1) {
      return new Response("I'm fresh out of Bitcoin addresses.", { status: 503 });
    }

    const ip = context.ip || "unknown";
    const city = context.geo.city || "unknown";
    const today = new Date().toISOString().slice(0, 10);
    const fingerprint = `${ip}|${city}|${today}`;
    const clientOffset = hashToInt(fingerprint) % unusedAddresses.length;

    const address = unusedAddresses[clientOffset].address;

    return new Response(JSON.stringify({ address }), {
      status: 200, headers: {
        "Access-Control-Allow-Origin": "*",
        "Content-Type": "application/json",
      },
    });
  } catch (error) {
    return new Response("Internal server error", { status: 500 });
  }
};

A note on brute-force resistance: I recommend you modify your implementation so it doesn’t match exactly what I’ve suggested above. You could change your address pool size, rotate through a daily windowed subset of addresses, or apply a per-day random salt into the hash of the client fingerprint. Anything to make it less predictable.

The example above is a Netlify function, which you’d save as index.mts in /netlify/functions/get-address/ within your project’s base directory. For Vercel it would be very similar except you’d save it as /app/api/get-address/route.ts. For Cloudflare Workers, I suggest following the Workers middleware tutorial.

The function expects your addresses to be available in addresses.js in the same directory, following this structure:

export const addresses = [
  {
    index: 0,
    address: "bc1qmf938srapfwva6hexnmtrpd77nns27et29n9dm",
    used: false
  },
  {
    index: 1,
    address: "bc1q3vhdg3gr4luvruzk42z9wtcevffy0ryw838xhe",
    used: false
  },
  {
    index: 2,
    address: "bc1q78xceyj6yel7a55hzurh6s84qvms8l59vgv2ag",
    used: false
  }
]

This structure lets us keep track of which addresses have already been used. You’ll manually set used: true for addresses that should no longer be served (more on that later).

To convert the CSV from Sparrow into this JSON format, you can adapt my simple utility script here. Just run it once, and you’re good to go.

3.3 Fetch an address from the browser

Time to write the frontend code. We need it to do 3 things:

  1. Call the endpoint to get a Bitcoin address
  2. Save the address to the browser’s localStorage for 24 hours (avoiding repeated calls to your endpoint)
  3. Render the saved address to a QR code and create a “Copy” button, using a fallback address if the endpoint failed

You can find a complete, working example - including the serverless function and frontend code - on my GitHub repo. Clone it, tweak it, deploy it. For the purposes of this guide, I’ll break it down into illustrative steps and omit some boilerplate.

First, we’ll write the script to call your endpoint:

// Cache config & fallback
const ADDRESS_KEY = "btc-address";
const TIMESTAMP_KEY = "btc-timestamp";
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
const FALLBACK_ADDRESS = "YOUR_FALLBACK_ADDRESS_HERE";

async function getBitcoinAddress() {

  // Check localStorage cache
  const storedAddress = localStorage.getItem(ADDRESS_KEY);
  const storedTimestamp = localStorage.getItem(TIMESTAMP_KEY);

  if (storedAddress && storedTimestamp) {
    const timestamp = parseInt(storedTimestamp);
    const now = Date.now();
    if (now - timestamp < CACHE_DURATION) {
      return storedAddress;
    }
  }

  // Fetch from server if no valid cache
  try {
    const response = await fetch("/.netlify/functions/get-address");
    if (!response.ok) { throw new Error("Failed to fetch Bitcoin address"); }

    const data = await response.json();
    localStorage.setItem(ADDRESS_KEY, data.address);
    localStorage.setItem(TIMESTAMP_KEY, Date.now().toString());
    return data.address;
  } catch (err) {
    console.error("Failed to fetch Bitcoin address", err);
    return FALLBACK_ADDRESS;
  }
}

Note: For your fallback address, I suggest using the address at index 0 in your addresses.js file and marking it as “used”.

Calling getBitcoinAddress() will check the browser cache and - if no valid address is found - query your endpoint to get a new one and then cache it. Alternatively if the endpoint fails, the function returns a fallback address.

Next, we need to use the address. I’ll assume you have these two elements on your page: a canvas for a QR code, and a copy button:

<canvas id="btc-qr"></canvas>
<button id="btc-btn">Copy Address</button>

Now we’ll write our main function. I’ve assumed you’ve loaded the qrcode npm package as a dependency.

const btcQr = document.getElementById("btc-qr");
const btcBtn = document.getElementById("btc-btn");

async function initPayments() {
  const address = await getBitcoinAddress();

  // Render QR code
  QRCode.toCanvas(
    btcQr, `bitcoin:${address}`,
    { width: 200, margin: 1 },
    function (error) {
      if (error) { console.error("QR code error:", error); }
    }
  );

  // Initialise copy button
  btcBtn.addEventListener("click", async () => {
    try {
      await navigator.clipboard.writeText(address);
      btcBtn.textContent = "Copied!";
      setTimeout(() => {
        btcBtn.textContent = "Copy Address";
      }, 2000);
    } catch (err) {
      btcBtn.textContent = "Failed to copy";
      setTimeout(() => {
        btcBtn.textContent = "Copy Address";
      }, 2000);
    }
  });
}

Calling initPayments() will initialise everything. Once you’ve given it some styling, it should work something like this (again, ignore my Lightning tab):

Loading...

Loading...

If you’re wondering how I got the Bitcoin logo into the centre of the QRCode, you can check out the GitHub repository - there I use a different QR library which allows embedded images.

Congratulations! You’re done. Now you can wait to receive your first Bitcoin payment. This brings me nicely onto maintenance and next steps…

4. Maintenance & enhancements

There are dozens of ways you could extend or improve this solution. Rather than make this tutorial even longer, I’m going to summarise a few such opportunities below, along with some general advice on maintenance.

4.1 How to check your balances

Easy - simply open Sparrow and wait for it to scan all your addresses. Assuming you’ve set the gap limit appropriately, any payments received will appear in your list of transactions.

This is a good opportunity to reiterate the benefits of running your own Bitcoin node. By doing so, you can connect Sparrow to your private Electrum server and check the balance of your addresses - and even broadcast transactions - without any reliance on third parties that might compromise your privacy.

Finished Bitcoin node, built with a Raspberry PI 5.
Finished Bitcoin node, built with a Raspberry PI 5.

4.2 How to retire used addresses

We established earlier that reusing addresses is not great for privacy. This means that when you receive a payment on one of your addresses, you should remove it from your address pool by marking it as used: true in addresses.js. There’s no immediate rush to do this - unless you’re a high traffic site receiving frequent payments, it’s unlikely that two visitors who both want to make a payment are given the same address.

Bear in mind, though, that this is a manual step. And so is checking your balance with Sparrow. How could this be made easier?

You could write a script which periodically checks addresses for transactions and marks them as used. This would require some kind of persistent storage (because Netlify/Vercel functions are stateless). A simple key-value store would be sufficient - any used addresses could then be removed from circulation automatically.

If enough people find this tutorial useful, I’ll expand it to include a solution for key-value storage and automated address checking. So please let me know if you like it, either by telling me, pinging me some sats, or by subscribing to my email list:

5. Closing thoughts

This approach isn’t for everyone - and that’s the point.

If you need invoices, CMS plugins, or a full point-of-sale app, BTCPay Server is an incredible tool. But if you’re an indie dev, blogger, or builder who’s just getting started with Bitcoin - and you want to avoid middlemen and compromised privacy - this is for you. I believe there’s a niche of independent technical creators who could benefit from a lightweight, free Bitcoin payments solution such as this.

I care deeply about seeing Bitcoin thrive as both a sovereign, peer-to-peer means of exchange and a long-term store of value. I want to do my bit to help that future along.

If this solution works for you, please let me know! I’ll then put more effort into developing this tutorial and the accompanying repository.

Thanks for reading! Happy stacking.

Guide last updated: July 27th, 2025