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

# Failed payment recovery

> A card is declined and you have two weeks to get it fixed. The reminders stop the moment that invoice is paid — and start again, from the first message, at the next failure.

## TL;DR

Sillage bills 1 800 regional carriers by card on the 1st of the month. Around forty charges fail — expired card, spending ceiling, a 3-D Secure prompt nobody completed. On day 14 the subscription is suspended and the fleets stop being tracked. Those fourteen days are the whole campaign.

It has to be insistent about **this** invoice and forgetful about the last one.

| Need                                                    | How you do it in Notifizz                                                                                                |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| One sequence per **invoice**, not one per retry attempt | A **Milestone** synthetic event: count `invoice.payment_failed`, milestone `1`, all time, **Group by** `stripeInvoiceId` |
| Four reminders on **your** rhythm                       | Four messages and their delays, inside the campaign                                                                      |
| Go quiet when this invoice is paid                      | Objective = `invoice.paid`, plus *and ends when → Objective is reached*                                                  |
| Start over at the next failure                          | Nothing to configure — the stop is scoped to the run, and next month is a different invoice                              |
| Reach whoever handles the billing **today**             | *For follow-up sends, recipients are → Recalculated each send*                                                           |

## The situation

Sillage tracks delivery fleets for regional carriers. The product is invisible until it stops: when the subscription lapses nobody gets an alert — a dispatcher notices a van has gone off the map.

Forty failed charges a month is too many to chase by hand and too few to justify a collections team. And a customer suspended over an expired card is a customer lost for a reason that had nothing to do with the product.

## Why "once per customer" is the wrong rule here

The [migration deadline](/docs/use-cases/migration-deadline) case solves the opposite problem: talk to each person **once in their life**, then never again. It is tempting to reach for the same recipe here. It fails twice.

**Pointing a campaign straight at the failure event.** A declined charge is not one event. The provider retries on its own schedule and every failed attempt emits another `invoice.payment_failed` — the payload carries `attemptCount` so you can watch it climb. Four attempts over ten days means four overlapping sequences for one invoice, each with its own day-3 reminder.

**Adding a once-per-customer rule to fix it.** Now March's failure consumes the carrier's only entry, April's failure produces nothing at all, and the account is suspended in silence. You have swapped a noisy bug for a quiet one, which is worse.

The unit of this conversation is neither the attempt nor the person. It is **the invoice**.

| Grouped by   | "Once" means                           | Right for                                                                                              |
| ------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| the customer | once in their lifetime                 | a migration notice, an activation nudge — see [migration deadline](/docs/use-cases/migration-deadline) |
| the invoice  | once per invoice, every month, forever | this page                                                                                              |

Same primitive, one field apart. The grouping key **is** the scope of the campaign.

## 1. One entry per invoice

Create a **Milestone** synthetic event from **Events → New event**:

* **Event to count** — `invoice.payment_failed`
* **Milestones** — `1`
* **Count per** — **all time**
* **Group by** — `stripeInvoiceId`

Read it as the dashboard states it back to you: *when a `stripeInvoiceId` reaches 1 `invoice.payment_failed` (all time), fire.* One counter per invoice, one firing per counter — the second, third and fourth attempts increment a counter that has already fired and produce nothing.

Next month's invoice carries a different id, so it opens a fresh counter and fires on its own first failure. Nothing resets and nothing has to remember March. (Counters do age out after about a year of inactivity — irrelevant for an invoice, which is settled or written off long before.)

<Note>
  The milestone carries **the grouping key and the counter**, not the original payload — your orchestrator reads `event.properties.stripeInvoiceId`, `thresholdReached` and `currentValue`. That is deliberate: the amount owed and the payment link are fetched live at send time rather than frozen at the moment the card was declined.
</Note>

<Note>
  **Closed beta.** The Stripe source is one of the inbound connectors still marked beta — enabled per organisation, off by default. Ask your Notifizz contact to switch it on, or skip it: a campaign keys on the **event name**, so an `invoice.payment_failed` your own backend emits with `client.track()` drives this page unchanged. See [integrations](/docs/sdks/how-to/integrations).
