Handle Failed Payments
What happens when a customer's payment fails and how they can reactivate from the Customer Portal.
When a renewal payment fails, the subscription moves to past_due and enters dunning. The customer keeps service during this grace window while Commet retries the charge. Customers can reactivate sooner from the Customer Portal by retrying payment or updating their card.
What happens when a payment fails
- The subscription status changes to
past_due - The failed invoice is marked as
outstanding - The customer keeps service: usage events and seat events still work (usage accrues as debt)
- The customer receives an email notification
- Commet retries the charge on a fixed schedule (dunning)
If a retry succeeds, the subscription returns to active. If all retries fail, the subscription is canceled and the invoice is marked as uncollectible.
Dunning retry schedule
Retries run on day 1, day 3, and day 5 after the original failure (3 retries). After the last failed retry the subscription is canceled.
Check subscription status
const { data } = await commet.subscriptions.getActive({ customerId: 'user_123' })
if (data.status === 'past_due') {
// Payment failed — prompt customer to reactivate from the portal
}response = commet.subscriptions.get_active(customer_id='user_123')
if response.data['status'] == 'past_due':
# Payment failed — prompt customer to reactivate from the portal
passresult, err := client.Subscriptions.GetActive(ctx, &commet.GetActiveSubscriptionParams{CustomerID: "user_123"})
if result.Data.Status == "past_due" {
// Payment failed — prompt customer to reactivate from the portal
}ApiResponse<Subscription> result = commet.subscriptions().getActive(GetActiveSubscriptionParams.builder("user_123").build());
if ("past_due".equals(result.getData().getStatus())) {
// Payment failed — prompt customer to reactivate from the portal
}$result = $commet->subscriptions->getActive('user_123');
if ($result->data['status'] === 'past_due') {
// Payment failed — prompt customer to reactivate from the portal
}curl "https://commet.co/api/v1/subscriptions/active?customerId=user_123" \
-H "x-api-key: $COMMET_API_KEY"Gate access based on status
Commet keeps serving past_due customers during the dunning window — usage and seat events still work. You decide whether to gate your own product on past_due. To grant access only while billing is healthy, treat active and trialing as the access states:
const { data } = await commet.subscriptions.getActive({ customerId: 'user_123' })
const hasAccess = data.status === 'active' || data.status === 'trialing'response = commet.subscriptions.get_active(customer_id='user_123')
has_access = response.data['status'] in ('active', 'trialing')result, err := client.Subscriptions.GetActive(ctx, &commet.GetActiveSubscriptionParams{CustomerID: "user_123"})
status := result.Data.Status
hasAccess := status == "active" || status == "trialing"ApiResponse<Subscription> result = commet.subscriptions().getActive(GetActiveSubscriptionParams.builder("user_123").build());
String status = result.getData().getStatus();
boolean hasAccess = "active".equals(status) || "trialing".equals(status);$result = $commet->subscriptions->getActive('user_123');
$hasAccess = in_array($result->data['status'], ['active', 'trialing'], true);curl "https://commet.co/api/v1/subscriptions/active?customerId=user_123" \
-H "x-api-key: $COMMET_API_KEY"Recover a subscription programmatically
The SDK exposes three server-side recovery primitives (SDK v7.2.0, @commet/node). For past_due subscriptions they all operate on the same outstanding renewal invoice — none of them void it. reactivate also reactivates canceled subscriptions.
Retry the charge server-to-server
reactivate charges the subscription's saved payment method. It works on both past_due and canceled subscriptions, with different effects:
past_due: retries the same outstanding renewal invoice. The billing anchor stays fixed. On success the subscription returns toactiveandpayment.recoveredfires.canceled: generates a fresh invoice, resets the billing period anchor to now, and charges the saved card. On success the subscription returns toactiveandsubscription.reactivatedfires. Requires the plan to still be available in the subscription's currency, otherwise it returnsPLAN_UNAVAILABLE(422).
const { data } = await commet.subscriptions.reactivate({ id: 'sub_123' })
// data.retryInitiated === trueOn a declined charge or no saved card, the response returns a recoveryUrl in the error details — a hosted page where the customer adds a new card and pays. This matters for subscriptions canceled by dunning: they reached canceled precisely because the saved card kept failing.
Send the customer a recovery link
createRecoveryLink returns a hosted, signed link so the customer pays the outstanding renewal themselves. Deliver it through your own email, SMS, or dashboard. The link stays valid until the charge is paid or the subscription is no longer past_due.
The payment.failed webhook already carries a recoveryUrl: the checkout URL for a first failed charge, a signed recovery link for a failed renewal. If you consume webhooks, no separate createRecoveryLink call is needed.
const { data } = await commet.subscriptions.createRecoveryLink({ id: 'sub_123' })
// data.url → hosted payment page
// data.token → signed token embedded in the URLUpdate the payment method
updatePaymentMethod returns a hosted checkout where the customer updates the subscription's default payment method.
const { data } = await commet.subscriptions.updatePaymentMethod({
id: 'sub_123',
successUrl: 'https://yourapp.com/billing',
})
// redirect(data.checkoutUrl)Customer Portal reactivation
Customers in past_due see their subscription in the Customer Portal with a Reactivate Subscription button. They can choose:
- Retry with their current card — useful when the failure was temporary (insufficient funds that are now available, a bank hold that cleared).
- Update their payment method — enter a new card through the subscription's payment provider and retry in the same step.
A successful retry settles the outstanding invoice, moves the subscription back to active, and emits a payment.recovered event. Retry attempts are rate-limited to 3 per day per customer.
Prompt payment update
Redirect customers to the Customer Portal to reactivate:
const portal = await commet.portal.getUrl({ customerId: 'user_123' })
redirect(portal.data.portalUrl)portal = commet.portal.get_url(customer_id='user_123')
redirect(portal.data['portal_url'])portal, err := client.Portal.GetURL(ctx, &commet.GetPortalURLParams{
CustomerID: "user_123",
})
// redirect(portal.Data.PortalURL)ApiResponse<PortalSession> portal = commet.portal().getUrl("user_123", null, null);
// redirect(portal.getData().getPortalUrl())$portal = $commet->portal->getUrl(customerId: 'user_123');
redirect($portal->data['portalUrl']);curl -X POST https://commet.co/api/v1/portal/request-access \
-H "x-api-key: $COMMET_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customerId": "user_123"}'Related
- Invoices and Billing Cycles — Invoice types and charge timing
- Manage Subscriptions — Create and manage customer subscriptions
- Customer Portal — Self-service billing portal for customers
How is this guide?