APISendingBestPractices

Schedule email API: send later, time zones, and limits

Schedule a transactional email with one API field, send it at 9:00 in each user's time zone, and cancel it if plans change.

Olivier Bazoud
October 10, 2026
8 min read

Your app needs to send a trial-ending reminder three days before the trial ends, at 9:00 in the user's time zone. The usual answer is a cron job, a jobs table, a worker, and a way to skip the reminder if the user upgrades in the meantime. You have better things to do than maintain all that for one email.

A schedule email API replaces most of it. You send the email now with a future scheduledAt, keep the returned id, and cancel it if plans change. Nuntly supports it through the same POST /emails call you already use.

The examples follow one trial reminder with the Nuntly TypeScript SDK: you schedule it at the right local time, store its id, and cancel it when the user upgrades.

When to use a scheduled email

Schedule the email when you already know what to send and when, at the moment you call the API.

Scheduled email: an email accepted by the API now and delivered at a future time you set, without your application having to be up at that moment.

A send email later API fits cases like these:

  • Trial-ending and renewal reminders
  • "Your appointment is tomorrow" confirmations
  • A follow-up 24 hours after signup
  • A send aligned to the recipient's morning instead of your server's

Recurring sends (a weekly digest) are a loop over users, not a single email, so the schedule email API is not the right tool for them.

Schedule email API request

Add scheduledAt to a normal send request. The value is an ISO 8601 date-time. Always include the offset (Z for UTC) so the API never has to guess your time zone. RFC 3339 defines the profile of ISO 8601 used for internet timestamps.

TypeScript
import { Nuntly } from '@nuntly/sdk';
const nuntly = new Nuntly({ apiKey: process.env.NUNTLY_API_KEY });
const email = await nuntly.emails.send({
from: 'billing@yourapp.com',
to: 'user@example.com',
subject: 'Your trial ends in 3 days',
html: '<p>Your trial ends on Friday. Pick a plan to keep your data.</p>',
scheduledAt: '2026-11-03T14:00:00.000Z',
});
// { id: 'em_...', status: 'scheduled' }
await db.trials.update(trial.id, { reminderEmailId: email.id });

The same request with cURL:

Shell
curl -X POST 'https://api.nuntly.com/emails' \
-H 'Authorization: Bearer ntly_your_api_key_here' \
-H 'Content-Type: application/json' \
-d '{
"from": "billing@yourapp.com",
"to": "user@example.com",
"subject": "Your trial ends in 3 days",
"html": "<p>Your trial ends on Friday.</p>",
"scheduledAt": "2026-11-03T14:00:00.000Z"
}'

The API answers 202 Accepted with the email id and the status scheduled. No webhook fires at this point. At the scheduled time the email goes through the normal pipeline and your webhooks receive the usual email.sent, email.delivered, or email.bounced events. The sending webhooks guide covers endpoint setup and signature verification.

Store the returned id on your side, next to the trial or appointment that caused the send. You need it to cancel.

Computing "9:00 in the user's time zone"

scheduledAt is a point in time, not a local time, so converting "9:00 for this user" is up to you. Use the user's time zone name, like Europe/Paris, and let a date library do the conversion:

TypeScript
import { DateTime } from 'luxon';
const scheduledAt = DateTime.fromISO(trial.endsAt, { zone: user.timeZone })
.minus({ days: 3 })
.set({ hour: 9 })
.startOf('hour')
.toJSDate()
.toISOString();
await nuntly.emails.send({
// ...
scheduledAt,
});

A fixed offset is not enough. Paris is +01:00 in winter and +02:00 in summer, so 9:00 at +01:00 arrives at 10:00 in summer.

The limits of a scheduled send

A scheduled email must be between 5 minutes and 30 days in the future. Nuntly enforces every limit when you call the API, so a bad request fails immediately instead of at send time.

RuleLimitWhat happens otherwise
Minimum delay5 minutes in the future400 email_short_delay
Maximum horizon30 days in the future400 email_schedule_too_far
Date in the pastNot allowed400 email_past_date
Bulk requestsscheduledAt not availableSend scheduled emails one by one

The 5-minute minimum matters for "send now-ish" code. If you compute scheduledAt as "now plus a few seconds" to delay a send slightly, you will get email_short_delay. Below 5 minutes, send immediately.

Handle these errors in one place. With the TypeScript SDK they surface as a BadRequestError carrying the code:

