> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Schedule a workflow to run on cron or an interval

> Run a deployed ModuleX workflow automatically on a cron or interval schedule: create, pause, resume, and retry schedules; inspect run history and stats; every parameter, response field, and error — with cURL, Python, and JavaScript.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

A schedule runs a workflow on a timetable without anyone pressing a button. You point a schedule at a workflow's [live deployment](/workflow-builder/execution/deploy), choose either a fixed `interval` or a `cron` cadence, and ModuleX fires the workflow on that cadence in the background. Each firing produces a scheduled run with its own history, status, and statistics that you can inspect or retry later.

This page is the complete reference for schedules: how to create one, the difference between `interval` and `cron`, how recurrence is computed, how to pause and resume, how runs are recorded, and every parameter, response field, and error involved. Every operation is shown once via cURL, Python, and JavaScript.

<CardGroup cols={2}>
  <Card title="Create a schedule" icon="calendar-plus" href="#create-a-schedule">
    Attach a cron or interval cadence to a deployed workflow.
  </Card>

  <Card title="Cron vs. interval" icon="clock" href="#cron-and-interval-cadence">
    How each cadence is defined, validated, and advanced.
  </Card>

  <Card title="Pause and resume" icon="circle-pause" href="#pause-and-resume">
    Stop a schedule from firing, then bring it back.
  </Card>

  <Card title="Run history and stats" icon="list-checks" href="#run-history-and-statistics">
    Inspect, filter, and retry the runs a schedule produced.
  </Card>
</CardGroup>

<Note>
  Looking for a step-by-step walkthrough instead of a reference? See the guide [Schedule a workflow](/guides/schedule-a-workflow).
</Note>

## Before you start

Three things must be true before a schedule will run a workflow.

<Steps>
  <Step title="The workflow has a live deployment">
    A schedule fires a workflow's **live deployment**, not its draft. If the target workflow has no live deployment, creating the schedule fails with a `400` (not a `404`). Deploy first — see [Deploy & versions](/workflow-builder/execution/deploy).
  </Step>

  <Step title="You are an owner or admin">
    Every schedule endpoint — including read-only ones — requires the **`owner`** or **`admin`** role in the organization. A plain member cannot create, read, or manage schedules. See [Roles & permissions](/security/roles-permissions) and [Organizations, roles & membership](/concepts/organizations-roles).
  </Step>

  <Step title="You authenticate with an API key and an org header">
    Programmatic calls use `Authorization: Bearer mx_live_…` plus `X-Organization-ID`. See [Authentication](/api-reference/authentication).
  </Step>
</Steps>

<Warning>
  Schedules are gated to **owner/admin on every route, reads included**. This is broader than most read endpoints in the API: an org member who can view workflows still cannot list or fetch schedules. If a teammate gets a `403` reading schedules, check their org role first. *(Doc-blocking finding §2.9 — owner/admin required for schedules.)*
</Warning>

<MediaEmbed id="MX-MEDIA-3180" type="screenshot" caption={"The schedules panel for a deployed workflow, showing a cron schedule and its next run time."} />

## How scheduling works

You never schedule a draft. A schedule references a workflow whose live deployment is run when the schedule fires.

1. You **create a schedule** against a workflow that has a live deployment. ModuleX computes the first `next_run_at` from the current time and marks the schedule active.
2. A background **scheduler tick** runs every minute or so, finds every active schedule whose `next_run_at` has passed, and creates a pending **scheduled run** for each.
3. Each pending run is **executed in the background**: ModuleX loads the deployment, merges inputs, runs the workflow, and records the result. The schedule's `next_run_at` advances to the next occurrence.
4. You **inspect run history and stats** for the schedule, and can **retry** any run that failed or was cancelled.

Firing happens out of band in a background worker, not inside your HTTP request. Creating a schedule returns immediately; the workflow itself runs later, on cadence.

<Note>
  The scheduler tick fires roughly **every 30 to 60 seconds** depending on deployment configuration. Treat any cadence finer than about one minute as best-effort — the effective floor on how often a schedule can fire is governed by the tick, not by the value you set. The hard minimum for an `interval` is **60 seconds**.
</Note>

### The three identifiers on a scheduled run

