Schedule a message
/api/v1/messages/scheduledAccepts a message now and submits it at send_at. The body is exactly POST /messages,
plus send_at.
It is a separate endpoint, and that is deliberate. Other providers put the delivery time
on the send call, and the cost of that is a send endpoint whose response is a message on one
call and a schedule on the next — two object types from one operation. Here you get a
message from /messages and a scheduled_message from /messages/scheduled, always.
POST /messages refuses a send_at rather than ignoring it: a scheduling parameter
that is silently dropped delivers the message immediately, to everyone, at the wrong time,
with nothing said.
Everything that can be checked now is checked now — the body, the recipients, the
attachments, the limits, and that from is a verified Sender of yours. A scheduled send
that fails validation three days later is a message that silently never went, and you find
out from the recipient.
Suppression and rate limits are checked when it leaves, not when you schedule it. They
are questions about the world at the moment of delivery: somebody who unsubscribes on
Tuesday must not receive a message you scheduled on Monday. When that happens the row ends
failed with failure_reason set, so it says what happened rather than disappearing.
send_at must be at least 60 seconds ahead — the sweep runs every minute and we will not
promise a precision we do not have — and at most 30 days, because the whole message,
attachments included, is stored here until it goes.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Send a unique value — a UUID is ideal — to make this request safe to retry. If we have already answered a request with the same key and the same body, you get that exact response back with `Idempotent-Replayed: true` and nothing is sent a second time. Reusing a key with a DIFFERENT body is a 409, because answering the first response to a second message would silently swallow it. Keys are scoped to your organization and honoured for 24 hours. A 5xx does not record a key: we cannot say whether the message left, so your retry genuinely retries. Omit the header and nothing changes. |
Request body
send_atstringrequiredISO 8601. At least 60 seconds ahead, at most 30 days. A time we cannot honour is refused rather than rounded, and a past `send_at` is refused rather than sent immediately — it almost always means a timezone was dropped somewhere in your stack.
Request
/api/v1/messages/scheduledcurl -X POST 'https://app.mailyte.com/api/v1/messages/scheduled' \
-H 'Authorization: Bearer mk_live_YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"to": [
"ada@example.com"
],
"from": "hello@yourdomain.com",
"subject": "Your weekly summary",
"text": "Here is what happened this week.",
"send_at": "2026-09-21T09:00:00Z"
}'const response = await fetch('https://app.mailyte.com/api/v1/messages/scheduled', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"to": [
"ada@example.com"
],
"from": "hello@yourdomain.com",
"subject": "Your weekly summary",
"text": "Here is what happened this week.",
"send_at": "2026-09-21T09:00:00Z"
}),
});
const { data } = await response.json();import requests
response = requests.post(
"https://app.mailyte.com/api/v1/messages/scheduled",
headers={"Authorization": "Bearer mk_live_YOUR_API_KEY"},
json={
"to": [
"ada@example.com"
],
"from": "hello@yourdomain.com",
"subject": "Your weekly summary",
"text": "Here is what happened this week.",
"send_at": "2026-09-21T09:00:00Z"
},
)
data = response.json()["data"]<?php
$response = Http::withToken('mk_live_YOUR_API_KEY')
->post('https://app.mailyte.com/api/v1/messages/scheduled', [
'to' => [
'ada@example.com',
],
'from' => 'hello@yourdomain.com',
'subject' => 'Your weekly summary',
'text' => 'Here is what happened this week.',
'send_at' => '2026-09-21T09:00:00Z',
]);
$data = $response->json('data');require "net/http"
require "json"
uri = URI("https://app.mailyte.com/api/v1/messages/scheduled")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer mk_live_YOUR_API_KEY"
request["Content-Type"] = "application/json"
request.body = {
"to": [
"ada@example.com"
],
"from": "hello@yourdomain.com",
"subject": "Your weekly summary",
"text": "Here is what happened this week.",
"send_at": "2026-09-21T09:00:00Z"
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }Response
201 with the `scheduled_message`. Use its `id` to list, inspect or cancel it.
dataobjectA message accepted now and submitted at `send_at`. It is not a `message`: a message has left, and this has not. Once it goes, `message_id` names the message it became.
objectstringscheduled_messageidstringAddress this with `GET` and `DELETE /messages/scheduled/{id}`. It identifies the SCHEDULE, not the message — see `message_id`.
statusstringscheduled | sending | sent | cancelled | failedOne of: `scheduled` (waiting), `sending` (claimed for submission — the window in which cancelling is no longer possible), `sent`, `cancelled`, `failed`.
modestringlive | test`test` when a test key accepted this message: it runs the whole flow — validation, scheduling, listing, cancelling — and submits nothing when it comes due. Captured when the message is accepted, not when it is sent, because test mode is a property of the KEY and the sweep has no key to read. `live` is the default.
send_atstringWhen it is due. Honoured to the minute — we refuse a `send_at` less than 60 seconds out rather than promise a precision we do not have.
fromstringThe sender, as scheduled.
toarray<string>The recipients, as scheduled. `cc` and `bcc` are held but not published back — a Bcc that reappears in a readable response is a Bcc that leaked.
subjectstring`null` MEANS NONE WAS SET — a message may legitimately have no subject, and a template supplies its own at render time.
message_idstringThe Message-ID it got when it left, for following it into the delivery log. `null` MEANS IT HAS NOT LEFT — still scheduled, cancelled, or failed. Never a placeholder.
sent_atstringWhen it was submitted. `null` MEANS IT HAS NOT BEEN.
cancelled_atstringWhen it was cancelled. `null` MEANS IT WAS NOT.
failure_reasonstringWhy it could not be sent, in words. `null` MEANS NOTHING WENT WRONG — not that the reason is unknown. Only a `failed` row carries one. Suppression and rate limits are evaluated when the message leaves, not when it was accepted, so a message scheduled on Monday for somebody who unsubscribes on Tuesday fails here and says so.
created_atstringWhen it was accepted.
Returned inside the standard envelope.
Errors
| Status | When |
|---|---|
401 | The API key is missing, unknown, revoked or expired. All four answer identically, on purpose: distinguishing them would confirm which keys exist. |
403 | The key is valid but may not do this: it lacks the required scope, its IP allowlist does not include you, or this endpoint does not accept API keys. |
404 | No such resource in this organization. |
422 | The request was understood but the values were not acceptable. |
429 | Too many requests, or the organization has spent its sending allowance. `Retry-After` says how long to wait. |
Every status, with what causes it and what to do, is on the error reference.