Webhooks
Stripe webhook handling for subscription and product sync.
HyperSaaS processes Stripe webhooks to keep the local database in sync with Stripe's state.
Webhook Endpoint
POST /api/subscriptions/webhookNo trailing slash. No authentication: each request is verified with Stripe's signature, and one without a valid Stripe-Signature header is refused with 400.
event = stripe.Webhook.construct_event(
payload=request.body,
sig_header=request.META["HTTP_STRIPE_SIGNATURE"],
secret=STRIPE_WEBHOOK_SECRET,
)Handled Events
Subscription Events
| Event | Handler |
|---|---|
customer.subscription.created | Create/update local Subscription + SubscriptionItems |
customer.subscription.updated | Update subscription fields and items |
customer.subscription.deleted | Update subscription status to cancelled/ended |
Processing:
- Find the
StripeUserby the subscription'scustomerID. A customer with no account here is logged and ignored. - Ignore the event if the subscription has already ended (canceled or incomplete-expired) and the event says otherwise. Stripe doesn't promise delivery order, so such an event is a late one.
- Update or create the
Subscriptionwith all fields (period, status, trial, cancellation), and recreate itsSubscriptionItemrecords. - Attach it to the workspace named in its
metadata.workspace_id, set at checkout. It isn't attached if that workspace already has a different subscription that grants access; one workspace never has two. A subscription created outside checkout, such as in the Stripe dashboard, is recorded but on no workspace.
Product Events
| Event | Handler |
|---|---|
product.created | Create local Product + Feature mappings |
product.updated | Update Product + sync Feature mappings |
product.deleted | Update Product (mark inactive) |
Plan key: the product's metadata.plan (pro, team, business) is copied into Product.plan. That's how a subscription finds its plan. A product with no plan key counts as Free.
Space-delimited metadata.features are still synced into Feature and ProductFeature records, but plan limits never come from them.
Price Events
| Event | Handler |
|---|---|
price.created | Create local Price record |
price.updated | Update Price fields |
price.deleted | Update Price (mark inactive) |
Frequency Calculation:
Stripe's price.recurring is converted to a freq string:
# Stripe: {"interval": "month", "interval_count": 1}
# Local: freq = "month_1"
# Stripe: {"interval": "year", "interval_count": 1}
# Local: freq = "year_1"Webhook Setup
1. Configure in Stripe Dashboard
Go to Developers → Webhooks and add an endpoint:
URL: https://your-domain.com/api/subscriptions/webhookSelect these events:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedproduct.createdproduct.updatedproduct.deletedprice.createdprice.updatedprice.deleted
2. Set Webhook Secret
Copy the webhook signing secret and add it to your environment:
STRIPE_WEBHOOK_SECRET=whsec_...3. Local Testing with Stripe CLI
# Forward webhook events to local server
stripe listen --forward-to localhost:8000/api/subscriptions/webhook
# Trigger test events
stripe trigger customer.subscription.createdEvent Validation
All webhook payloads are validated with Pydantic models before processing:
class StripeEvent(BaseModel):
id: str
type: str
data: dict
# Routes to specific Pydantic models based on event type
# e.g., customer.subscription.* → StripeSubscription
# e.g., product.* → StripeProductInvalid payloads are rejected with appropriate error logging.
Idempotency
Webhook handlers use update_or_create, so they're safe to replay: if Stripe sends the same event twice, the result is the same. Events arriving out of order can't bring an ended subscription back.
Error Handling
| Scenario | Response |
|---|---|
| Missing or invalid signature | 400 Bad Request |
| Unhandled event type | 200 OK (acknowledged but ignored) |
| Processing error | 500 (Stripe will retry) |
| Customer with no account here | Logged as a warning, event skipped |
| Workspace already has another subscription | Logged as a warning, subscription recorded but not attached |