The Nylas Templates and Workflows endpoints work together to enable custom message flows triggered by specific events.
Workflows are an outbound email tool, which is why they live alongside the Email API. A workflow’s only action is to send a message, so it watches for an event anywhere in Nylas and sends email to somebody when it happens. The event can come from Scheduler, Notetaker, the Events API, or the account lifecycle, but the workflow always responds the same way, by sending email.
How templates and workflows work
Section titled “How templates and workflows work”How a workflow sends a templated email
POST /v3/workflows. When the event happens, Nylas finds your enabled workflows for it, picks the recipients and sender, and fills in the template from the event's data. If the template uses a value the event doesn't have, nothing is sent. The Dashboard's System logs record every skip and failure.Diagram as text
- Trigger: Something happens: For example, someone books a meeting (
booking.created), a user's connection expires (grant.expired), or a Notetaker recording is ready (notetaker.media). - Nylas: Find the workflows for this event: Nylas looks for enabled workflows set up for this event, both app-wide ones and ones for this specific account (grant).
- Nylas: Decide who gets the email: For bookings, the booking's participants. For created or updated calendar events (
event.created,event.updated), the event's participants. For everything else, the owner of the connected account.- One copy each:
notify_individually:"true"in bookingadditional_fieldsor eventmetadatasends one copy per participant. Without it, one message goes to everyone. - No participants → Skipped: Nobody to send to. Logged in System logs.
- One copy each:
- Nylas: Decide who the email comes from: If the workflow sets a
fromaddress, the email comes from your verified domain. Otherwise it comes from the connected account's own mailbox.- No
from, and the account can't send → Skipped: The connected account (grant) is invalid or doesn't have permission to send email. - Same event already handled → Skipped as a duplicate: If the same event arrives twice, each workflow sends only once.
- No
- Nylas: Fill in the template: Nylas replaces the template's placeholders with values from the event's data, plus
recipientwhen there's a single recipient. Every placeholder must have a value (strict mode). (Now, or afterdelayminutes)- Fallbacks:
{{#if}}…{{else}}: guard optional values such as metadata, one level at a time, with a fallback. - A placeholder has no value → Not sent: The template can't be filled in, so nothing goes out. System logs show the send as rejected.
- Fallbacks:
- Ends in one of:
- Accepted → Email sent: System logs record the send.
- Send refused → Send failed: The sending limit was reached or the send was rejected. System logs give the reason.
Application-level or grant-level workflow
Diagram as text
- Application-level workflow (Start here): Runs for every connected account (grant) in your application, so you set it up once. Create it in the Dashboard (Workflows) or with
POST /v3/workflows.- Application-level template: One email template shared across your application. An application-level workflow can only use these. Create it in the Dashboard (Email templates) or with
POST /v3/templates.
- Application-level template: One email template shared across your application. An application-level workflow can only use these. Create it in the Dashboard (Email templates) or with
- Grant-level workflow (Per-customer branding): Runs only for one connected account, so a customer can get their own version. You create one for each grant that needs it. Request:
POST /.v3/ grants/ {grant_id}/ workflows - Grant-level or application-level template: Use a template with that account's own wording, or reuse an application-level template. Request:
POST /for that grant's own template.v3/ grants/ {grant_id}/ templates
- Grant-level or application-level template: Use a template with that account's own wording, or reuse an application-level template. Request:
- Rules for either level:
- Both levels send: If an application-level and a grant-level workflow handle the same event, each one sends an email.
- Who the email comes from: Without a
fromaddress, the email comes from the connected account's own mailbox. Withfrom, it comes from your verified domain. - A custom sender needs an application-level template: A workflow that sets
fromcan't use a grant-level template. The request fails with400. - Expired or deleted accounts need your domain as sender: The account can no longer send, so
grant.expiredandgrant.deletedworkflows must send from your verified domain withfrom.
You can use the Templates endpoints to create and manage reusable email templates with custom variables. When you create a template, you specify its content and the templating engine to use when rendering it.
curl --request POST \ --url "https://api.us.nylas.com/v3/templates" \ --header 'Content-Type: application/json' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --data '{ "body": "<p>Hello, {{user.name}}. I'm testing templates from Nylas.</p>", "name": "Testing template", "subject": "Testing Nylas templates", "engine": "mustache" }'const response = await fetch("https://api.us.nylas.com/v3/templates", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer <NYLAS_API_KEY>", }, body: JSON.stringify({ body: "<p>Hello, {{user.name}}. I'm testing templates from Nylas.</p>", name: "Testing template", subject: "Testing Nylas templates", engine: "mustache", }),});
const template = await response.json();console.log(template);import requests
response = requests.post( "https://api.us.nylas.com/v3/templates", headers={ "Content-Type": "application/json", "Authorization": "Bearer <NYLAS_API_KEY>", }, json={ "body": "<p>Hello, {{user.name}}. I'm testing templates from Nylas.</p>", "name": "Testing template", "subject": "Testing Nylas templates", "engine": "mustache", },)
template = response.json()print(template)nylas template create \ --name "Testing template" \ --subject "Testing Nylas templates" \ --body "<p>Hello, {{user.name}}. I'm testing templates from Nylas.</p>"{ "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88", "data": { "app_id": "6c45fe5e-0bb6-41b9-9acc-ccb15bfc51eb", "body": "<p>Hello, {{user.name}}. I'm testing templates from Nylas.</p>", "created_at": 1640995200, "engine": "mustache", "id": "b79c82b2-a51b-4c54-8469-28006a43551a", "name": "Testing template", "object": "template", "subject": "Testing Nylas templates", "updated_at": 1640995200 }}See nylas template create for full options. Other template subcommands: list.
Nylas supports the following templating engines:
After you create a template, you can pass its ID in a Create Workflow request to define the message the workflow sends.
curl --request POST \ --url "https://api.us.nylas.com/v3/workflows" \ --header 'Content-Type: application/json' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --data '{ "delay": 5, "is_enabled": true, "name": "Test workflow", "template_id": "b79c82b2-a51b-4c54-8469-28006a43551a", "trigger_event": "message.created" }'const response = await fetch("https://api.us.nylas.com/v3/workflows", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer <NYLAS_API_KEY>", }, body: JSON.stringify({ delay: 5, is_enabled: true, name: "Test workflow", template_id: "b79c82b2-a51b-4c54-8469-28006a43551a", trigger_event: "message.created", }),});
const workflow = await response.json();console.log(workflow);import requests
response = requests.post( "https://api.us.nylas.com/v3/workflows", headers={ "Content-Type": "application/json", "Authorization": "Bearer <NYLAS_API_KEY>", }, json={ "delay": 5, "is_enabled": True, "name": "Test workflow", "template_id": "b79c82b2-a51b-4c54-8469-28006a43551a", "trigger_event": "message.created", },)
workflow = response.json()print(workflow)nylas workflow create \ --name "Test workflow" \ --template-id "b79c82b2-a51b-4c54-8469-28006a43551a" \ --trigger-event "message.created"{ "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88", "data": { "app_id": "6c45fe5e-0bb6-41b9-9acc-ccb15bfc51eb", "date_created": 1756477389, "delay": 5, "id": "b79c82b2-a51b-4c54-8469-28006a43551a", "is_enabled": true, "name": "Test workflow", "template_id": "b79c82b2-a51b-4c54-8469-28006a43551a", "trigger_event": "message.created" }}See nylas workflow create for full options. Other workflow subcommands: list.
Format dates and times in templates
Section titled “Format dates and times in templates”When you create a template, you might want to show a date or time in it given a Unix timestamp. Nylas supports formatDate, a helper function for Handlebars that renders dates and times from a Unix timestamp.
formatDate accepts the following parameters:
| Parameter | Description | Default |
|---|---|---|
timestamp * | The time to render, in seconds using the Unix timestamp format. | — |
timezone | The IANA-formatted time zone used to calculate the output. | "UTC" |
format | The output format. Supports the options described in the Luxon library. | "fff" |
locale | The output language. Supports the language codes defined in ISO 639. | "en" |
When you use formatDate, the ordering of its parameters matters. If you want to use only one optional parameter, you need to pass null for the other options. When a parameter is null, Nylas uses its default value.
Format dates and times in Handlebars templates
Section titled “Format dates and times in Handlebars templates”formatDate follows the {{ formatDate timestamp timezone format locale }} format in Handlebars templates.
curl --location 'https://api.us.nylas.com/v3/templates/render' \ --header 'Content-Type: application/json' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --data '{ "body": "The event is on {{ formatDate event.ts event.tz null \"en\" }}.", "engine": "handlebars", "variables": { "event": { "ts": 1758978000, "tz": "America/Toronto" } } }'const response = await fetch("https://api.us.nylas.com/v3/templates/render", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer <NYLAS_API_KEY>", }, body: JSON.stringify({ body: 'The event is on {{ formatDate event.ts event.tz null "en" }}.', engine: "handlebars", variables: { event: { ts: 1758978000, tz: "America/Toronto", }, }, }),});
const rendered = await response.json();console.log(rendered);import requests
response = requests.post( "https://api.us.nylas.com/v3/templates/render", headers={ "Content-Type": "application/json", "Authorization": "Bearer <NYLAS_API_KEY>", }, json={ "body": 'The event is on {{ formatDate event.ts event.tz null "en" }}.', "engine": "handlebars", "variables": { "event": { "ts": 1758978000, "tz": "America/Toronto", }, }, },)
rendered = response.json()print(rendered){ "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88", "data": { "body": "The event is on September 27, 2025 at 9:00 AM EDT." }}Where does a workflow send from?
Section titled “Where does a workflow send from?”From one of 2 places, chosen by whether the workflow carries a from object.
Omit from and the workflow sends through the grant that triggered the event, so the message comes from that user’s own mailbox. It goes out through the provider’s own API, so it lands in their Sent folder, replies return to their inbox, and it’s subject to their provider’s per-mailbox sending limits. The grant needs mail send scopes. Without them, the workflow writes Workflow <ID> skipped: the grant is not valid or is missing the scopes required to send email to the Dashboard’s System logs.
Set from and the workflow sends through transactional send on a domain you registered with Nylas. The message carries your branding rather than a person’s name, works even when the grant is broken, and needs the domain verified for sending first. A workflow with from.email must use an application-level template: a create or update request that pairs it with a grant-level template returns 400 with a Workflow with a from email must use an application-level template.
Neither is the default answer. A Scheduler confirmation usually reads better from the host’s own address, while an account notification should come from your product. grant.expired and grant.deleted have no choice and require from, because the grant that triggered them can no longer send.
Choose before you create the workflow. Once a workflow has from, you can change the address but not remove it: a PUT with "from": null returns no fields provided to update. To move a workflow back to sending through the grant, create a new workflow without from and delete the old one.
Which helper functions can I use?
Section titled “Which helper functions can I use?”Handlebars ships 6 built-in helpers, and Nylas adds formatDate, eq, and ne on top of them.
| Helper | Does | Source |
|---|---|---|
if / unless | Branch on whether a value is set | Handlebars |
each | Iterate an array | Handlebars |
with | Change the current context | Handlebars |
lookup | Read a property by a key held in a variable | Handlebars |
log | Write to the render log | Handlebars |
formatDate | Render a Unix timestamp in a timezone and locale | Nylas |
eq / ne | Test whether two values are equal or not equal | Nylas |
Use eq inside if to branch on a value, and chain cases with {{else if}}:
{{#if (eq booking_info.guest_language "es")}} Tu reunión está confirmada.{{else if (eq booking_info.guest_language "fr")}} Votre réunion est confirmée.{{else}} Your meeting is confirmed.{{/if}}There’s no or or and, so each value gets its own branch. eq follows the same strict-mode rules as any other expression: comparing a missing top-level variable is false, but (eq parent.leaf "x") throws when parent is missing.
lookup reads a property whose key is held in another variable. It works as a nested expression, which is how a Scheduler template picks a value per recipient from the booking’s additional fields:
{{lookup booking_info.additional_fields recipient.email}}That reads the additional field named after the recipient’s email address. An unmatched key renders as empty rather than throwing.
mustache supports no helpers at all, including formatDate. Set engine to handlebars explicitly on any template that formats a date or compares values.
Why does my template fail to render?
Section titled “Why does my template fail to render?”A variable was missing. The renderer runs Handlebars in strict mode, so any variable absent from the payload throws, the render returns 400, and no message is sent. The request that caused the trigger still succeeds, so nothing in your own code reports a problem.
Standard Handlebars renders a missing value as an empty string. This renderer doesn’t, so check any template you port from another system.
| Expression | Parent missing | Leaf missing, parent present |
|---|---|---|
{{parent.leaf}} | throws | throws |
{{#if parent.leaf}} | throws | safe |
{{#if parent}} | safe | n/a |
{{ formatDate missing_ts }} | throws | throws |
Guard one level at a time. A guard that reaches through a parent that doesn’t exist throws exactly like the unguarded expression:
{{#if recipient}}{{#if recipient.first_name}}Hi {{recipient.first_name}},{{else}}Hi there,{{/if}}{{else}}Hi there,{{/if}}Empty strings are safe, and several payload fields arrive empty rather than absent. Test every template twice before enabling its workflow, once with a full payload and once with the optional fields removed. Why isn’t my workflow sending email? covers the remaining failure modes.
Use notification data in template variables
Section titled “Use notification data in template variables”Workflows support 34 of the notification triggers that Nylas sends. The Workflows overview lists them. Triggers such as message.deleted and the message.transactional.* deliverability events aren’t supported. When an event triggers a workflow, Nylas automatically includes the information from its data object as a set of template variables. For example, if your project receives a booking.created notification, you can access its information in your template.
{ "specversion": "1.0", "type": "booking.created", "source": "/nylas/passthru", "id": "<WEBHOOK_ID>", "time": 1725895310, "webhook_delivery_attempt": 1, "data": { "application_id": "<NYLAS_APPLICATION_ID>", "grant_id": "<NYLAS_GRANT_ID>", "object": { "booking_id": "<BOOKING_ID>", "booking_ref": "<BOOKING_REFERENCE>", "configuration_id": "<CONFIGURATION_ID>", "object": "booking", "booking_info": { "event_id": "<EVENT_ID>", "start_time": 1719842400, "end_time": 1719846000, "participants": [ { "name": "Leyah Miller" }, { "name": "Nyla" } ], "additional_fields": { "phone": "+1-415-555-0137", "company": "Nylas Inc." }, "hide_cancellation_options": false, "hide_rescheduling_options": false, "participant_rescheduling_url": "https://example.com/reschedule/<BOOKING_REFERENCE>", "participant_cancellation_url": "https://example.com/cancel/<BOOKING_REFERENCE>", "host_language": "en", "guest_language": "en", "title": "Project sync with Leyah", "duration": 60, "location": "https://meet.google.com/abc-defg-hij", "organizer_timezone": "America/Toronto", "guest_timezone": "America/Toronto", "is_group_event": false, "organizer_calendar_id": "primary", "event_description": "Discuss Q3 roadmap and next steps", "event_html_link": "https://calendar.google.com/calendar/event?eid=abc123", "ical_uid": "040000008200E00074C5B7101A82E0080000000000000000000000000000000000", "host_confirmation_url": "https://example.com/confirm/<BOOKING_REFERENCE>" } } }}Your meeting for <b>{{ booking_info.title }}</b> is scheduled on{{ formatDate booking_info.start_time booking_info.guest_timezone }}. If youneed to reschedule,<a href="{{ booking_info.participant_rescheduling_url }}">click here</a>.Personalize notifications
Section titled “Personalize notifications”If you’re sending notifications to a single recipient, Nylas automatically passes their email address, first name, and last name to the template as a set of variables: recipient.email, recipient.first_name, and recipient.last_name respectively. You can reference these variables in your template to personalize the notification.
Notify individual participants
Section titled “Notify individual participants”For trigger events that include a participants object (for example, booking.created notifications), you can set up your workflow to send a message to all participants at once, or each participant individually.
By default, Nylas sends messages to all participants at once. To notify individual participants for bookings notifications, set notify_individually to the string "true" in the booking’s additional_fields. Any other value, including the boolean true, sends one message to everyone.
Declare notify_individually on the configuration first, as a scheduler.additional_fields entry with type: "metadata" and default: "true". Bookings made on a scheduling page then get the value automatically. A booking request that passes an undeclared field returns 400 Additional field 'notify_individually' not found in configuration. If your own code creates bookings through the Bookings API, include the value in each request, as in the following example, because the API doesn’t read default.
curl --request POST \ --url "https://api.us.nylas.com/v3/scheduling/bookings?configuration_id=<SCHEDULER_CONFIG_ID>" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "additional_fields": { "notify_individually": "true" }, "end_time": 1709645400, "guest": { "name": "Nyla", "email": "[email protected]" }, "participants": [], "start_time": 1709643600 }'To notify individual participants for events notifications, add "notify_individually": "true" to the event’s metadata object. It applies to event.created and event.updated. event.deleted workflows send to the grant owner instead.
curl --request POST \ --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events?calendar_id=<CALENDAR_ID>" \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json' \ --data '{ "busy": true, "description": "Come ready to talk philosophy!", "location": "New York Public Library, Cave room", "metadata": { "notify_individually": "true" }, "participants": [ { "name": "Leyah Miller", "email": "[email protected]" }, { "name": "Nyla", "email": "[email protected]" } ], "title": "Annual Philosophy Club Meeting", "when": { "start_time": 1674604800, "end_time": 1722382420, "start_timezone": "America/New_York", "end_timezone": "America/New_York" } }'Who receives a workflow email?
Section titled “Who receives a workflow email?”A workflow’s recipients depend on the trigger. Scheduler and event triggers send to the people on the booking or event. Every other trigger sends to the owner of the grant that triggered it.
| Trigger | Recipients |
|---|---|
booking.* | The booking’s booking_info.participants |
event.created, event.updated | The event’s participants |
All other triggers, including event.deleted | The grant owner |
Use metadata in templates
Section titled “Use metadata in templates”Templates can read custom values from 2 places. Scheduler booking triggers carry the configuration’s hidden metadata fields in booking_info.additional_fields. Event triggers carry the event’s metadata object. Guard each value with {{#if}} and give it an {{else}} fallback, because strict mode fails the render when a value is missing. A custom value can change what the email says, but it can’t stop the workflow from sending. See Use metadata in workflow templates for a per-configuration logo example.