How to schedule email API sends: a chosen time, a reschedule and a cancel
Published Updated 11 min readBy the Rasket team

What a schedule email API does
A schedule email API is an endpoint that accepts a message now and sends it at a time you name. The request is the same POST /emails you use for an immediate send, with one extra field, scheduled_at, and the response is the same id. What changes is the email’s state between the two: it sits at last_event: scheduled until the instant arrives, and while it sits there it can be moved or cancelled.
That is a narrower promise than a job queue makes, and deliberately so. The API holds one message for one time; it does not run a cron expression, repeat, or evaluate a rule when the time comes. If the decision to send depends on something that happens later — a customer who upgrades before the reminder goes out — your code makes that decision and cancels. The rest of this guide is the five calls that cover it.
Scheduling a send in five steps
- Pick an instant in ISO 8601 — Write the time as a full timestamp with a zone, such as 2026-09-23T07:00:00Z, between one minute and thirty days from now. Convert the customer's wall-clock time to UTC on your side first.
- Send with scheduled_at — Call POST /emails exactly as for an immediate send, with scheduled_at added to the body, a User-Agent header and an Idempotency-Key derived from the reminder. The response is the email id; store it.
- Read the scheduled state — GET /emails/{id} reports last_event: scheduled until the instant arrives, then the ordinary states from sent onwards. Subscribe to email.scheduled if you would rather not poll.
- Reschedule with PATCH — PATCH /emails/{id} with a new scheduled_at moves the send. It is accepted only while last_event is still scheduled; nothing else about the message can be changed this way.
- Cancel with POST /emails/{id}/cancel — A cancel before dispatch answers 200 and emits email.canceled. After dispatch it answers 409 resource_locked, and there is no recall of a message that has already left.
Schedule
curl https://api.rasket.com/emails \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: trial-reminder-1042" \ -d '{ "from": "Acme <orders@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": "Your trial ends tomorrow", "html": "<p>Your trial ends on 24 September.</p>", "scheduled_at": "2026-09-23T07:00:00Z" }'Two headers are the same as on any send. User-Agent is required — a request without one is refused — and Idempotency-Key names the thing that caused the send, here the trial reminder for order 1042, so a retried request cannot schedule the reminder twice. The scheduled_at value is a full timestamp with a zone; the Z means UTC.
The 200 means the message is recorded durably, not that anything has been sent. That is the useful property of scheduling on the API rather than with a timer in your own process: a deploy, a crash or a scaled-down worker between now and the instant changes nothing, because the thing holding the message is not your process. The id in the response is what every later call and every later event refers to, so store it against the row that caused the send.
Check
curl https://api.rasket.com/emails/3f0c1d2e-9a4b-4c8d-b7e6-5f1a2b3c4d5e \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0"
# ... "last_event": "scheduled" ...last_event is the furthest state the email has reached, not a history. A scheduled email reports scheduled until dispatch, then walks the same path as any other send: sent, delivered, or bounced, failed and the rest. The emails reference lists every value.
Reschedule
# Move it by a day. Allowed only while last_event is "scheduled".curl -X PATCH https://api.rasket.com/emails/3f0c1d2e-9a4b-4c8d-b7e6-5f1a2b3c4d5e \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0" \ -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-09-24T07:00:00Z" }'Cancel
curl -X POST https://api.rasket.com/emails/3f0c1d2e-9a4b-4c8d-b7e6-5f1a2b3c4d5e/cancel \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0"
# 200 { "object": "email", "id": "3f0c1d2e-9a4b-4c8d-b7e6-5f1a2b3c4d5e" }Both of the last two are only valid while the email is still scheduled. Once it has been dispatched, either call answers with a 409:
HTTP/1.1 409 ConflictContent-Type: application/json
{ "statusCode": 409, "name": "resource_locked", "message": "This email has already been dispatched."}The same flow from Node and Python
The clients wrap the four calls without changing them. The send takes the idempotency key as an option, and update and cancel take the id the send returned.
import { Rasket } from "rasket";
const rasket = new Rasket({ apiKey: process.env.RASKET_API_KEY, userAgent: "acme-billing/1.0",});
// 09:00 on the customer's clock (UTC+2 that day), sent to the API as an instant.const sendAt = new Date("2026-09-23T09:00:00+02:00").toISOString();// "2026-09-23T07:00:00.000Z"
const { body: email } = await rasket.emails.send( { from: "Acme <orders@send.acme.example>", to: ["ronald.williams@example.com"], subject: "Your trial ends tomorrow", html: "<p>Your trial ends on 24 September.</p>", scheduled_at: sendAt, }, { idempotencyKey: "trial-reminder-1042" },);
// The customer extended the trial: push the reminder by a day.await rasket.emails.update(email.id, { scheduled_at: "2026-09-24T07:00:00Z" });
// They upgraded: the reminder is no longer wanted.await rasket.emails.cancel(email.id);The Node sample builds the timestamp from an offset. Date parses the +02:00, and toISOString() always emits UTC with a Z, so the API receives an instant rather than a wall-clock reading.
import osfrom datetime import datetime, timezonefrom zoneinfo import ZoneInfo
from rasket import Rasket, RasketApiError
rasket = Rasket( api_key=os.environ["RASKET_API_KEY"], user_agent="acme-billing/1.0",)
# 09:00 on the customer's wall clock, converted to UTC before it leaves.local = datetime(2026, 9, 23, 9, 0, tzinfo=ZoneInfo("Europe/Berlin"))send_at = local.astimezone(timezone.utc).isoformat() # "2026-09-23T07:00:00+00:00"
result = rasket.emails.send( { "from": "Acme <orders@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": "Your trial ends tomorrow", "html": "<p>Your trial ends on 24 September.</p>", "scheduled_at": send_at, }, idempotency_key="trial-reminder-1042",)email_id = result.body["id"]
# Later: the customer extended, then upgraded.rasket.emails.update(email_id, {"scheduled_at": "2026-09-24T07:00:00Z"})
try: rasket.emails.cancel(email_id)except RasketApiError as error: if error.name != "resource_locked": raise # Already dispatched. There is nothing to cancel and no recall.The Python sample does the conversion explicitly with zoneinfo from the standard library: build the wall-clock time in the customer’s zone, then astimezone to UTC. isoformat() writes the offset as +00:00, which the API accepts exactly as it accepts Z. The cancel is wrapped because by the time a customer upgrades, the reminder may already have gone.
A bad time fails at the request, not at the instant. A scheduled_at the API cannot parse, or one outside the window, is a 422 validation_error whose errors array names the field, so the calling job sees the mistake while it can still fix it. In Node that is a RasketApiError with name set; in Python the same class with error.errors. Treat it like any other validation error: log it and do not retry, since the same payload will fail the same way.
Time zones and daylight saving
scheduled_at is an instant, not a wall-clock time. The API does not know where the recipient lives and does not try to guess, so the conversion from “nine in the morning for this customer” to a point on the timeline is yours, and it is where scheduling bugs live. Three rules cover almost all of them.
- Send UTC or an explicit offset.
2026-09-23T07:00:00Zand2026-09-23T09:00:00+02:00are the same instant and both are accepted. A timestamp with no zone at all is ambiguous, and RFC 3339, the profile of ISO 8601 the API follows, requires the offset for exactly that reason. - Store the zone, not the offset. A customer is in
Europe/Berlin, an IANA time zone name, and that zone is+02:00in September and+01:00in November. If you store the offset you saw at sign-up, every reminder scheduled across a clock change lands an hour off. Store the zone and compute the offset when you schedule. - Compute the instant on your side. In Python,
zoneinfoplusastimezone, as above. In JavaScript,Intl.DateTimeFormatcan read a wall-clock time in a named zone andTemporal.ZonedDateTimecan build one from it directly; a plainDateonly knows the offset of the machine it runs on.
The daylight saving trap
Twice a year a wall-clock time is not a valid instant. When clocks go forward, an hour is skipped: 02:30 on that morning in a zone that springs forward does not exist. When they go back, an hour is repeated: 02:30 happens twice, an hour apart. A library that converts wall-clock to instant has to decide what to do with both, and the decisions differ. zoneinfo follows the fold attribute for the repeated hour and shifts the missing one forward; Temporal takes a disambiguation option.
from datetime import datetime, timezonefrom zoneinfo import ZoneInfo
berlin = ZoneInfo("Europe/Berlin")
# 29 March 2026: clocks go forward at 02:00, so 02:30 does not exist.datetime(2026, 3, 29, 2, 30, tzinfo=berlin).astimezone(timezone.utc)# 2026-03-29 01:30:00+00:00 (read as 03:30 on the new clock)
# 25 October 2026: clocks go back at 03:00, so 02:30 happens twice.datetime(2026, 10, 25, 2, 30, tzinfo=berlin, fold=0).astimezone(timezone.utc)# 2026-10-25 00:30:00+00:00 (the first 02:30)datetime(2026, 10, 25, 2, 30, tzinfo=berlin, fold=1).astimezone(timezone.utc)# 2026-10-25 01:30:00+00:00 (the second, an hour later)Nothing in that sample is wrong, and both results are surprising if the customer asked for “half past two”. A reminder in the skipped hour goes out an hour later than the wall clock said; a reminder in the repeated hour goes out at whichever of the two the library chose, and a colleague’s library may choose the other.
The practical fix is to schedule product mail at times that are never inside the changeover window — 09:00 is safe everywhere, 02:30 is not — and to compute the instant as late as you can, so a zone rule that changed since sign-up is the rule you use.
Idempotency with schedules
A scheduled send is more exposed to duplicates than an immediate one, because the code that schedules it usually runs from a job — a nightly pass over trials ending tomorrow — and jobs get retried. The Idempotency-Key header is what makes that safe: the same key with the same payload inside 24 hours replays the first response and schedules nothing, and the same key with a different payload is refused.
Derive the key from the reminder, not from the run. trial-reminder-1042 is the right shape; a key with the job’s start time in it is not, since a retried job has a new start time and therefore a new key. And note the window: the key is remembered for 24 hours from the request, not until the scheduled instant, so a reminder scheduled a week out is protected against the retry storm around its creation, which is where the duplicates come from, and not against a second job that decides to schedule it again five days later. That second decision belongs in your own table, keyed on the email id the first request returned.
The rules and the header’s limits are in the idempotency guide, and the idempotent sends guide works through the retry timing; the glossary entry has the one-paragraph version.
What you cannot do
| You want to | What happens |
|---|---|
Write tomorrow at 9am | Refused with a 422 validation_error. Only ISO 8601 with a zone is parsed. |
| Schedule under a minute out or over 30 days out | Refused. Send now for the first; store the intent and schedule later for the second. |
| Move or cancel after dispatch | 409 resource_locked. A message that has left cannot be recalled. |
| Schedule a repeat | Not a feature. Each occurrence is its own scheduled send. |
| Change the body while it waits | PATCH takes scheduled_at only. Cancel and schedule a new one. |
The window is worth planning around. Thirty days covers a trial reminder, a renewal notice and a follow-up; it does not cover an anniversary. For anything further out, keep the row in your own database and schedule the email when it comes inside the window, which also means a customer who cancels in month three never has a stale message waiting.
The events a schedule emits
A scheduled email produces two events the immediate kind never does. email.scheduled arrives when the request is accepted, and email.canceled arrives when a cancel succeeds. From dispatch on, the events are the ordinary ones, email.sent first. If your application shows a customer their pending messages, those two events are what keep the list honest without polling.
Cancel is not recall
Cancel answers 200 only while the email is still waiting. The moment it is dispatched the message belongs to the recipient’s mailbox provider, and no API can take it back. If a message might need withdrawing, schedule it as late as the product allows and put the decision that might withdraw it before the instant, not after. The email API overview covers what else the same endpoint can do.
Frequently asked questions
How far ahead can I schedule an email?
Between one minute and thirty days from the moment of the request. Anything sooner should be sent immediately, and anything later should live in your own database until it comes inside the window, which also means a customer who cancels in the meantime never has a stale message waiting.
Can I write the time as tomorrow at 9am?
No. scheduled_at is parsed as ISO 8601 only, and natural language is refused with a 422 validation_error naming the field. Build the timestamp in your own code, where you know the customer's time zone, and send the instant rather than the phrase.
What time zone should scheduled_at be in?
Any, as long as it is stated: a trailing Z for UTC or an explicit offset such as +02:00. Both describe the same instant and both are accepted. A timestamp with no zone is ambiguous, so compute the instant from the customer's IANA zone on your side and send UTC to keep the log easy to read.
Can I change the body of a scheduled email?
Not in place. PATCH /emails/{id} accepts scheduled_at and nothing else, so a change to the subject or body means cancelling the waiting email and scheduling a new one with a new idempotency key. The cancel is a single request and emits email.canceled.
What happens if I cancel after the email has been sent?
The API answers 409 resource_locked, and the same code comes back from a PATCH. Once a message has been dispatched it belongs to the recipient's mailbox provider and cannot be recalled, so put any decision that might withdraw a message before its scheduled instant, not after.
Does an idempotency key stop a scheduled email being created twice?
Yes, for 24 hours from the request. A retried job that sends the same key with the same payload gets the original response back and schedules nothing, which is what protects you against the retry storm around creation. It does not cover a second job a week later; that decision belongs in your own table, keyed on the email id.
Sources
- RFC 3339: Date and Time on the Internet: Timestamps — IETF, read 2026-09-16
- Time Zone Database — IANA, read 2026-09-16
- zoneinfo — IANA time zone support — Python Software Foundation, read 2026-09-16
Related
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- Idempotency — Retry a send without sending it twice.
- Idempotent email sends: never send twice — A timeout does not tell you whether the send happened. What an idempotency key is, how the 24-hour window behaves, how to choose keys, and what a batch does.
- Email API — Send email over one REST call: idempotent sends, batches, scheduling, attachments, templates and a delivery timeline for every message.
- Idempotency key — An idempotency key is a string you attach to a request so the server can tell a retry from a new request. A network timeout never says whether the work…