Skip to main content

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"
}
}
}
ParameterValue
Path variable {provider}stripe · paypal (provider-agnostic routing)
MethodPOST
AuthNo JWT required — validated by provider signature
Required response200 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 eventAction
invoice.paidsubscription.status → active. Publishes billing.subscription.changed to Event Bus → Gateway invalidates plan_info:{tenantId} cache.
invoice.payment_failedsubscription.status → past_due. Begins escalation timeline. Notifies Tenant Owner via Notify.
subscription.canceledsubscription.status → canceled. Tenant notified. Access revoked at period end.
customer.subscription.updatedSync plan limits from Stripe (price ID → plan limits). Updates Valkey cache.
subscription.pending_updatePlan change pending — Stripe is awaiting next billing cycle.
Any other eventLogged 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:

RequirementValue
Maximum response time10 seconds
Required status on success200 OK
Required status on duplicate200 OK (idempotent success)
Status on signature failure401 Unauthorized
What triggers a Stripe retryAny non-2xx response, or timeout
Stripe retry scheduleExponential 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

VariableDescription
STRIPE_WEBHOOK_SECRETSigning secret from Stripe Dashboard (per-endpoint)
STRIPE_API_KEYStripe secret key (for outbound API calls)

Error Reference

ScenarioHTTPCode
Missing or invalid signature401unauthorized
Body cannot be parsed as JSON400validation-error
Internal error during processing500internal-error — Stripe will retry
Duplicate event (already processed)200— (idempotent, no body needed)