One application-level workflow sends the same email to every user of your product, and it can still show each customer their own logo. If you sell a white-label product, or your customers are agencies who send under their own name, you may need more than a logo: a different sender, layout, or language per customer. That means grant-level workflows.
This recipe covers per-customer logos with metadata, 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 sender, layout, tone, or language that belongs to your customer rather than to your product. A different logo or brand color doesn’t need them. Store it in a hidden field on the customer’s Scheduler configuration, as described in the next section. 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 hidden field on the configuration solves it instead. The sender can’t: from is a field on the workflow, not the booking, so a per-customer sender always needs a grant-level workflow.
How do I give each customer their own logo?
Section titled “How do I give each customer their own logo?”Store each customer’s logo URL in a hidden metadata field on their Scheduler configuration. The booking templates in Customize Scheduler email for your app read it from booking_info.additional_fields.logo_url and fall back to your default logo when it’s missing. One application-level workflow per trigger covers every customer, with no grant-level objects.
-
Host the customer’s logo anywhere public, such as your own storage or the customer’s website. Nylas stores only the URL.
-
When you create or update the customer’s configuration, add a
logo_urlfield withtype: "metadata"and the URL indefault. Do this for each configuration the customer owns, because the value lives on the configuration, not the grant.curl -X PUT "https://api.us.nylas.com/v3/grants/$GRANT_ID/scheduling/configurations/$CONFIGURATION_ID" \-H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \-d '{"scheduler": {"additional_fields": {"notify_individually": {"label": "Notify individually","type": "metadata","required": false,"default": "true"},"logo_url": {"label": "Logo URL","type": "metadata","required": false,"default": "https://cdn.example.com/customers/acme/logo.png"}}}}'scheduler.additional_fieldsis replaced as a whole, so include every field the configuration already has. -
Use the booking templates unchanged. Each one checks for
logo_urland shows your default logo in the{{else}}branches when it’s missing.
Bookings made on the customer’s scheduling page get their logo automatically. If your own code creates bookings through the Bookings API, pass logo_url in the request’s additional_fields, or the email shows your default. Group-event reminders also show the default, because they carry no additional_fields.
Each booking keeps the logo URL it was created with. If a customer changes their logo, update the file behind the same URL, or existing bookings keep showing the old one in reschedules and reminders. Brand colors and support addresses work the same way. Add a field per value and read it in the template with the same two-level guard. Use metadata in workflow templates covers which values are safe to store.
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. If only the logo or colors differ, use the third. If the sender or layout differs too, 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 on the configuration. Keep a single application-level workflow and store each customer’s logo, colors, or support address in hidden metadata fields on their configuration, as in How do I give each customer their own logo?. No grant-level objects at all. This works when the differences are values, not layout or sender.
The shapes combine. A grant-level workflow that points at a shared template still reads logo_url, so customers can have their own sender and their own logo while you maintain a handful of templates. Either way, 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.
<!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>{{booking_info.title}}</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; } .notes a { color: #0B7D72; text-decoration: underline; word-break: break-all; } @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;"> {{!-- Dates render in the guest's language through booking_info.guest_language. The copy is English; branch on guest_language with eq to translate it. --}} <div style="display: none; max-height: 0; overflow: hidden; opacity: 0; mso-hide: all;">It's on your calendar. Here are the details.</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;"> {{!-- Logo: the logo_url metadata field on the Scheduler configuration. Both guards are needed, because strict mode fails the render when additional_fields is missing. Replace the fallback with your own logo. --}} {{#if booking_info.additional_fields}}{{#if booking_info.additional_fields.logo_url}} <img src="{{booking_info.additional_fields.logo_url}}" alt="Company logo" width="96" style="display: block; width: 96px; height: auto; border: 0;" /> {{else}} <img src="https://developer.nylas.com/_images/email/nylas-logo.png" alt="Nylas" width="96" style="display: block; width: 96px; height: auto; border: 0;" /> {{/if}}{{else}} <img src="https://developer.nylas.com/_images/email/nylas-logo.png" alt="Nylas" width="96" style="display: block; width: 96px; height: auto; border: 0;" /> {{/if}} </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%, #18AA9C 0%, #1D4D48 45%, #1F1F1F 78%);"> {{!-- Time: the recipient's own timezone from additional_fields, then guest_timezone, then organizer_timezone. An empty organizer_timezone renders in UTC. --}} <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; word-break: break-word; overflow-wrap: anywhere;">{{booking_info.title}} is confirmed</h1> <p style="margin: 18px 0 0 0; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 17px; line-height: 26px; color: #FFFFFF; word-break: break-word;">{{#if booking_info.guest_language}}{{#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) "DDDD, t ZZZZ" booking_info.guest_language }}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "DDDD, t ZZZZ" booking_info.guest_language }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "DDDD, t ZZZZ" booking_info.guest_language }}{{/if}}{{/if}}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "DDDD, t ZZZZ" booking_info.guest_language }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "DDDD, t ZZZZ" booking_info.guest_language }}{{/if}}{{/if}}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "DDDD, t ZZZZ" booking_info.guest_language }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "DDDD, t ZZZZ" booking_info.guest_language }}{{/if}}{{/if}}{{else}}{{#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) "DDDD, t ZZZZ" "en" }}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "DDDD, t ZZZZ" "en" }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "DDDD, t ZZZZ" "en" }}{{/if}}{{/if}}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "DDDD, t ZZZZ" "en" }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "DDDD, t ZZZZ" "en" }}{{/if}}{{/if}}{{else}}{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "DDDD, t ZZZZ" "en" }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "DDDD, t ZZZZ" "en" }}{{/if}}{{/if}}{{/if}}</p> {{!-- Reschedule and cancel buttons hide when the configuration sets hide_rescheduling_options or hide_cancellation_options. Cancel buttons are always red. --}} <div style="margin-top: 20px; font-size: 0; line-height: 0;">{{#unless booking_info.hide_rescheduling_options}}{{#if booking_info.participant_rescheduling_url}}<a href="{{booking_info.participant_rescheduling_url}}" 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;">Reschedule</a>{{/if}}{{/unless}}{{#unless booking_info.hide_cancellation_options}}{{#if booking_info.participant_cancellation_url}}<a href="{{booking_info.participant_cancellation_url}}" 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: #503330; color: #FFFFFF; border: 1px solid #9A5048;">Cancel meeting</a>{{/if}}{{/unless}}</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;">It's on your calendar. Here are the details.</p> </td> </tr>
<tr> <td class="pad" style="padding: 24px 48px 0 48px;"> {{!-- Details: optional fields are wrapped in an if block, so a missing value hides the row instead of failing the render. --}} <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="table-layout: fixed;"> {{#if booking_info.duration}} <tr> <td valign="top" width="104" style="width: 104px; padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #5F6368;">Duration</td> <td valign="top" style="padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #1F1F1F; word-break: break-word; overflow-wrap: anywhere;">{{booking_info.duration}} minutes</td> </tr> {{/if}} {{#if booking_info.location}} <tr> <td valign="top" width="104" style="width: 104px; padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #5F6368;">Where</td> <td valign="top" style="padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #1F1F1F; word-break: break-word; overflow-wrap: anywhere;"><a href="{{booking_info.location}}" style="color: #0B7D72; text-decoration: underline; word-break: break-all;">{{booking_info.location}}</a></td> </tr> {{/if}} <tr> <td valign="top" width="104" style="width: 104px; padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #5F6368;">Who</td> <td valign="top" style="padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #1F1F1F; word-break: break-word; overflow-wrap: anywhere;">{{#each booking_info.participants}}{{#if name}}{{name}}{{else}}{{email}}{{/if}}{{#unless @last}}<br />{{/unless}}{{/each}}</td> </tr> {{#if booking_info.event_description}} <tr> <td valign="top" width="104" style="width: 104px; padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #5F6368;">Event description</td> <td valign="top" style="padding: 12px 0; border-bottom: 1px solid #EFEFEF; font-family: Geist, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 24px; color: #1F1F1F; word-break: break-word; overflow-wrap: anywhere;"><div class="notes">{{{booking_info.event_description}}}</div></td> </tr> {{/if}} </table> </td> </tr>
<tr> <td class="pad" style="padding: 44px 48px 48px 48px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <tr> <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;">You're receiving this email because you're a participant in this meeting.</td> </tr> </table> </td> </tr>
</table> </td> </tr> </table></body></html>The template already reads the customer’s logo from logo_url, so set that on the configuration rather than editing the template. Change colors and the sender per customer if you need to, 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