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
|
|
@ -188,10 +188,17 @@ class User(Base):
|
|||
# NULL = use INTERMEDIATE at render time. Server-side mirror of the
|
||||
# dashboard tone, decoupled because the dashboard pref is localStorage.
|
||||
digest_tone: Mapped[str | None] = mapped_column(String(16))
|
||||
# Polar (MoR) linkage — populated by the polar_webhook handler the
|
||||
# first time we see a subscription/order event for the user. The
|
||||
# customer id is the stable join key; the subscription id is what
|
||||
# we cancel against from /settings.
|
||||
polar_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
polar_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("email", name="uq_users_email"),
|
||||
UniqueConstraint("referral_code", name="uq_users_referral_code"),
|
||||
UniqueConstraint("polar_customer_id", name="uq_users_polar_customer"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -347,3 +354,34 @@ class EmailSend(Base):
|
|||
__table_args__ = (
|
||||
Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"),
|
||||
)
|
||||
|
||||
|
||||
class PolarEvent(Base):
|
||||
"""Audit + idempotency table for inbound Polar (MoR) webhook deliveries.
|
||||
|
||||
Polar uses the Standard Webhooks spec, which guarantees each delivery
|
||||
carries a unique `webhook-id` header. We store that ID under a UNIQUE
|
||||
constraint so a replay of the same event is a no-op (the INSERT fails
|
||||
and the handler returns the prior result).
|
||||
|
||||
`processed_at` distinguishes "delivered and handled" from "delivered
|
||||
but the handler crashed mid-flight" — the latter rows are what an
|
||||
operator looks at when investigating a stuck subscription."""
|
||||
__tablename__ = "polar_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False,
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
# Raw JSON body, kept for forensics. Truncated to 16 KiB to keep
|
||||
# one bad request from blowing up the row.
|
||||
payload: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id", name="uq_polar_events_event_id"),
|
||||
Index("ix_polar_events_type_received", "event_type", "received_at"),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue