You want an email to go out at 9am in the recipient's timezone, not at 2am when your batch job happens to run. Or you're sending a follow-up three days after a signup, or a reminder the morning of an event. The naive answer is a job queue, a worker, and a row in a database tracking when to fire. The Nylas Email API gives you scheduled send directly: set one field on the send request and the message goes out at the time you pick, with nothing to run in between.
This post is a working tour of scheduled send from two angles: the HTTP API for your backend, and the Nylas CLI for the terminal and quick tests. I work on the CLI, so the terminal commands below are the ones I reach for when I'm checking that a schedule landed.
How scheduled send works
Scheduled send is a normal send with one extra field. On a POST /messages/send request, you add send_at set to a Unix timestamp, and Nylas holds the message until that time instead of sending immediately. Omit send_at and the message goes out now; include it and you've scheduled it. There's no separate endpoint and no queue to operate.
Where the message waits depends on how you schedule it, and the choice changes the limits:
-
Stored by Nylas (default). The message is held until
send_at, then sent. The time must be at least one minute in the future and no more than 30 days out. -
Stored on the provider. Set
use_drafttotrueand the message is kept as a draft in the user's Drafts folder on Google or Microsoft, which lets you schedule it for any time in the future with no 30-day ceiling. This option works for Google and Microsoft Graph accounts only.
Either way, Nylas keeps the schedule's tracking details for 72 hours after the scheduled send time, whether the send succeeds or fails, then clears them from its cache. That window is what you query to inspect a scheduled message's status.
Schedule a message
To schedule over the API, add send_at to the send body as a Unix timestamp. The request below sends a reminder at a fixed future time; everything else is an ordinary send with to, subject, and body. The response returns a schedule_id you keep if you want to check or cancel the send later.
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"subject": "Your appointment is tomorrow",
"body": "This is a reminder about your 10am appointment.",
"to": [{ "email": "customer@example.com" }],
"send_at": 1744714800
}'
The CLI hides the timestamp math behind a --schedule flag that accepts human-friendly times. nylas email send --schedule takes relative expressions like "in 2 hours" or "tomorrow 9am" as well as absolute timestamps, which is far easier than converting a date to epoch seconds by hand when you're testing:
nylas email send \
--to customer@example.com \
--subject "Your appointment is tomorrow" \
--body "This is a reminder about your 10am appointment." \
--schedule "tomorrow 9am"
To store the message as a provider draft instead of on Nylas, add "use_draft": true to the API body. That removes the 30-day limit and parks the message in the user's Drafts folder until it sends, which is the right choice for sends scheduled far in advance on a Google or Microsoft account.
Send at the recipient's local time
The most common reason to schedule is timezone: you want a message to land at 9am where the recipient is, not at 9am where your server runs. send_at is an absolute Unix timestamp, so the work happens in your application, where you convert "9am tomorrow in the recipient's zone" to epoch seconds and pass that. Store each contact's timezone and the conversion is one line.
# Python: 9am tomorrow in the recipient's timezone as a Unix timestamp
python3 -c "
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
tz = ZoneInfo('America/New_York')
when = (datetime.now(tz) + timedelta(days=1)).replace(hour=9, minute=0, second=0, microsecond=0)
print(int(when.timestamp()))
"
Because send_at resolves to an absolute moment in time, the same timestamp fires at the correct local hour no matter where your servers sit. That's the whole appeal: compute the local time once, hand over the epoch seconds, and the message arrives in the recipient's morning whether your worker runs in Oregon or Frankfurt. For a quick manual test the CLI's --schedule "tomorrow 9am" is easier, but it resolves against the machine running the command, so production timezone logic belongs in your code with an explicit send_at.
List your scheduled messages
Once messages are queued, you'll want to see what's pending. GET /messages/schedules returns every scheduled message for the grant as an array, each with a schedule_id, a status object, and a close_time once the send has run. These endpoints need no scopes, because the schedule records live with Nylas rather than the provider.
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/schedules" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
The status.code tells you where each send is: pending means it's queued and awaiting its send_at time, and success means it has already gone out, with a close_time Unix timestamp recording when. A response listing one of each looks like this:
[
{
"schedule_id": "8cd56334-6d95-432c-86d1-c5dab0ce98be",
"status": { "code": "pending", "description": "schedule send awaiting send at time" }
},
{
"schedule_id": "rb856334-6d95-432c-86d1-c5dab0ce98be",
"status": { "code": "success", "description": "schedule send succeeded" },
"close_time": 1690579819
}
]
From the terminal, nylas email scheduled list prints the same set in a readable table, and nylas email scheduled list --json gives you the raw objects for scripting. It's the fastest way to glance at what's queued without writing a request by hand.
Check a single scheduled message
When you have a schedule_id and want just that one, GET /messages/schedules/{scheduleId} returns its current status. This is what you'd poll after scheduling to confirm a specific message moved from pending to success, rather than pulling the whole list each time.
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/schedules/<SCHEDULE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
The CLI equivalent is nylas email scheduled show <SCHEDULE_ID>, which prints the same status for one message. Remember the 72-hour window: after a message sends, its schedule record stays queryable for three days and then disappears. A lookup for an old schedule_id comes back as a 404 once the record has cleared that window, and that 404 tells you nothing about whether the send succeeded or failed — rely on the message.send_success and message.send_failed webhooks below to confirm the actual outcome.
Cancel a scheduled message
Plans change, and a queued message can be pulled back as long as it hasn't sent yet. DELETE /messages/schedules/{scheduleId} cancels a pending send and returns a 202 confirming the cancellation was requested. The one hard rule: send the cancel request at least 10 seconds before the send_at time, because inside that final window the message is already on its way out.
curl --request DELETE \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/schedules/<SCHEDULE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
From the CLI, nylas email scheduled cancel <SCHEDULE_ID> does the same. Build your application logic around that 10-second cutoff: if a user can cancel a scheduled message from your UI, stop offering the option once the send is within ten seconds, or you'll show a cancel button that can't reliably work.
Build a reminder or follow-up sequence
Scheduled send shines when one event needs several messages spread over time, like a signup that triggers a welcome immediately, a tips email in three days, and a check-in after a week. The welcome goes out as an ordinary send with no send_at, while the two later messages are scheduled at signup with future send_at values. There's no scheduler waking up daily to decide what to send, and both scheduled times sit well inside the 30-day ceiling.
# Day 3 tips email, queued at signup
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"subject": "Getting the most out of your account",
"body": "Here are three things to try this week...",
"to": [{ "email": "newuser@example.com" }],
"send_at": 1744974000
}'
Store each returned schedule_id against the user record, and the sequence becomes cancellable as a unit: if the user unsubscribes or churns on day two, you cancel the remaining schedule_id values and the later messages never go out. That's far simpler than a stateful worker tracking where each user sits in the funnel, because the schedule itself holds the timing.
Reschedule by cancelling and re-creating
There's no reschedule endpoint, and that's a deliberate simplicity rather than a gap. To move a queued message to a new time, cancel the existing schedule and create a fresh one with the new send_at. The flow is two calls: DELETE the old schedule_id, then POST /messages/send again with the updated timestamp, capturing the new schedule_id it returns.
Because the cancel has the same 10-second cutoff as any other cancellation, do the reschedule well before the original send time, not seconds before it. If you let your users drag an appointment reminder to a new slot, apply the change as soon as they make it rather than batching reschedules, so you're never racing the original send_at. The new schedule then follows the same one-minute-to-30-day rule as any other Nylas-stored send.
Track delivery with webhooks
Polling the schedules endpoint gives you a message's status whenever you ask, but webhooks push the outcome the moment it resolves, with no timer of your own to run. Scheduled send emits two triggers that fire only when send_at is set: message.send_success when a scheduled message is sent and delivered, and message.send_failed when it's attempted but not delivered. Subscribe to both and your application learns the outcome of every scheduled send without checking the schedules endpoint on a timer.
nylas webhook create \
--url https://yourapp.example.com/webhooks/nylas \
--triggers message.send_success,message.send_failed
The same subscription over the API is a POST /v3/webhooks with those two trigger_types and your callback URL. Wiring up message.send_failed in particular matters: a scheduled message that fails at send time, hours after you queued it, is invisible to your application otherwise, and the failure webhook is how you catch it and retry or alert.
Provider requirements and scopes
Scheduled send works across all supported providers, but actually sending the message still needs the right send scope on the grant. For a Google grant that's gmail.send; for Microsoft it's Mail.ReadWrite and Mail.Send. The schedule management endpoints themselves, listing, checking, and cancelling, require no scopes, because those records are stored by Nylas, so you can always inspect and cancel a queued message even while you're still sorting out send permissions.
The provider-draft option narrows further. Storing the message as a draft with use_draft: true is supported on Google and Microsoft Graph accounts only, since it relies on the provider's own Drafts folder. On other providers, stick with the default Nylas-stored mode and its one-minute-to-30-day window, which covers the large majority of scheduling needs anyway.
Things to keep in mind
A few details separate a scheduled-send feature that works from one that surprises you in production.
-
Save the
schedule_id. It's the only handle for checking or cancelling a send. Store it alongside whatever record triggered the schedule. -
Respect the 10-second cancel cutoff. A cancel inside the final ten seconds before
send_atisn't guaranteed, so gate your cancel UI on it. -
Mind the 30-day ceiling on Nylas-stored sends. For anything further out, use
use_draft: trueon a Google or Microsoft grant. -
Schedule records expire 72 hours after send. Don't treat a missing old
schedule_idas an error; it just aged out of the cache. -
Subscribe to
message.send_failed. A failure at send time is otherwise silent, and it's the one outcome you most need to know about. -
Queue whole sequences up front. For a multi-step drip, schedule every message at once and store its
schedule_id, rather than running a worker that decides what to send each day. - Reschedule early, not late. Cancelling and re-creating is the only way to move a send, and it inherits the 10-second cutoff, so apply changes well before the original time.
Wrapping up
Scheduled send turns "email the user at the right time" from a queue-and-worker project into one field on a send request. Set send_at, keep the schedule_id, and use the schedules endpoints or the CLI's nylas email scheduled commands to list, check, and cancel. Add the two send webhooks and you have full visibility into when each message actually goes out, all without running any scheduling infrastructure of your own.
Where to go next:
- Schedule messages to send in the future — the full guide, including provider drafts
-
Send a message — the
send_atanduse_draftfields and every other send option - Get scheduled messages and cancel a scheduled message — the schedule management endpoints
-
Nylas CLI email send — the
--scheduleflag and supported time formats
Top comments (0)