subscription.payment_failed
Fires when a charge attempt against a subscription fails — renewal-time, retry, or initial charge after trial. The subscription's failedPaymentCount is incremented and (if this is the first failure) the status moves to past_due and subscription.past_due fires alongside.
When it fires
In the renewal/retry cron, after the provider returns a non-success:
- Charge attempt fires.
- Provider returns failure (insufficient funds, card declined, expired card, ...).
failedPaymentCountincrements.- This event fires.
- If
failedPaymentCount == 1: status flips topast_dueandsubscription.past_duealso fires. - If
failedPaymentCount >= max_retries: status moves tounpaidorcanceledper the workspace's dunning policy.
Payload
{
"id": "evt_01HX...",
"type": "subscription.payment_failed",
"createdAt": "2026-06-01T00:00:18Z",
"accountId": "acc_01HX...",
"data": {
"subscriptionId": "sub_01HX...",
"attempt": 1,
"error": "insufficient_funds"
}
}
attempt— 1-indexed, same asfailedPaymentCountafter increment. Soattempt: 3means this is the third consecutive failure.error— provider-side reason code, lowercased. Common values:insufficient_funds,card_declined,expired_card,processing_error,authentication_required,do_not_honor.
Handler examples
// Node
if (event.type === 'subscription.payment_failed') {
const { subscriptionId, attempt, error } = event.data;
const sub = await client.subscriptions.retrieve(subscriptionId);
await emails.sendPaymentFailure(sub.customerId, { attempt, error });
if (attempt >= 3) {
await ops.alertCsTeam({ subscriptionId, attempt });
}
}
# Python
if event["type"] == "subscription.payment_failed":
d = event["data"]
sub = client.subscriptions.retrieve(d["subscriptionId"])
emails.send_payment_failure(sub["customerId"], attempt=d["attempt"], error=d["error"])
// Go
if event.Type == "subscription.payment_failed" {
var d struct {
SubscriptionID string `json:"subscriptionId"`
Attempt int `json:"attempt"`
Error string `json:"error"`
}
_ = json.Unmarshal(event.Data, &d)
notifications.PaymentFailed(ctx, d.SubscriptionID, d.Attempt, d.Error)
}
What to do
- Notify the buyer with a "your payment failed, please update your card" link. Use
errorto tune the message:insufficient_funds— "try again or top up"expired_card— "update your card"authentication_required— "your bank wants you to confirm"
- After 2–3 failures, escalate to customer success.
- Don't revoke access yet — the retry might succeed. Wait for
subscription.canceledor your own grace-window expiry.
Common pitfalls
- Revoking on the first failure.
past_dueis recoverable. Most card declines are transient; retries land within a day or two. Revoke only onsubscription.canceled(or your defined grace window frompast_due). - Not deduping. Every retry that fails fires a new event.
event.idis your dedupe key. - Mapping
errorto your own taxonomy. The set isn't strictly enumerated — new provider codes can appear. Default-bucket unknowns into a generic "your bank declined the charge".
Related events
subscription.past_due— fires alongside on the first failure.subscription.payment_succeeded— the retry-success outcome.subscription.canceled— dunning-exhausted terminal state.