A single scheduled execution carries three distinct IDs. Keep them separate — they answer different questions. See [Workflows & runs](/concepts/workflows-and-runs) for the full run-identity model.

| Field       | Example                                | What it identifies                                                                         |
| ----------- | -------------------------------------- | ------------------------------------------------------------------------------------------ |
| `id`        | `11111111-2222-3333-4444-555555555555` | The scheduled-run record (a row in the schedule's history). Use it with the run endpoints. |
| `run_id`    | `sched_11111111-..._a1b2c3d4`          | The workflow execution's run ID, minted at execution time and prefixed `sched_`.           |
| `thread_id` | `sched_thread_8f1c2a40-..._9f8e7d6c`   | The execution thread, prefixed `sched_thread_`.                                            |

When you call `getRun` or `retryRun`, the `runId` argument is the scheduled-run record's `id` — not the `run_id` and not the `thread_id`.

## Authentication and roles

Every endpoint under `/schedules` shares one auth dependency. The rules below apply uniformly to creates, reads, updates, pause/resume, run history, and retries.

<ParamField header="Authorization" type="string" required>
  `Bearer mx_live_…` (your API key) or `Bearer <clerk_jwt>` (an app session token). Both are accepted.
</ParamField>

<ParamField header="X-Organization-ID" type="string" required>
  The organization the schedule belongs to. Omitting it returns `400`. If you authenticate with an API key whose scope is a different org, the request returns `403`.
</ParamField>

<ParamField header="Content-Type" type="string">
  `application/json` for `POST` and `PUT` bodies.
</ParamField>

| Condition                                            | Status   |
| ---------------------------------------------------- | -------- |
| Valid owner/admin token + matching org header        | proceeds |
| No or invalid token                                  | `401`    |
| Authenticated, but role is below admin (e.g. member) | `403`    |
| Authenticated, but the user is inactive              | `403`    |
| API key org scope does not match `X-Organization-ID` | `403`    |
| `X-Organization-ID` header missing                   | `400`    |
| Per-key / per-user / per-org rate limit exhausted    | `429`    |

<Note>
  Schedule routes are **not** behind the credits billing gate, so they never return the flat `DenialEnvelope` (`{code, layer, key, current, limit, reason}`). The only throttle on the schedule API itself is the standard request rate limit (`429`). Credits are still consumed when a scheduled run actually executes the workflow — see [Credit impact](#credit-impact). For the full error model, see [Errors & status codes](/api-reference/errors) and [Rate limiting](/api-reference/rate-limiting).
</Note>

## Base path

The schedules router is mounted at the API root with no version or service prefix.

```text theme={null}
https://api.modulex.dev/schedules
```

There is no `/v1` or `/api` segment. See [Base URLs & versioning](/api-reference/environments).

## Create a schedule

`POST /schedules` creates a schedule and computes its first `next_run_at`. The target workflow must already have a live deployment, or the call returns `400`.

<Note>
  Success returns **`200 OK`** with the schedule object — not `201`.
</Note>

### Request body

<ParamField body="workflow_id" type="string (UUID)" required>
  The workflow to run on this schedule. The workflow must have a live deployment.
</ParamField>

<ParamField body="name" type="string" required>
  A human-readable name. Length 1–255 characters.
</ParamField>

<ParamField body="schedule_type" type="string" required>
  Either `interval` or `cron`. Determines which cadence field is required.
</ParamField>

<ParamField body="interval_seconds" type="integer">
  Required when `schedule_type` is `interval`. Minimum **60**. Rejected below 60 at three layers (request validation, service validation, and a database constraint). Leave unset for cron schedules.
</ParamField>

<ParamField body="cron_expression" type="string">
  Required when `schedule_type` is `cron`. A standard 5-field cron string, max 100 characters, parsed and validated server-side. Leave unset for interval schedules.
</ParamField>

<ParamField body="timezone" type="string" default="UTC">
  An IANA timezone name (e.g. `America/New_York`). Max 50 characters. Validated server-side; an unknown name returns `400`. Cron expressions are evaluated in this timezone.
</ParamField>

<ParamField body="description" type="string">
  Optional free-text description.
</ParamField>

<ParamField body="input" type="object" default="{}">
  Per-run state input. Merged over the deployment's default input on every firing — schedule input wins on conflicting keys.
</ParamField>

<ParamField body="config" type="object" default="{}">
  Per-run execution config (for example `timeout` and `recursion_limit`). Merged over the deployment's config on every firing.
</ParamField>

<Warning>
  Supply exactly one cadence field for the chosen type: `interval_seconds` for `interval`, `cron_expression` for `cron`. A type/field mismatch (for example `schedule_type: "cron"` with no `cron_expression`) returns `400`.
</Warning>

### Example

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/schedules \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "2b9e7c10-aaaa-bbbb-cccc-1234567890ab",
      "name": "Nightly report",
      "schedule_type": "cron",
      "cron_expression": "0 9 * * 1-5",
      "timezone": "America/New_York",
      "input": { "mode": "full" }
    }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex


  async def main():
      client = Modulex(
          api_key="mx_live_xxx",
          organization_id="0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      )
      schedule = await client.schedules.create(
          workflow_id="2b9e7c10-aaaa-bbbb-cccc-1234567890ab",
          name="Nightly report",
          schedule_type="cron",
          cron_expression="0 9 * * 1-5",
          timezone="America/New_York",
          input={"mode": "full"},
      )
      print(schedule.id, schedule.next_run_at)


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex";

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  });

  const schedule = await client.schedules.create({
    workflowId: "2b9e7c10-aaaa-bbbb-cccc-1234567890ab",
    name: "Nightly report",
    scheduleType: "cron",
    cronExpression: "0 9 * * 1-5",
    timezone: "America/New_York",
    input: { mode: "full" },
  });

  console.log(schedule.id, schedule.next_run_at);
  ```
</CodeGroup>

<Note>
  The SDKs accept **camelCase** arguments (`workflowId`, `scheduleType`, `cronExpression`) and convert them to snake\_case on the wire. Response objects always come back with **snake\_case** fields (`workflow_id`, `next_run_at`). The Python client is async — every call is awaited.
</Note>

### Response

A `ScheduleResponse` object. The wire format is snake\_case throughout.

<ResponseField name="id" type="string (UUID)">The schedule's ID.</ResponseField>
<ResponseField name="workflow_id" type="string (UUID)">The scheduled workflow.</ResponseField>
<ResponseField name="organization_id" type="string (UUID)">The owning organization.</ResponseField>
<ResponseField name="name" type="string">The schedule name.</ResponseField>
<ResponseField name="description" type="string | null">The description, if any.</ResponseField>
<ResponseField name="schedule_type" type="string">`interval` or `cron`.</ResponseField>
<ResponseField name="interval_seconds" type="integer | null">The interval, for interval schedules.</ResponseField>
<ResponseField name="cron_expression" type="string | null">The cron string, for cron schedules.</ResponseField>
<ResponseField name="timezone" type="string">The IANA timezone the cadence is evaluated in.</ResponseField>
<ResponseField name="input" type="object">The per-run state input.</ResponseField>
<ResponseField name="config" type="object">The per-run execution config.</ResponseField>
<ResponseField name="is_active" type="boolean">Whether the schedule is firing. `false` while paused.</ResponseField>
<ResponseField name="next_run_at" type="string (ISO 8601, UTC) | null">When the schedule will next fire. Always stored in UTC.</ResponseField>
<ResponseField name="last_run_at" type="string (ISO 8601, UTC) | null">When the schedule last fired. `null` until the first firing.</ResponseField>
<ResponseField name="last_run_status" type="string | null">The status of the most recent run.</ResponseField>
<ResponseField name="total_runs" type="integer">Lifetime count of runs the schedule has produced.</ResponseField>
<ResponseField name="successful_runs" type="integer">Lifetime count of succeeded runs.</ResponseField>
<ResponseField name="failed_runs" type="integer">Lifetime count of failed runs.</ResponseField>
<ResponseField name="created_at" type="string (ISO 8601, UTC)">When the schedule was created.</ResponseField>
<ResponseField name="updated_at" type="string (ISO 8601, UTC)">When the schedule was last updated.</ResponseField>

<Expandable title="Example response">
  ```json theme={null}
  {
    "id": "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    "workflow_id": "2b9e7c10-aaaa-bbbb-cccc-1234567890ab",
    "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
    "name": "Nightly report",
    "description": "Runs the report workflow every weekday morning",
    "schedule_type": "cron",
    "interval_seconds": null,
    "cron_expression": "0 9 * * 1-5",
    "timezone": "America/New_York",
    "input": { "mode": "full" },
    "config": {},
    "is_active": true,
    "next_run_at": "2026-06-22T13:00:00Z",
    "last_run_at": null,
    "last_run_status": null,
    "total_runs": 0,
    "successful_runs": 0,
    "failed_runs": 0,
    "created_at": "2026-06-21T18:30:00Z",
    "updated_at": "2026-06-21T18:30:00Z"
  }
  ```
</Expandable>

### Errors

| Status | When                                                                                                                                                           |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Workflow not found, no live deployment, invalid cron, invalid timezone, `interval_seconds < 60`, or a type/cadence mismatch. Shape: `{"detail": "<message>"}`. |
| `401`  | Missing or invalid token.                                                                                                                                      |
| `403`  | Not owner/admin, inactive user, or API-key org-scope mismatch.                                                                                                 |
| `400`  | `X-Organization-ID` header missing.                                                                                                                            |
| `422`  | Request-body validation (e.g. `schedule_type` outside `interval`/`cron`, name length). Shape: `{"detail": [{"loc": [...], "msg": "...", "type": "..."}]}`.     |
| `429`  | Rate limit exhausted. The `detail` is an **object**: `{code, layer, key, current, limit, reason}`.                                                             |
| `500`  | Unexpected server error: `{"detail": "An unexpected internal server error occurred."}`.                                                                        |

## Cron and interval cadence

A schedule fires either every fixed number of seconds (`interval`) or on a cron calendar (`cron`). The cadence is set at create time and can be changed with [update](#update-a-schedule).

<Tabs>
  <Tab title="Cron">
    Set `schedule_type` to `cron` and supply a `cron_expression`.

    * The expression is a **standard 5-field cron** string: `minute hour day-of-month month day-of-week`. For example, `0 9 * * 1-5` is 09:00 on weekdays.
    * The expression is parsed and validated server-side; anything unparseable returns `400`.
    * Cron is evaluated **in the schedule's `timezone`** (default `UTC`). The next occurrence is computed in local time and then stored back as UTC in `next_run_at`.
    * Max length is 100 characters.

    ```text theme={null}
    0 9 * * 1-5     # 09:00 every weekday
    0 0 1 * *       # 00:00 on the 1st of every month
    */15 * * * *    # every 15 minutes (subject to the tick floor)
    ```
  </Tab>

  <Tab title="Interval">
    Set `schedule_type` to `interval` and supply `interval_seconds`.

    * Minimum **60** seconds. Values below 60 are rejected at request validation, service validation, and a database constraint.
    * The next firing is computed as `base_time + interval_seconds`. If that lands in the past (for example after a long pause), it is recomputed from now.

    ```text theme={null}
    interval_seconds: 3600     # hourly
    interval_seconds: 86400    # daily
    interval_seconds: 300      # every 5 minutes (subject to the tick floor)
    ```
  </Tab>
</Tabs>

### How `next_run_at` is computed

* The base time is `last_run_at` if the schedule has fired before, otherwise the current time.
* On **create** and on **resume**, there is no prior run, so the first/next firing is computed from **now**.
* For cron, the next occurrence is computed in the schedule's timezone and converted to UTC for storage. `next_run_at` is always UTC.
* For interval, `next_run_at = base_time + interval_seconds`, recomputed from now if that would be in the past.
* Changing `schedule_type`, `interval_seconds`, `cron_expression`, or `timezone` via [update](#update-a-schedule) recalculates `next_run_at` from the last run.

<Note>
  Because the scheduler tick runs roughly every 30–60 seconds, a schedule fires at or shortly after `next_run_at`, never before. Sub-minute cron fields and intervals near 60 seconds are best-effort, bounded by the tick.
</Note>

## Read schedules

### List schedules

`GET /schedules` returns the org's schedules, newest first.

<ParamField query="workflow_id" type="string (UUID)">Filter to one workflow.</ParamField>
<ParamField query="is_active" type="boolean">Filter by active (`true`) or paused (`false`).</ParamField>
<ParamField query="limit" type="integer" default="50">Page size, 1–100.</ParamField>
<ParamField query="offset" type="integer" default="0">Items to skip. There is no auto-pagination helper — page with `limit`/`offset`.</ParamField>

The response is `{ "schedules": [ScheduleResponse...], "total": int, "limit": int, "offset": int }`.

<Warning>
  `total` is computed with a coarse strategy that caps at roughly 10,000 and re-runs the list query. Treat it as an approximate count for very large schedule sets, not an exact total.
</Warning>

### Get one schedule

`GET /schedules/{schedule_id}` returns a single `ScheduleResponse`. A schedule that does not exist or belongs to another org returns `404` with `{"detail": "Schedule not found"}`.

<CodeGroup>
  ```bash cURL theme={null}
  # List active schedules for one workflow
  curl "https://api.modulex.dev/schedules?workflow_id=2b9e7c10-aaaa-bbbb-cccc-1234567890ab&is_active=true&limit=20" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"

  # Get one schedule
  curl "https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
  ```

  ```python Python theme={null}
  listing = await client.schedules.list(
      workflow_id="2b9e7c10-aaaa-bbbb-cccc-1234567890ab",
      is_active=True,
      limit=20,
  )
  for s in listing.schedules:
      print(s.id, s.name, s.next_run_at)

  schedule = await client.schedules.get("8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f")
  ```

  ```javascript JavaScript theme={null}
  const { schedules, total } = await client.schedules.list({
    workflowId: "2b9e7c10-aaaa-bbbb-cccc-1234567890ab",
    isActive: true,
    limit: 20,
  });

  const schedule = await client.schedules.get(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
  );
  ```
</CodeGroup>

## Update a schedule

`PUT /schedules/{schedule_id}` changes a schedule. Every body field is optional; only the fields you send are changed. The accepted fields are the same as create: `name`, `description`, `schedule_type`, `interval_seconds`, `cron_expression`, `timezone`, `input`, and `config`.

<Warning>
  A `null` value is **dropped**, not applied — sending `"description": null` is a no-op, so you cannot clear `description`, `input`, or `config` back to empty through update. If every field resolves to `null`, the call returns `400` with `{"detail": "No updates provided"}`.
</Warning>

`is_active` is **not** an updatable field here — use [pause and resume](#pause-and-resume) to toggle it. Changing any of `schedule_type`, `interval_seconds`, `cron_expression`, or `timezone` recalculates `next_run_at`. Invalid cadence or timezone values return `400`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PUT https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9" \
    -H "Content-Type: application/json" \
    -d '{ "name": "Nightly report (08:00 ET)", "cron_expression": "0 8 * * 1-5" }'
  ```

  ```python Python theme={null}
  updated = await client.schedules.update(
      "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
      name="Nightly report (08:00 ET)",
      cron_expression="0 8 * * 1-5",
  )
  ```

  ```javascript JavaScript theme={null}
  const updated = await client.schedules.update(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    { name: "Nightly report (08:00 ET)", cronExpression: "0 8 * * 1-5" },
  );
  ```
