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”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; one authenticated with identity-only scopes writes Grant is not valid or does not have required scopes to send emails to the log and delivers nothing.
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.
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 all notifications that Nylas sends. 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. If you want to notify individual participants for bookings notifications, add "notify_individually": "true" to the additional_fields object.
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 metadata object.
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" } }'