Scheduled send (send later)
Schedule an email to be dispatched at a specific future time. ReplyLayer holds the draft and runs the full send-time gate stack at dispatch time — an authoritative scanner rescan plus the suppression list, recipient allowlist, reply-loop check, domain/account state, and your send budget — so any policy change between scheduling and dispatch is honored. Scheduling never hands a future send-time off to the delivery provider; the message stays under your policy until it actually leaves.
You schedule by creating (or updating) a draft with a send_at timestamp.
Everything you can do with a draft — inspect it, edit the body, reschedule,
cancel — applies to a scheduled send.
At a glance
| Action | Where you set it | Notes |
|---|---|---|
| Schedule | POST /v1/drafts with send_at (or PATCH an existing draft) | ISO-8601 with an explicit offset or Z. Must be at least 60s in the future and within the scheduling horizon (90 days by default). |
| Reschedule | PATCH /v1/drafts/:id with a new send_at | Resets send_attempts and original_send_at to the new intent. Shifts of 60s or less are debounced (no webhook). |
| Cancel | PATCH /v1/drafts/:id with send_at: null, or DELETE /v1/drafts/:id | Emits message.schedule_cancelled. |
| Retry-safety | Idempotency-Key header on POST /v1/drafts | A repeated create with the same key returns the existing draft instead of a duplicate. |
| Dispatch | Automatic — ReplyLayer checks for due sends roughly every 30s | The full gate stack re-runs. Failures surface as webhook events, not as errors on your original request. |
Why hold the draft instead of delegating scheduling?
The point of scheduling through ReplyLayer is that policy is re-evaluated at dispatch, not frozen at schedule time:
- Suppression is live. Add a recipient to your suppression list at 10:58 and a send scheduled for 11:00 is rejected — the message is never dispatched.
- The scanner re-runs authoritatively. If your mailbox scanning policy tightened between scheduling and dispatch, a draft that passed earlier but would now block gets caught at dispatch.
- Account, mailbox, and domain state are checked live. A suspension, paused mailbox, or unhealthy domain that appeared after you scheduled all cause the dispatch to be rejected.
The trade-off is up to ~30s of dispatch drift (the check cadence). For anything that isn't second-resolution timing, re-evaluating policy at dispatch is the whole value.
Lifecycle
POST /v1/drafts { send_at: T }
→ row: state='draft', send_at=T, original_send_at=T, send_attempts=0
emits: message.scheduled
... time passes ...
Dispatch check at or after T — full send-time gate stack runs:
├─ success: state='available', message is sent, send_at cleared
│ later: message.delivered (normal delivery event)
├─ transient: send_at pushed to a backoff time, send_attempts++
│ (no webhook on retry)
└─ permanent: send_at cleared, row stays state='draft'
emits: message.dispatch_failed { reason_code }On a permanent failure the row stays in state='draft' so you can inspect
what went wrong and reschedule. For the full releasable-vs-terminal state model,
see /agents/messages.
send_at format
send_at must be an ISO-8601 timestamp with an explicit timezone:
Zsuffix (UTC):2026-05-05T16:00:00Zor2026-05-05T16:00:00.123Z- Numeric offset:
2026-05-05T09:00:00-07:00or2026-05-05T16:00:00+05:30
Naive strings with no offset and no Z (2026-05-05T16:00:00) are rejected
with 400 TIMEZONE_REQUIRED. This is deliberate — "2pm Tuesday" in your head
is not what the server sees, and a silent multi-hour drift is the worst failure
mode. TypeScript's Date.toISOString() always produces a Z timestamp; the
Python SDK requires a timezone-aware datetime and raises TimezoneRequiredError
before the HTTP call if you pass a naive one.
Scheduling validation
These errors are returned synchronously when you create or reschedule. For the full catalog across all endpoints, see /agents/errors.
| Code | Status | Condition | Fix |
|---|---|---|---|
TIMEZONE_REQUIRED | 400 | send_at has no offset or Z | Add Z or a numeric offset |
SEND_AT_TOO_SOON | 400 | send_at is less than 60s in the future (details.min_lead_seconds) | Use immediate send (POST /v1/messages) or pick a later time |
SEND_AT_TOO_FAR | 400 | send_at is beyond the scheduling horizon (90 days by default) | Shorten the window |
SCHEDULED_SEND_QUOTA_EXCEEDED | 429 | You already have the maximum pending scheduled drafts (10,000 by default) | Cancel some, or wait for dispatch to drain the queue |
DRAFT_ALREADY_SENT | 409 | A PATCH/DELETE raced the dispatch check — the draft already dispatched | Treat as success; inspect via GET /v1/drafts/:id |
Quickstart
TypeScript
import { ReplyLayer, SchedulingError } from '@replylayer/sdk';
const client = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });
try {
const draft = await client.drafts.create(
{
mailbox_id: 'sales',
to: '[email protected]',
subject: 'Following up next Tuesday',
body: 'Hi — circling back per our chat...',
send_at: new Date('2026-05-05T09:00:00-07:00').toISOString(),
},
{ idempotencyKey: `followup-${customerId}` },
);
console.log('Scheduled', draft.id, 'for', draft.send_at);
} catch (err) {
if (err instanceof SchedulingError) {
console.error('Schedule rejected:', err.reasonCode, err.message);
} else {
throw err;
}
}Python
import os
from datetime import datetime, timezone, timedelta
from replylayer import ReplyLayer, SchedulingError, TimezoneRequiredError
client = ReplyLayer(api_key=os.environ["REPLYLAYER_API_KEY"])
send_at = datetime.now(timezone.utc) + timedelta(hours=24) # MUST be tz-aware
try:
draft = client.drafts.create(
mailbox_id="sales",
to="[email protected]",
subject="Following up next Tuesday",
body="Hi — circling back per our chat...",
send_at=send_at, # or an ISO string with an offset / Z
idempotency_key=f"followup-{customer_id}",
)
print("Scheduled", draft["id"], "for", draft["send_at"])
except TimezoneRequiredError:
# Raised client-side (before HTTP) if send_at is a naive datetime.
raise
except SchedulingError as err:
print("Schedule rejected:", err.reason_code, err)CLI
# Schedule a draft for tomorrow 9am Pacific.
rly draft create \
--mailbox sales \
--to [email protected] \
--subject "Following up next Tuesday" \
--body "Hi — circling back per our chat..." \
--send-at "2026-05-05T09:00:00-07:00" \
--idempotency-key "followup-abc"
# Reschedule.
rly draft update $DRAFT_ID --send-at "2026-05-06T09:00:00-07:00"
# Cancel the schedule (keeps the draft, just clears send_at).
rly draft update $DRAFT_ID --send-at none
# Inspect — shows send_at, send_attempts, and the last dispatch error.
rly draft show $DRAFT_IDRaw HTTP
POST /v1/drafts HTTP/1.1
Host: api.replylayer.ai
Authorization: Bearer $REPLYLAYER_API_KEY
Content-Type: application/json
Idempotency-Key: followup-abc
{
"mailbox_id": "sales",
"to": "[email protected]",
"subject": "Following up next Tuesday",
"body": "Hi — circling back per our chat...",
"send_at": "2026-05-05T16:00:00Z"
}A 201 response carries the scheduled draft:
{
"id": "1e5e3cbe-...",
"state": "draft",
"send_at": "2026-05-05T16:00:00.000Z",
"original_send_at": "2026-05-05T16:00:00.000Z",
"send_attempts": 0,
"last_dispatch_error_code": null,
"last_dispatch_attempt_at": null,
"scan_results": [],
"worst_decision": "allow"
}Dispatch-time failures
When send_at passes, ReplyLayer picks up the draft and runs the full send-time
gate stack. If a gate rejects the send, you learn about it through a
message.dispatch_failed webhook — not as an SDK throw, since your original
request returned 201 hours or days earlier.
Transient failures (temporary conditions — domain health, rate limits,
reply-loop detection) are retried on a backoff schedule: up to 8 attempts,
or until 24 hours past your original_send_at, whichever comes first. No
webhook fires on a transient retry — subscribers only hear about the permanent
outcome, to keep event noise down.
Permanent failures (a scanner block, a suppressed or non-allowlisted
recipient, an account/mailbox suspension, a stale schedule) clear send_at
immediately and fire message.dispatch_failed with a specific reason_code. The
draft stays in state='draft' for you to inspect and reschedule. The
reason_code values form a closed, stable enum — branch on it to decide whether
to retry, notify a human, or drop silently. See
/agents/webhooks for the catalog and payload shape.
Any gate that would block an immediate send blocks a scheduled dispatch too. For the full "why was my send blocked" decision tree — suppression, recipient containment, thread-reply bypass, deliverability — see /agents/send-gates.
Diagnosing from the draft row
Every draft GET response carries the forensic fields:
{
"send_at": null,
"original_send_at": "2026-05-05T16:00:00Z",
"send_attempts": 8,
"last_dispatch_error_code": "RECIPIENT_NOT_ON_ALLOWLIST",
"last_dispatch_attempt_at": "2026-05-05T16:04:12Z"
}After a permanent failure send_at becomes null, but
last_dispatch_error_code and last_dispatch_attempt_at stick around so you can
see what happened without a support ticket.
last_dispatch_error_code and the webhook reason_code are different strings
for the same failure, by design. last_dispatch_error_code is the raw error
code from the send path — the same string an immediate POST /v1/messages/send
would return for the same problem — and is best treated as free-form text for
humans. The webhook reason_code is the normalized, closed enum. If you branch
programmatically, branch on the webhook reason_code; if you display the draft
row, treat last_dispatch_error_code as a human-readable hint.
Cancelling a scheduled send
Two equivalent paths:
PATCH /v1/drafts/:idwithsend_at: nullclears the schedule. The draft stays instate='draft'as an ordinary unscheduled draft. Emitsmessage.schedule_cancelled.DELETE /v1/drafts/:idsoft-deletes the draft (state='deleted'). Also emitsmessage.schedule_cancelled(withreason: 'draft_deleted') if the draft was scheduled.
If the dispatch check already sent the message, both paths return
409 DRAFT_ALREADY_SENT. In that case the message is in state='available'
(or delivered/bounced once delivery events arrive) — inspect it via GET.
Idempotency
Scheduling amplifies duplicate-send risk: a dropped-connection retry on your
create could otherwise produce two real deliveries hours later. An
Idempotency-Key header makes the create retry-safe.
- Send the key as a header on
POST /v1/drafts(the SDKs and CLI accept anidempotencyKey/idempotency_key/--idempotency-keyargument that maps to it). - Scope: keys are unique per
(account, key)and durable. A repeated create with the same key returns the existing draft (201), re-serialized from the row's current state — not a cached response body. If you laterPATCHthe draft, a replay reflects the current values, which is whatGETwould return anyway. - Concurrent first-writes resolve to a single row — you never end up with two drafts under one key.
- Cross-namespace reuse is rejected. If the key was already used for an
immediate send/reply you get
409 IDEMPOTENCY_KEY_BOUND_TO_IMMEDIATE_SEND; if the draft it created was since sent or deleted you get409 IDEMPOTENCY_KEY_DRAFT_CONSUMED. Use a distinct key for a new draft.
Attachments and scheduled send
Attachments and scheduling are mutually exclusive. A staged attachment handle
is single-use and expires shortly after upload, so it cannot survive until a
future dispatch. POST and PATCH /v1/drafts reject a request that combines
attachment_ids with send_at up front; if a draft somehow becomes both
scheduled and attachment-bearing, the dispatch fails permanently with
reason_code: 'ATTACHMENTS_REQUIRE_SYNC_SEND' rather than sending. To send with
attachments, send synchronously (POST /v1/drafts/:id/send). For the staging
lifecycle, see /agents/attachments and
/docs/guides/attachments.
Limits
| Limit | Default |
|---|---|
| Minimum lead time | 60 seconds |
| Maximum scheduling horizon | 90 days |
| Maximum pending scheduled drafts per account | 10,000 |
| Dispatch check cadence | ~30 seconds |
| Transient retry ceiling | 8 attempts |
| Stale threshold | 24 hours past original_send_at |
Some of these are deployment-configurable, so treat them as defaults rather than hard guarantees. Per-tier send budgets and sandbox caps are covered on /docs/limits.
Webhook events
Scheduled send has four lifecycle events — names only here; the full catalog, payload fields, and signature verification live on /agents/webhooks:
message.scheduled— thesend_atintent registered.message.rescheduled— an already-scheduled draft'ssend_atmoved by more than 60s.message.schedule_cancelled— the schedule was cleared or the draft deleted.message.dispatch_failed— a permanent dispatch failure; branch onreason_code.
Successful dispatch surfaces through the normal delivery events (for example
message.delivered), not a scheduled-send-specific event.
Related
- /docs/webhooks — webhook setup and delivery guide
- /docs/guides/suppressions — the send gate every dispatch passes
- /docs/guides/recipient-allowlist — allowlist containment
- /agents/messages — message state machine and verdict vocabulary
- /agents/send-gates — the full send-gate decision tree