Diagnose Stripe `card_declined` patterns

Group Stripe payment failures by decline_code. Surface the top causes and the customers affected. Different decline = different fix.

Published

difficulty intermediate · time to value 2 minutes · tools used 3

Just say this

Group Stripe payment failures by decline_code from the last 30 days. Surface the top causes and recent affected customers.

Why this matters

Payment failures cluster by reason — and each reason needs a different response:

  • insufficient_funds → customer issue, retry in 3 days
  • do_not_honor → bank issue, retry in 1 day, try different processor
  • card_velocity_exceeded → your retry logic is too aggressive, slow down
  • incorrect_cvc → UX issue, surface a clearer error in your form
  • card_declined (generic) → ambiguous, possibly fraud — different ladder entirely

Most teams treat all payment failures as a single “Stripe error” line in their error tracker. They retry the same way regardless. The result: aggressive retries on real bank declines (annoys customer), gentle retries on velocity-exceeded (just stalls), and no UX fix for CVC errors (which are 100% addressable).

Agentry groups by decline_code in one query and tells you which patterns to fix in code vs which to handle in CS workflow.

What you get

  • Top decline codes from the last 30 days with count and ARR exposure
  • For each: a recommended response strategy (retry timing, UX fix, CS outreach)
  • The specific customers in the top bucket so support can reach out before they churn

Walk through it

You

Group Stripe payment failures by decline_code from the last 30 days. Surface the top causes and recent affected customers.

Agent

Let me query the failures grouped by code.

agentry_analytics_query {
  project_id: "default",
  hogql: "SELECT properties.decline_code AS code, count() AS cnt, uniq(distinct_id) AS users, sum(toFloat(properties.amount_cents) / 100) AS dollars_failed FROM events WHERE event = 'payment_failed' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY code ORDER BY cnt DESC"
}
{
  "rows": [
    { "code": "insufficient_funds",       "cnt": 412, "users": 388, "dollars_failed": 28840 },
    { "code": "do_not_honor",             "cnt": 188, "users": 142, "dollars_failed": 14200 },
    { "code": "card_velocity_exceeded",   "cnt":  96, "users":  18, "dollars_failed":  4800 },
    { "code": "incorrect_cvc",            "cnt":  78, "users":  64, "dollars_failed":  4100 },
    { "code": "expired_card",             "cnt":  44, "users":  44, "dollars_failed":  2200 },
    { "code": "card_declined",            "cnt":  31, "users":  28, "dollars_failed":  1860 }
  ]
}
Agent

Three patterns worth your attention:

  1. insufficient_funds (412) — Customer-side, not your bug. Retry strategy should be 1d / 3d / 7d (Stripe’s default Smart Retries handles this). Make sure dunning emails are firing.
  2. card_velocity_exceeded (96, only 18 users) — This is YOUR retry logic. 18 customers got slammed 5x each in 30 days. Your retry intervals are too tight. Stripe’s banks throttle when you retry too fast. Fix: bump min retry interval to 24h.
  3. incorrect_cvc (78) — Pure UX. Your form isn’t surfacing the error clearly enough; customers re-enter the same wrong CVC. Fix: better inline validation.

Let me check who the velocity-exceeded customers are — these are at high churn risk.

agentry_analytics_query {
  project_id: "default",
  hogql: "SELECT distinct_id, count() AS retry_attempts FROM events WHERE event = 'payment_failed' AND properties.decline_code = 'card_velocity_exceeded' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY distinct_id ORDER BY retry_attempts DESC LIMIT 5"
}
{
  "rows": [
    { "distinct_id": "[email protected]", "retry_attempts": 12 },
    { "distinct_id": "[email protected]", "retry_attempts": 9 },
    { "distinct_id": "[email protected]", "retry_attempts": 8 }
  ]
}
Agent

Alice got 12 failed attempts. She probably opened 12 support tickets. CS should reach out today and apologize — the failure was on your side, not hers.

The output

Stripe Payment Failures (30d), grouped by decline_code

CODE                         COUNT  USERS  $ EXPOSED   FIX TYPE
insufficient_funds            412    388    $28,840   CS workflow
do_not_honor                  188    142    $14,200   CS workflow
card_velocity_exceeded         96     18     $4,800   ★ YOUR BUG
incorrect_cvc                  78     64     $4,100   ★ UX FIX
expired_card                   44     44     $2,200   email reminder
card_declined (generic)        31     28     $1,860   ambiguous

★ ACTION ITEMS:

  card_velocity_exceeded (your retry logic is too aggressive)
    Affected customers (top 3):
       [email protected]         12 failed retries
       [email protected]             9 failed retries
       [email protected]      8 failed retries
    Fix: bump retry interval to >=24h between attempts.
    Comm: CS apology email to each affected user today.

  incorrect_cvc (UX is hiding the error)
    78 customers re-entered wrong CVC; many gave up.
    Fix: inline real-time validation + clearer error message.

  All others: Stripe Smart Retries + dunning workflow handles these.

Setting it up

You need a webhook handler for Stripe’s payment_intent.payment_failed or charge.failed event that re-emits to Agentry with decline_code:

// /api/webhooks/stripe
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, req.headers["stripe-signature"]!, STRIPE_WEBHOOK_SECRET
  );

  if (event.type === "payment_intent.payment_failed") {
    const pi = event.data.object;
    const lastError = pi.last_payment_error;

    await fetch(`https://api.agentry.sh/v1/analytics/${process.env.AGENTRY_PROJECT_ID}/`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.AGENTRY_DSN}`,
        "Content-Type": "application/json",
        "User-Agent": "myapp-stripe/1.0",  // REQUIRED — Cloudflare 403s default UAs
      },
      body: JSON.stringify({
        event: "payment_failed",
        distinct_id: pi.receipt_email ?? pi.customer,  // user identifier
        properties: {
          decline_code: lastError?.decline_code ?? "unknown",
          error_code:   lastError?.code,
          amount_cents: pi.amount,
          currency:     pi.currency,
          payment_intent_id: pi.id,
        },
      }),
    });
  }

  res.json({ received: true });
});

For richer grouping, also capture card_country, card_brand, and processor_response_code — useful for diagnosing fraud-vs-real declines down the line.

Variations

  • “Same decline-code breakdown but split by checkout type (one-time vs subscription renewal). Different patterns dominate each.”
  • “Show me the % of insufficient_funds decisions that EVENTUALLY succeed (next retry). My Smart Retries config feels too aggressive.”
  • “For each customer with 3+ card_velocity_exceeded failures, draft a CS apology email offering account credit.”
  • “Daily cron at 8am: post yesterday’s top decline_code and trend vs prior 7d average to #payments-oncall.” (uses a Routine)

Try this recipe in your own agent.

Paste the prompt above into your agent. It'll set up Agentry against your data, then run the recipe and show you the real output.

Install https://agentry.sh/agentry.md and set it up