Skip to main content
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

Prerequisites

  • A Stripe account, and somewhere to host an HTTPS route
  • A published for the dunning email

Set it up

1

Install the packages

.env.local
2

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.
app/api/stripe/route.ts
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.
3

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 to apply, so the recipient’s 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:
app/api/stripe/route.ts
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.
4

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.

Verify

1

Forward events to localhost

The command prints a signing secret for this session. Use that one while testing.
2

Trigger a failure

3

Confirm the send

Open . You should see one message with the amount and a hosted_invoice_url in its data.
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 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

The trial row pairs well with a . Stripe fires once, three days ahead, and the journey handles the follow-ups from there.