Payment Service & Polar Webhooks
tektree intermediate 6 min read
ELI5
Polar is the cashier; tektree is the bouncer. Polar takes the money, then radios in via a webhook saying “user 42 just bought Pro.” The bouncer checks the radio’s secret signature, updates the wristband, and lets the user into the Pro lounge.
Technical Deep Dive
Payment-service is the smallest public-facing surface but the hardest to get wrong: every state change must verify a webhook signature and persist before responding 200, otherwise Polar will retry and you risk double-applying upgrades.
Required Configuration
services/payment-service/internal/config/config.go panics if any of these are missing:
MONGODB_URIREDIS_URLPOLAR_API_KEYPOLAR_WEBHOOK_SECRET
Subscription Lifecycle
stateDiagram-v2 [*] --> Pending: POST /api/v1/checkout Pending --> Active: webhook subscription.created Active --> Active: webhook subscription.updated (period_end advances) Active --> Canceling: webhook subscription.canceled (cancel_at set) Canceling --> Expired: current_period_end reached Active --> PastDue: invoice.paid missed PastDue --> Active: invoice.paid received PastDue --> Expired: dunning timeout Expired --> [*]End-to-End Sequence
sequenceDiagram autonumber participant Client participant GW as api-gateway participant PS as payment-service :8084 participant Polar as Polar API participant Mongo as MongoDB participant US as user-service Client->>GW: POST /api/v1/checkout {price_id} GW->>PS: forward + X-User-ID PS->>Polar: POST /v1/checkouts {price_id, success_url, cancel_url, metadata: {user_id}} Polar-->>PS: {checkout_url} PS-->>Client: 200 {url} Client->>Polar: completes payment Polar->>PS: POST /webhooks/polar (event: subscription.created) PS->>PS: HMAC-SHA256 verify with POLAR_WEBHOOK_SECRET alt signature invalid PS-->>Polar: 401 else valid PS->>Mongo: upsert subscriptions {user_id, tier, status, period_*} PS->>US: PATCH user.tier = pro (out of scope today, via event) PS-->>Polar: 200 endWebhook Verification
services/payment-service/internal/handlers/handlers.go:136-141 reads the signature header, computes HMAC-SHA256(body, POLAR_WEBHOOK_SECRET) and compares constant-time. The body is read before parsing so the signature covers the exact bytes Polar signed — JSON re-marshalling between read and verify will silently fail HMAC.
Webhook Event Types
Handled in the HandleWebhook switch (handlers.go):
subscription.created→ insert subscription, set tier, emitpayment.subscription.createdsubscription.updated→ update period_end, possibly bump tier, emitpayment.subscription.upgradedsubscription.canceled→ markcancel_at, do not immediately downgradeinvoice.paid→ insert invoice row, refreshcurrent_period_end
Persisted Models
services/payment-service/internal/models/models.go:
| Collection | Key fields |
|---|---|
subscriptions | user_id, tier, status, polar_subscription_id, current_period_start, current_period_end, cancel_at |
invoices | user_id, polar_invoice_id, amount, currency, status, paid_at |
usage_records | (scaffolded) |
Routes
GET /api/v1/subscriptions/:id GetSubscriptionPOST /api/v1/checkout CreateCheckoutGET /api/v1/usage/:subId GetUsagePOST /webhooks/polar HandleWebhookThe webhook route is not wired through the gateway’s auth middleware — Polar cannot mint a JWT. It is gateway-routed by path and reaches payment-service directly; HMAC is the only authentication.
Key Terms
- Webhook signature → header
Polar-Signature(or equivalent), HMAC-SHA256 of raw body withPOLAR_WEBHOOK_SECRET. polar_subscription_id→ external id; the join key when correlating Polar dashboard with our DB.cancel_at→ “scheduled cancel” flag; presence does not yet mean downgraded.- Idempotency → enforced by
(polar_subscription_id, event_type, occurred_at)on insert.
Q&A
Q: A user paid but their JWT still says tier: free after refresh. What broke?
A: Either the webhook never landed (check Polar’s redelivery history), the HMAC failed (logged but returns 401 to Polar), or payment-service updated subscriptions but the user-service was not notified — only the next access-token mint after the user-record’s tier flips will reflect Pro.
Q: A retry storm sends the same subscription.updated four times. Will tier flip back and forth?
A: No, if the upsert is by polar_subscription_id and uses the event’s occurred_at to skip stale ones. If it blindly applies, you can race a canceled after an updated — order webhooks by their timestamp before applying.
Q: Why does cancel_at not immediately downgrade?
A: Polar’s contract is “service through current_period_end.” Downgrade fires from a separate scheduled job (or a period_end_reached event) so the user keeps Pro until the paid window ends.
Examples
Adding a team tier upgrade path: define the new product in Polar, capture the price id, add a team case in subscription.updated handler that sets tier: "team" on the subscription doc, and ensure user-service mints subsequent JWTs with tier: "team".
neighbors on the map
- Billing Architecture debugging why a user's tier did not update after payment