</CodeGroup>

## Pause and resume

Pause and resume toggle whether a schedule fires. While paused, the scheduler tick skips it entirely.

<ParamField path="POST /schedules/{schedule_id}/pause" type="endpoint">
  Sets `is_active` to `false`. The schedule stops firing until resumed. Returns the updated `ScheduleResponse`. There is no separate `enable`/`disable` route — `resume` is the enable counterpart.
</ParamField>

<ParamField path="POST /schedules/{schedule_id}/resume" type="endpoint">
  Sets `is_active` to `true` and **recomputes `next_run_at` from now** (it does not resume from where it left off). Returns the updated `ScheduleResponse`.
</ParamField>

<Note>
  Resume restarts the clock. A paused daily schedule resumed at 14:00 computes its next firing from 14:00, not from the original time. If you need the original cadence to hold, leave the schedule active and rely on its computed `next_run_at` rather than pausing.
</Note>

<CodeGroup>
  ```bash cURL theme={null}
  # Pause
  curl -X POST https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/pause \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"

  # Resume
  curl -X POST https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/resume \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
  ```

  ```python Python theme={null}
  await client.schedules.pause("8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f")
  await client.schedules.resume("8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f")
  ```

  ```javascript JavaScript theme={null}
  await client.schedules.pause("8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f");
  await client.schedules.resume("8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f");
  ```
