Validate BTCPay webhooks with BTCPAY-SIG HMAC-SHA256

Replace URL token auth with the official webhook secret signature check.
Add SETUP.md and separate TEST_TOKEN for the Postmark test endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Erling 2026-06-08 12:20:00 +02:00
parent 203bdd3567
commit c2b5d23477
5 changed files with 202 additions and 59 deletions

View File

@ -9,7 +9,8 @@ CONTAINER_NAME=btcpay-mailer
HOST_PORT=5000
# Required secrets
WEBHOOK_SECRET=your_super_secret_token_123
BTCPAY_WEBHOOK_SECRET=secret_from_btcpay_webhook_settings
TEST_TOKEN=generate_with_openssl_rand_hex_32
BTCPAY_API_KEY=your_btcpay_api_key
POSTMARK_API_KEY=your_postmark_server_token
@ -22,4 +23,4 @@ DEBUG=false
# Test Postmark after deploy (docker exec or Portainer console):
# python app.py test-email you@example.com
# Or from host:
# curl -X POST "http://localhost:5000/test-email?token=YOUR_WEBHOOK_SECRET&to=you@example.com"
# curl -X POST "http://localhost:5000/test-email?token=YOUR_TEST_TOKEN&to=you@example.com"

130
SETUP.md Normal file
View File

@ -0,0 +1,130 @@
# btcpaymailer setup guide
## Overview
One mailer instance handles **multiple BTCPay stores** on the same server (`payment.nxtgroup.org`). Each store registers the same webhook URL; the app uses `storeId` from each payload to fetch the correct invoice.
## 1. Postmark
1. Copy **Server API token**`POSTMARK_API_KEY`
2. Verify sender → `FROM_EMAIL` (e.g. `billing@nxtgroup.org`)
3. Set `BCC_EMAIL` (comma-separated, optional)
## 2. BTCPay API key (all stores)
Use **one account-level API key** with `btcpay.store.canviewinvoices` on every store that will use the mailer.
1. BTCPay → **Account****API keys** → Create
2. Enable `canviewinvoices` for each store
3. Copy token → `BTCPAY_API_KEY`
Per-store-only keys are not supported unless you extend the app.
## 3. Portainer registry
**Registries → Add registry**
| Field | Value |
|-------|--------|
| URL | `git.nxtgroup.org` |
| Username | `erling` |
| Password | Gitea PAT (`read:package`) |
## 4. Deploy stack (Swarm + Traefik)
Wildcard DNS (`*.nxtgroup.org`) is enough — add a Traefik `Host()` label, no extra Cloudflare record required.
```yaml
version: '3.8'
services:
btcpay-mailer:
image: git.nxtgroup.org/erling/btcpaymailer:1.0.0
environment:
BTCPAY_WEBHOOK_SECRET: PASTE_BTCPAY_WEBHOOK_SECRET
TEST_TOKEN: PASTE_RANDOM_TEST_TOKEN
BTCPAY_URL: https://payment.nxtgroup.org
BTCPAY_API_KEY: PASTE_BTCPAY_API_KEY
POSTMARK_API_KEY: PASTE_POSTMARK_TOKEN
FROM_EMAIL: billing@nxtgroup.org
BCC_EMAIL: admin@nxtgroup.org
DEBUG: "true"
networks:
- public
deploy:
replicas: 1
placement:
constraints:
- node.role == manager
labels:
- traefik.enable=true
- traefik.http.routers.btcpay-mailer.rule=Host(`mailer.nxtgroup.org`)
- traefik.http.routers.btcpay-mailer.entrypoints=websecure
- traefik.http.routers.btcpay-mailer.tls.certresolver=cloudflare
- traefik.http.services.btcpay-mailer.loadbalancer.server.port=5000
restart_policy:
condition: on-failure
networks:
public:
external: true
```
Generate `TEST_TOKEN`:
```bash
openssl rand -hex 32
```
Set `DEBUG=false` after initial testing.
## 5. BTCPay webhook (per store)
Repeat for each store:
1. Store → **Settings****Webhooks** → **Create webhook**
2. **Payload URL:** `https://mailer.nxtgroup.org/btcpay-webhook` (no `?token=`)
3. **Secret:** generate or accept BTCPays secret → copy into Portainer as `BTCPAY_WEBHOOK_SECRET`
Use the **same secret** for all stores **or** one mailer per secret (current app supports one `BTCPAY_WEBHOOK_SECRET` only). Simplest: use the **same webhook secret** when creating each store webhook, or recreate webhooks with one shared secret.
4. **Events:** `InvoiceReceivedPayment` only
5. **Enabled:** on
6. Save
The Secret in BTCPay must **exactly match** `BTCPAY_WEBHOOK_SECRET` in Portainer.
## 6. Test Postmark
Portainer → container console:
```bash
python app.py test-email you@example.com
```
Or HTTP:
```bash
curl -X POST "https://mailer.nxtgroup.org/test-email?token=YOUR_TEST_TOKEN&to=you@example.com"
```
## 7. Test BTCPay webhook
1. Create a test invoice with **buyer email** in metadata
2. Pay **part** of the amount (invoice stays `New`, partial paid)
3. Check Portainer **Logs** for `[evaluation]` lines
4. BTCPay → Webhooks → **Deliveries** should show `200`
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| Webhook `401` | `BTCPAY_WEBHOOK_SECRET` ≠ BTCPay webhook Secret |
| Webhook `500` fetch invoice | `BTCPAY_API_KEY` lacks access to that `storeId` |
| `ignored` in logs | Full payment, wrong status, or not partial |
| No email, `400` | Invoice missing `metadata.buyerEmail` |
## Shutdown
- BTCPay: disable/delete webhooks
- Portainer: remove stack

