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

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Send a dunning email from Stripe

> Turn Stripe's invoice.payment_failed webhook into an email that gets the card updated.

export const Tags = ({items}) => {
  const routes = {
    Email: "/integrations/email/overview",
    SMS: "/integrations/sms/overview",
    Push: "/integrations/push/overview",
    Inbox: "/in-app/overview",
    Chat: "/integrations/direct-message/overview",
    Templates: "/design/templates/overview",
    Variables: "/design/templates/variables",
    Elemental: "/design/elemental/overview",
    Brands: "/design/brands",
    Translations: "/design/elemental/locales",
    Routing: "/send/routing",
    Preferences: "/recipients/preferences/overview",
    Journeys: "/journeys/overview",
    Broadcasts: "/broadcasts/overview",
    Tenants: "/tenants/overview",
    Logs: "/monitor/overview",
    Webhooks: "/monitor/webhooks/outbound",
    Lists: "/recipients/lists-and-audiences/overview",
    Users: "/recipients/overview",
    Digests: "/journeys/nodes/digest",
    Environments: "/workspaces/overview",
    MCP: "/resources/mcp"
  };
  const icons = {
    Email: "envelope",
    SMS: "comment",
    Push: "mobile",
    Inbox: "inbox",
    Chat: "comments",
    Templates: "pen-ruler",
    Variables: "pen-ruler",
    Elemental: "pen-ruler",
    Brands: "pen-ruler",
    Translations: "pen-ruler",
    Routing: "paper-plane",
    Preferences: "users",
    Journeys: "route",
    Broadcasts: "bullhorn",
    Tenants: "building",
    Logs: "chart-simple",
    Webhooks: "chart-simple",
    Lists: "users",
    Users: "users",
    Digests: "route",
    Environments: "briefcase",
    MCP: "toolbox"
  };
  const base = "https://d3gk2c5xim1je2.cloudfront.net/fontawesome/v7.2.0/regular/";
  const names = String(items || "").split(",").map(entry => entry.trim()).filter(Boolean);
  return <div className="cx-tags">
      {names.map(name => {
    const href = routes[name];
    const icon = icons[name];
    const url = icon ? "url(" + base + icon + ".svg)" : null;
    const style = url ? {
      "--cx-tag-icon": url
    } : null;
    if (!href) {
      return <span className="cx-tag" data-icon={icon} style={style} key={name}>
              {name}
            </span>;
    }
    return <a className="cx-tag" data-icon={icon} style={style} href={href} key={name}>
            {name}
          </a>;
  })}
    </div>;
};

export const Endpoint = ({method, path, name, href, children, bare}) => {
  const verb = String(method || "").toUpperCase();
  const title = verb + " " + path;
  const label = children || name || path;
  if (bare) {
    return href ? <a href={href}><code>{title}</code></a> : <code>{title}</code>;
  }
  if (!href) {
    return <span className="cx-endpoint" data-method={verb} title={title}>
        <span className="cx-endpoint-label">{label}</span>
        <span className="cx-endpoint-method">{verb}</span>
      </span>;
  }
  return <a className="cx-endpoint" data-method={verb} href={href} title={title}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">{verb}</span>
    </a>;
};

