Design retry sequences and state transitions so failed payments do not corrupt subscription records or grant incorrect access.

Model Subscription State Separately from Payment Attempts

Store subscription status in its own table or document with explicit values such as active, past_due, grace, and canceled. Record each payment attempt in a separate attempts table that references the subscription but never mutates its status directly.

On a failed charge, write only to the attempts table and enqueue a retry job. Update the subscription status only after the retry policy reaches its final state or after an explicit cancel event arrives from the payment provider.

Use a Dedicated Retry Queue with Idempotent Handlers

Place failed payment jobs on a queue that supports visibility timeouts and exponential backoff. Each job carries the subscription identifier, attempt count, and a unique idempotency key derived from the invoice identifier.

The worker first checks whether a later successful payment or cancel webhook has already advanced the subscription. If the current state already reflects a terminal event, the job exits without further state changes. This prevents duplicate transitions when webhooks and retry jobs race.

Handle Provider Webhooks Before Local Retry Logic

Process incoming webhooks in a dedicated endpoint that validates the signature and stores the raw payload before any business logic runs. Apply updates inside a transaction that also marks the corresponding retry job as superseded.

When the webhook indicates a successful payment, move the subscription to active and cancel any queued retry jobs for that invoice. When it indicates a permanent failure, move the subscription to canceled only after confirming no active grace period exists.

Gate Access Checks on Subscription State, Not Payment Timestamps

Feature flags and API middleware should read only the subscription status field. During the dunning window the status remains past_due or grace, so paid features stay available until the final retry fails or the provider sends a cancel event.

Cache the subscription status with a short TTL and invalidate it on every webhook or successful retry completion. This avoids serving stale access decisions when a background worker updates state outside the request path.