View File

@ -3,31 +3,36 @@
## Endpoint
```
POST https://mailer.nxtgroup.org/btcpay-webhook?token=YOUR_WEBHOOK_SECRET
POST https://mailer.nxtgroup.org/btcpay-webhook
```
Auth is the `token` query parameter (must match `WEBHOOK_SECRET`). BTCPays optional webhook signing secret is not used by this app.
No query-string token. BTCPay signs each request with the **webhook secret** from the store webhook settings.
## Authentication
BTCPay sends header `BTCPAY-SIG`:
```
sha256=<hmac-sha256-hex>
```
The HMAC is computed over the **raw request body bytes** using the webhook **Secret** shown in BTCPay (same value as `BTCPAY_WEBHOOK_SECRET` in Portainer).
## Disable quickly
1. **BTCPay** → Store → Settings → **Webhooks** → delete or disable the webhook.
1. **BTCPay** → Store → **Webhooks** → delete or disable the webhook.
2. **Portainer** → Stacks → `btcpaymailer` → stop/remove the stack.
No emails are sent while the webhook is disabled or the stack is down.
## Event subscribed
Only **`InvoiceReceivedPayment`** is processed. All other event types return `200` with `"ignored"` and are silent unless `DEBUG=true`.
Only **`InvoiceReceivedPayment`** is processed. Other events return `200` with `"ignored"` (silent unless `DEBUG=true`).
## Webhook body (BTCPay → mailer)
Typical payload:
```json
{
"deliveryId": "abc123",
"webhookId": "wh_xyz",
"originalDeliveryId": "abc123",
"isRedelivery": false,
"type": "InvoiceReceivedPayment",
"timestamp": 1717843200,
@ -36,55 +41,33 @@ Typical payload:
}
```
Fields used by this app from the webhook:
| Field | Use |
|-------|-----|
| `type` | Must be `InvoiceReceivedPayment` |
| `storeId` | Fetch invoice from BTCPay API |
| `invoiceId` | Fetch invoice from BTCPay API |
The webhook does **not** include full payment amounts or buyer email. The app calls:
The app then fetches the full invoice:
```
GET {BTCPAY_URL}/api/v1/stores/{storeId}/invoices/{invoiceId}
```
## Invoice fields used (BTCPay API response)
## Send conditions
| Field | Use |
|-------|-----|
| `amount` | Invoice total |
| `amountPaid` | Paid so far |
| `currency` | e.g. `USD`, `BTC` |
| `status` | Must be `New` for partial-payment email |
| `metadata.buyerEmail` | Recipient (required to send) |
| `metadata.orderId` | Shown in email subject/body |
## Send conditions (all must pass)
1. `type == InvoiceReceivedPayment`
2. BTCPay API returns invoice successfully
3. `status == "New"`
4. `0 < amountPaid < amount` (partial payment, not zero or full)
5. `metadata.buyerEmail` is set
Otherwise the handler returns `200` with `"ignored"` or `400` if buyer email is missing on a qualifying partial payment.
## Logging
| `DEBUG` | What appears in container logs |
|---------|------------------------------|
| `false` (default) | Evaluation + send result for `InvoiceReceivedPayment` only |
| `true` | Every webhook, full payload, each decision step |
View logs in **Portainer → Containers → btcpay-mailer → Logs**.
1. Valid `BTCPAY-SIG` signature
2. `type == InvoiceReceivedPayment`
3. BTCPay API returns invoice
4. `status == "New"`
5. `0 < amountPaid < amount`
6. `metadata.buyerEmail` is set
## Response codes
| Code | Meaning |
|------|---------|
| `401` | Wrong or missing `token` |
| `401` | Missing or invalid `BTCPAY-SIG` |
| `400` | Partial payment but no `buyerEmail` |
| `500` | BTCPay API or Postmark failure |
| `200` | Ignored, or email sent successfully |
| `200` | Ignored, or email sent |
## Logging
| `DEBUG` | Container logs |
|---------|----------------|
| `false` | Evaluation + send result for payment events only |
| `true` | Full payload and every decision step |

