Webhooks
The Billing Service receives webhooks from payment providers (Stripe, PayPal) to learn about real payment outcomes. Without webhooks, the service cannot know that a payment succeeded — Stripe does not notify through any other channel for asynchronous payment events.
Endpoint
POST https://api.septemcore.com/v1/billing/webhooks/{provider}
Content-Type: application/json
Stripe-Signature: t=1681234567,v1=abc123def456...
{
"id": "evt_01j9pstr000000000000",
"type": "invoice.paid",
"data": {
"object": {
"subscription": "sub_01j9psub0000",
"amount_paid": 2999,
"currency": "usd"
}
}
}
| Parameter | Value |
|---|---|
Path variable {provider} | stripe · paypal (provider-agnostic routing) |
| Method | POST |
| Auth | No JWT required — validated by provider signature |
| Required response | 200 OK within 10 seconds |
Signature Validation (Stripe)
Every incoming webhook body is verified before processing:
Stripe-Signature: t=1681234567,v1=abc123def456ghi789...
Billing Service:
stripe.ConstructEvent(body, header, STRIPE_WEBHOOK_SECRET)
→ signature valid → proceed
→ signature invalid → 401 Unauthorized (body discarded, not processed)
Signature validation uses the STRIPE_WEBHOOK_SECRET environment
variable (Stripe Dashboard → Webhooks → Signing secret). This secret
is never exposed to clients.
Invalid or missing signature:
{
"type": "https://api.septemcore.com/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Webhook signature validation failed. Stripe-Signature header is invalid or missing.",
"traceId": "01j9ptr0000000000000011"
}
Idempotency
Each webhook event carries a unique id from the provider
(evt_01j9p...). The Billing Service records every processed
event_id in the webhook_events PostgreSQL table:
-- webhook_events table (created by billing migration)
SELECT event_id FROM webhook_events
WHERE event_id = $providerEventId;
-- Found → skip (idempotent), return 200 OK immediately
-- Not found → process + INSERT event_id
This guarantees that even if Stripe delivers the same event multiple
times (network retry, timeout), the subscription is updated exactly
once. The 200 OK response on a duplicate event is intentional —
Stripe treats any non-2xx as a delivery failure and retries.
Handled Events
| Provider event | Action |
|---|---|
invoice.paid | subscription.status → active. Publishes billing.subscription.changed to Event Bus → Gateway invalidates plan_info:{tenantId} cache. |
invoice.payment_failed | subscription.status → past_due. Begins escalation timeline. Notifies Tenant Owner via Notify. |
subscription.canceled | subscription.status → canceled. Tenant notified. Access revoked at period end. |
customer.subscription.updated | Sync plan limits from Stripe (price ID → plan limits). Updates Valkey cache. |
subscription.pending_update | Plan change pending — Stripe is awaiting next billing cycle. |
| Any other event | Logged and acknowledged with 200 OK. No action taken. |
invoice.paid Processing Flow
POST /billing/webhooks/stripe (invoice.paid)
1. Validate Stripe-Signature
2. Check webhook_events: event_id already processed?
┌─ Yes → return 200 OK (idempotent)
└─ No → continue
3. Resolve subscription by Stripe subscription ID
4. UPDATE subscription SET status = 'active'
5. INSERT INTO webhook_events (event_id, type, processed_at)
6. Publish to Event Bus: billing.subscription.changed { tenantId, status: 'active' }
7. Gateway receives event → DELETE plan_info:{tenantId} from Valkey
8. Return 200 OK
Response Requirements
Stripe enforces a 10-second response window. If the endpoint does not respond within 10 seconds, Stripe marks the delivery as failed and retries:
| Requirement | Value |
|---|---|
| Maximum response time | 10 seconds |
| Required status on success | 200 OK |
| Required status on duplicate | 200 OK (idempotent success) |
| Status on signature failure | 401 Unauthorized |
| What triggers a Stripe retry | Any non-2xx response, or timeout |
| Stripe retry schedule | Exponential backoff for up to 3 days |
Stripe retry schedule (approximate):
Attempt 1: immediate
Attempt 2: +5 minutes
Attempt 3: +30 minutes
Attempt 4: +2 hours
Attempt 5: +5 hours
Attempt 6: +10 hours
...continues for 3 days, then marks delivery as failed
If the Billing Service is unavailable for an extended period and Stripe exhausts all retries, the event is permanently lost from Stripe's delivery queue. The Platform Owner must manually reconcile the subscription state using the Stripe Dashboard.
Environment Variables
| Variable | Description |
|---|---|
STRIPE_WEBHOOK_SECRET | Signing secret from Stripe Dashboard (per-endpoint) |
STRIPE_API_KEY | Stripe secret key (for outbound API calls) |
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
| Missing or invalid signature | 401 | unauthorized |
| Body cannot be parsed as JSON | 400 | validation-error |
| Internal error during processing | 500 | internal-error — Stripe will retry |
| Duplicate event (already processed) | 200 | — (idempotent, no body needed) |