Your workflow is enabled, the trigger fired, the booking request returned 200, and no email arrived. Nothing in the API response tells you why.
Almost always the template failed to render. This page covers that failure first, then the other causes in the order worth checking them.
Why does a workflow fire but send nothing?
Section titled “Why does a workflow fire but send nothing?”A template variable was missing. The renderer runs Handlebars in strict mode, so any variable absent from the event payload throws, the render returns 400, and the send is abandoned. The request that caused the trigger still succeeds, because creating a booking and delivering a workflow email are independent operations.
Standard Handlebars renders a missing value as an empty string. This renderer doesn’t, which is the single most common surprise when moving an existing template across.
Confirm it by rendering the template yourself against the payload shape the trigger actually sends. POST /v3/templates/render takes an ad-hoc body, engine, and variables, returns the rendered HTML, and saves nothing.
curl -X POST 'https://api.us.nylas.com/v3/templates/render' \ -H "Authorization: Bearer $NYLAS_API_KEY" -H 'Content-Type: application/json' \ -d '{"body":"<p>Hi {{recipient.first_name}}</p>","engine":"handlebars", "variables":{"recipient":{"first_name":"John"}}}'A 400 here is your answer, and the message names the variable:
{ "error": { "type": "api.invalid_request_error", "message": "Error rendering template: \"first_name\" not defined in undefined - 1:8" } }not defined in undefined means the parent was missing, not the leaf. In that example recipient itself was absent, which happens whenever a message goes to all participants at once rather than to each one individually.
How do I guard a variable correctly?
Section titled “How do I guard a variable correctly?”Guard one level at a time. A guard that reaches through a missing parent throws exactly like the unguarded expression does, so {{#if parent.leaf}} isn’t a safe way to test an object that might not exist.
| 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 |
Empty strings are safe, and several fields arrive empty rather than absent. On real bookings location and guest_timezone are usually "", which renders harmlessly. Genuinely absent fields are the dangerous ones, and they vary by trigger.
For an object that might not exist, nest the guards:
{{#if recipient}}{{#if recipient.first_name}}Hi {{recipient.first_name}},{{else}}Hi there,{{/if}}{{else}}Hi there,{{/if}}Both levels are needed. The outer one handles recipient being absent, the inner one handles it being present but unnamed. Skipping the outer guard reintroduces the original failure.
Test every template twice before enabling it: once with a full payload, once with a payload where the optional fields are empty and any optional object is absent. If the second render succeeds, the guards hold. Sample payloads for every trigger are published in the notification reference.
Why do payload fields differ between triggers?
Section titled “Why do payload fields differ between triggers?”Each trigger carries its own payload, and templates written for one often break on another. Fields that exist on booking.created are missing on booking.rescheduled and booking.reminder, so a shared template throws on whichever trigger lacks a field it references.
| Trigger | Drops, relative to booking.created | Adds |
|---|---|---|
booking.rescheduled | event_description, event_html_link, ical_uid, host_confirmation_url | old_start_time, old_end_time |
booking.cancelled | host_confirmation_url, and no booking_ref on the object | cancellation_reason |
booking.reminder | event_description, event_html_link, ical_uid, host_confirmation_url | organizer_email, provider, time_until_event |
Write one template per trigger. Reusing a single template across all of them is the second most common cause of a workflow that sends for some events and not others.
The template renders, but still nothing arrives
Section titled “The template renders, but still nothing arrives”Work through these in order.
The workflow has no sender. Without a from object the workflow sends through the grant that triggered the event. A grant authenticated with identity-only scopes can’t send mail, so the attempt writes a warning to the log, delivers nothing, and returns no error. Set from to an address on a domain verified for transactional send.
The workflow is disabled. Check is_enabled on the workflow, not just that it exists.
The trigger never fired. Some triggers depend on configuration rather than on the workflow. booking.reminder only fires when the Scheduler configuration carries a reminders array with type: "webhook". booking.pending only fires on a configuration using booking_type: "organizer-confirmation".
The engine can’t do what the template asks. mustache is the API default and can’t call helper functions at all, so {{ formatDate start_time }} fails there with Missing variable: formatDate start_time. Set engine to handlebars explicitly on every template.
Why did the same person get two messages?
Section titled “Why did the same person get two messages?”You have a workflow at both scopes. Application-level and grant-level workflows fire independently on the same trigger, so a grant covered by both receives 2 messages.
The Dashboard lists only application-level workflows, which makes the grant-level ones easy to forget. List both:
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"Duplicates also appear when Nylas’s own email is still on alongside your workflow. For Scheduler, set disable_emails: true on the configuration so the workflow is the only sender.
Messages arrive, but a name or time is wrong
Section titled “Messages arrive, but a name or time is wrong”Hi , with an empty name. The variable is present but empty, which passes a strict-mode render. Guard it with {{#if}} rather than assuming a value, and give the else branch a neutral greeting.
Times show UTC. booking_info.guest_timezone is only set when the guest supplies a timezone, and formatDate treats an empty timezone as UTC without returning an error. Fall back to booking_info.organizer_timezone, which carries the configuration’s event_booking.timezone.
Everyone gets the same timezone. booking_info is identical in every copy of a message and only recipient changes between them, so one timezone expression gives every recipient the same zone. Customize Scheduler email for your app shows how to resolve it per recipient with lookup.
What’s next
Section titled “What’s next”- Workflows overview lists every trigger and which ones are safe at volume
- Templates and workflows documents the variable model and the
formatDatehelper - Notification reference has a sample payload for every trigger to test against