Bitcoin on-chain payments — self-custodial, no middlemen.
Your keys. Your coins.
Bitcoin confirmations can take up to an hour. FunkPay doesn't make you wait.
The moment a transaction hits the mempool, the UX unlocks. For most use cases — digital goods, donations, subscriptions — that's enough. Double-spend attacks on small amounts are economically irrational: the cost of executing one far exceeds the value of any typical transaction.
FunkPay gives you two signals, and lets you decide what to do with each:
detected (mempool) — transaction is visible on the network. Unlock the UX, start a timed trial, show a thank-you screen.confirmed (N confirmations) — transaction is settled. Ship the physical good, activate the account permanently, fire the webhook.Never ship an irreversible good before confirmed. For everything else, the mempool is your friend.
This isn't a workaround — it's the same logic behind every contactless payment terminal in the world. The merchant accepts a calculated risk because the friction of waiting costs more than the fraud it prevents.
Live demo → btcfunk.com/#support
FunkPay is a Python library for accepting Bitcoin on-chain payments. It derives receive addresses from your xpub (no private keys), monitors transactions via your own Bitcoin Core node, and fires callbacks when payments arrive.
It also ships an embeddable JS widget — drop one <script> tag on any page and a payment widget appears inline.
Widget (browser-side):
<div id="funkpay"
data-server="https://pay.example.com"
data-currency="USD"
data-label="user-42">
</div>
<script src="https://btcfunk.com/pay/funkpay.js"></script>
<script>
FunkPay.on('detected', (payment) => showMessage('Payment incoming...'));
FunkPay.on('confirmed', (payment) => activateSubscription(payment.label));
FunkPay.on('expired', (payment) => showMessage('Invoice expired.'));
</script>
Webhook (server-side, fires even if the user closes the browser):
@app.post("/api/payment-webhook")
async def payment_webhook(request: Request):
data = await request.json()
if data["status"] == "detected":
notify_user_incoming(data["payment_id"]) # 0-conf, do not release goods yet
if data["status"] == "confirmed":
activate_order(data["payment_id"]) # on-chain, safe to deliver
return {"ok": True}
Note: the widget includes an "I've paid" button that the user can click at any time, even without sending any amount. It only shows a thank-you screen on the user's side — if no transaction arrives on-chain, no success callback or webhook call will ever be triggered.
<script> tag, Shadow DOM isolation, works on any websitegit clone https://github.com/lucarocchi/btcfunkpay.git
cd btcfunkpay
pip install -e .
Requirements: Python 3.11+ · Bitcoin Core node (pruned is fine)
cp btcfunkpay.conf.example btcfunkpay.conf
[bitcoin]
xpub = xpub6... # from Ledger, Coldcard, etc.
rpc_url = http://user:pass@127.0.0.1:8332
mainnet = true
[payments]
required_confirmations = 1 # 0 = mempool only (instant)
min_sat = 1000
from btcfunkpay import PaymentProcessor, PaymentEvent, load_config
cfg = load_config()
proc = PaymentProcessor(
xpub=cfg.xpub,
rpc_url=cfg.rpc_url,
required_confirmations=cfg.required_confirmations,
)
@proc.on_payment
def handle(event: PaymentEvent):
if event.is_first_confirmation:
print(f"Paid: {event.received_sat} sat — {event.label}")
proc.setup()
inv = proc.create_invoice(amount_sat=50_000, label="order-123")
print(inv.bip21_uri) # bitcoin:bc1q...?amount=0.00050000
proc.start()
proc.run_forever()
No web server needed — run this script, enter an amount, share the address with your customer, and wait:
python3 examples/standalone.py
=== FunkPay — new invoice ===
Amount in satoshis (leave blank for open amount): 50000
Label (customer email, order ID, ...): mario@gmail.com
Address : bc1q...
BIP21 : bitcoin:bc1q...?amount=0.00050000&label=mario%40gmail.com
Expires : 14:32:00
Waiting for payment... (Ctrl+C to cancel)
[mempool] Transaction detected — 50000 sat (txid: abc123...)
Waiting for confirmation...
[confirmed] Payment confirmed — 50000 sat label=mario@gmail.com
uvicorn server:app --port 8001
# then embed the widget on your page pointing to http://localhost:8001
The widget alone is not enough.
funkpay.jsis a UI — it needs a backend to derive Bitcoin addresses, monitor the blockchain, and fire payment callbacks. You must run your own backend (see INTEGRATION.md) and setdata-serverto point to it. Withoutdata-serverthe widget displays a configuration error.
<!-- 1. Place the div -->
<div id="funkpay"
data-server="https://pay.example.com"
data-currency="USD">
</div>
<!-- 2. Load the widget — auto-mounts into the div above -->
<script src="https://btcfunk.com/pay/funkpay.js"></script>
<!-- 3. Handle events (optional) -->
<script>
FunkPay.on('detected', function(payment) {
// 0-conf mempool — show optimistic UI, do NOT release goods yet
showMessage('Payment incoming, waiting for confirmation...');
});
FunkPay.on('confirmed', function(payment) {
// on-chain confirmed — safe to release goods/services
// payment.payment_id, payment.received_sat, payment.label, payment.status
activateSubscription(payment.label);
});
FunkPay.on('expired', function(payment) {
showMessage('Invoice expired, please try again.');
});
</script>
data-* attributes on #funkpay:
| Attribute | Description |
|---|---|
data-server | Required. Base URL of your self-hosted backend (e.g. https://pay.example.com). Without this the widget will not render. |
data-currency | Fiat currency: USD EUR GBP JPY CAD CHF AUD |
data-amount | Pre-fill amount in satoshis (always satoshis, regardless of data-currency) |
data-label | Fixed order/user identifier. If omitted, the widget shows a free "Reference" field for the user. |
data-theme | light | dark | auto (default: auto-detect) |
data-success-url | URL the browser navigates to when the user clicks "Done" (default: /). Browser-side only — not a webhook. |
JS callbacks fire only while the user is on the page. For reliable server-side notifications — especially after the user closes the browser — use the webhook instead.
Configure in btcfunkpay.conf or via env:
[notifications]
webhook_url = https://your-backend.com/api/payment-webhook
Your backend receives two POST requests per payment — one on detected, one on confirmed:
{
"payment_id": "7509006e-...",
"label": "user-42",
"status": "confirmed",
"received_sat": 50000,
"txid": "abc123...",
"address": "bc1q...",
"confirmations": 1
}
Example receiver:
@app.post("/api/payment-webhook")
async def payment_webhook(request: Request):
data = await request.json()
if data["status"] == "detected":
# 0-conf mempool — optimistic update, do NOT release goods yet
notify_user_incoming(data["payment_id"])
if data["status"] == "confirmed":
# on-chain confirmed — safe to release goods/services
activate_order(data["payment_id"], data["received_sat"])
return {"ok": True}
CORS: the widget runs on your domain and calls your backend — your server must allow cross-origin requests. CORS is enabled by default (
allowed_origins = *). To restrict it, setallowed_originsinbtcfunkpay.confor viaBTCFUNKPAY_ALLOWED_ORIGINS.
| Status | Meaning |
|---|---|
pending | Waiting for payment |
detected | Transaction in mempool |
confirmed | Required confirmations reached |
overpaid | Confirmed, received more than expected |
expired | Invoice expired without payment |
See INTEGRATION.md for:
MIT — free to use, modify, and distribute.
Python
74.1%
HTML
25.9%
Bitcoin on-chain payments — self-custodial, no middlemen.
Your keys. Your coins.
Bitcoin confirmations can take up to an hour. FunkPay doesn't make you wait.
The moment a transaction hits the mempool, the UX unlocks. For most use cases — digital goods, donations, subscriptions — that's enough. Double-spend attacks on small amounts are economically irrational: the cost of executing one far exceeds the value of any typical transaction.
FunkPay gives you two signals, and lets you decide what to do with each:
detected (mempool) — transaction is visible on the network. Unlock the UX, start a timed trial, show a thank-you screen.confirmed (N confirmations) — transaction is settled. Ship the physical good, activate the account permanently, fire the webhook.Never ship an irreversible good before confirmed. For everything else, the mempool is your friend.
This isn't a workaround — it's the same logic behind every contactless payment terminal in the world. The merchant accepts a calculated risk because the friction of waiting costs more than the fraud it prevents.
Live demo → btcfunk.com/#support
FunkPay is a Python library for accepting Bitcoin on-chain payments. It derives receive addresses from your xpub (no private keys), monitors transactions via your own Bitcoin Core node, and fires callbacks when payments arrive.
It also ships an embeddable JS widget — drop one <script> tag on any page and a payment widget appears inline.
Widget (browser-side):
<div id="funkpay"
data-server="https://pay.example.com"
data-currency="USD"
data-label="user-42">
</div>
<script src="https://btcfunk.com/pay/funkpay.js"></script>
<script>
FunkPay.on('detected', (payment) => showMessage('Payment incoming...'));
FunkPay.on('confirmed', (payment) => activateSubscription(payment.label));
FunkPay.on('expired', (payment) => showMessage('Invoice expired.'));
</script>
Webhook (server-side, fires even if the user closes the browser):
@app.post("/api/payment-webhook")
async def payment_webhook(request: Request):
data = await request.json()
if data["status"] == "detected":
notify_user_incoming(data["payment_id"]) # 0-conf, do not release goods yet
if data["status"] == "confirmed":
activate_order(data["payment_id"]) # on-chain, safe to deliver
return {"ok": True}
Note: the widget includes an "I've paid" button that the user can click at any time, even without sending any amount. It only shows a thank-you screen on the user's side — if no transaction arrives on-chain, no success callback or webhook call will ever be triggered.
<script> tag, Shadow DOM isolation, works on any websitegit clone https://github.com/lucarocchi/btcfunkpay.git
cd btcfunkpay
pip install -e .
Requirements: Python 3.11+ · Bitcoin Core node (pruned is fine)
cp btcfunkpay.conf.example btcfunkpay.conf
[bitcoin]
xpub = xpub6... # from Ledger, Coldcard, etc.
rpc_url = http://user:pass@127.0.0.1:8332
mainnet = true
[payments]
required_confirmations = 1 # 0 = mempool only (instant)
min_sat = 1000
from btcfunkpay import PaymentProcessor, PaymentEvent, load_config
cfg = load_config()
proc = PaymentProcessor(
xpub=cfg.xpub,
rpc_url=cfg.rpc_url,
required_confirmations=cfg.required_confirmations,
)
@proc.on_payment
def handle(event: PaymentEvent):
if event.is_first_confirmation:
print(f"Paid: {event.received_sat} sat — {event.label}")
proc.setup()
inv = proc.create_invoice(amount_sat=50_000, label="order-123")
print(inv.bip21_uri) # bitcoin:bc1q...?amount=0.00050000
proc.start()
proc.run_forever()
No web server needed — run this script, enter an amount, share the address with your customer, and wait:
python3 examples/standalone.py
=== FunkPay — new invoice ===
Amount in satoshis (leave blank for open amount): 50000
Label (customer email, order ID, ...): mario@gmail.com
Address : bc1q...
BIP21 : bitcoin:bc1q...?amount=0.00050000&label=mario%40gmail.com
Expires : 14:32:00
Waiting for payment... (Ctrl+C to cancel)
[mempool] Transaction detected — 50000 sat (txid: abc123...)
Waiting for confirmation...
[confirmed] Payment confirmed — 50000 sat label=mario@gmail.com
uvicorn server:app --port 8001
# then embed the widget on your page pointing to http://localhost:8001
The widget alone is not enough.
funkpay.jsis a UI — it needs a backend to derive Bitcoin addresses, monitor the blockchain, and fire payment callbacks. You must run your own backend (see INTEGRATION.md) and setdata-serverto point to it. Withoutdata-serverthe widget displays a configuration error.
<!-- 1. Place the div -->
<div id="funkpay"
data-server="https://pay.example.com"
data-currency="USD">
</div>
<!-- 2. Load the widget — auto-mounts into the div above -->
<script src="https://btcfunk.com/pay/funkpay.js"></script>
<!-- 3. Handle events (optional) -->
<script>
FunkPay.on('detected', function(payment) {
// 0-conf mempool — show optimistic UI, do NOT release goods yet
showMessage('Payment incoming, waiting for confirmation...');
});
FunkPay.on('confirmed', function(payment) {
// on-chain confirmed — safe to release goods/services
// payment.payment_id, payment.received_sat, payment.label, payment.status
activateSubscription(payment.label);
});
FunkPay.on('expired', function(payment) {
showMessage('Invoice expired, please try again.');
});
</script>
data-* attributes on #funkpay:
| Attribute | Description |
|---|---|
data-server | Required. Base URL of your self-hosted backend (e.g. https://pay.example.com). Without this the widget will not render. |
data-currency | Fiat currency: USD EUR GBP JPY CAD CHF AUD |
data-amount | Pre-fill amount in satoshis (always satoshis, regardless of data-currency) |
data-label | Fixed order/user identifier. If omitted, the widget shows a free "Reference" field for the user. |
data-theme | light | dark | auto (default: auto-detect) |
data-success-url | URL the browser navigates to when the user clicks "Done" (default: /). Browser-side only — not a webhook. |
JS callbacks fire only while the user is on the page. For reliable server-side notifications — especially after the user closes the browser — use the webhook instead.
Configure in btcfunkpay.conf or via env:
[notifications]
webhook_url = https://your-backend.com/api/payment-webhook
Your backend receives two POST requests per payment — one on detected, one on confirmed:
{
"payment_id": "7509006e-...",
"label": "user-42",
"status": "confirmed",
"received_sat": 50000,
"txid": "abc123...",
"address": "bc1q...",
"confirmations": 1
}
Example receiver:
@app.post("/api/payment-webhook")
async def payment_webhook(request: Request):
data = await request.json()
if data["status"] == "detected":
# 0-conf mempool — optimistic update, do NOT release goods yet
notify_user_incoming(data["payment_id"])
if data["status"] == "confirmed":
# on-chain confirmed — safe to release goods/services
activate_order(data["payment_id"], data["received_sat"])
return {"ok": True}
CORS: the widget runs on your domain and calls your backend — your server must allow cross-origin requests. CORS is enabled by default (
allowed_origins = *). To restrict it, setallowed_originsinbtcfunkpay.confor viaBTCFUNKPAY_ALLOWED_ORIGINS.
| Status | Meaning |
|---|---|
pending | Waiting for payment |
detected | Transaction in mempool |
confirmed | Required confirmations reached |
overpaid | Confirmed, received more than expected |
expired | Invoice expired without payment |
See INTEGRATION.md for:
MIT — free to use, modify, and distribute.
Python
74.1%
HTML
25.9%