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.

Why do expired grants cause churn?
Section titled “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?
Section titled “Which events should I watch?”Two carry churn signal, and they mean different things. The grant lifecycle diagram shows where each one fires.
| Event | Code | What it means | Urgency |
|---|---|---|---|
grant.expired | 25009 | Credentials stopped working, the user hasn’t left | High. Recoverable. |
grant.deleted | 25013 | The grant was removed by a Delete Grant request | Lower, but tells you why |
Grant lifecycle and notifications
grant.updated.Diagram as text
- States:
valid: API requests and notifications work.invalid: Grant, data, and object IDs are kept.Deleted: Next sign-in creates a new grant ID.
- Transitions:
- Start →
valid: First sign-in,grant.created valid→invalid: Provider access lost,grant.expiredinvalid→valid: Re-authenticated, same grant ID,grant.updatedvalid→Deleted: Deleted,grant.deletedinvalid→Deleted: Deleted,grant.deleted
- Start →
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?
Section titled “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 on a verified domain 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.
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": "[email protected]", "name": "Your Company" } }'Register and verify the sending domain first through Managing domains, or the workflow has nowhere to send from.
How do I avoid emailing people who already fixed it?
Section titled “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, 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?
Section titled “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
Section titled “The complete template”This guards every field, so it renders on a payload carrying nothing but grant_id.
<!DOCTYPE html><html lang="en" xmlns="http://www.w3.org/1999/xhtml"><head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="color-scheme" content="light only" /> <meta name="supported-color-schemes" content="light" /> <title>Reconnect your account</title> <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&display=swap" rel="stylesheet" /> <style> body { margin: 0; padding: 0; background-color: #F2F2F4; } @media only screen and (max-width: 700px) { .wrap { width: 100% !important; } .pad { padding-left: 24px !important; padding-right: 24px !important; } .hero-pad { padding: 40px 16px 44px 16px !important; } .hero-wrap { padding: 0 8px !important; } .page-pad { padding: 12px 8px !important; } .wrap { border-radius: 10px !important; } .hero-pad a { white-space: normal !important; } .headline { font-size: 28px !important; line-height: 34px !important; } } </style></head>
<body style="margin: 0; padding: 0; background-color: #F2F2F4;"> {{!-- Provider names are written out, because the payload sends them in lowercase. Unknown providers leave the phrase out. --}} {{!-- The payload has no expiry time. grant_updated_at is the last update before the grant expired, so it isn't shown as a date. --}} <div style="display: none; max-height: 0; overflow: hidden; opacity: 0; mso-hide: all;">We can no longer sync your account. Reconnect it to keep things running.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F2F2F4" style="background-color: #F2F2F4;"> <tr> <td class="page-pad" align="center" style="padding: 40px 16px;"> <table role="presentation" class="wrap" width="680" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width: 680px; max-width: 680px; background-color: #FFFFFF; border-collapse: separate; border-radius: 12px; overflow: hidden; border: 1px solid #E4E4E8;">
<tr> <td class="pad" style="padding: 32px 48px 28px 48px;"> {{!-- Replace with your own logo. --}} <img src="https://developer.nylas.com/_images/email/nylas-logo.png" alt="Nylas" width="96" style="display: block; width: 96px; height: auto; border: 0;" /> </td> </tr>
<tr> <td class="hero-wrap" style="padding: 0 12px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse: separate; border-radius: 8px; overflow: hidden;"> <tr> <td class="hero-pad" bgcolor="#1F1F1F" style="padding: 52px 36px 56px 36px; border-radius: 8px; background-color: #1F1F1F; background-image: radial-gradient(ellipse 55% 60% at 100% 100%, #365462 0%, rgba(31, 31, 31, 0) 70%), radial-gradient(ellipse 80% 90% at 85% 0%, #3C5EC4 0%, #293455 45%, #1F1F1F 78%);"> <h1 class="headline" style="margin: 0; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 34px; line-height: 40px; font-weight: 500; letter-spacing: -0.5px; color: #FFFFFF;">Reconnect your account</h1> <p style="margin: 18px 0 0 0; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 26px; color: #BCCAF4; word-break: break-word;">We can no longer sync {{#if email}}<span style="font-weight: 500;">{{email}}</span>{{else}}your account{{/if}}{{#if provider}}{{#if (eq provider "google")}} on Google{{else if (eq provider "microsoft")}} on Microsoft{{else if (eq provider "icloud")}} on iCloud{{else if (eq provider "yahoo")}} on Yahoo{{else if (eq provider "imap")}} on IMAP{{else if (eq provider "ews")}} on Exchange{{else if (eq provider "eas")}} on Exchange{{/if}}{{/if}}. Nothing updates until you reconnect.</p> {{!-- Point the button at a page you control that starts a fresh OAuth flow. --}} <div style="margin-top: 20px; font-size: 0; line-height: 0;"><a href="https://example.com/reconnect" style="display: inline-block; margin: 12px 12px 0 0; padding: 14px 28px; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 500; line-height: 20px; text-decoration: none; border-radius: 999px; white-space: nowrap; background-color: #FFFFFF; color: #080817; border: 1px solid #FFFFFF;">Reconnect account</a></div> </td> </tr> </table> </td> </tr>
<tr> <td class="pad" style="padding: 40px 48px 0 48px; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 26px; color: #1F1F1F; word-break: break-word;"> <p style="margin: 0;">{{#if recipient}}{{#if recipient.first_name}}Hi {{recipient.first_name}},{{else}}Hi,{{/if}}{{else}}Hi,{{/if}}</p> <p style="margin: 16px 0 0 0;">This usually happens after a password change, or when access is removed in the account's security settings.</p> <p style="margin: 16px 0 0 0;">Reconnect with the same account to pick up where you left off. It takes about a minute.</p> </td> </tr>
<tr> <td class="pad" style="padding: 44px 48px 48px 48px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <tr> {{!-- IDs for support: a user who forwards the email instead of clicking saves a round of questions. --}} <td style="border-top: 1px solid #E6E6E6; padding-top: 20px; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 13px; line-height: 20px; color: #5F6368; word-break: break-all;">{{#if grant_id}}Grant ID {{grant_id}}<br />{{/if}}{{#if integration_id}}Integration ID {{integration_id}}<br />{{/if}}{{#if code}}Notification code {{code}}{{/if}}</td> </tr> </table> </td> </tr>
</table> </td> </tr> </table></body></html><!DOCTYPE html><html lang="en" xmlns="http://www.w3.org/1999/xhtml"><head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="color-scheme" content="light only" /> <meta name="supported-color-schemes" content="light" /> <title>Your account was disconnected</title> <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&display=swap" rel="stylesheet" /> <style> body { margin: 0; padding: 0; background-color: #F2F2F4; } @media only screen and (max-width: 700px) { .wrap { width: 100% !important; } .pad { padding-left: 24px !important; padding-right: 24px !important; } .hero-pad { padding: 40px 16px 44px 16px !important; } .hero-wrap { padding: 0 8px !important; } .page-pad { padding: 12px 8px !important; } .wrap { border-radius: 10px !important; } .hero-pad a { white-space: normal !important; } .headline { font-size: 28px !important; line-height: 34px !important; } } </style></head>
<body style="margin: 0; padding: 0; background-color: #F2F2F4;"> {{!-- grant.deleted can't send from the grant, so its workflow must set a from address on a verified domain. --}} <div style="display: none; max-height: 0; overflow: hidden; opacity: 0; mso-hide: all;">Your account was disconnected and syncing has stopped.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F2F2F4" style="background-color: #F2F2F4;"> <tr> <td class="page-pad" align="center" style="padding: 40px 16px;"> <table role="presentation" class="wrap" width="680" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width: 680px; max-width: 680px; background-color: #FFFFFF; border-collapse: separate; border-radius: 12px; overflow: hidden; border: 1px solid #E4E4E8;">
<tr> <td class="pad" style="padding: 32px 48px 28px 48px;"> {{!-- Replace with your own logo. --}} <img src="https://developer.nylas.com/_images/email/nylas-logo.png" alt="Nylas" width="96" style="display: block; width: 96px; height: auto; border: 0;" /> </td> </tr>
<tr> <td class="hero-wrap" style="padding: 0 12px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse: separate; border-radius: 8px; overflow: hidden;"> <tr> <td class="hero-pad" bgcolor="#1F1F1F" style="padding: 52px 36px 56px 36px; border-radius: 8px; background-color: #1F1F1F; background-image: radial-gradient(ellipse 80% 90% at 85% 0%, #DD6B5F 0%, #5E3834 45%, #1F1F1F 78%);"> <h1 class="headline" style="margin: 0; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 34px; line-height: 40px; font-weight: 500; letter-spacing: -0.5px; color: #FFFFFF;">Your account was disconnected</h1> <p style="margin: 18px 0 0 0; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 26px; color: #FFD0CB; word-break: break-word;">{{#if email}}<span style="font-weight: 500;">{{email}}</span>{{else}}your account{{/if}}{{#if provider}}{{#if (eq provider "google")}} on Google{{else if (eq provider "microsoft")}} on Microsoft{{else if (eq provider "icloud")}} on iCloud{{else if (eq provider "yahoo")}} on Yahoo{{else if (eq provider "imap")}} on IMAP{{else if (eq provider "ews")}} on Exchange{{else if (eq provider "eas")}} on Exchange{{/if}}{{/if}} is no longer connected, and syncing has stopped.</p> {{!-- Point the button at a page you control that starts a fresh OAuth flow. --}} <div style="margin-top: 20px; font-size: 0; line-height: 0;"><a href="https://example.com/reconnect" style="display: inline-block; margin: 12px 12px 0 0; padding: 14px 28px; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 500; line-height: 20px; text-decoration: none; border-radius: 999px; white-space: nowrap; background-color: #FFFFFF; color: #080817; border: 1px solid #FFFFFF;">Reconnect account</a></div> </td> </tr> </table> </td> </tr>
<tr> <td class="pad" style="padding: 40px 48px 0 48px; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 26px; color: #1F1F1F; word-break: break-word;"> <p style="margin: 0;">{{#if recipient}}{{#if recipient.first_name}}Hi {{recipient.first_name}},{{else}}Hi,{{/if}}{{else}}Hi,{{/if}}</p> <p style="margin: 16px 0 0 0;">If you didn't expect this, you can reconnect at any time.</p> <p style="margin: 16px 0 0 0;">Reconnecting creates a new connection. It doesn't restore the old one.</p> </td> </tr>
<tr> <td class="pad" style="padding: 44px 48px 48px 48px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <tr> {{!-- IDs for support: a user who forwards the email instead of clicking saves a round of questions. --}} <td style="border-top: 1px solid #E6E6E6; padding-top: 20px; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 13px; line-height: 20px; color: #5F6368; word-break: break-all;">{{#if grant_id}}Grant ID {{grant_id}}<br />{{/if}}{{#if integration_id}}Integration ID {{integration_id}}<br />{{/if}}{{#if code}}Notification code {{code}}{{/if}}</td> </tr> </table> </td> </tr>
</table> </td> </tr> </table></body></html>The grant.expired payload doesn’t include the time the grant expired. grant_updated_at is the last successful update to the grant before it expired, which can be days earlier, so the template doesn’t show it as an expiry date.
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
Section titled “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
Section titled “What’s next”- Welcome users when they connect is the other half of the account lifecycle
- Handle grant expiry and re-authentication for detecting expiry in code and restoring access
- Why isn’t my workflow sending email? for silent render failures
- Grant notifications for the full payload of every grant event