44
app.py
View File

@ -1,3 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
@ -9,7 +11,8 @@ from flask import Flask, request, jsonify
app = Flask(__name__)
# --- Configuration ---
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your_secret_token_here")
BTCPAY_WEBHOOK_SECRET = os.getenv("BTCPAY_WEBHOOK_SECRET")
TEST_TOKEN = os.getenv("TEST_TOKEN")
BTCPAY_URL = os.getenv("BTCPAY_URL", "https://payment.nxtgroup.org")
BTCPAY_API_KEY = os.getenv("BTCPAY_API_KEY")
POSTMARK_API_KEY = os.getenv("POSTMARK_API_KEY")
@ -36,6 +39,24 @@ def log_evaluation(message, **fields):
logger.info(f"[evaluation] {message}{suffix}")
def get_btcpay_sig_header():
for key, value in request.headers.items():
if key.lower() == "btcpay-sig":
return value
return None
def verify_btcpay_webhook(body: bytes, signature: str | None, secret: str | None) -> bool:
if not body or not signature or not secret:
return False
expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
def get_html_template(order_id, invoice_id, amount, currency, amount_paid, remaining):
return f"""
<div style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; background-color: #f4f5f7; padding: 40px 20px; color: #333333; line-height: 1.6;">
@ -166,7 +187,19 @@ def evaluate_partial_payment(invoice_data, invoice_id):
@app.route('/btcpay-webhook', methods=['POST'])
def btcpay_webhook():
data = request.json or {}
raw_body = request.get_data()
signature = get_btcpay_sig_header()
if not verify_btcpay_webhook(raw_body, signature, BTCPAY_WEBHOOK_SECRET):
log_debug("webhook rejected", reason="invalid BTCPAY-SIG signature")
return jsonify({"error": "Unauthorized"}), 401
try:
data = json.loads(raw_body) if raw_body else {}
except json.JSONDecodeError:
log_debug("webhook rejected", reason="invalid JSON body")
return jsonify({"error": "Invalid JSON"}), 400
event_type = data.get('type')
log_debug(
@ -179,11 +212,6 @@ def btcpay_webhook():
payload=data,
)
token = request.args.get('token')
if token != WEBHOOK_SECRET:
log_debug("webhook rejected", reason="invalid token")
return jsonify({"error": "Unauthorized"}), 401
if event_type != 'InvoiceReceivedPayment':
log_debug("webhook ignored", reason="not InvoiceReceivedPayment", event_type=event_type)
return jsonify({"status": "ignored", "reason": "Not a payment event"}), 200
@ -246,7 +274,7 @@ def btcpay_webhook():
@app.route('/test-email', methods=['POST'])
def test_email():
token = request.args.get('token')
if token != WEBHOOK_SECRET:
if not TEST_TOKEN or token != TEST_TOKEN:
return jsonify({"error": "Unauthorized"}), 401
to_email = request.args.get('to') or request.json.get('to') if request.is_json else None

View File

@ -8,7 +8,8 @@ services:
ports:
- "${HOST_PORT:-5000}:5000"
environment:
WEBHOOK_SECRET: ${WEBHOOK_SECRET:?Set WEBHOOK_SECRET in .env or environment}
BTCPAY_WEBHOOK_SECRET: ${BTCPAY_WEBHOOK_SECRET:?Set BTCPAY_WEBHOOK_SECRET in .env or environment}
TEST_TOKEN: ${TEST_TOKEN:?Set TEST_TOKEN in .env or environment}
BTCPAY_URL: ${BTCPAY_URL:-https://payment.nxtgroup.org}
BTCPAY_API_KEY: ${BTCPAY_API_KEY:?Set BTCPAY_API_KEY in .env or environment}
POSTMARK_API_KEY: ${POSTMARK_API_KEY:?Set POSTMARK_API_KEY in .env or environment}