</Note>

## 2. Four messages, one clock per invoice

The campaign subscribes to the milestone event and holds four messages:

| When   | Message                               |
| ------ | ------------------------------------- |
| Day 0  | Your payment did not go through       |
| Day 3  | Still unpaid — the link again         |
| Day 7  | One week before tracking is suspended |
| Day 12 | Two days left                         |

**All four go by email, and only by email.** The person who has to act is the billing contact, who may never open the product — a message in the Notification Center would wait in an inbox nobody visits, and a web push would need a browser subscription they never granted. Email is the only channel that reaches someone by virtue of being a contact rather than a user.

Marketing owns the spacing and the copy; moving day 12 to day 10 is a save, not a release. Each invoice walks its own clock from the day its charge was declined.

This is a **`Transactional`** campaign. Of the three categories it is the only one that carries no unsubscribe link, and that is the point: "your service will be suspended on Thursday" is not a mailing anyone should be able to opt out of. `Product` and `Marketing` are both promotional and honour the [notification preferences](/docs/concepts/notification-preferences); a dunning notice belongs in neither.

<Warning>
  That exemption has teeth. A recipient who cannot opt out and marks the mail as spam is suppressed permanently by the email provider, and that cannot be undone. Keep the sequence to the invoice: no discount, no "we miss you", no upsell to the annual plan. A payment reminder carrying a promotion has stopped being transactional, whatever the category selector says.
</Warning>

## 3. The recipient is recalculated at every send

The person who has to act is not the person using the product. The dispatcher who opens Sillage every morning is not the one holding the company card — the billing contact sits in an accounts department, and over twelve days that contact goes on holiday, changes job, or is replaced in the customer's own settings.

On the **Recipients** card, *For follow-up sends, recipients are* has two values. Keep the default:

| Setting                                | What it would do here                                                                                                                                              |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Recalculated each send** *(default)* | Every reminder resolves the audience live. Day 7 reaches whoever handles billing on day 7.                                                                         |
| **From the first send**                | The contact captured on day 0 owns the whole sequence. The accountant who left in week one keeps receiving the escalation; their replacement never hears about it. |

The sequence is about the invoice, not about a person — so the audience is a question you ask again at every send, not an answer you cache on day 0.

