Skip to main content

Stripe Integration

The Billing service processes Stripe webhook events to maintain subscription state and derive entitlements. All webhook processing is idempotent.

Webhook Flow

Stripe

▼ (webhook POST)
Gateway (:8080)
└── /api/v1/billing/webhook (public, no JWT)


Billing Service (:8300)
├── Verify Stripe signature
├── Check idempotency (gl_stripe_events)
├── Process event by type
├── Update gl_subscriptions
├── Refresh entitlement cache in Redis
└── Mark event as processed

Handled Events

Stripe EventAction
customer.subscription.createdCreate subscription record, cache entitlements
customer.subscription.updatedUpdate plan/status, refresh entitlement cache
customer.subscription.deletedMark subscription canceled, revoke entitlements
invoice.payment_succeededUpdate subscription status to active
invoice.payment_failedMark subscription as past_due

Webhook Handler

// services/billing/internal/webhooks/stripe.go
func (h *StripeHandler) Handle(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
// ... Stripe signature validation ...

event, err := stripe.ConstructEvent(payload, sigHeader, h.cfg.WebhookSecret)

// Idempotency check — skip if already processed
if processed, _ := h.db.IsEventProcessed(ctx, event.ID); processed {
render.JSON(w, r, map[string]string{"status": "already_processed"})
return
}

switch event.Type {
case "customer.subscription.created",
"customer.subscription.updated":
h.handleSubscriptionChange(ctx, event)
case "customer.subscription.deleted":
h.handleSubscriptionCancelled(ctx, event)
case "invoice.payment_succeeded":
h.handlePaymentSucceeded(ctx, event)
case "invoice.payment_failed":
h.handlePaymentFailed(ctx, event)
}

h.db.MarkEventProcessed(ctx, event.ID)
render.JSON(w, r, map[string]string{"status": "ok"})
}

Idempotency

Stripe may deliver the same webhook event multiple times. The billing service deduplicates via the gl_stripe_events table:

  1. Before processing — Check if stripe_event_id exists in gl_stripe_events
  2. If exists — Return 200 OK with "already_processed" status (no-op)
  3. If new — Process the event, then insert stripe_event_id into the table
  4. After processing — Mark the event as processed with a timestamp

This ensures that retried webhooks never create duplicate subscriptions or conflicting state.

Entitlement Mapping

Entitlements are derived from plan configuration at billig service startup and cached in Redis:

Plan Configuration

# services/billing/config/plans.yaml
plans:
free:
display_name: 'Free'
entitlements:
- scriptures_read
- basic_search
- topical_guide_browse
limits:
ai_requests_per_hour: 5
search_per_minute: 20

scholar:
display_name: 'GospeLib Scholar'
entitlements:
- scriptures_read
- basic_search
- topical_guide_browse
- interlinear_hebrew_greek
- manuscript_witnesses
- scholarly_commentary
- knowledge_graph_explorer
- cross_references_advanced
- ai_features
limits:
ai_requests_per_hour: 50
search_per_minute: 200

academic:
display_name: 'GospeLib Academic'
entitlements:
- '*' # All entitlements
limits:
ai_requests_per_hour: 200
search_per_minute: 1000

Entitlement Cache

When a subscription changes, the billing service updates Redis:

gl:entitlements:<planId>:<feature> → "1" or "0" (TTL: 60s)

The gateway reads these keys to enforce plan-gated routes without making synchronous calls to the billing service.

Subscription Lifecycle

New User ──► free plan (default)

▼ (Stripe checkout)
Subscribe ──► scholar or academic

├── active ← payment succeeded
├── trialing ← free trial period
├── past_due ← payment failed (retrying)
└── canceled ← user canceled or payment permanently failed

When a subscription moves to canceled, the user reverts to the free plan. The plan_id on their gl_users record is denormalized for fast lookups.