CRUMB a card from devarno-cloud

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_URI
  • REDIS_URL
  • POLAR_API_KEY
  • POLAR_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
end

Webhook 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, emit payment.subscription.created
  • subscription.updated → update period_end, possibly bump tier, emit payment.subscription.upgraded
  • subscription.canceled → mark cancel_at, do not immediately downgrade
  • invoice.paid → insert invoice row, refresh current_period_end

Persisted Models

services/payment-service/internal/models/models.go:

CollectionKey fields
subscriptionsuser_id, tier, status, polar_subscription_id, current_period_start, current_period_end, cancel_at
invoicesuser_id, polar_invoice_id, amount, currency, status, paid_at
usage_records(scaffolded)

Routes

GET /api/v1/subscriptions/:id GetSubscription
POST /api/v1/checkout CreateCheckout
GET /api/v1/usage/:subId GetUsage
POST /webhooks/polar HandleWebhook

The 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 with POLAR_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