HyperSaaS
BackendSubscriptions

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/webhook

No 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

EventHandler
customer.subscription.createdCreate/update local Subscription + SubscriptionItems
customer.subscription.updatedUpdate subscription fields and items
customer.subscription.deletedUpdate subscription status to cancelled/ended

Processing:

  1. Find the StripeUser by the subscription's customer ID. A customer with no account here is logged and ignored.
  2. 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.
  3. Update or create the Subscription with all fields (period, status, trial, cancellation), and recreate its SubscriptionItem records.
  4. 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

EventHandler
product.createdCreate local Product + Feature mappings
product.updatedUpdate Product + sync Feature mappings
product.deletedUpdate 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

EventHandler
price.createdCreate local Price record
price.updatedUpdate Price fields
price.deletedUpdate 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/webhook

Select these events:

  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • product.created
  • product.updated
  • product.deleted
  • price.created
  • price.updated
  • price.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.created

Event 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.* → StripeProduct

Invalid 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

ScenarioResponse
Missing or invalid signature400 Bad Request
Unhandled event type200 OK (acknowledged but ignored)
Processing error500 (Stripe will retry)
Customer with no account hereLogged as a warning, event skipped
Workspace already has another subscriptionLogged as a warning, subscription recorded but not attached

On this page