# Why isn't my workflow sending email?

Source: https://developer.nylas.com/docs/cookbook/workflows/workflow-not-sending/

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?

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.

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

```json
{ "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?

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:

```handlebars
{{#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](/docs/reference/notifications/).

## 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

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](/docs/v3/getting-started/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?

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:

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

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

**`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](/docs/cookbook/workflows/scheduler-booking-email/) shows how to resolve it per recipient with `lookup`.

## What's next

- [Workflows overview](/docs/cookbook/workflows/) lists every trigger and which ones are safe at volume
- [Templates and workflows](/docs/v3/email/templates-workflows/) documents the variable model and the `formatDate` helper
- [Notification reference](/docs/reference/notifications/) has a sample payload for every trigger to test against