# 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](/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](/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

```ts
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: 'customer@example.com',
      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

```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="customer@example.com",
        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

```bash
# Schedule a draft for tomorrow 9am Pacific.
rly draft create \
  --mailbox sales \
  --to customer@example.com \
  --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

```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": "customer@example.com",
  "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:

```json
{
  "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](/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](/agents/send-gates).

### Diagnosing from the draft row

Every draft `GET` response carries the forensic fields:

```json
{
  "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](/agents/attachments) and
[/docs/guides/attachments](/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](/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](/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.

## Related

- [/docs/webhooks](/docs/webhooks) — webhook setup and delivery guide
- [/docs/guides/suppressions](/docs/guides/suppressions) — the send gate every dispatch passes
- [/docs/guides/recipient-allowlist](/docs/guides/recipient-allowlist) — allowlist containment
- [/agents/messages](/agents/messages) — message state machine and verdict vocabulary
- [/agents/send-gates](/agents/send-gates) — the full send-gate decision tree
