> ## Documentation Index
> Fetch the complete documentation index at: https://docs.senderkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Recover failed payments & win back churned users

> Send branded dunning, win-back, and trial-ending emails from Stripe webhooks — the lifecycle emails Stripe doesn't send for you.

By the end of this guide, three revenue-critical emails send automatically from Stripe
events: a **payment-failed** notice when a charge fails, a **win-back** when a
subscription is canceled, and a **trial-ending** reminder before a trial lapses — each
from a dashboard template you can rewrite without a deploy.

Stripe already emails receipts and invoices, so don't rebuild those. What Stripe
*doesn't* send well is the branded, copy-sensitive lifecycle mail: a multi-step dunning
nudge, a "we'd love you back" after cancellation, or a trial reminder on your own
schedule. That copy changes constantly — exactly what belongs in a
[template](/concepts/templates), not a redeploy.

<Note>
  **You'll need:** a [SenderKit account](https://senderkit.com) with an API key, a Stripe
  account with subscriptions or Checkout live, and a Next.js app. Use an `sk_test_`
  SenderKit key and Stripe **test mode** while you build.
</Note>

<Steps>
  <Step title="Author the three templates">
    Create three `email` [templates](/concepts/templates) in the dashboard. Suggested
    slugs and [variables](/concepts/variables):

    | Slug                    | When it sends             | Variables                       |
    | ----------------------- | ------------------------- | ------------------------------- |
    | `payment-failed`        | A renewal charge fails    | `attempt`, `update_payment_url` |
    | `subscription-canceled` | A subscription ends       | `comeback_url`                  |
    | `trial-ending`          | A trial is about to lapse | `trial_end`, `final`            |

    Write the copy for each — these are the messages you'll iterate on most, so lean on
    [AI authoring](/concepts/ai-authoring) for a first draft and **publish** when ready.
  </Step>

  <Step title="Install dependencies and configure clients">
    <CodeGroup>
      ```bash npm theme={null}
      npm install @senderkit/sdk stripe
      ```

      ```bash pnpm theme={null}
      pnpm add @senderkit/sdk stripe
      ```

      ```bash bun theme={null}
      bun add @senderkit/sdk stripe
      ```
    </CodeGroup>

    ```ts lib/senderkit.ts theme={null}
    import { SenderKit } from "@senderkit/sdk";

    export const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! });
    ```
  </Step>

  <Step title="Stand up the Stripe webhook route">
    Stripe signs every webhook, and you **must** verify the signature against the *raw*
    request body. In the Next.js App Router, `await req.text()` gives you the untouched
    body — there's no `bodyParser` config to disable (that was the Pages Router).

    ```ts app/api/webhooks/stripe/route.ts theme={null}
    import Stripe from "stripe";
    import { NextRequest, NextResponse } from "next/server";
    import { senderkit } from "@/lib/senderkit";

    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

    export async function POST(req: NextRequest) {
      const body = await req.text(); // raw body — required for verification
      const sig = req.headers.get("stripe-signature")!;

      let event: Stripe.Event;
      try {
        event = stripe.webhooks.constructEvent(
          body,
          sig,
          process.env.STRIPE_WEBHOOK_SECRET!,
        );
      } catch (err) {
        return NextResponse.json(
          { error: `Webhook signature verification failed: ${(err as Error).message}` },
          { status: 400 },
        );
      }

      switch (event.type) {
        case "invoice.payment_failed":
          await onPaymentFailed(event.data.object as Stripe.Invoice);
          break;
        case "customer.subscription.deleted":
          await onSubscriptionCanceled(stripe, event.data.object as Stripe.Subscription);
          break;
        case "customer.subscription.trial_will_end":
          await onTrialWillEnd(stripe, event.data.object as Stripe.Subscription);
          break;
      }

      return NextResponse.json({ received: true });
    }
    ```

    <Warning>
      Webhook delivery is **at-least-once** — Stripe will retry, and the same event can
      arrive more than once. Every `send()` below carries a stable `idempotencyKey` so a
      replay doesn't double-email the customer.
    </Warning>
  </Step>

  <Step title="Send on a failed payment (dunning)">
    The `invoice.payment_failed` event carries everything you need directly on the
    invoice — including `customer_email`, so no extra lookup is needed.

    ```ts theme={null}
    async function onPaymentFailed(invoice: Stripe.Invoice) {
      if (!invoice.customer_email) return;

      await senderkit.send({
        template: "payment-failed",
        to: invoice.customer_email,
        vars: {
          attempt: invoice.attempt_count,
          update_payment_url: invoice.hosted_invoice_url ?? "",
        },
        idempotencyKey: `payment-failed:${invoice.id}:${invoice.attempt_count}`,
        metadata: { customerId: String(invoice.customer) },
      });
    }
    ```

    Keying on `${invoice.id}:${invoice.attempt_count}` means each *retry* attempt gets its
    own email, but a duplicate delivery of the same attempt does not.
  </Step>

  <Step title="Win back a canceled subscription">
    A subscription object doesn't carry the email, so look up the customer.

    ```ts theme={null}
    async function onSubscriptionCanceled(stripe: Stripe, sub: Stripe.Subscription) {
      const customer = await stripe.customers.retrieve(sub.customer as string);
      if (customer.deleted || !customer.email) return;

      await senderkit.send({
        template: "subscription-canceled",
        to: customer.email,
        vars: { comeback_url: "https://app.example.com/billing" },
        idempotencyKey: `subscription-canceled:${sub.id}`,
        metadata: { subscriptionId: sub.id },
      });
    }
    ```
  </Step>

  <Step title="Remind before a trial ends — as a scheduled sequence">
    `customer.subscription.trial_will_end` fires roughly three days out. Send the reminder
    now, and use [`scheduledAt`](/concepts/sending#scheduling-sends) to queue a final nudge
    for the day before the trial lapses — one event, a two-touch sequence.

    ```ts theme={null}
    async function onTrialWillEnd(stripe: Stripe, sub: Stripe.Subscription) {
      const customer = await stripe.customers.retrieve(sub.customer as string);
      if (customer.deleted || !customer.email || !sub.trial_end) return;

      const email = customer.email;
      const trialEnd = new Date(sub.trial_end * 1000);

      // Touch 1 — now.
      await senderkit.send({
        template: "trial-ending",
        to: email,
        vars: { trial_end: trialEnd.toISOString(), final: false },
        idempotencyKey: `trial-ending:${sub.id}`,
        metadata: { subscriptionId: sub.id },
      });

      // Touch 2 — one day before the trial ends.
      const oneDayBefore = new Date(sub.trial_end * 1000 - 24 * 60 * 60 * 1000);
      if (oneDayBefore > new Date()) {
        await senderkit.send({
          template: "trial-ending",
          to: email,
          vars: { trial_end: trialEnd.toISOString(), final: true },
          scheduledAt: oneDayBefore,
          idempotencyKey: `trial-ending-final:${sub.id}`,
          metadata: { subscriptionId: sub.id },
        });
      }
    }
    ```

    The `final` variable lets the *one* template render two ways — use a
    [show-if block](/concepts/variables#conditionals-and-loops) (`{{#final}}…{{/final}}`)
    to sharpen the copy on the last reminder.
  </Step>

  <Step title="Verify it works">
    Forward events to your local route and trigger one with the
    [Stripe CLI](https://docs.stripe.com/webhooks#test-webhook):

    ```bash theme={null}
    stripe login
    # Prints a whsec_… secret → set it as STRIPE_WEBHOOK_SECRET
    stripe listen --forward-to localhost:3000/api/webhooks/stripe

    # In another terminal:
    stripe trigger invoice.payment_failed
    ```

    With your `sk_test_` key, watch the message move through its
    [lifecycle](/concepts/messages) in the SenderKit dashboard — no real email sent.
  </Step>

  <Step title="Go live">
    Register a production endpoint in the **Stripe Dashboard → Webhooks**, set its
    `whsec_` secret in your environment, and swap your SenderKit key to `sk_live_`. Same
    code, real delivery.
  </Step>
</Steps>

## What's next

<CardGroup cols={2}>
  <Card title="Welcome on signup" icon="hand-wave" href="/guides/welcome-email">
    The other end of the lifecycle — greet new users automatically.
  </Card>

  <Card title="Scheduling sends" icon="clock" href="/concepts/sending#scheduling-sends">
    How `scheduledAt` defers delivery up to 30 days out.
  </Card>

  <Card title="Idempotency" icon="shield-check" href="/concepts/sending#idempotency">
    Why every webhook handler needs a stable key.
  </Card>

  <Card title="Messages" icon="list-check" href="/concepts/messages">
    Filter by `metadata` to find a customer's sends.
  </Card>
</CardGroup>
