# Customize Scheduler email for each user

Source: https://developer.nylas.com/docs/cookbook/workflows/scheduler-email-per-user/

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?

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?

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.

```bash
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>"
  }'
```

```bash
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": "bookings@acme.com", "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?

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?

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`](/docs/reference/notifications/grants/grant-created/) as a webhook, and on delivery create the grant-level workflows for that grant. Mirror it with [`grant.deleted`](/docs/reference/notifications/grants/grant-deleted/) so workflows are removed when a customer disconnects, or you accumulate workflows pointing at grants that no longer exist.

```bash
# list what a grant currently has, for reconciliation
curl "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?

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:

```bash
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

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](/docs/cookbook/workflows/scheduler-booking-email/) explains each of those, and the templates there work unmodified at the grant level.

```html [schedulerUserTemplate-booking.created]

<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}} &lt;{{email}}&gt;{{#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

- [Customize Scheduler email for your app](/docs/cookbook/workflows/scheduler-booking-email/) for the template walkthrough and the Scheduler configuration settings
- [Why isn't my workflow sending email?](/docs/cookbook/workflows/workflow-not-sending/) for duplicate email and silent render failures
- [Welcome users when they connect an account](/docs/cookbook/workflows/welcome-connected-account/) uses the same `grant.created` event you provision from
- [Grant-level workflows API](/docs/reference/api/grant-level-workflows/) and [grant-level templates API](/docs/reference/api/grant-level-templates/)