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:

  1. Charge attempt fires.
  2. Provider returns failure (insufficient funds, card declined, expired card, ...).
  3. failedPaymentCount increments.
  4. This event fires.
  5. If failedPaymentCount == 1: status flips to past_due and subscription.past_due also fires.
  6. If failedPaymentCount >= max_retries: status moves to unpaid or canceled per 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 as failedPaymentCount after increment. So attempt: 3 means 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 error to 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.canceled or your own grace-window expiry.

Common pitfalls

  • Revoking on the first failure. past_due is recoverable. Most card declines are transient; retries land within a day or two. Revoke only on subscription.canceled (or your defined grace window from past_due).
  • Not deduping. Every retry that fails fires a new event. event.id is your dedupe key.
  • Mapping error to 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

Next