# Reduce churn by catching dead accounts

Source: https://developer.nylas.com/docs/cookbook/workflows/reduce-churn-inactive-accounts/

Nobody churns the day their grant expires. They churn six weeks later, after their mailbox quietly stopped syncing, your product quietly stopped working, and they quietly stopped opening it. By the time anyone notices, the story they tell is that your product broke.

An expired grant is the cheapest churn signal you will ever get, and it arrives the moment the problem starts. A workflow on `grant.expired` turns it into a reconnect email while the user still remembers what your product does.

![A reconnect email explaining the account connection expired, with the account address, provider, expiry time, and a reconnect button](/_images/workflows/grant-expired.png)

## Why do expired grants cause churn?

Because the failure is invisible to the user and silent in your product. Credentials stop working after a password change, a revoked permission, or an expired OAuth token, and none of those feel to the user like an event that should break an integration.

What they see is data that stopped updating. What they conclude is that your product is unreliable. The gap between the grant expiring and the user noticing is where the relationship is lost.

A reconnect email costs one send. Winning the same user back after they cancel costs a sales cycle.

## Which events should I watch?

Two carry churn signal, and they mean different things.

| Event | Code | What it means | Urgency |
| --- | --- | --- | --- |
| [`grant.expired`](/docs/reference/notifications/grants/grant-expired/) | 25009 | Credentials stopped working, the user hasn't left | High. Recoverable. |
| [`grant.deleted`](/docs/reference/notifications/grants/grant-deleted/) | 25013 | The grant was removed by a Delete Grant request | Lower, but tells you why |

`grant.expired` is the one that matters. The user hasn't decided anything, something broke, and a single email recovers them. Treat it as an incident rather than a notification.

`grant.deleted` is a departure. The email won't usually bring them back, but it confirms the disconnection was deliberate and gives someone who didn't expect it a way to respond. It also separates accidental disconnections from real cancellations in your own data.

Also available: `grant.updated` carries `reauthentication_flag`, which tells you when a user reconnected. Use it to close the loop and suppress follow-ups, not to send more email.

## Why do these need a sender address?

You can't send email through a mailbox that has stopped working. `grant.expired` and `grant.deleted` both require a `from` object, which routes the message through [transactional send](/docs/v3/getting-started/transactional-send/) on a [verified domain](/docs/v3/email/domains/) rather than through the grant.

Omitting it returns `400 This event type requires transactional send. Please provide a sender email address.` That restriction is the whole point: a workflow without `from` sends through the grant that triggered it, which for an expired credential is a contradiction.

```bash
curl -X POST 'https://api.us.nylas.com/v3/workflows' \
  -H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "name": "Reconnect prompt",
    "trigger_event": "grant.expired",
    "template_id": "<TEMPLATE_ID>",
    "delay": 0,
    "is_enabled": true,
    "from": { "email": "accounts@yourdomain.com", "name": "Your Company" }
  }'
```

Register and verify the sending domain first through [Managing domains](/docs/v3/email/domains/), or the workflow has nowhere to send from.

## How do I avoid emailing people who already fixed it?

Set a `delay`, then check before you send. `delay` is in minutes, and a short one covers the common case where the user reconnects within the same session because they were already in your product when it broke.

A delay alone isn't a suppression list. A workflow can't check the grant's current state, so the send happens whether or not the grant recovered. For anything beyond a short delay, subscribe to the [`grant.expired` webhook](/docs/reference/notifications/grants/grant-expired/), record the expiry in your own system, and send from your code once you have confirmed the grant is still invalid.

The rule of thumb: use the workflow when one prompt is enough, and move to a webhook when you want a sequence. A reconnect campaign that messages on day 1, day 3, and day 7 needs state that workflows don't have.

## What should the reconnect email say?

Lead with the consequence, not the event. "Your account connection has expired" means nothing on its own. "We can no longer sync your mailbox, and nothing will update until you reconnect" tells the user why they should care.

Four things earn their place:

**Which account.** `{{email}}` is the only field that identifies which of the user's connections broke, and a user with two mailboxes can't act without it.

