Skip to main content

Accept Bitcoin Payments

This guide walks you through building a complete Bitcoin payment flow from scratch. It assumes you have a backend server (Node.js or PHP) but no prior experience with payment APIs.


What you're building

Your customer clicks "Pay with Bitcoin" on your site. Your server asks Coinsnap to create a payment request, then sends the customer to a Coinsnap-hosted page where they scan a QR code with their Bitcoin wallet. Once paid, Coinsnap notifies your server and you fulfill the order.

Customer clicks "Pay"
→ Your server creates an invoice with the order amount
→ Customer is sent to the Coinsnap checkout page
→ Customer pays via Lightning or Bitcoin
→ Coinsnap notifies your server that payment is confirmed
→ Your server fulfills the order

Before you start — three concepts to understand

What is an invoice?

In Coinsnap, an invoice is a payment request for a specific amount. You create one for each order. Coinsnap converts the fiat amount (e.g. €49.99) to Bitcoin at the current rate and generates a QR code for your customer. The invoice expires after 15 minutes if unpaid.

What is a webhook?

A webhook is a notification that Coinsnap sends to your server when something happens — most importantly, when a payment is confirmed. It works like this: Coinsnap makes an HTTP POST request to a URL on your server, and your server processes the event.

This is how you reliably know a payment went through. You cannot rely on the customer being redirected back to your site — their browser tab might close, their internet might drop, or someone could try to fake the redirect. The webhook comes directly from Coinsnap's servers to yours.

What is signature verification?

When Coinsnap sends a webhook, it includes a signature — a code generated using a secret that only you and Coinsnap know. Your server verifies this code before trusting the request. This prevents anyone from sending fake "payment confirmed" messages to your server.


Setup

1. Get your credentials

  1. Log in to app.coinsnap.io
  2. Go to Settings → Store
  3. Scroll to the API Keys section, enter a label, and click Create API key
  4. Your Store ID is on the same page

2. Store credentials as environment variables

Never paste API keys directly into your code — if you commit the code to GitHub or share it, the key is exposed. Instead, store credentials in environment variables.

Create a .env file in your project root:

.env
COINSNAP_API_KEY=cs_live_xxxxxxxxxxxxxxxxxxxx
COINSNAP_STORE_ID=7CVKXVxM7BtbkMEie8yoNeR8EetExpQhJUYEFY3ftfwR
COINSNAP_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx
APP_URL=https://yoursite.com
VariableWhat it is
COINSNAP_API_KEYAuthenticates your requests to the Coinsnap API
COINSNAP_STORE_IDIdentifies which of your Coinsnap stores to use
COINSNAP_WEBHOOK_SECRETUsed to verify webhook signatures — you'll get this in Step 3
APP_URLYour site's base URL, used to build redirect links

Make sure .env is listed in your .gitignore so it's never committed to version control.

Install dotenv to load the .env file:

npm install dotenv

At the top of your main server file:

import 'dotenv/config';

Step 1 — Create an invoice

When a customer is ready to pay, your server calls the Coinsnap API to create an invoice. The API returns a checkoutLink — the URL of the Coinsnap-hosted payment page where your customer will pay.

routes/checkout.js
import express from 'express';
const router = express.Router();

router.post('/pay', async (req, res) => {
const { amount, currency, orderId, email } = req.body;

const response = await fetch(
`https://app.coinsnap.io/api/v1/stores/${process.env.COINSNAP_STORE_ID}/invoices`,
{
method: 'POST',
headers: {
'x-api-key': process.env.COINSNAP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount,
currency,
orderId,
buyerEmail: email,
redirectUrl: `${process.env.APP_URL}/orders/${orderId}/success`,
}),
}
);

if (!response.ok) {
const err = await response.json();
return res.status(502).json({ error: err });
}

const invoice = await response.json();

// Save the invoice ID so you can match it when the webhook arrives
await db.orders.update({ id: orderId }, { invoiceId: invoice.id });

// Send customer to the Coinsnap payment page
res.redirect(invoice.checkoutLink);
});

export default router;

What the API returns

The response from Coinsnap looks like this:

{
"id": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"status": "New",
"amount": 49.99,
"currency": "EUR",
"checkoutLink": "https://app.coinsnap.io/i/4Kz9mXpQ2rNvBtYwLs8cDf",
"lightningInvoice": "lnbc...",
"onchainAddress": "bc1q...",
"bip21": "bitcoin:bc1q...?amount=0.00050000",
"createdAt": 1705312800
}

The most important field is checkoutLink — send your customer there.