</CodeGroup>

## Delete a schedule

`DELETE /schedules/{schedule_id}` removes a schedule **and all of its run history**, and cancels any pending executions. The response is `200` with `{"message": "Schedule deleted successfully"}` (not `204`). A missing or cross-org schedule returns `404`.

<Warning>
  Deleting the schedule also deletes every run record it produced and cannot be undone. To stop a schedule temporarily while keeping its history, [pause](#pause-and-resume) it instead.
</Warning>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X DELETE https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
  ```

  ```python Python theme={null}
  await client.schedules.delete("8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f")
  ```

  ```javascript JavaScript theme={null}
  await client.schedules.delete("8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f");
  ```
</CodeGroup>

## Run history and statistics

Every firing produces a scheduled-run record. These endpoints list, fetch, aggregate, and retry those runs.

### List run history

`GET /schedules/{schedule_id}/runs` lists runs for a schedule, newest first by scheduled time.

<ParamField query="status" type="string">Filter by run status (see the status table below).</ParamField>
<ParamField query="limit" type="integer" default="50">Page size, 1–100.</ParamField>
<ParamField query="offset" type="integer" default="0">Items to skip.</ParamField>

The response is `{ "runs": [ScheduleRunResponse...], "total": int, "limit": int, "offset": int }`. As with the schedule list, `total` is an approximate count. A missing schedule returns `404`.

### Get one run

`GET /schedules/{schedule_id}/runs/{run_id}` returns a single `ScheduleRunResponse`, where `run_id` is the scheduled-run record's `id`. There are two `404` paths: the schedule is not in your org, or the run does not exist (or belongs to a different schedule), returning `{"detail": "Run not found"}`.

#### The `ScheduleRunResponse` object

<ResponseField name="id" type="string (UUID)">The scheduled-run record ID.</ResponseField>
<ResponseField name="schedule_id" type="string (UUID)">The schedule that produced this run.</ResponseField>
<ResponseField name="workflow_id" type="string (UUID)">The workflow that was run.</ResponseField>
<ResponseField name="run_id" type="string | null">The workflow execution run ID, prefixed `sched_`. Populated once execution starts.</ResponseField>
<ResponseField name="thread_id" type="string | null">The execution thread ID, prefixed `sched_thread_`.</ResponseField>
<ResponseField name="scheduled_at" type="string (ISO 8601, UTC)">When the run was supposed to fire.</ResponseField>
<ResponseField name="started_at" type="string (ISO 8601, UTC) | null">When execution began.</ResponseField>
<ResponseField name="completed_at" type="string (ISO 8601, UTC) | null">When execution finished.</ResponseField>
<ResponseField name="duration_seconds" type="number | null">`completed_at − started_at`, in seconds.</ResponseField>
<ResponseField name="status" type="string">The run status (see the table below).</ResponseField>
<ResponseField name="error_message" type="string | null">The failure reason, when failed.</ResponseField>
<ResponseField name="triggered_by" type="string">What triggered the run: `scheduler` or `retry`.</ResponseField>
<ResponseField name="deployment_id" type="string (UUID) | null">The deployment that was executed.</ResponseField>
<ResponseField name="created_at" type="string (ISO 8601, UTC)">When the run record was created.</ResponseField>

<Expandable title="Example run record">
  ```json theme={null}
  {
    "id": "11111111-2222-3333-4444-555555555555",
    "schedule_id": "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    "workflow_id": "2b9e7c10-aaaa-bbbb-cccc-1234567890ab",
    "run_id": "sched_11111111-2222-3333-4444-555555555555_a1b2c3d4",
    "thread_id": "sched_thread_8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f_9f8e7d6c",
    "scheduled_at": "2026-06-22T13:00:00Z",
    "started_at": "2026-06-22T13:00:01Z",
    "completed_at": "2026-06-22T13:00:42Z",
    "duration_seconds": 41.2,
    "status": "succeeded",
    "error_message": null,
    "triggered_by": "scheduler",
    "deployment_id": "cccccccc-dddd-eeee-ffff-000011112222",
    "created_at": "2026-06-22T13:00:00Z"
  }
  ```
</Expandable>

#### Run status values

| Status      | Meaning                                                                                                      |
| ----------- | ------------------------------------------------------------------------------------------------------------ |
| `pending`   | The run record exists; execution has not started.                                                            |
| `running`   | Execution is in progress.                                                                                    |
| `succeeded` | The workflow completed successfully.                                                                         |
| `failed`    | Execution raised an error; see `error_message`.                                                              |
| `cancelled` | The run was cancelled.                                                                                       |
| `skipped`   | The firing was skipped — for example the workflow lost its live deployment between scheduling and execution. |

### Run statistics

`GET /schedules/{schedule_id}/runs/stats` aggregates a schedule's recent runs.

<ParamField query="days" type="integer" default="7">The lookback window in days, 1–90.</ParamField>

<ResponseField name="period_days" type="integer">The window the stats cover.</ResponseField>
<ResponseField name="total_runs" type="integer">Runs scheduled within the window.</ResponseField>
<ResponseField name="successful_runs" type="integer">Runs with status `succeeded`.</ResponseField>
<ResponseField name="failed_runs" type="integer">Runs with status `failed`.</ResponseField>
<ResponseField name="success_rate" type="number">Success rate over the window. See the note below on its scale.</ResponseField>
<ResponseField name="avg_duration_seconds" type="number | null">Mean duration over succeeded runs; `null` if none qualify.</ResponseField>
<ResponseField name="min_duration_seconds" type="number | null">Shortest succeeded-run duration; `null` if none qualify.</ResponseField>
<ResponseField name="max_duration_seconds" type="number | null">Longest succeeded-run duration; `null` if none qualify.</ResponseField>

<Warning>
  The scale of `success_rate` is reported inconsistently across our sources: the API computes it as a **percentage** (for example `95.24`), while the JavaScript SDK types describe it as a **fraction** (0.0–1.0). Read the value defensively — branch on whether it exceeds `1` — until this is reconciled.
</Warning>

<Expandable title="Example stats response">
  ```json theme={null}
  {
    "period_days": 7,
    "total_runs": 168,
    "successful_runs": 160,
    "failed_runs": 8,
    "success_rate": 95.24,
    "avg_duration_seconds": 38.7,
    "min_duration_seconds": 12.1,
    "max_duration_seconds": 91.4
  }
  ```
</Expandable>

### Retry a failed or cancelled run

`POST /schedules/{schedule_id}/runs/{run_id}/retry` re-runs a finished run by queueing a brand-new background execution. It does not run inline and does not modify the original run.

* The original run's status must be `failed` or `cancelled`; any other status returns `400` with `{"detail": "Can only retry failed or cancelled runs. Current status: <status>"}`.
* A missing schedule or run returns `404`.
* On success the response is `{ "message": "Retry scheduled", "original_run_id": "<id>" }`. The retry creates a fresh run with `triggered_by` set to `retry`.

<CodeGroup>
  ```bash cURL theme={null}
  # List failed runs
  curl "https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/runs?status=failed&limit=20" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"

  # Stats for the last 30 days
  curl "https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/runs/stats?days=30" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"

  # Retry a failed run
  curl -X POST https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/runs/11111111-2222-3333-4444-555555555555/retry \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
  ```

  ```python Python theme={null}
  runs = await client.schedules.list_runs(
      "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f", status="failed", limit=20,
  )
  stats = await client.schedules.run_stats(
      "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f", days=30,
  )
  print(stats.success_rate, stats.avg_duration_seconds)

  result = await client.schedules.retry_run(
      "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
      "11111111-2222-3333-4444-555555555555",
  )
  print(result.original_run_id)
  ```

  ```javascript JavaScript theme={null}
  const { runs } = await client.schedules.runs(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    { status: "failed", limit: 20 },
  );

  const stats = await client.schedules.runStats(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    { days: 30 },
  );

  const result = await client.schedules.retryRun(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    "11111111-2222-3333-4444-555555555555",
  );
  console.log(result.original_run_id);
  ```
</CodeGroup>

<Note>
  There is no public "run now" endpoint for a single schedule. To trigger an ad-hoc execution, either [retry](#retry-a-failed-or-cancelled-run) a finished run, or run the workflow directly through [Run via API](/workflow-builder/execution/api-endpoint). The retry path queues a new background run rather than blocking on the result.
</Note>

## Inputs and config on each firing

Every firing merges two layers, with the schedule winning on conflicting keys:

* **`input`** — merged over the deployment's default input. Use it to pin per-schedule state, like the `mode` field in the create example.
* **`config`** — merged over the deployment's config. Use it for execution settings such as `timeout` (the per-run execution timeout, which defaults to one hour) and `recursion_limit`.

A scheduled firing does not accept `{{node_id.field}}` references in `input`, because there is no upstream node to reference at trigger time — the schedule is the trigger. The `{{ref}}` system applies **inside** the workflow graph at execution. For how references resolve within a run, see [Variables & references](/workflow-builder/variables-and-references).

## Credit impact

The schedule API itself — create, read, update, pause/resume, run history — does **not** consume credits and is not behind the credits billing gate. The only throttle on these calls is the request rate limit.

Credits are consumed when a scheduled firing **executes the workflow**, exactly as they would for a manual run: any managed-model, managed-knowledge, or other metered work inside the workflow draws down credits at execution time. A schedule that fires every minute meters the same as running that workflow manually every minute. Size the cadence with that in mind, and review [Credits & metering](/billing/credits) and [Usage gating & limits](/billing/usage-gating) before scheduling a high-frequency, credit-heavy workflow.

## Error reference

Schedule routes use FastAPI's `HTTPException` envelope — `{"detail": <string-or-object>}` — not the credits `DenialEnvelope`. The full error model is on [Errors & status codes](/api-reference/errors).

| Status | Shape                                                                                                                            | Typical cause                                                                                                                                   |
| ------ | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `{"detail": "<message>"}`                                                                                                        | No live deployment, invalid cron/timezone/interval, `No updates provided`, retry of a non-failed/cancelled run, or missing `X-Organization-ID`. |
| `401`  | `{"detail": "<message>"}`                                                                                                        | Missing or invalid token.                                                                                                                       |
| `403`  | `{"detail": "<message>"}`                                                                                                        | Not owner/admin, inactive user, or API-key org-scope mismatch.                                                                                  |
| `404`  | `{"detail": "Schedule not found"}` / `{"detail": "Run not found"}`                                                               | Schedule or run missing, or owned by another org.                                                                                               |
| `422`  | `{"detail": [{"loc": [...], "msg": "...", "type": "..."}]}`                                                                      | Request body or query failed validation.                                                                                                        |
| `429`  | `{"detail": {"code": "rate_limited", "layer": "rate", "key": "api", "current": N, "limit": N, "reason": "rate_limit_exceeded"}}` | Request rate limit exhausted. Carries `Retry-After` and `X-RateLimit-*` headers.                                                                |
| `500`  | `{"detail": "An unexpected internal server error occurred."}`                                                                    | Unexpected server error.                                                                                                                        |

## Related pages

<CardGroup cols={2}>
  <Card title="Schedule a workflow (guide)" icon="map" href="/guides/schedule-a-workflow">
    A walkthrough that takes a workflow from deployment to a live schedule.
  </Card>

  <Card title="Deploy & versions" icon="rocket" href="/workflow-builder/execution/deploy">
    Create the live deployment a schedule needs before it can run.
  </Card>

  <Card title="Run via API" icon="terminal" href="/workflow-builder/execution/api-endpoint">
    Trigger a workflow on demand instead of on a cadence.
  </Card>

  <Card title="Roles & permissions" icon="shield-check" href="/security/roles-permissions">
    Why schedules require the owner or admin role.
  </Card>

  <Card title="Credits & metering" icon="coins" href="/billing/credits">
    How scheduled runs consume credits when they execute.
  </Card>

  <Card title="Workflows & runs" icon="git-branch" href="/concepts/workflows-and-runs">
    The run-identity model behind a scheduled run's three IDs.
  </Card>
</CardGroup>