export const AppLink = ({href, children, name, bare}) => {
  const label = children || name || "Open in Courier";
  if (bare) {
    return <a href={href} target="_blank" rel="noreferrer">{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="app" href={href} target="_blank" rel="noreferrer">
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method" aria-hidden="true">↗</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

<Tags items="Email, Users" />

A card fails and the subscription lapses quietly. This guide turns `invoice.payment_failed` into an email that gets the card updated.

## What you will build

```mermaid theme={null}
flowchart LR
    A["Payment fails"] --> B["Stripe webhook"]
    B --> C{"Signature valid?"}
    C -->|Yes| D["Find the user"]
    D --> E["Dunning email"]
    C -->|No| F["Reject"]
```

## Prerequisites

* <AppLink href="https://app.courier.com/~/test/platform/api-keys">A Courier Test API key</AppLink>
* A Stripe account, and somewhere to host an HTTPS route
* A published <Doc href="/docs/design/templates/overview">template</Doc> for the dunning email

## Set it up

<Steps>
  <Step title="Install the packages">
    ```bash theme={null}
    npm install stripe @trycourier/courier
    ```

    ```bash .env.local theme={null}
    COURIER_API_KEY=YOUR_COURIER_API_KEY
    STRIPE_SECRET_KEY=sk_test_...
    STRIPE_WEBHOOK_SECRET=whsec_...
    ```
  </Step>

  <Step title="Write the route handler">
    Stripe signs the **raw bytes** of the request. Read the body as text and hand that exact string to `constructEvent`, because parsing it to JSON first changes it and verification fails.

    ```tsx app/api/stripe/route.ts lines theme={null}
    import Stripe from "stripe";
    import Courier from "@trycourier/courier";

    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
    const courier = new Courier({ apiKey: process.env.COURIER_API_KEY! });

    export async function POST(request: Request) {
      // The raw string, not request.json(). Signature verification needs the
      // bytes exactly as Stripe sent them.
      const body = await request.text();
      const signature = request.headers.get("stripe-signature")!;

      let event: Stripe.Event;
      try {
        event = stripe.webhooks.constructEvent(
          body,
          signature,
          process.env.STRIPE_WEBHOOK_SECRET!,
        );
      } catch {
        return new Response("Invalid signature", { status: 400 });
      }

      if (event.type !== "invoice.payment_failed") {
        return new Response("Ignored", { status: 200 });
      }

      const invoice = event.data.object as Stripe.Invoice;

      await courier.send.message({
        message: {
          to: { email: invoice.customer_email ?? undefined },
          template: "nt_01kx4h2jdafq8bk9aftxak4b40",
          data: {
            amount_due: (invoice.amount_due / 100).toFixed(2),
            currency: invoice.currency.toUpperCase(),
            update_url: invoice.hosted_invoice_url,
            attempt: invoice.attempt_count,
          },
        },
      });

      return new Response("OK", { status: 200 });
    }
    ```

    `hosted_invoice_url` is the field that makes this email work. It is a Stripe-hosted page where the customer pays or updates the card, so your template needs no billing UI of its own.
  </Step>

  <Step title="Address the right recipient">
    The snippet above sends to `invoice.customer_email`, which is the quickest path and needs no profile.

    It also means Courier has no <Doc href="/docs/recipients/overview">profile</Doc> to apply, so the recipient's <Doc href="/docs/recipients/preferences/overview">preferences</Doc> and locale are not considered. For a billing email that is often the point, since dunning is transactional and should reach people who muted marketing.

    When you do want the profile, store your own user id on the Stripe customer and read it back:

    ```tsx app/api/stripe/route.ts theme={null}
    // When you create the Stripe customer, stamp your own id on it.
    await stripe.customers.create({
      email: user.email,
      metadata: { app_user_id: user.id },
    });

    // Then in the handler, resolve it and address the profile instead.
    const customer = await stripe.customers.retrieve(invoice.customer as string);
    const userId = !customer.deleted ? customer.metadata.app_user_id : undefined;

    await courier.send.message({
      message: {
        to: userId ? { user_id: userId } : { email: invoice.customer_email ?? undefined },
        template: "nt_01kx4h2jdafq8bk9aftxak4b40",
        data: { update_url: invoice.hosted_invoice_url },
      },
    });
    ```

    Stamping the id at customer-creation time is the part to get right. Retrofitting the mapping later means reconciling two systems that never agreed on an identifier.
  </Step>

  <Step title="Point Stripe at the route">
    In the Stripe Dashboard, open **Workbench → Webhooks**, create an event destination for your route's public URL, and subscribe it to **`invoice.payment_failed`**. Reveal the signing secret and copy it into `STRIPE_WEBHOOK_SECRET`.

    Subscribe only to the events you handle. Stripe warns that listening to everything puts avoidable load on your endpoint.
  </Step>
</Steps>

## Verify

<Steps>
  <Step title="Forward events to localhost">
    ```bash theme={null}
    stripe listen --forward-to localhost:3000/api/stripe
    ```

    The command prints a signing secret for this session. Use that one while testing.
  </Step>

  <Step title="Trigger a failure">
    ```bash theme={null}
    stripe trigger invoice.payment_failed
    ```
  </Step>

  <Step title="Confirm the send">
    Open <AppLink href="https://app.courier.com/logs">Logs</AppLink>. You should see one message with the amount and a `hosted_invoice_url` in its data.
  </Step>
</Steps>

If the route answers `400`, the body was modified before verification. That is the most common cause, and reading it with `request.text()` rather than `request.json()` fixes it.

## Make it reliable

Stripe retries a failed delivery for up to three days, so a handler that throws will be called again. Three habits keep that from causing damage.

**Return `2xx` before slow work.** Stripe times out a slow endpoint and counts it as failed. For anything heavier than a single send, acknowledge first and queue the work.

**Expect duplicates.** The same event can arrive more than once. Stripe's guidance is to record the `event.id` values you have processed and skip repeats. Courier's <Doc href="/docs/reference/api-overview#idempotency">idempotency key</Doc> covers the other half, so pass `event.id` as the key and a replay cannot send twice.

**Do not depend on ordering.** Stripe makes no ordering guarantee, so `invoice.payment_failed` may arrive before the event you expected to precede it.

## Adapt it for other events

| Stripe event                           | What to send                           |
| :------------------------------------- | :------------------------------------- |
| `invoice.payment_failed`               | Dunning, with the hosted invoice link  |
| `invoice.payment_succeeded`            | A receipt, or a recovery confirmation  |
| `customer.subscription.trial_will_end` | A trial-ending nudge, three days out   |
| `customer.subscription.deleted`        | A win-back, or an offboarding sequence |

The trial row pairs well with a <Doc href="/docs/journeys/overview">journey</Doc>. Stripe fires once, three days ahead, and the journey handles the follow-ups from there.
