polar: build /api/polar/webhook handler
Standalone router for inbound Polar (merchant-of-record) deliveries.
No bearer-token dep — authenticity comes from the Standard Webhooks
HMAC instead. Wired up so it's safe to deploy dark: empty
POLAR_WEBHOOK_SECRET makes the endpoint return 503 (loud) rather than
accept unsigned events.
Behaviour
- Standard Webhooks signature verification: HMAC-SHA256 over
`{webhook-id}.{webhook-timestamp}.{body}`, base64 secret prefixed
whsec_, ±5min replay window, constant-time compare against any of
the space-separated v1 tokens.
- Idempotency via UNIQUE on polar_events.event_id — a replayed
webhook-id short-circuits to 200 "duplicate" without re-running.
- Event dispatch table covers the 10 events we subscribed to:
subscription.{created,active,updated,uncanceled} -> tier=paid +
persist polar_customer_id / polar_subscription_id.
subscription.revoked -> tier=free (customer id kept so a resub
matches the same User row).
canceled / past_due / order.* / refund.created -> audit only.
- Unknown event types are acked 200 + recorded; we don't want to 4xx
on something Polar adds in the future and trigger their retry loop.
Schema (migration 0018)
- users.polar_customer_id, users.polar_subscription_id (both nullable
String(64)); UNIQUE on polar_customer_id so two users can't claim
the same Polar identity.
- polar_events table: event_id (unique), event_type, received_at,
processed_at, error, raw payload (truncated to 16 KiB).
Tests
- 7 in tests/test_polar_webhook.py: bad signature -> 401, stale
timestamp -> 401, missing headers -> 400, subscription.active flips
tier to paid + stores IDs, subscription.revoked drops to free while
keeping customer link, replayed webhook-id is no-op, unknown event
is acked.
- Full suite: 212 passed, 5 skipped.
Operator next steps before saving the webhook in Polar
1. Pull this branch to prod and apply migration 0018.
2. Save the webhook in Polar pointing at
https://read.markets/api/polar/webhook — Polar will accept the
save even though our endpoint still 503s (no secret yet).
3. Copy the secret Polar reveals into the prod .env as
POLAR_WEBHOOK_SECRET=whsec_... and restart the app.
4. Trigger a test event from Polar's dashboard to confirm 200 OK.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2297f9b2ed
commit
6c13f855e9
6 changed files with 624 additions and 0 deletions
304
app/routers/polar_webhook.py
Normal file
304
app/routers/polar_webhook.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""Polar (merchant-of-record) webhook endpoint.
|
||||
|
||||
Polar uses the Standard Webhooks spec (https://www.standardwebhooks.com).
|
||||
Every delivery carries three headers:
|
||||
|
||||
webhook-id — unique ID for THIS delivery (use for idempotency).
|
||||
webhook-timestamp — Unix seconds at send time (use for replay defence).
|
||||
webhook-signature — space-separated list of `v1,<base64-hmac-sha256>`
|
||||
tokens. Verifying any one of them means the
|
||||
payload is authentic.
|
||||
|
||||
The signed content is the literal string `{id}.{timestamp}.{body}`, signed
|
||||
with the raw secret bytes (the secret is base64-encoded after the
|
||||
`whsec_` prefix). We verify in constant time and reject anything that
|
||||
doesn't match — including stale deliveries older than ±5 minutes — before
|
||||
parsing JSON or touching the database.
|
||||
|
||||
Idempotency is keyed on `webhook-id` via a unique constraint on
|
||||
`polar_events.event_id`. A second delivery of the same id finds the row
|
||||
already there and returns 200 without re-running the handler — Polar
|
||||
will retry on non-2xx, so we must always 2xx after a successful first
|
||||
processing.
|
||||
|
||||
The router is mounted without the app's bearer-token dependency: webhook
|
||||
authenticity is established via the HMAC, not the token."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import get_session, utcnow
|
||||
from app.logging import get_logger
|
||||
from app.models import PolarEvent, User
|
||||
|
||||
|
||||
log = get_logger("polar_webhook")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Max clock skew we'll tolerate on the `webhook-timestamp` header. Standard
|
||||
# Webhooks recommends ±5 min; anything older is almost certainly replay.
|
||||
_TIMESTAMP_TOLERANCE_S = 300
|
||||
# Cap stored payload at 16 KiB so a hostile (or buggy) sender can't blow
|
||||
# up a single row.
|
||||
_PAYLOAD_STORE_MAX = 16 * 1024
|
||||
|
||||
|
||||
def _decode_secret(secret: str) -> bytes:
|
||||
"""Polar/Standard-Webhooks secrets are base64 with a `whsec_` prefix.
|
||||
Returns the raw HMAC key. Raises ValueError on malformed input."""
|
||||
if not secret:
|
||||
raise ValueError("empty webhook secret")
|
||||
s = secret
|
||||
if s.startswith("whsec_"):
|
||||
s = s[len("whsec_"):]
|
||||
return base64.b64decode(s)
|
||||
|
||||
|
||||
def _compute_signature(key: bytes, signed_payload: str) -> str:
|
||||
"""Return `v1,<base64>` — the format a single signature token uses."""
|
||||
mac = hmac.new(key, signed_payload.encode("utf-8"), hashlib.sha256).digest()
|
||||
return "v1," + base64.b64encode(mac).decode("ascii")
|
||||
|
||||
|
||||
def verify_standard_webhook(
|
||||
*,
|
||||
secret: str,
|
||||
msg_id: str,
|
||||
msg_timestamp: str,
|
||||
msg_signature: str,
|
||||
body: bytes,
|
||||
now: float | None = None,
|
||||
) -> None:
|
||||
"""Verify a Standard Webhooks delivery. Raises HTTPException(401) on
|
||||
any failure. No return value — success is "did not raise"."""
|
||||
try:
|
||||
key = _decode_secret(secret)
|
||||
except (ValueError, base64.binascii.Error) as e:
|
||||
raise HTTPException(status_code=500, detail=f"bad webhook secret: {e}")
|
||||
|
||||
# Timestamp / replay window.
|
||||
try:
|
||||
ts = int(msg_timestamp)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="invalid timestamp")
|
||||
drift = abs((now if now is not None else time.time()) - ts)
|
||||
if drift > _TIMESTAMP_TOLERANCE_S:
|
||||
raise HTTPException(status_code=401, detail="stale timestamp")
|
||||
|
||||
signed_payload = f"{msg_id}.{msg_timestamp}.{body.decode('utf-8')}"
|
||||
expected = _compute_signature(key, signed_payload)
|
||||
|
||||
# The header can carry several space-separated tokens (key rotation).
|
||||
# Any match — in constant time — is success.
|
||||
candidates = msg_signature.split()
|
||||
if not any(hmac.compare_digest(expected, c) for c in candidates):
|
||||
raise HTTPException(status_code=401, detail="bad signature")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _customer_id_from_payload(payload_data: dict[str, Any]) -> str | None:
|
||||
"""Polar nests the customer object under `customer`. Some events also
|
||||
surface `customer_id` at the top of `data` — accept either."""
|
||||
cust = payload_data.get("customer") or {}
|
||||
return cust.get("id") or payload_data.get("customer_id")
|
||||
|
||||
|
||||
def _customer_email_from_payload(payload_data: dict[str, Any]) -> str | None:
|
||||
cust = payload_data.get("customer") or {}
|
||||
return cust.get("email")
|
||||
|
||||
|
||||
async def _find_user(session: AsyncSession, data: dict[str, Any]) -> User | None:
|
||||
"""Locate the User row that owns this event.
|
||||
|
||||
Strategy: join by stored Polar customer id first (the only stable
|
||||
link once we've seen a user). Fall back to email — the first time
|
||||
Polar fires an event for a brand-new customer, we won't have the id
|
||||
yet, but the customer record on Polar's side was created with the
|
||||
user's email by our checkout call."""
|
||||
cid = _customer_id_from_payload(data)
|
||||
if cid:
|
||||
row = (await session.execute(
|
||||
select(User).where(User.polar_customer_id == cid)
|
||||
)).scalar_one_or_none()
|
||||
if row is not None:
|
||||
return row
|
||||
email = _customer_email_from_payload(data)
|
||||
if email:
|
||||
row = (await session.execute(
|
||||
select(User).where(User.email == email)
|
||||
)).scalar_one_or_none()
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
async def _grant_paid(
|
||||
session: AsyncSession, user: User, data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Flip the user to the paid tier and persist the Polar IDs we now
|
||||
know. Safe to call repeatedly: tier is idempotent and the IDs only
|
||||
change if Polar issued new ones."""
|
||||
user.tier = "paid"
|
||||
cid = _customer_id_from_payload(data)
|
||||
if cid and user.polar_customer_id != cid:
|
||||
user.polar_customer_id = cid
|
||||
sub_id = data.get("id") # subscription event payloads put sub id at top
|
||||
if sub_id and user.polar_subscription_id != sub_id:
|
||||
user.polar_subscription_id = sub_id
|
||||
|
||||
|
||||
async def _revoke_paid(session: AsyncSession, user: User) -> None:
|
||||
"""Drop the user back to the free tier. We deliberately leave the
|
||||
polar_customer_id in place so a re-subscription matches them back to
|
||||
the same row."""
|
||||
user.tier = "free"
|
||||
user.polar_subscription_id = None
|
||||
|
||||
|
||||
async def _handle_subscription_active(
|
||||
session: AsyncSession, data: dict[str, Any], event_type: str,
|
||||
) -> None:
|
||||
user = await _find_user(session, data)
|
||||
if user is None:
|
||||
log.warning("polar.user_not_found", event=event_type,
|
||||
customer_id=_customer_id_from_payload(data))
|
||||
return
|
||||
await _grant_paid(session, user, data)
|
||||
|
||||
|
||||
async def _handle_subscription_revoked(
|
||||
session: AsyncSession, data: dict[str, Any], event_type: str,
|
||||
) -> None:
|
||||
user = await _find_user(session, data)
|
||||
if user is None:
|
||||
log.warning("polar.user_not_found", event=event_type,
|
||||
customer_id=_customer_id_from_payload(data))
|
||||
return
|
||||
await _revoke_paid(session, user)
|
||||
|
||||
|
||||
async def _handle_no_state_change(
|
||||
session: AsyncSession, data: dict[str, Any], event_type: str,
|
||||
) -> None:
|
||||
"""For events we want to record in the audit table but where the
|
||||
tier doesn't move — canceled (still active until period end),
|
||||
uncanceled, past_due, order events, refund created. The PolarEvent
|
||||
row is the record."""
|
||||
return None
|
||||
|
||||
|
||||
# Map event type → handler. Anything not in this map is acknowledged
|
||||
# (200) but ignored, on the principle that Polar may add new event types
|
||||
# over time and we don't want to start 4xx-ing on unknown ones.
|
||||
_HANDLERS = {
|
||||
"subscription.created": _handle_subscription_active,
|
||||
"subscription.active": _handle_subscription_active,
|
||||
"subscription.updated": _handle_subscription_active,
|
||||
"subscription.uncanceled": _handle_subscription_active,
|
||||
"subscription.canceled": _handle_no_state_change,
|
||||
"subscription.revoked": _handle_subscription_revoked,
|
||||
"subscription.past_due": _handle_no_state_change,
|
||||
"order.paid": _handle_no_state_change,
|
||||
"order.refunded": _handle_no_state_change,
|
||||
"refund.created": _handle_no_state_change,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/api/polar/webhook")
|
||||
async def polar_webhook(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
s = get_settings()
|
||||
if not s.POLAR_WEBHOOK_SECRET:
|
||||
# Loud failure rather than accepting an unsigned event.
|
||||
raise HTTPException(status_code=503, detail="webhook not configured")
|
||||
|
||||
msg_id = request.headers.get("webhook-id", "")
|
||||
msg_ts = request.headers.get("webhook-timestamp", "")
|
||||
msg_sig = request.headers.get("webhook-signature", "")
|
||||
if not (msg_id and msg_ts and msg_sig):
|
||||
raise HTTPException(status_code=400, detail="missing standard-webhooks headers")
|
||||
|
||||
body = await request.body()
|
||||
verify_standard_webhook(
|
||||
secret=s.POLAR_WEBHOOK_SECRET,
|
||||
msg_id=msg_id,
|
||||
msg_timestamp=msg_ts,
|
||||
msg_signature=msg_sig,
|
||||
body=body,
|
||||
)
|
||||
|
||||
try:
|
||||
envelope = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="invalid JSON")
|
||||
|
||||
event_type = envelope.get("type") or "unknown"
|
||||
data = envelope.get("data") or {}
|
||||
|
||||
# Idempotency: insert the audit row first. If the webhook-id was
|
||||
# already delivered, the UNIQUE constraint short-circuits with a
|
||||
# 200 (Polar will stop retrying).
|
||||
body_text = body.decode("utf-8", errors="replace")[:_PAYLOAD_STORE_MAX]
|
||||
audit = PolarEvent(
|
||||
event_id=msg_id,
|
||||
event_type=event_type,
|
||||
received_at=utcnow(),
|
||||
payload=body_text,
|
||||
)
|
||||
session.add(audit)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
# Already processed — return 200 so Polar doesn't keep retrying.
|
||||
await session.rollback()
|
||||
log.info("polar.duplicate_delivery", event_id=msg_id, type=event_type)
|
||||
return {"status": "duplicate"}
|
||||
|
||||
handler = _HANDLERS.get(event_type)
|
||||
if handler is None:
|
||||
# Unknown but well-signed event — record it, ack 200.
|
||||
audit.processed_at = utcnow()
|
||||
await session.commit()
|
||||
log.info("polar.event_unhandled", type=event_type, id=msg_id)
|
||||
return {"status": "ignored"}
|
||||
|
||||
try:
|
||||
await handler(session, data, event_type)
|
||||
except Exception as e:
|
||||
# Mark as errored so an operator can see what's stuck, then
|
||||
# commit + ack 200. We do NOT want Polar to retry an event that
|
||||
# broke handler logic — the same code will break the same way.
|
||||
# Operator gets paged from the error column instead.
|
||||
audit.error = str(e)[:1024]
|
||||
await session.commit()
|
||||
log.exception("polar.handler_error", type=event_type, id=msg_id)
|
||||
return {"status": "handler_error"}
|
||||
|
||||
audit.processed_at = utcnow()
|
||||
await session.commit()
|
||||
log.info("polar.processed", type=event_type, id=msg_id)
|
||||
return {"status": "ok"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue