Skip to content
Skip to main content

Customize Scheduler email for your app

Last updated:

Scheduler sends booking confirmations for you, and you can’t change their wording or branding. An application-level workflow replaces them with your own HTML, using one template that applies to every user of your product.

This recipe covers the whole booking lifecycle: confirmation, reschedule, cancellation, pending approval, and reminder. If each of your customers needs their own branding, use Customize Scheduler email for each user instead.

A branded booking confirmation email showing the meeting title, time in the recipient's timezone, duration, join link, attendee list, and a reschedule button

How do I replace the Scheduler confirmation email?

Section titled “How do I replace the Scheduler confirmation email?”

Create a template, point an application-level workflow at it, then turn off Nylas’s own email on the Scheduler configuration. The workflow fires for every grant in the application, so one template covers every user.

Start with the template. Set engine to handlebars explicitly, because the API default is mustache and mustache can’t call the formatDate helper you need for times.

curl -X POST 'https://api.us.nylas.com/v3/templates' \
-H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \
-d '{
"name": "Booking confirmed",
"engine": "handlebars",
"subject": "Confirmed: {{booking_info.title}}",
"body": "<html>…</html>"
}'

body is a JSON string, so every quote and line break in the HTML needs escaping. Rather than escaping by hand, let jq build the request from the template file:

jq -n --rawfile body booking-created.html \
'{name: "Booking confirmed", engine: "handlebars",
subject: "Confirmed: {{booking_info.title}}", body: $body}' \
| curl -X POST 'https://api.us.nylas.com/v3/templates' \
-H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \
-d @-

Then the workflow. delay is in minutes and 0 sends immediately.

The from address decides who the confirmation appears to come from. Omit it and the message sends through the host’s own grant, so the guest sees it arrive from the host’s real address, in the same thread as their other correspondence, with replies landing in the host’s inbox. That needs no domain setup, only mail send scopes on the grant. Set from and it sends through transactional send on a domain you registered, which gives every host the same branding. The Workflows overview compares both.

For booking confirmations the host’s own address is often the better choice, because a confirmation that looks personal gets replied to and a no-reply one doesn’t.

curl -X POST 'https://api.us.nylas.com/v3/workflows' \
-H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \
-d '{
"name": "Booking confirmed",
"trigger_event": "booking.created",
"template_id": "<TEMPLATE_ID>",
"delay": 0,
"is_enabled": true,
"from": { "email": "[email protected]", "name": "Your Company" }
}'

Drop the from line entirely to send from the host’s mailbox instead. Everything else stays the same.

Repeat for each of the 5 booking triggers with its own template. booking.rescheduled and booking.reminder drop 4 fields that booking.created carries, and booking.cancelled has no booking_ref, so one shared template throws on whichever trigger lacks a field it references.

How do I stop Nylas sending its own email?

Section titled “How do I stop Nylas sending its own email?”

Set disable_emails: true on the Scheduler configuration. Two fields control this independently, and your choice depends on whether guests also need the calendar provider’s invitation.

disable_emailsnotify_participantsMessages the guest receives
falsefalseNylas Scheduler email plus your workflow email
truefalseYour workflow email only
truetrueYour workflow email plus the Google or Microsoft calendar invitation

Choose notify_participants: true when guests need a calendar reply button and a .ics attachment, which an HTML email can’t provide. The provider sends that invitation from the organizer’s own address rather than from Nylas, so it won’t carry your branding. Microsoft grants ignore notify_participants and always notify, so the workflow-only setup isn’t achievable on Microsoft.

{
"event_booking": {
"title": "Meeting with ${invitee}",
"booking_type": "booking",
"disable_emails": true,
"notify_participants": false,
"reminders": [{ "type": "webhook", "minutes_before_event": 30 }]
},
"scheduler": {
"additional_fields": {
"notify_individually": {
"label": "Notify individually",
"type": "metadata",
"required": false,
"default": "true"
}
}
}
}

reminders with type: "webhook" is what makes booking.reminder fire, and without it that trigger never runs. Use booking_type: "organizer-confirmation" for booking.pending, which also requires scheduler.organizer_confirmation_url or the request returns 400. Updating a configuration uses PUT and replaces the whole object, so send the existing configuration back with your changes rather than a partial body.

Set notify_individually on the booking. Nylas then sends one message per participant and populates recipient.email, recipient.first_name, and recipient.last_name for each. By default it sends a single message to all participants, leaving recipient undefined so any template referencing it fails to render.

The field has to be declared on the configuration first, as shown above. Passing it on the booking alone returns 400 Additional field 'notify_individually' not found in configuration.

{
"additional_fields": { "notify_individually": "true" },
"start_time": 1790182800,
"end_time": 1790184600,
"participants": [],
"guest": { "name": "John Smith", "email": "[email protected]" }
}

A booking with a guest named John Smith and an organizer named Nick then produces 2 messages from one template, reading Hi John, and Hi Nick,.

The template is ordinary HTML with {{variable}} placeholders filled from the event payload. Four parts do the real work.

The greeting guards two levels. recipient is absent whenever notify_individually is off, and present but unnamed when a participant has no display name. The renderer throws on a missing parent, so a single guard isn’t enough.

{{#if recipient}}{{#if recipient.first_name}}Hi {{recipient.first_name}},{{else}}Hi there,{{/if}}{{else}}Hi there,{{/if}}

The time resolves per recipient. booking_info is identical in every copy of a message and only recipient changes between them, so one timezone expression would give every recipient the same zone. lookup works as a nested expression, which is enough to pick a zone per person.

{{#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" booking_info.guest_language }}
{{else}}
{{#if booking_info.guest_timezone}}{{ formatDate booking_info.start_time booking_info.guest_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" booking_info.guest_language }}{{else}}{{ formatDate booking_info.start_time booking_info.organizer_timezone "EEEE d LLLL yyyy, h:mm a ZZZZ" booking_info.guest_language }}{{/if}}
{{/if}}{{/if}}{{/if}}

That resolves in 4 steps: the recipient’s own timezone, then guest_timezone, then organizer_timezone, then UTC. guest_timezone is only set when the guest supplies one and is an empty string otherwise, which formatDate would silently treat as UTC, so the {{#if}} skips it when empty. organizer_timezone carries the configuration’s event_booking.timezone, and if that’s empty too, formatDate uses UTC.

Supply the per-recipient dictionary in the booking request, because the configuration’s default value isn’t applied to the payload. Email addresses are valid field keys.

"additional_fields": {
"notify_individually": "true",
"[email protected]": "America/Chicago",
"[email protected]": "America/New_York"
}

One booking with a host in America/Chicago and a guest in America/New_York then produces 1:00 PM EDT for the guest and 12:00 PM CDT for the host, from the same template. Leave the guest out of the dictionary and they get organizer_timezone instead, because guest_timezone is empty on bookings made through the API.

Optional fields are wrapped. location arrives as an empty string on many bookings, so the Where row is wrapped in {{#if booking_info.location}} and disappears cleanly rather than leaving a dangling label.

The locale is a variable. formatDate takes a timestamp, an IANA timezone, a Luxon token string, and a locale, and all 4 accept variables. Passing booking_info.guest_language renders miércoles 23 septiembre 2026 for es and mercredi 23 septembre 2026 for fr with no extra work.

This is the booking.created template from the screenshot above. The other 4 booking triggers follow the same structure with their own fields.

Render each one with POST /v3/templates/render before enabling its workflow. A missing variable returns 400 and sends nothing, with no error on the booking request, so Why isn’t my workflow sending email? is worth reading before you go live.