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
- Log in to app.coinsnap.io
- Go to Settings → Store
- Scroll to the API Keys section, enter a label, and click Create API key
- 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:
COINSNAP_API_KEY=cs_live_xxxxxxxxxxxxxxxxxxxx
COINSNAP_STORE_ID=7CVKXVxM7BtbkMEie8yoNeR8EetExpQhJUYEFY3ftfwR
COINSNAP_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx
APP_URL=https://yoursite.com
| Variable | What it is |
|---|---|
COINSNAP_API_KEY | Authenticates your requests to the Coinsnap API |
COINSNAP_STORE_ID | Identifies which of your Coinsnap stores to use |
COINSNAP_WEBHOOK_SECRET | Used to verify webhook signatures — you'll get this in Step 3 |
APP_URL | Your 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.
- Node.js
- PHP
Install dotenv to load the .env file:
npm install dotenv
At the top of your main server file:
import 'dotenv/config';
PHP reads environment variables with getenv(). You can set them in your .env file and load them with a library like vlucas/phpdotenv:
composer require vlucas/phpdotenv
<?php
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
Or set them directly in your server configuration (Apache/Nginx) or hosting panel.
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.
- Node.js
- PHP
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;
<?php
$storeId = getenv('COINSNAP_STORE_ID');
$apiKey = getenv('COINSNAP_API_KEY');
$ch = curl_init("https://app.coinsnap.io/api/v1/stores/{$storeId}/invoices");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => $amount,
'currency' => $currency,
'orderId' => $orderId,
'buyerEmail' => $email,
'redirectUrl' => getenv('APP_URL') . '/orders/' . $orderId . '/success',
]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
http_response_code(502);
exit(json_encode(['error' => $body]));
}
// Save the invoice ID so you can match it when the webhook arrives
$db->prepare('UPDATE orders SET invoice_id = ? WHERE id = ?')
->execute([$body['id'], $orderId]);
// Send customer to the Coinsnap payment page
header('Location: ' . $body['checkoutLink']);
exit;
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
| Field | Required | Description |
|---|---|---|
amount | Yes | Order total in your fiat currency (e.g. 49.99) |
currency | Yes | 3-letter currency code (e.g. EUR, USD, GBP) |
orderId | Yes | Your internal order ID — Coinsnap will include this in webhook events so you can identify which order was paid |
buyerEmail | No | Customer's email — stored on the invoice and included in webhook payloads so your server can send a confirmation |
redirectUrl | No | Where 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
redirectUrlwhen 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.
- Go to Coinsnap Dashboard → Webhooks
- Click Add Webhook
- Enter your webhook URL:
https://yoursite.com/webhooks/coinsnap - Select events — enable at minimum: Settled
- Click Save — Coinsnap shows you a Signing Secret
- Copy the Signing Secret and save it as
COINSNAP_WEBHOOK_SECRETin 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:
- Verify the signature (confirm the request is genuinely from Coinsnap)
- Read the event type
- Take the appropriate action
- Node.js
- PHP
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.
<?php
// Read the raw request body
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_COINSNAP_SIG'] ?? '';
$secret = getenv('COINSNAP_WEBHOOK_SECRET');
if (!hash_equals('sha256=' . hash_hmac('sha256', $payload, $secret), $signature)) {
http_response_code(401);
exit('Unauthorized');
}
$event = json_decode($payload, true);
$orderId = $event['metadata']['orderId'] ?? null;
switch ($event['type']) {
case 'Settled':
// Payment confirmed — fulfill the order
$db->prepare('UPDATE orders SET status = ?, paid_at = NOW() WHERE invoice_id = ?')
->execute(['paid', $event['invoiceId']]);
break;
case 'Expired':
// Customer didn't pay in time — release any reserved stock
$db->prepare('UPDATE orders SET status = ? WHERE invoice_id = ?')
->execute(['expired', $event['invoiceId']]);
break;
}
// Always respond 200
http_response_code(200);
echo 'OK';
Events you need to handle
| Event | What happened | What to do |
|---|---|---|
Settled | Payment confirmed | Mark order as paid, trigger fulfillment |
Expired | Customer didn't pay in time | Cancel/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:
- Create a test order on your site and click "Pay with Bitcoin"
- Verify you're redirected to a Coinsnap checkout page showing the correct amount
- Pay the invoice using a Lightning wallet (e.g. Phoenix, Breez, Muun)
- Check your server logs — you should see the webhook arrive
- Verify the order status changed to
paidin 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
- Checkout Flow — add expiry handling, iframe embedding, and a full order state machine
- Webhook Events — full payload schemas for all event types
- Signature Verification — deeper explanation with more language examples
- API Reference — all invoice fields and response structure