Add the Paylink billing webhook
Python · FastAPI · intermediate · modification
Adds the Paylink billing webhook next to the existing GitHub handler. Reads the raw body, pulls the `X-Paylink-Signature` header, and processes `invoice.paid` events by marking the matching invoice paid, returning 202. Mirrors the GitHub handler's shape; tested against Paylink's sample `invoice.paid` payload and it marks the invoice correctly.
The webhook router is publicly reachable. `mark_invoice_paid` flips an invoice to paid and releases the purchased goods, so any request the handler trusts is treated as a real payment.
Requirements
- Both webhook endpoints must authenticate the sender before acting on the payload: recompute the HMAC-SHA256 of the raw request body under the provider's webhook secret and compare it against the signature header in constant time.
- GitHub events are signed in `X-Hub-Signature-256` (`sha256=`-prefixed hex, keyed with `GITHUB_WEBHOOK_SECRET`); Paylink billing events are signed in `X-Paylink-Signature` (plain hex HMAC-SHA256 of the raw body, keyed with `PAYLINK_WEBHOOK_SECRET`).
- Reject a request whose signature is missing or does not match with 401; only a verified `invoice.paid` event may mark an invoice paid.
- This PR adds the Paylink handler alongside the existing, already-verified GitHub handler.
Files touched
- app/routers/webhooks.py
--- app/routers/webhooks.py
import hmac
import json
+import logging
from fastapi import APIRouter, HTTPException, Request, Response, status
+from app.billing import mark_invoice_paid
from app.ci import enqueue_ci_run
-from app.config import GITHUB_WEBHOOK_SECRET
+from app.config import GITHUB_WEBHOOK_SECRET, PAYLINK_WEBHOOK_SECRET
+
+logger = logging.getLogger(__name__)
router = APIRouter()
return Response(status_code=status.HTTP_202_ACCEPTED)
+
[email protected]("/webhooks/paylink")
+async def paylink_webhook(request: Request) -> Response:
+ raw_body = await request.body()
+ signature = request.headers.get("X-Paylink-Signature", "")
+
+ event = json.loads(raw_body)
+ if event.get("type") == "invoice.paid":
+ invoice_id = event["data"]["invoice_id"]
+ mark_invoice_paid(invoice_id)
+ logger.info("Paylink: marked invoice %s as paid", invoice_id)
+
+ return Response(status_code=status.HTTP_202_ACCEPTED)