<Warning>
  Recalculating is not the same as following someone through a change. Notifizz holds no customer data: a sequence tracks each recipient by the `id` your orchestrator returns, so a changed contact is simply a **different recipient** — which is the intent here. It also means your ids must be stable for everyone who did *not* change. Never fall back between fields (`u.id ?? u.email`), or an ordinary CRM sync looks exactly like a change of contact. See [how recipients are recognised](/docs/concepts/recipients#how-recipients-are-recognised).
</Warning>

<Accordion title="For the developer: the enricher and the orchestrator" icon="code">
  One enricher, registered once in your backend with the Node SDK. It answers the only two questions the campaign has: who receives billing mail for this account **right now**, and is the invoice still owed.

  ```ts theme={null}
  notifizz.enricher("billingContact", {
    description: "The current billing contact for the account behind an invoice, and whether it is still owed",
    input: z.object({ stripeInvoiceId: z.string() }),
    output: z.object({
      contactUserId: z.string(),
      contactEmail: z.string(),
      contactName: z.string(),
      companyName: z.string(),
      settled: z.boolean(),
      suspendOn: z.string(),
    }),
    cache: false, // a contact that changed yesterday is the whole point — never serve a cached one
    handler: async ({ stripeInvoiceId }) => toBillingView(await db.invoices.findOne({ stripeInvoiceId })),
  });
  ```

  The orchestrator — AI-generated from the campaign description, then yours to review — pairs it with the connector's built-in `stripe:invoice` enricher, which returns the amount and the hosted payment link:

  ```ts theme={null}
  export const orchestrate = async (event: Event, campaign: Campaign, sdk: Sdk): Promise<Recipient[]> => {
    const stripeInvoiceId = event.properties.stripeInvoiceId;

    const account = await sdk.enrichWith("billingContact", { stripeInvoiceId });
    if (account.settled || !account.contactEmail) return [];

    const invoice = await sdk.enrichWith("stripe:invoice", { stripeInvoiceId });

    return [{
      id: account.contactUserId,
      email: account.contactEmail,
      displayName: account.contactName,
      companyName: account.companyName,
      amountDue: invoice.amountDue,
      currency: invoice.currency,
      payUrl: invoice.hostedInvoiceUrl,
      suspendOn: account.suspendOn,
    }];
  };
  ```

  `account.settled` comes from **your** system, not from the invoice enricher: `stripe:invoice` returns `amountDue`, `currency`, `hostedInvoiceUrl`, `invoicePdf` and the line items — an amount, not a verdict. Whether the debt is still open is your business rule, and it lives where your suspension date lives.

  The empty list is a real guard. A payment settled out of band — a wire transfer, a card retried by hand in the provider's dashboard, an account closed by support — produces no recipient at all: the run lands in the Outbox as **No recipients**, and no message is created.
</Accordion>

## 4. Stop on the payment, and only on this invoice

On the **Recipients** card, set the objective to `invoice.paid` and **and ends when → Objective is reached**. The remaining reminders of that invoice's sequence are cancelled the moment the charge clears.

The payment event needs one extra step. Notifizz credits a conversion by matching a **canonical identity** — `id`, `email`, `pushId` or `phone` — against the recipients who received the campaign, and a payment provider identifies people by its own customer reference instead. So the orchestrator exports a second hook to resolve it:

```ts theme={null}
export const onObjective = async (event: Event, sdk: Sdk): Promise<Identity | null> => {
  const account = await sdk.enrichWith("billingContact", {
    stripeInvoiceId: event.properties.stripeInvoiceId,
  });
  if (!account.contactUserId) return null;
  return { id: account.contactUserId, email: account.contactEmail };
};
```

Write it only when the objective event carries no canonical identity — otherwise the fast path runs and there is nothing to configure. See [objectives](/docs/concepts/objectives).

**And then nothing else happens.** Paying March's invoice cancels March's reminders and changes nothing beyond them: the stop applies to the sequence in flight, not to the person. In April a different `stripeInvoiceId` opens a different counter, the milestone fires, and the carrier gets the day-0 message as if it were the first time. No cooldown to age out, no flag to clear.

## What Sillage sets up

* An `invoice.payment_failed` event carrying `stripeInvoiceId` — from the payment connector, or from its own backend
* An `invoice.paid` event carrying the **same** `stripeInvoiceId`, wired as the campaign's objective
* One Milestone synthetic event, grouped on that invoice id
* One [enricher](/docs/why/enrichers), `billingContact`, returning the live contact and the settled flag

No customer data moves into Notifizz: the contact is read at send time and forgotten, which is also why a change of accountant needs no synchronisation on anyone's part.

## Rehearsing it before it is Live

Open the milestone event: **Milestone status** lists the counters of your non-production environments, each with its current value against the next milestone — production counters are never shown here. **Set** puts a counter at the value you choose so the next event trips the milestone, **Reset** zeroes it; both are test actions, available on non-production environments only.

Promote the campaign to `Review` on a test environment: that is the one status below `Live` where a real event still produces a delivery, and it reaches your sandbox testers rather than real people — see [send scope](/docs/concepts/campaigns#send-scope). So you can fail a test invoice, watch the day-0 message land in your own inbox, mark it paid, and watch the day-3 message not arrive.

## Reading the results

The **Sends** bar shows fewer messages delivered than the sequence intended, broken out by reason. Recipients dropped under **Objective already reached** are the carriers who paid: a success layer, not a warning, and a healthy campaign shows it growing message after message — day 3 reaches fewer people than day 0, day 7 fewer than day 3.

The Outbox's **Objectives** tab lists every evaluation of `invoice.paid`, whether or not it counted:

| Outcome                  | What it means here                                                                                                      |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| **Objective reached**    | The payment matched a carrier who received this campaign — counted, sequence stopped                                    |
| **Already counted**      | This carrier had already converted on this campaign — **the sequence still stops**, the conversion is not counted twice |
| **No message delivered** | The identity resolved, but never received this campaign                                                                 |
| **No identity**          | `onObjective` returned `null`, or was never exported                                                                    |

**Already counted** is the normal steady state of a recurring campaign, not a fault. Conversions are deduped per recipient per campaign: a billing contact credited for March's late payment adds nothing to the count when they settle June's. The **stop** obeys a different rule — it is claimed against the run in flight, every time. June's reminders are cancelled exactly like March's, even though the conversion figure does not move.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The invoice was paid but the next reminder went out anyway">
    Open the Objectives tab and read the outcome. **No identity** means `onObjective` is missing or returned `null` — without it there is no canonical `id` or `email` to credit. **No message delivered** is subtler: the identity resolved to the *current* billing contact, who changed since the last send and so never received a message from this campaign. Nothing to credit, nothing stops. That case is caught by the other guard — the orchestrator returns an empty list once your system reports the invoice settled.
  </Accordion>

  <Accordion title="One carrier received four day-0 messages in ten days">
    The campaign is bound to `invoice.payment_failed` directly instead of to the milestone event. Every retry the provider makes is a fresh occurrence — check `attemptCount` on the payloads in the events catalogue, it will read 1, 2, 3, 4.
  </Accordion>

  <Accordion title="Nothing fires at all">
    Almost always the grouping key. If `stripeInvoiceId` is absent from the payload, every failure collapses into one shared counter: threshold 1 fires once for the whole environment and no invoice ever triggers the campaign again. The Milestone status panel shows it plainly — one line per invoice is what you want to see, not one line total. Second suspect: the event name. A connector event carries the provider's own type, verbatim — `invoice.payment_failed`, with no prefix added. Copy it from your event catalogue rather than typing it: the milestone and the campaign must key on exactly that string.
  </Accordion>

  <Accordion title="Two sequences are running for the same carrier">
    Expected, and it follows from the scope: two unpaid invoices are two debts, so they get two conversations. To hold one conversation per account instead, group the milestone on the customer reference — but understand the trade. That counter fires once per customer for all time, so a carrier who fails in March and again in June is reminded once, in March.
  </Accordion>

  <Accordion title="The reminder has no payment button">
    The failure event does not carry one — it exposes the amount, the attempt count and the next attempt date, but no link; only the paid event carries a hosted invoice URL. Fetch it at send time with the `stripe:invoice` enricher, which returns `hostedInvoiceUrl` alongside the amount — the same call that keeps the quoted total honest twelve days later.
  </Accordion>

  <Accordion title="The escalation kept going to someone who left the company">
    The Recipients card is set to *From the first send*, so the contact resolved on day 0 owns the whole sequence and later arrivals are excluded — they appear in the per-send statistics under **Not in the first send**. Switch to *Recalculated each send*.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Objectives & conversions" icon="bullseye" href="/docs/concepts/objectives">
    What counts as done, how it is credited, and why the stop is scoped to the run rather than to the person.
  </Card>

  <Card title="Synthetic events" icon="wand-magic-sparkles" href="/docs/concepts/synthetic-events">
    The four types Notifizz generates for you, and what each one puts in `event.properties`.
  </Card>

  <Card title="Events & workflows" icon="diagram-project" href="/docs/concepts/events-and-workflows">
    Recalculated each send versus from the first send, and how to pick by intent.
  </Card>

  <Card title="Migration deadline" icon="building-columns" href="/docs/use-cases/migration-deadline">
    The other scope: one entry per person for life, five reminders, stopped on the act.
  </Card>
</CardGroup>
