One application-level workflow sends the same email to every user of your product. That’s the right answer for most teams. If you sell a white-label product, or your customers are agencies who send under their own name, you need a different email per customer, and that means grant-level workflows.
This recipe covers how to scope workflows and templates to a single grant, what it costs to operate one per customer, and the duplicate-email trap that catches people who mix the two levels.
When should I use grant-level workflows?
Section titled “When should I use grant-level workflows?”Use them when the email has to differ per customer in ways a template variable can’t express: a different logo, sender name, tone, or language that belongs to your customer rather than to your product. Everything else is cheaper at the application level.
| Application-level | Grant-level | |
|---|---|---|
| Endpoint | POST /v3/workflows | POST /v3/grants/{grant_id}/workflows |
| Fires for | Every grant in the application | One grant |
| Objects to manage | 1 per trigger | 1 per trigger, per grant |
| Visible in the Dashboard | Yes | No |
| Provisioning | Once, by hand | On every new grant, in code |
The cost is real. Five booking triggers across 500 customers is 2,500 workflows to create, keep in sync, and remove when a grant is deleted. Before committing, check whether a variable solves it instead. Per-customer text in the email body, such as a company name or a support address, can be passed through the booking’s additional_fields and read in an application-level template with lookup, which keeps you at one workflow per trigger. The sender is different: from is a field on the workflow, not the booking, so a per-customer sender always needs a grant-level workflow.
How do I create a grant-level workflow?
Section titled “How do I create a grant-level workflow?”Create a template scoped to the grant, then a workflow scoped to the same grant. Both use the /v3/grants/{grant_id}/ prefix, and the template comes back with grant_id set and app_id null.
curl -X POST "https://api.us.nylas.com/v3/grants/$GRANT_ID/templates" \ -H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \ -d '{ "name": "Acme booking confirmed", "engine": "handlebars", "subject": "Confirmed: {{booking_info.title}}", "body": "<html>…</html>" }'curl -X POST "https://api.us.nylas.com/v3/grants/$GRANT_ID/workflows" \ -H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \ -d '{ "name": "Acme booking confirmed", "trigger_event": "booking.created", "template_id": "<GRANT_TEMPLATE_ID>", "delay": 0, "is_enabled": true, "from": { "email": "[email protected]", "name": "Acme" } }'A grant-level workflow can reference either a grant-level template or an application-level one. That distinction is what gives you the middle option below.
There’s a third way to get per-customer senders without any of this. Omit from and each workflow sends through its own grant, so the message already arrives from that customer’s address with no domain registration and no per-customer sender to configure. That covers identity but not branding, so reach for grant-level workflows when the template itself has to differ, and for grant sending when only the address does.
Do I need a template per customer?
Section titled “Do I need a template per customer?”Not necessarily. There are 3 shapes, and most teams want the middle one.
One template per customer. Maximum flexibility and maximum maintenance. A copy change means updating every template you have issued. Choose this only when customers genuinely author their own email.
One template per segment, many grants pointing at it. Create a handful of application-level templates, one per brand or tier, and point each customer’s grant-level workflow at the right one. You get per-customer senders and per-brand design while maintaining 3 or 4 templates instead of 500. The workflow carries the from address, so the sender still differs per customer even when the template is shared.
One shared template, per-customer values passed in. Keep a single application-level workflow and pass the customer’s details through the booking’s additional_fields, reading them with lookup. No grant-level objects at all. This works when the differences are body text, not layout or sender, and only for bookings that submit those fields. Declare them on the configuration’s scheduler.additional_fields, and if you book through the API, include them in every booking request.
The second shape is usually the right trade. Branding lives in a few templates, identity lives on the workflow, and the number of objects you maintain grows with your brand count rather than your customer count.
How do I provision workflows for new grants?
Section titled “How do I provision workflows for new grants?”Create them from your own code when the grant appears. A workflow can’t create another workflow, so this is a webhook handler rather than a chained workflow.
Subscribe to grant.created as a webhook, and on delivery create the grant-level workflows for that grant. Mirror it with grant.deleted so workflows are removed when a customer disconnects, or you accumulate workflows pointing at grants that no longer exist.
# list what a grant currently has, for reconciliationcurl "https://api.us.nylas.com/v3/grants/$GRANT_ID/workflows" \ -H "Authorization: Bearer $NYLAS_API_KEY"Reconcile on a schedule as well as on the webhook. Provisioning that runs only on grant.created drifts the first time a delivery is missed, and there is no interface that shows you the gap.
Why is everyone getting two messages?
Section titled “Why is everyone getting two messages?”You have a workflow at both scopes on the same trigger. Application-level and grant-level workflows fire independently, so a grant covered by both sends 2 messages to the same person.
This is the most common failure when moving from one model to the other, and it’s hard to spot because the Dashboard lists only application-level workflows. A grant-level workflow created months ago keeps firing with nothing in the interface to show it exists.
List both scopes before debugging anything else:
curl "https://api.us.nylas.com/v3/workflows" \ -H "Authorization: Bearer $NYLAS_API_KEY"
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/workflows" \ -H "Authorization: Bearer $NYLAS_API_KEY"When you migrate a customer from the application-level email to their own, disable the application-level workflow for everyone or accept that migrated customers get duplicates. There’s no per-grant exclusion on an application-level workflow.
What stays the same at either level
Section titled “What stays the same at either level”The template model doesn’t change with scope. The greeting still needs both guard levels, times still resolve through the same fallback chain, and notify_individually still has to be declared on the Scheduler configuration before a booking can pass it. Customize Scheduler email for your app explains each of those, and the templates there work unmodified at the grant level.
<html>
<head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Your meeting is confirmed</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 meeting is confirmed</h1>
{{#if recipient}}{{#if recipient.first_name}}<p>Hi {{recipient.first_name}},</p>{{else}}<p>Hi there,</p>{{/if}}{{else}}<p>Hi there,</p>{{/if}}
<p> <strong>{{booking_info.title}}</strong> is booked. The details are below, and the invitation is already on your calendar. </p>
<hr style="border: 0; border-top: 1px solid #eeeeee; margin: 20px 0;" />
<h2 style="color: #4D4D4D; font-weight: 500;">Meeting details</h2>
<table cellpadding="0" cellspacing="0" border="0" style="width: 100%;"> <tr> <td class="detail-label">When</td> <td class="detail-value"> {{#if recipient}}{{#if booking_info.additional_fields}}{{#if (lookup booking_info.additional_fields recipient.email)}}{{ formatDate booking_info.start_time (lookup booking_info.additional_fields recipient.email) "EEEE d LLLL yyyy, h:mm a ZZZZ" "en" }}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" "en" }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" "en" }}{{/if}}{{/if}}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" "en" }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" "en" }}{{/if}}{{/if}}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" "en" }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" "en" }}{{/if}}{{/if}} </td> </tr> <tr> <td class="detail-label">Duration</td> <td class="detail-value">{{booking_info.duration}} minutes</td> </tr> {{#if booking_info.location}} <tr> <td class="detail-label">Where</td> <td class="detail-value"><a href="{{booking_info.location}}" style="color: #0D6EFD;">{{booking_info.location}}</a></td> </tr> {{/if}} <tr> <td class="detail-label">Who</td> <td class="detail-value"> {{#each booking_info.participants}}{{name}} <{{email}}>{{#unless @last}}<br />{{/unless}}{{/each}} </td> </tr> </table>
<div style="text-align: center; margin: 30px 0;"> <a href="{{booking_info.participant_rescheduling_url}}" style="background-color: #0D6EFD; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Reschedule</a> </div>
<p style="font-size: 14px; color: #666666; text-align: center;"> Can no longer make it? <a href="{{booking_info.participant_cancellation_url}}" style="color: #0D6EFD;">Cancel this meeting</a>. </p> </div>
<div class="footer"> <p>© 2026 <a href="https://www.nylas.com/" style="color: #0D6EFD;">Nylas</a></p> <p style="font-size: 12px; color: #999999; margin-top: 4px;"> Booking reference: {{booking_info.event_id}} </p> </div> </div></body>
</html>Change the logo, colours, and sender for a customer, and leave the guards and the timezone chain alone. They’re the parts that stop the template failing silently on a payload that’s missing a field.
What’s next
Section titled “What’s next”- Customize Scheduler email for your app for the template walkthrough and the Scheduler configuration settings
- Why isn’t my workflow sending email? for duplicate email and silent render failures
- Welcome users when they connect an account uses the same
grant.createdevent you provision from - Grant-level workflows API and grant-level templates API