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>
122 lines
4.2 KiB
Python
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}
|