What fields to send

FieldRequiredDescription
amountYesOrder total in your fiat currency (e.g. 49.99)
currencyYes3-letter currency code (e.g. EUR, USD, GBP)
orderIdYesYour internal order ID — Coinsnap will include this in webhook events so you can identify which order was paid
buyerEmailNoCustomer's email — stored on the invoice and included in webhook payloads so your server can send a confirmation
redirectUrlNoWhere to send the customer after payment

Step 2 — What the customer sees

After you redirect the customer to checkoutLink, Coinsnap shows them a payment page that:

  • Displays the amount in Bitcoin (converted from your fiat currency at the current rate)
  • Shows a QR code they scan with their Bitcoin wallet
  • Offers both Lightning and Bitcoin payment options
  • Updates in real time when payment is detected
  • Redirects them to your redirectUrl when done

You don't need to build any of this — Coinsnap hosts it for you.

The redirect is not a payment confirmation. When the customer lands on your redirectUrl, it means they finished on the Coinsnap checkout page — not necessarily that they paid. Always use the webhook (Step 4) to confirm payment before fulfilling an order.


Step 3 — Register your webhook

A webhook is a URL on your server that Coinsnap will call when a payment event occurs. You need to register this URL in the Coinsnap dashboard.

  1. Go to Coinsnap Dashboard → Webhooks
  2. Click Add Webhook
  3. Enter your webhook URL: https://yoursite.com/webhooks/coinsnap
  4. Select events — enable at minimum: Settled
  5. Click Save — Coinsnap shows you a Signing Secret
  6. Copy the Signing Secret and save it as COINSNAP_WEBHOOK_SECRET in your .env

Testing locally? Your webhook URL must be publicly reachable. Use a tunnel to expose your local server:

# ngrok (free tier requires account)
ngrok http 3000

# cloudflared (free, no account needed)
npx cloudflared tunnel --url http://localhost:3000

Copy the generated HTTPS URL and use it as your webhook URL in the dashboard. Note: the URL changes every time you restart the tunnel.


Step 4 — Handle the webhook

When Coinsnap sends a webhook, your server needs to:

  1. Verify the signature (confirm the request is genuinely from Coinsnap)
  2. Read the event type
  3. Take the appropriate action
routes/webhooks.js
import express from 'express';
import crypto from 'crypto';
const router = express.Router();

// express.raw() gives us the body as a Buffer — required for signature verification
router.post('/coinsnap', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['x-coinsnap-sig'] ?? '';
const secret = process.env.COINSNAP_WEBHOOK_SECRET;

const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');

const sigBuf = Buffer.from(sig.padEnd(expected.length));
const expBuf = Buffer.from(expected);

if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('Unauthorized');
}

const event = JSON.parse(req.body);
const orderId = event.metadata?.orderId;

switch (event.type) {
case 'Settled':
// Payment confirmed — fulfill the order
await db.orders.update(
{ invoiceId: event.invoiceId },
{ status: 'paid', paidAt: new Date() }
);
break;

case 'Expired':
// Customer didn't pay in time — release any reserved stock
await db.orders.update(
{ invoiceId: event.invoiceId },
{ status: 'expired' }
);
break;
}

res.status(200).send('OK');
});

export default router;

Use express.raw(), not express.json() — parsing the body changes the bytes and breaks signature verification.

Events you need to handle

EventWhat happenedWhat to do
SettledPayment confirmedMark order as paid, trigger fulfillment
ExpiredCustomer didn't pay in timeCancel/expire the order, release reserved stock

See Webhook Events for the full list including Overpayment, Underpayment, and PaidLate.


Step 5 — Test your integration end to end

Before going live, verify the full flow works:

  1. Create a test order on your site and click "Pay with Bitcoin"
  2. Verify you're redirected to a Coinsnap checkout page showing the correct amount
  3. Pay the invoice using a Lightning wallet (e.g. Phoenix, Breez, Muun)
  4. Check your server logs — you should see the webhook arrive
  5. Verify the order status changed to paid in your database

If the webhook isn't arriving:

  • Make sure your webhook URL is registered in the Coinsnap dashboard
  • If testing locally, confirm your tunnel (ngrok or cloudflared) is running and the URL is up to date
  • Check that your server is responding 200 OK — look at the delivery log in Dashboard → Webhooks → [your webhook] → Deliveries

If signature verification is failing:

  • Confirm you copied the Signing Secret correctly (no extra spaces)
  • Make sure you're reading the raw body, not a parsed version

What's next