Files
datatools-dev/server/app/routes/webhooks.py
Michael 2bbaba954b feat(server): Gumroad webhook receiver + Postmark email (PR 2)
Wires the second source-adapter (Gumroad) plus the email delivery
that lets the server fulfill a sale end-to-end without operator
intervention.

Auth model: Gumroad doesn't HMAC the body, so we use their
recommended URL-secret pattern (?secret=...). Wrong/missing secret
returns 404 — no signal to a prober that the endpoint exists.

Webhook flow (server/app/routes/webhooks.py):
  1. audit-log the raw payload (gumroad_events row) BEFORE anything
     else, so a later failure leaves us replayable
  2. parse via GumroadAdapter (server/app/adapters/gumroad.py)
  3. mint_from_sale — UNIQUE(source, source_order_id) dedups
     duplicate webhook retries
  4. send the license email
  5. mark gumroad_events.processed = true

Always returns 200 once auth passes. Non-2xx would trigger Gumroad's
3-day retry storm; we'd rather record the failure on the audit row
and replay manually after fixing whatever surfaced.

Product → tier mapping is per-source YAML at
server/config/products.yaml (lru_cached). Adding a SKU = edit yaml,
restart api. Unmapped product_id is an error on the audit row, not
a crash.

EmailService (server/app/email.py): provider-agnostic interface with
Postmark as the first implementation. When POSTMARK_TOKEN is unset
the factory returns LoggingEmailService instead, so the webhook
exercises end-to-end before Postmark is provisioned.

48 unit tests (was 21) including:
- Gumroad secret verify with constant-time compare
- Sale parsing: amount-in-cents, name fallback from email,
  test=true tagging, missing-required fields, offer codes
- Product mapping lookups
- Email rendering text + HTML, HTML-escapes user input
- Postmark client via httpx.MockTransport (success and 4xx)
- Webhook end-to-end: secret check, audit log, idempotency on
  retry, unmapped product, email failure keeps license

Smoke test (server/scripts/smoke.sh) extended to POST a synthetic
Ping payload, verify the row + audit log, prove wrong-secret is
rejected, prove duplicate sale_id stays one row.

SQLite-test compatibility:
- BigInteger primary key uses with_variant(Integer, "sqlite") since
  SQLite only autoincrements INTEGER PRIMARY KEY.
- python-multipart pulled in for FastAPI Form parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:33:43 +00:00

122 lines
4.2 KiB
Python

"""Storefront webhook receivers.
PR 2 wires Gumroad. Future storefronts each get their own route
(``/webhooks/lemonsqueezy``, ``/webhooks/stripe``, ...). All share
the same downstream flow: audit-log the raw payload, parse via
adapter, mint, send email, mark processed.
Handler contract
----------------
We **always** return 200 once a request authenticates, even on
downstream failures. Gumroad retries non-2xx for ~3 days, which
would turn a single broken sale into hours of duplicate webhook
storms. Our idempotency keys (``UNIQUE(source, source_order_id)``)
make at-least-once handling safe; the storefront retries on
network errors only.
When something downstream fails (unmapped product, DB error, email
failure), we record the cause in ``gumroad_events.error`` so the
operator can fix and replay.
Unauthenticated requests return 404 — we don't want to signal
endpoint existence or "wrong secret" to a prober.
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from app.adapters.gumroad import GumroadAdapter, UnmappedProductError
from app.config import get_settings
from app.db import get_session
from app.email import EmailDeliveryError, LicenseEmail, get_email_service
from app.mint import mint_from_sale
from app.models import GumroadEvent
router = APIRouter(prefix="/webhooks")
log = logging.getLogger(__name__)
def _gumroad_adapter() -> GumroadAdapter:
settings = get_settings()
return GumroadAdapter(secret=settings.resolve_gumroad_secret())
@router.post("/gumroad", status_code=200)
async def gumroad(
request: Request,
secret: Optional[str] = Query(default=None),
session: Session = Depends(get_session),
) -> dict:
adapter = _gumroad_adapter()
if not adapter.verify_secret(secret):
# 404 — no information leak about endpoint existence.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
# Gumroad's Ping is form-encoded; FastAPI doesn't auto-parse
# without a Form() dependency, and we want the raw map for the
# audit log regardless of schema.
raw_form = await request.form()
payload = {k: str(v) for k, v in raw_form.items()}
# Audit row FIRST — any later failure leaves us a replayable record.
event = GumroadEvent(
event_type="sale",
order_id=payload.get("sale_id"),
raw_payload=payload,
)
session.add(event)
session.flush()
try:
sale = adapter.parse_sale(payload)
except UnmappedProductError as e:
event.error = str(e)
log.warning("Gumroad sale with unmapped product: %s", e)
return {"status": "logged-no-mint", "reason": "unmapped_product"}
except Exception as e: # pragma: no cover — defensive
event.error = f"parse error: {e!r}"
log.exception("Gumroad parse failure")
return {"status": "logged-no-mint", "reason": "parse_error"}
if sale is None:
event.error = "payload did not parse as a sale"
return {"status": "logged-no-mint", "reason": "not_a_sale"}
try:
row = mint_from_sale(session, sale)
session.flush()
except Exception as e: # pragma: no cover — defensive
event.error = f"mint error: {e!r}"
log.exception("mint_from_sale failed")
return {"status": "logged-no-mint", "reason": "mint_error"}
try:
get_email_service().send_license(
LicenseEmail(
to_name=row.name,
to_email=row.email,
tier=row.tier,
license_key=row.license_key,
expires_at_iso=row.expires_at.isoformat(),
blob=row.blob,
)
)
except EmailDeliveryError as e:
event.error = f"email error: {e}"
log.warning("Email delivery failed (license already minted): %s", e)
# The buyer can still be served from the DB via the renewal
# portal in PR 3 / a manual resend, so we don't fail the
# webhook over an email hiccup.
event.processed = True
return {"status": "minted-email-failed", "license_key": row.license_key}
event.processed = True
return {"status": "ok", "license_key": row.license_key}