TypeScript
import { BadRequestError } from '@nuntly/sdk';
try {
await nuntly.emails.send({ ...params, scheduledAt });
} catch (error) {
if (error instanceof BadRequestError && error.code === 'email_short_delay') {
// Less than 5 minutes away: send it now instead
await nuntly.emails.send(params);
return;
}
throw error;
}

Cancel a scheduled email

Call DELETE /emails/{id} with the id you stored. In the SDK that is emails.cancel:

TypeScript
const result = await nuntly.emails.cancel(trial.reminderEmailId);
// { id: 'em_...', status: 'canceled' }

The API answers 202 Accepted and the email's status becomes canceled. It never sends, and it stays visible with that status in the dashboard and through GET /emails/{id}. See the cancel email reference for the full contract.

A typical place to call it is the upgrade handler: the user picked a plan, so the "your trial ends soon" email no longer makes sense.

Ready to get started?

Schedule your first email with the API

Free plan available. No credit card required.

Start sending free

Handle the cancel that lost the race

Cancellation only works while the email is still scheduled. Once the send time arrives and delivery starts, the email moves on and a cancel returns 409 Conflict with the code email_not_cancellable. RFC 9110, section 15.5.10 defines the status:

The 409 (Conflict) status code indicates that the request could not be completed due to a conflict with the current state of the target resource.

This happens when your cancel call and the send time land close together. Handle it as a normal outcome:

TypeScript
import { ConflictError } from '@nuntly/sdk';
async function cancelTrialReminder(trial: Trial) {
if (!trial.reminderEmailId) return;
try {
await nuntly.emails.cancel(trial.reminderEmailId);
} catch (error) {
if (error instanceof ConflictError && error.code === 'email_not_cancellable') {
// Already sent or sending. Nothing left to cancel.
return;
}
throw error;
}
await db.trials.update(trial.id, { reminderEmailId: null });
}

Two more cases return an error instead of canceling:

  • An email that was sent immediately (never scheduled) returns the same email_not_cancellable.
  • An email that belongs to a bulk request returns 422 email_bulk_cancel_unsupported.

Avoid scheduling the same email twice

Send scheduled emails with an Idempotency-Key built from the trial id (the business event behind the send), so a retried request returns the original response instead of creating a second scheduled email.

The SDK already covers its own retries: it generates one key per send call and reuses it on each retry. The risk is your code calling send twice for the same trial, for example when a job queue redelivers a message after a timeout. You get two scheduled reminders, your database keeps only one id, and your cancel stops only one of them.

TypeScript
await nuntly.emails.send(
{ ...params, scheduledAt },
{ idempotencyKey: `trial-reminder-${trial.id}` },
);

An idempotency key is a client-chosen identifier that lets the server recognize a retried request and return the first result instead of acting twice. When you pass none, the TypeScript SDK sends a random UUID, so two separate calls never deduplicate against each other.

Nuntly keeps idempotency keys for 24 hours. Two calls for the same trial within that window return the same scheduled email. A call after 24 hours creates a second one, so check the stored email id before calling send again. The idempotency keys guide covers key strategies in depth.

What happens between the API call and the send

A scheduled email is a real email record from the moment the API accepts it. You can find it in the dashboard with status scheduled, retrieve it with GET /emails/{id}, and see its scheduled time.

The retention guide states that a scheduled email's retention window starts when the email is actually sent, not when you make the API call. A message scheduled 30 days out is never purged before it goes out. Its maximum lifetime in Nuntly is the scheduling delay plus your plan's retention window.

Once it sends, a scheduled email follows the same path as any other: same events, same observability, same webhooks.

Checklist

  • Send with scheduledAt as an ISO 8601 instant with an explicit offset
  • Compute local times from a time zone name like Europe/Paris, never a fixed offset
  • Store the returned email id on your side
  • Stay between 5 minutes and 30 days, and send immediately below 5 minutes
  • Treat 409 email_not_cancellable on cancel as "too late to cancel"
  • Add an Idempotency-Key built from the trial id (the business event behind the send)

Scheduling one email should take one field, not a cron job. Create a free Nuntly account, follow the quickstart to send your first email, then add scheduledAt and cancel it from the API to see the full lifecycle.

Ready to get started?

Ship emails, not infrastructure

Free plan available. No credit card required.

Start sending free