# One-shot scheduling

> Schedule a single future HTTP delivery three ways — a relative delay, an absolute RFC3339 instant, or a DST-correct local wall-clock time.

A one-shot schedule fires **once**: you give SchedStack a destination and a time, and it
owns delivery from there — retried until it lands, never silently lost. You pick the time
in whichever form fits your code:

- **[`delay`](#delay--relative)** — a relative duration from now (`"24h"`, `"90s"`).
- **[`fire_at`](#fire_at--absolute-instant)** — an absolute RFC3339 instant (`"2026-07-01T09:00:00Z"`).
- **[`local_fire_at` + `timezone`](#local_fire_at--wall-clock-time)** — a wall-clock time in an IANA zone, mapped to the correct instant across DST.

A POST with a `cron` field instead makes a **recurring** schedule — see
[Recurring schedules](/docs/guides/recurring/).

## Pick exactly one timing source

`delay`, `fire_at`, `local_fire_at`, and `cron` are mutually exclusive. You must send
**exactly one**:

- Send none → `422 missing_timing`.
- Send more than one → `400 multiple_timing`.

Everything else on the request (`method`, `headers`, `body`, `ttl`, `retry_policy`,
`metadata`, `idempotency_key`) is shared across all three forms and optional. The only
other required field is the destination: an inline `endpoint` (an `https` URL) or an
`endpoint_id` referencing a saved [endpoint profile](/docs/guides/endpoint-profiles/).

The whole API request body is capped at **1 MB** (`400 invalid_json` if larger), and the
delivery `body` it carries is capped at **256 KB** (`422 payload_too_large`). See
[Limits & constraints](/docs/limits/).

## `delay` — relative

The simplest form: fire this many time-units from now. `delay` is a Go
[`time.ParseDuration`](https://pkg.go.dev/time#ParseDuration) string, so units are `ns`,
`us`/`µs`, `ms`, `s`, `m`, `h` and you can combine them (`"1h30m"`).

```bash
curl -X POST https://api.schedstack.com/v1/schedules \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://acme.dev/hooks/reminder",
    "delay": "24h",
    "body": "{\"kind\":\"trial_ending\"}"
  }'
```

```json
{
  "id": "sch_01J9ZK3F8Q",
  "object": "schedule",
  "mode": "test",
  "kind": "one_shot",
  "state": "active",
  "endpoint": "https://acme.dev/hooks/reminder",
  "method": "POST",
  "header_keys": [],
  "fire_at": "2026-06-28T18:42:11Z",
  "cron": null,
  "timezone": null,
  "next_fire_at": "2026-06-28T18:42:11Z",
  "next_runs": ["2026-06-28T18:42:11Z"],
  "ttl": null,
  "retry_policy": {
    "max_attempts": 8,
    "strategy": "exponential",
    "base": "5s",
    "factor": 2,
    "max": "1h",
    "jitter": true
  },
  "metadata": {},
  "created_at": "2026-06-27T18:42:11Z",
  "updated_at": "2026-06-27T18:42:11Z"
}
```

The response resolves your `delay` to a concrete `fire_at` (now + the duration) so you can
confirm the exact instant.

**Minimum delay is 1 second**

`delay` must be a valid Go duration string — a malformed one is rejected with
`400 invalid_duration` — and at least `1s`; a `delay` under `1s` is rejected with
`422 sub_floor_delay`. SchedStack is a durable scheduler, not a sub-second timer — see the
[dispatch accuracy SLO](/docs/concepts/overview/) for what timing it does guarantee.

## `fire_at` — absolute instant

When you already know the exact UTC instant, send it as **strict RFC3339**. It must be in
the future and no more than **10 years** out.

```bash
curl -X POST https://api.schedstack.com/v1/schedules \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://acme.dev/hooks/invoice",
    "fire_at": "2026-07-01T09:00:00Z",
    "body": "{\"invoice\":\"inv_204\"}"
  }'
```

```json
{
  "id": "sch_01J9ZK7M2D",
  "object": "schedule",
  "kind": "one_shot",
  "state": "active",
  "fire_at": "2026-07-01T09:00:00Z",
  "next_fire_at": "2026-07-01T09:00:00Z",
  "next_runs": ["2026-07-01T09:00:00Z"],
  "timezone": null,
  "cron": null
}
```

**RFC3339 is parsed strictly**

Include the offset (`Z` for UTC, or `+05:30`). A bare wall-clock string like
`2026-07-01T09:00:00` has no offset and is **not** a valid `fire_at` — use `local_fire_at`
for that. A non-RFC3339 value is rejected as an `invalid_request_error`; a past instant is
rejected with `422 fire_at_in_past`; an instant more than **10 years** out is rejected with
`422 fire_at_too_far`.

## `local_fire_at` — wall-clock time

When the time means "9am **where the user is**", send an **offset-less** local timestamp
plus an IANA `timezone`. SchedStack maps the wall-clock time to the correct absolute
instant for that zone — including across daylight-saving transitions.

```bash
curl -X POST https://api.schedstack.com/v1/schedules \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://acme.dev/hooks/digest",
    "local_fire_at": "2026-07-01T09:00:00",
    "timezone": "America/New_York",
    "body": "{\"digest\":\"daily\"}"
  }'
```

```json
{
  "id": "sch_01J9ZKA4WP",
  "object": "schedule",
  "kind": "one_shot",
  "state": "active",
  "fire_at": "2026-07-01T13:00:00Z",
  "timezone": "America/New_York",
  "next_fire_at": "2026-07-01T13:00:00Z",
  "next_runs": ["2026-07-01T13:00:00Z"],
  "cron": null
}
```

`09:00` in `America/New_York` (EDT, UTC−4 in July) resolves to `13:00:00Z`. The response
always reports the resolved absolute `fire_at` in UTC alongside the `timezone` you sent.

**How DST edge cases resolve**

Wall-clock times that don't map cleanly are handled deterministically, and the schedule
still fires exactly once:

- **Spring-forward gap** (a local time that doesn't exist) → shifted to the **end of the
  gap**, the next valid instant.
- **Fall-back overlap** (a local time that happens twice) → fires at the **first**
  occurrence, before the clocks turn back.

Omit `timezone` and it defaults to `UTC`, in which case `local_fire_at` behaves like an
offset-less `fire_at`.

## After it's created

A one-shot schedule is created in `state: active` and carries a single occurrence. Track
that occurrence — scheduled → succeeded, or dead-lettered / expired — and replay it from
the delivery, in [Schedule lifecycle](/docs/guides/lifecycle/).

1. **Preview before committing.** Want to confirm the instant without creating anything?
   The MCP `preview_schedule` tool validates timing and returns the next fire time. See
   [MCP server](/docs/mcp/).

2. **Make retries safe.** Delivery is at-least-once, so your receiver should verify the
   `Sched-Signature` and dedupe on the `Idempotency-Key` header — see
   [Verifying signatures](/docs/guides/verify-signatures/).

3. **Bound how long it can run.** Add a `ttl` (a duration) to set a deliver-by deadline;
   past it, the occurrence is marked `expired` rather than retried forever.

## Related

- [Recurring schedules](/docs/guides/recurring/) — fire on a `cron` schedule instead of once.
- [Schedule lifecycle](/docs/guides/lifecycle/) — reschedule, pause, cancel, and replay.
- [Endpoint profiles](/docs/guides/endpoint-profiles/) — reuse a destination and policy via `endpoint_id`.