**Why it usually happens.** A password change, a revoked permission, or an expired token. Naming the likely cause stops the user assuming your product is broken, which is the belief that leads to churn.

**What stops working.** Be specific about the feature they use. Generic language about syncing doesn't create urgency.

**One button.** Point at a page you control that starts a fresh OAuth flow. Never embed a pre-signed link, because it will have expired by the time someone opens a four-day-old email.

Put `grant_id`, `integration_id`, and `code` in small footer text. When the user forwards the email to support instead of clicking the button, that footer saves a round of questions.

## The complete template

This guards every field, so it renders on a payload carrying nothing but `grant_id`.

```html [churnTemplate-grant.expired]

<html>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Reconnect your account</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
        Helvetica, Arial, sans-serif;
      line-height: 1.6;
      color: #333333;
      margin: 0;
      padding: 0;
      background-color: #f9fbfe;
      font-size: 16px;
    }

    .container {
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
    }

    .header {
      text-align: center;
      padding: 20px 0;
    }

    .content {
      background-color: #ffffff;
      padding: 30px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }

    .detail-label {
      color: #666666;
      font-size: 14px;
      padding: 6px 16px 6px 0;
      vertical-align: top;
      white-space: nowrap;
    }

    .detail-value {
      padding: 6px 0;
      vertical-align: top;
    }

    .footer {
      text-align: center;
      padding: 20px 0;
      color: #666666;
      font-size: 14px;
    }

    @media only screen and (max-width: 600px) {
      .container {
        width: 100% !important;
        padding: 10px !important;
      }

      .content {
        padding: 20px !important;
      }
    }
  </style>
</head>

<body>
  <div class="container">
    <div class="header">
      <img src="https://brand.nylas.com/assets/site_images/Nylas-Logo_Horizontal-Blue.png" alt="Nylas Logo"
        style="max-width: 150px;" />
    </div>

    <div class="content">
      <h1 style="margin-top: 0; color: #1A1A1A;">Your account connection has expired</h1>

      <p>We can no longer sync {{#if email}}<strong>{{email}}</strong>{{else}}your connected account{{/if}}. This usually happens after a password change, a revoked permission, or an expired OAuth token on the provider's side.</p><p>Nothing syncs until the account is reconnected, and anything scheduled against it may fail.</p>

      <hr style="border: 0; border-top: 1px solid #eeeeee; margin: 20px 0;" />

      <h2 style="color: #4D4D4D; font-weight: 500;">Account details</h2>

      <table cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
        <tr><td class="detail-label">Account</td><td class="detail-value">{{#if email}}{{email}}{{else}}your connected account{{/if}}</td></tr>
        <tr><td class="detail-label">Provider</td><td class="detail-value">{{#if provider}}{{provider}}{{else}}not reported{{/if}}</td></tr>
        <tr><td class="detail-label">Expired</td><td class="detail-value">{{#if grant_updated_at}}{{ formatDate grant_updated_at "UTC" "d LLLL yyyy, h:mm a ZZZZ" "en" }}{{else}}just now{{/if}}</td></tr>
      </table>

      <div style="text-align: center; margin: 30px 0;">
        <a href="https://example.com/reconnect"
          style="background-color: #0D6EFD; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Reconnect account</a>
      </div>

      <p style="font-size: 14px; color: #666666;">If reconnecting doesn't resolve it, contact support.</p>
    </div>

    <div class="footer">
      <p>&copy; 2026 <a href="https://www.nylas.com/" style="color: #0D6EFD;">Nylas</a></p>
      <p style="font-size: 12px; color: #999999; margin-top: 4px;">
        {{#if grant_id}}Grant ID: {{grant_id}}<br />{{/if}}
        {{#if integration_id}}Integration ID: {{integration_id}}<br />{{/if}}
        {{#if code}}Error code: {{code}}{{/if}}
      </p>
    </div>
  </div>
</body>

</html>


```

