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:
parent
203bdd3567
commit
c2b5d23477
@ -9,7 +9,8 @@ CONTAINER_NAME=btcpay-mailer
|
|||||||
HOST_PORT=5000
|
HOST_PORT=5000
|
||||||
|
|
||||||
# Required secrets
|
# 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
|
BTCPAY_API_KEY=your_btcpay_api_key
|
||||||
POSTMARK_API_KEY=your_postmark_server_token
|
POSTMARK_API_KEY=your_postmark_server_token
|
||||||
|
|
||||||
@ -22,4 +23,4 @@ DEBUG=false
|
|||||||
# Test Postmark after deploy (docker exec or Portainer console):
|
# Test Postmark after deploy (docker exec or Portainer console):
|
||||||
# python app.py test-email you@example.com
|
# python app.py test-email you@example.com
|
||||||
# Or from host:
|
# 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
130
SETUP.md
Normal 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 BTCPay’s 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
|
||||||
79
WEBHOOK.md
79
WEBHOOK.md
@ -3,31 +3,36 @@
|
|||||||
## Endpoint
|
## 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`). BTCPay’s 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
|
## 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.
|
2. **Portainer** → Stacks → `btcpaymailer` → stop/remove the stack.
|
||||||
|
|
||||||
No emails are sent while the webhook is disabled or the stack is down.
|
|
||||||
|
|
||||||
## Event subscribed
|
## 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)
|
## Webhook body (BTCPay → mailer)
|
||||||
|
|
||||||
Typical payload:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"deliveryId": "abc123",
|
"deliveryId": "abc123",
|
||||||
"webhookId": "wh_xyz",
|
"webhookId": "wh_xyz",
|
||||||
"originalDeliveryId": "abc123",
|
|
||||||
"isRedelivery": false,
|
"isRedelivery": false,
|
||||||
"type": "InvoiceReceivedPayment",
|
"type": "InvoiceReceivedPayment",
|
||||||
"timestamp": 1717843200,
|
"timestamp": 1717843200,
|
||||||
@ -36,55 +41,33 @@ Typical payload:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Fields used by this app from the webhook:
|
The app then fetches the full invoice:
|
||||||
|
|
||||||
| 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:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET {BTCPAY_URL}/api/v1/stores/{storeId}/invoices/{invoiceId}
|
GET {BTCPAY_URL}/api/v1/stores/{storeId}/invoices/{invoiceId}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Invoice fields used (BTCPay API response)
|
## Send conditions
|
||||||
|
|
||||||
| Field | Use |
|
1. Valid `BTCPAY-SIG` signature
|
||||||
|-------|-----|
|
2. `type == InvoiceReceivedPayment`
|
||||||
| `amount` | Invoice total |
|
3. BTCPay API returns invoice
|
||||||
| `amountPaid` | Paid so far |
|
4. `status == "New"`
|
||||||
| `currency` | e.g. `USD`, `BTC` |
|
5. `0 < amountPaid < amount`
|
||||||
| `status` | Must be `New` for partial-payment email |
|
6. `metadata.buyerEmail` is set
|
||||||
| `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**.
|
|
||||||
|
|
||||||
## Response codes
|
## Response codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `401` | Wrong or missing `token` |
|
| `401` | Missing or invalid `BTCPAY-SIG` |
|
||||||
| `400` | Partial payment but no `buyerEmail` |
|
| `400` | Partial payment but no `buyerEmail` |
|
||||||
| `500` | BTCPay API or Postmark failure |
|
| `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
44
app.py
@ -1,3 +1,5 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -9,7 +11,8 @@ from flask import Flask, request, jsonify
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- 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_URL = os.getenv("BTCPAY_URL", "https://payment.nxtgroup.org")
|
||||||
BTCPAY_API_KEY = os.getenv("BTCPAY_API_KEY")
|
BTCPAY_API_KEY = os.getenv("BTCPAY_API_KEY")
|
||||||
POSTMARK_API_KEY = os.getenv("POSTMARK_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}")
|
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):
|
def get_html_template(order_id, invoice_id, amount, currency, amount_paid, remaining):
|
||||||
return f"""
|
return f"""
|
||||||
<div style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; background-color: #f4f5f7; padding: 40px 20px; color: #333333; line-height: 1.6;">
|
<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'])
|
@app.route('/btcpay-webhook', methods=['POST'])
|
||||||
def btcpay_webhook():
|
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')
|
event_type = data.get('type')
|
||||||
|
|
||||||
log_debug(
|
log_debug(
|
||||||
@ -179,11 +212,6 @@ def btcpay_webhook():
|
|||||||
payload=data,
|
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':
|
if event_type != 'InvoiceReceivedPayment':
|
||||||
log_debug("webhook ignored", reason="not InvoiceReceivedPayment", event_type=event_type)
|
log_debug("webhook ignored", reason="not InvoiceReceivedPayment", event_type=event_type)
|
||||||
return jsonify({"status": "ignored", "reason": "Not a payment event"}), 200
|
return jsonify({"status": "ignored", "reason": "Not a payment event"}), 200
|
||||||
@ -246,7 +274,7 @@ def btcpay_webhook():
|
|||||||
@app.route('/test-email', methods=['POST'])
|
@app.route('/test-email', methods=['POST'])
|
||||||
def test_email():
|
def test_email():
|
||||||
token = request.args.get('token')
|
token = request.args.get('token')
|
||||||
if token != WEBHOOK_SECRET:
|
if not TEST_TOKEN or token != TEST_TOKEN:
|
||||||
return jsonify({"error": "Unauthorized"}), 401
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
to_email = request.args.get('to') or request.json.get('to') if request.is_json else None
|
to_email = request.args.get('to') or request.json.get('to') if request.is_json else None
|
||||||
|
|||||||
@ -8,7 +8,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-5000}:5000"
|
- "${HOST_PORT:-5000}:5000"
|
||||||
environment:
|
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_URL: ${BTCPAY_URL:-https://payment.nxtgroup.org}
|
||||||
BTCPAY_API_KEY: ${BTCPAY_API_KEY:?Set BTCPAY_API_KEY in .env or environment}
|
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}
|
POSTMARK_API_KEY: ${POSTMARK_API_KEY:?Set POSTMARK_API_KEY in .env or environment}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user