Skip to main content

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.

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, not a redeploy.
You’ll need: a SenderKit account 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.
1

Author the three templates

Create three email templates in the dashboard. Suggested slugs and variables:
SlugWhen it sendsVariables
payment-failedA renewal charge failsattempt, update_payment_url
subscription-canceledA subscription endscomeback_url
trial-endingA trial is about to lapsetrial_end, final
Write the copy for each — these are the messages you’ll iterate on most, so lean on AI authoring for a first draft and publish when ready.
2

Install dependencies and configure clients

npm install @senderkit/sdk stripe
lib/senderkit.ts
import { SenderKit } from "@senderkit/sdk";

export const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! });
3

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).
app/api/webhooks/stripe/route.ts
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 });
}
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.
4

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.
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.
5

Win back a canceled subscription

A subscription object doesn’t carry the email, so look up the customer.
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 },
  });
}
6

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 to queue a final nudge for the day before the trial lapses — one event, a two-touch sequence.
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 ({{#final}}…{{/final}}) to sharpen the copy on the last reminder.
7

Verify it works

Forward events to your local route and trigger one with the Stripe CLI:
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 in the SenderKit dashboard — no real email sent.
8

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.

What’s next

Welcome on signup

The other end of the lifecycle — greet new users automatically.

Scheduling sends

How scheduledAt defers delivery up to 30 days out.

Idempotency

Why every webhook handler needs a stable key.

Messages

Filter by metadata to find a customer’s sends.