```html [churnTemplate-grant.deleted]

<html>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Account disconnected</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
        Helvetica, Arial, sans-serif;
      line-height: 1.6;
      color: #333333;
      margin: 0;
      padding: 0;
      background-color: #f9fbfe;
      font-size: 16px;
    }

    .container {
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
    }

    .header {
      text-align: center;
      padding: 20px 0;
    }

    .content {
      background-color: #ffffff;
      padding: 30px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }

    .detail-label {
      color: #666666;
      font-size: 14px;
      padding: 6px 16px 6px 0;
      vertical-align: top;
      white-space: nowrap;
    }

    .detail-value {
      padding: 6px 0;
      vertical-align: top;
    }

    .footer {
      text-align: center;
      padding: 20px 0;
      color: #666666;
      font-size: 14px;
    }

    @media only screen and (max-width: 600px) {
      .container {
        width: 100% !important;
        padding: 10px !important;
      }

      .content {
        padding: 20px !important;
      }
    }
  </style>
</head>

<body>
  <div class="container">
    <div class="header">
      <img src="https://brand.nylas.com/assets/site_images/Nylas-Logo_Horizontal-Blue.png" alt="Nylas Logo"
        style="max-width: 150px;" />
    </div>

    <div class="content">
      <h1 style="margin-top: 0; color: #1A1A1A;">Your account was disconnected</h1>

      <p>{{#if email}}<strong>{{email}}</strong>{{else}}Your account{{/if}} has been disconnected and we've stopped syncing its data.</p><p>If you didn't expect this, you can reconnect at any time.</p>

      <hr style="border: 0; border-top: 1px solid #eeeeee; margin: 20px 0;" />

      <h2 style="color: #4D4D4D; font-weight: 500;">Account details</h2>

      <table cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
        <tr><td class="detail-label">Account</td><td class="detail-value">{{#if email}}{{email}}{{else}}your connected account{{/if}}</td></tr>
        <tr><td class="detail-label">Provider</td><td class="detail-value">{{#if provider}}{{provider}}{{else}}not reported{{/if}}</td></tr>
      </table>

      <div style="text-align: center; margin: 30px 0;">
        <a href="https://example.com/reconnect"
          style="background-color: #0D6EFD; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Reconnect account</a>
      </div>

      <p style="font-size: 14px; color: #666666;">Reconnecting creates a new connection; it doesn't restore the old one.</p>
    </div>

    <div class="footer">
      <p>&copy; 2026 <a href="https://www.nylas.com/" style="color: #0D6EFD;">Nylas</a></p>
      <p style="font-size: 12px; color: #999999; margin-top: 4px;">
        {{#if grant_id}}Grant ID: {{grant_id}}<br />{{/if}}
        {{#if integration_id}}Integration ID: {{integration_id}}<br />{{/if}}
        {{#if code}}Error code: {{code}}{{/if}}
      </p>
    </div>
  </div>
</body>

</html>


```

The expiry time is guarded because `grant_updated_at` exists only on `grant.expired`:

```handlebars
{{#if grant_updated_at}}{{ formatDate grant_updated_at "UTC" "d LLLL yyyy, h:mm a ZZZZ" "en" }}{{else}}just now{{/if}}
```

Render both with `POST /v3/templates/render` before enabling their workflows. A reconnect email that fails to render sends nothing and reports nothing, which converts a recoverable account straight into a lost one.

## Test without breaking a real account

Create a throwaway grant on an account you control, attach the workflow, then revoke the app's access from the provider's security settings. Google revocations happen at `myaccount.google.com/permissions`. Don't test by deleting a grant you still need, because deletion can't be undone.

Two behaviours are worth confirming on that disposable grant. Grant payloads carry no participants array and no `recipient` object, so the address should be the grant's own `email`, which matters most for `grant.deleted` where the mailbox may already be gone. And the delay between credentials failing and `grant.expired` firing isn't documented, so measure it before promising anyone a response time.

## What's next

- [Welcome users when they connect](/docs/cookbook/workflows/welcome-connected-account/) is the other half of the account lifecycle
- [Handle grant expiry and re-authentication](/docs/cookbook/use-cases/build/handle-grant-expiry/) for detecting expiry in code and restoring access
- [Why isn't my workflow sending email?](/docs/cookbook/workflows/workflow-not-sending/) for silent render failures
- [Grant notifications](/docs/reference/notifications/grants/) for the full payload of every grant event