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

ActionWhere you set itNotes
SchedulePOST /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).
ReschedulePATCH /v1/drafts/:id with a new send_atResets send_attempts and original_send_at to the new intent. Shifts of 60s or less are debounced (no webhook).
CancelPATCH /v1/drafts/:id with send_at: null, or DELETE /v1/drafts/:idEmits message.schedule_cancelled.
Retry-safetyIdempotency-Key header on POST /v1/draftsA repeated create with the same key returns the existing draft instead of a duplicate.
DispatchAutomatic — ReplyLayer checks for due sends roughly every 30sThe 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:

  • Z suffix (UTC): 2026-05-05T16:00:00Z or 2026-05-05T16:00:00.123Z
  • Numeric offset: 2026-05-05T09:00:00-07:00 or 2026-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.

CodeStatusConditionFix
TIMEZONE_REQUIRED400send_at has no offset or ZAdd Z or a numeric offset
SEND_AT_TOO_SOON400send_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_FAR400send_at is beyond the scheduling horizon (90 days by default)Shorten the window
SCHEDULED_SEND_QUOTA_EXCEEDED429You already have the maximum pending scheduled drafts (10,000 by default)Cancel some, or wait for dispatch to drain the queue
DRAFT_ALREADY_SENT409A PATCH/DELETE raced the dispatch check — the draft already dispatchedTreat 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_ID

Raw 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/:id with send_at: null clears the schedule. The draft stays in state='draft' as an ordinary unscheduled draft. Emits message.schedule_cancelled.
  • DELETE /v1/drafts/:id soft-deletes the draft (state='deleted'). Also emits message.schedule_cancelled (with reason: '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 an idempotencyKey / idempotency_key / --idempotency-key argument 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 later PATCH the draft, a replay reflects the current values, which is what GET would 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 get 409 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

LimitDefault
Minimum lead time60 seconds
Maximum scheduling horizon90 days
Maximum pending scheduled drafts per account10,000
Dispatch check cadence~30 seconds
Transient retry ceiling8 attempts
Stale threshold24 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 — the send_at intent registered.
  • message.rescheduled — an already-scheduled draft's send_at moved by more than 60s.
  • message.schedule_cancelled — the schedule was cleared or the draft deleted.
  • message.dispatch_failed — a permanent dispatch failure; branch on reason_code.

Successful dispatch surfaces through the normal delivery events (for example message.delivered), not a scheduled-send-specific event.