> ## 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

> Run a deployed ModuleX workflow automatically on a cron schedule: deploy, create the schedule, write a cron expression, and monitor scheduled runs — with cURL, Python, and JavaScript and full parameter and error coverage.

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>;
};

This guide walks you through running a workflow on a cron schedule, end to end: deploy the workflow, create a schedule, write a correct cron expression, then watch the scheduled runs and retry any that fail. Every step is shown via cURL, Python, and JavaScript.

If you want the full parameter, response, and error catalog for every schedule endpoint instead of a walkthrough, go straight to the reference, [Schedules](/workflow-builder/execution/schedule). This page focuses on the common task: a workflow that fires on a calendar cadence.

<CardGroup cols={2}>
  <Card title="Deploy first" icon="rocket" href="#step-1-deploy-the-workflow">
    A schedule fires a workflow's live deployment, never its draft.
  </Card>

  <Card title="Create the schedule" icon="calendar-plus" href="#step-2-create-the-schedule">
    Attach a cron cadence to the deployed workflow.
  </Card>

  <Card title="Write the cron" icon="clock" href="#cron-syntax">
    The 5-field cron format, timezones, and common patterns.
  </Card>

  <Card title="Monitor runs" icon="list-checks" href="#step-3-monitor-scheduled-runs">
    Inspect run history and stats, and retry failed runs.
  </Card>
</CardGroup>

## What you will build

A schedule that runs a deployed workflow automatically at a fixed calendar time — for example, a report workflow every weekday at 09:00 in your timezone. ModuleX fires the workflow's live deployment in the background on that cadence and records each firing as a scheduled run you can inspect.

This guide uses a `cron` schedule. ModuleX also supports a fixed-`interval` cadence (every N seconds); the reference covers both in [Cron and interval cadence](/workflow-builder/execution/schedule#cron-and-interval-cadence).

## Before you start

<Steps>
  <Step title="You are an owner or admin">
    **Every** schedule endpoint requires the `owner` or `admin` role in the organization — including the read-only ones that list schedules and runs. A plain member receives a `403`, even though they may be able to view the underlying workflow. See [Roles & permissions](/security/roles-permissions) and [Organizations, roles & membership](/concepts/organizations-roles).
  </Step>

  <Step title="The workflow has a live deployment">
    A schedule references a workflow's **live deployment**, not its draft. If the workflow has no live deployment when you create the schedule, the call fails with a `400` (not a `404`). [Step 1](#step-1-deploy-the-workflow) covers deploying.
  </Step>

  <Step title="You have an API key and your organization ID">
    Programmatic calls authenticate with `Authorization: Bearer mx_live_…` plus `X-Organization-ID`. Create a key from the app, or see [Authentication](/api-reference/authentication) and the [Quickstart](/get-started/quickstart).
  </Step>
</Steps>

<Warning>
  Schedules are gated to **owner/admin on every route, reads included**, which is broader than most read endpoints in the API. If a teammate gets a `403` listing or reading schedules, check their organization role first. *(Doc-blocking finding §2.9 — schedules require owner/admin; the `member` role is retired.)*
</Warning>

<MediaEmbed id="MX-MEDIA-4470" type="app_video" caption={"End-to-end walkthrough of scheduling a deployed workflow on a weekday-morning cron and watching the first scheduled run land in history."} />

### Set your variables

The cURL examples below reuse these placeholders. Replace them with your own values; the workflow and organization IDs are UUIDs.

```bash theme={null}
export MODULEX_API_KEY="mx_live_xxx"
export MODULEX_ORG_ID="0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
export WORKFLOW_ID="2b9e7c10-aaaa-bbbb-cccc-1234567890ab"
```

The Python and JavaScript examples create a client once and reuse it. The Python SDK is async, so every call is awaited; it also reads `MODULEX_API_KEY` and `MODULEX_ORGANIZATION_ID` from the environment if you omit the arguments. The JavaScript SDK has no environment-variable fallback — pass the values explicitly.

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

  client = Modulex(
      api_key="mx_live_xxx",
      organization_id="0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  )
  WORKFLOW_ID = "2b9e7c10-aaaa-bbbb-cccc-1234567890ab"
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  });
  const WORKFLOW_ID = "2b9e7c10-aaaa-bbbb-cccc-1234567890ab";
  ```
</CodeGroup>

## Step 1: deploy the workflow

A schedule runs the workflow's **live deployment**. Before scheduling, make sure the workflow is deployed — from the builder, or via the API. Skipping this is the most common reason a `POST /schedules` returns `400` with a message about a missing live deployment.

For how deployments work and how to create one, see [Deploy & versions](/workflow-builder/execution/deploy). Once the workflow has a live deployment, continue.

<Tip>
  You can confirm a deployment exists by running the workflow once on demand through [Run via API](/workflow-builder/execution/api-endpoint) before you schedule it. A workflow that runs manually has the live deployment a schedule needs.
</Tip>

## Step 2: create the schedule

Create the schedule with `POST /schedules`. The example below fires the workflow at 09:00 every weekday in `America/New_York` and pins a per-run `input` of `{"mode": "full"}`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/schedules \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "'"$WORKFLOW_ID"'",
      "name": "Nightly report",
      "schedule_type": "cron",
      "cron_expression": "0 9 * * 1-5",
      "timezone": "America/New_York",
      "input": { "mode": "full" }
    }'
  ```

  ```python Python theme={null}
  async def create_schedule():
      schedule = await client.schedules.create(
          workflow_id=WORKFLOW_ID,
          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)
      return schedule


  asyncio.run(create_schedule())
  ```

  ```javascript JavaScript theme={null}
  const schedule = await client.schedules.create({
    workflowId: WORKFLOW_ID,
    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 send them as snake\_case on the wire. Responses always come back **snake\_case** (`workflow_id`, `next_run_at`). Creating a schedule returns immediately with `200 OK` (not `201`); the workflow itself runs later, on cadence.
</Note>

### Create request fields

These are the fields you send to `POST /schedules`. For a cron schedule, supply `cron_expression` and leave `interval_seconds` unset.

<ParamField body="workflow_id" type="string (UUID)" required>
  The workflow to run. It must have a live deployment, or the call returns `400`.
</ParamField>

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

<ParamField body="schedule_type" type="string" required>
  `cron` for a calendar cadence, or `interval` for a fixed number of seconds. This guide uses `cron`.
</ParamField>

<ParamField body="cron_expression" type="string">
  Required when `schedule_type` is `cron`. A standard 5-field cron string (see [Cron syntax](#cron-syntax)). Max 100 characters. Validated server-side; an unparseable expression returns `400`.
</ParamField>

<ParamField body="interval_seconds" type="integer">
  Required only when `schedule_type` is `interval`. Minimum **60**. Leave unset for cron schedules.
</ParamField>

<ParamField body="timezone" type="string" default="UTC">
  An IANA timezone name (for example `America/New_York`). Max 50 characters. The cron expression is evaluated in this timezone. An unknown name returns `400`.
</ParamField>

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

<ParamField body="input" type="object" default="{}">
  Per-run state input. On every firing it is merged over the deployment's default input — schedule input wins on conflicting keys. Plain values only; `{{node_id.field}}` references do not resolve at trigger time because there is no upstream node.
</ParamField>

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

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

### What you get back

The response is a `ScheduleResponse`. The key field to note now is `next_run_at`: ModuleX computes the first firing from the current time and marks the schedule active (`is_active: true`).

<ResponseField name="id" type="string (UUID)">The schedule's ID. Use it for every follow-up call.</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 it last fired. `null` until the first firing.</ResponseField>
<ResponseField name="last_run_status" type="string | null">Status of the most recent run.</ResponseField>
<ResponseField name="total_runs" type="integer">Lifetime count of runs produced. `0` at creation.</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>

The response also echoes the fields you sent (`workflow_id`, `name`, `schedule_type`, `cron_expression`, `timezone`, `input`, `config`) plus `organization_id`, `created_at`, and `updated_at`. For the full field list, see the [reference response section](/workflow-builder/execution/schedule#response).

<Expandable title="Example create 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": null,
    "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>

<Note>
  In the example above the schedule is created on a Sunday, so the first weekday firing at 09:00 `America/New_York` lands the following morning. 09:00 Eastern is stored as `13:00Z` because `next_run_at` is always UTC.
</Note>

### Create errors

| Status | Cause                                                                                                                                                                             |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Workflow not found, no live deployment, invalid cron, invalid timezone, `interval_seconds < 60`, or a type/cadence mismatch. Shape: `{"detail": "<message>"}`.                    |
| `400`  | `X-Organization-ID` header missing.                                                                                                                                               |
| `401`  | Missing or invalid token.                                                                                                                                                         |
| `403`  | Caller is not owner/admin, the user is inactive, or the API key's org scope does not match `X-Organization-ID`.                                                                   |
| `422`  | Request-body validation, for example `schedule_type` outside `cron`/`interval` or a name length out of range. 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."}`.                                                                                           |

Schedule routes use FastAPI's `HTTPException` envelope and are **not** behind the credits billing gate, so they never return the flat `DenialEnvelope`. Credits are consumed only when a scheduled run actually executes the workflow (see [Cost of a schedule](#cost-of-a-schedule)). For the full error model, see [Errors & status codes](/api-reference/errors).

## Cron syntax

A `cron_expression` is a **standard 5-field cron string**, evaluated in the schedule's `timezone`:

```text theme={null}
┌───────────── minute        (0–59)
│ ┌───────────── hour        (0–23)
│ │ ┌───────────── day-of-month (1–31)
│ │ │ ┌───────────── month     (1–12)
│ │ │ │ ┌───────────── day-of-week (0–6, Sunday = 0)
│ │ │ │ │
* * * * *
```

Each field accepts a value, a list (`1,15`), a range (`1-5`), a step (`*/15`), or `*` for "every". ModuleX parses and validates the expression server-side; anything it cannot parse returns a `400`. Maximum length is 100 characters.

### Common patterns

| Expression       | Meaning                             |
| ---------------- | ----------------------------------- |
| `0 9 * * 1-5`    | 09:00 every weekday (Monday–Friday) |
| `0 * * * *`      | At the top of every hour            |
| `*/15 * * * *`   | Every 15 minutes                    |
| `0 0 1 * *`      | 00:00 on the 1st of each month      |
| `30 2 * * 0`     | 02:30 every Sunday                  |
| `0 9,17 * * 1-5` | 09:00 and 17:00 on weekdays         |

### Timezones and `next_run_at`

The cron expression is evaluated in the schedule's `timezone` (default `UTC`). ModuleX computes the next occurrence in that local timezone and then stores it in `next_run_at` as **UTC**. So a schedule with `0 9 * * 1-5` and `America/New_York` shows `13:00Z` (or `14:00Z` during standard time) — that is the same 09:00 local moment, expressed in UTC.

When you change the cadence or timezone with an update, `next_run_at` is recalculated. The base for the next firing is the last run if there is one, otherwise the current time. For the exact recurrence model, see [How `next_run_at` is computed](/workflow-builder/execution/schedule#how-next_run_at-is-computed).

<Warning>
  A background **scheduler tick** drives firing, and it runs roughly **every 30 to 60 seconds** depending on deployment. A schedule fires at or shortly after `next_run_at`, never before. Treat sub-minute cron fields and intervals near the 60-second minimum as best-effort — the effective floor on how often a workflow can fire is the tick, not the value you set.
</Warning>

### Edit the cadence later

To change the cron expression, timezone, name, input, or config after creation, send `PUT /schedules/{schedule_id}` with only the fields you want to change. Changing the cadence or timezone recalculates `next_run_at`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PUT https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID" \
    -H "Content-Type: application/json" \
    -d '{ "cron_expression": "0 8 * * 1-5" }'
  ```

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

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

<Note>
  A `null` field in an update is **dropped**, not applied, so you cannot clear `description`, `input`, or `config` back to empty through update. `is_active` is not updatable here — use [pause and resume](#pause-or-resume-a-schedule) instead. If every field resolves to `null`, the call returns `400` with `{"detail": "No updates provided"}`. Full update rules are in the [reference](/workflow-builder/execution/schedule#update-a-schedule).
</Note>

## Step 3: monitor scheduled runs

Every firing produces a **scheduled run** record. Use the run endpoints to confirm your schedule is working and to act on failures.

### List the schedule's runs

`GET /schedules/{schedule_id}/runs` lists runs newest-first by scheduled time. Filter by `status` while debugging, and page with `limit` (1–100, default 50) and `offset`.

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/runs?limit=20" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"
  ```

  ```python Python theme={null}
  runs = await client.schedules.list_runs(
      "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f", limit=20,
  )
  for r in runs.runs:
      print(r.scheduled_at, r.status, r.duration_seconds)
  ```

  ```javascript JavaScript theme={null}
  const { runs } = await client.schedules.runs(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    { limit: 20 },
  );
  for (const r of runs) {
    console.log(r.scheduled_at, r.status, r.duration_seconds);
  }
  ```
</CodeGroup>

Each item is a `ScheduleRunResponse`. The fields most useful for monitoring:

<ResponseField name="id" type="string (UUID)">The scheduled-run record ID. This is the `run_id` argument you pass to the get-run and retry endpoints.</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 the run failed.</ResponseField>
<ResponseField name="triggered_by" type="string">What triggered the run: `scheduler` for a normal firing, or `retry` for a manual retry.</ResponseField>
<ResponseField name="run_id" type="string | null">The workflow execution's run ID, prefixed `sched_`. Populated once execution starts. This is distinct from the record `id` above.</ResponseField>
<ResponseField name="thread_id" type="string | null">The execution thread ID, prefixed `sched_thread_`.</ResponseField>

<Warning>
  A scheduled run carries **three different IDs** — the record `id`, the execution `run_id` (`sched_…`), and the `thread_id` (`sched_thread_…`). When you call get-run or retry, the `run_id` argument is the record's `id`, **not** the execution `run_id`. Keep them straight; see [Workflows & runs](/concepts/workflows-and-runs) for the full run-identity model. *(Doc-blocking finding §2.9 — three distinct run-id identities.)*
</Warning>

#### 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. |

### Check the success rate

`GET /schedules/{schedule_id}/runs/stats` rolls up recent runs over a `days` window (1–90, default 7). Use it to spot a schedule that is silently failing.

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/runs/stats?days=30" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"
  ```

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

  ```javascript JavaScript theme={null}
  const stats = await client.schedules.runStats(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    { days: 30 },
  );
  console.log(stats.success_rate, stats.avg_duration_seconds);
  ```
</CodeGroup>

<Expandable title="Example stats response">
  ```json theme={null}
  {
    "period_days": 30,
    "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>

<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 it defensively — branch on whether the value exceeds `1` — until this is reconciled.
</Warning>

### Retry a failed run

If a firing failed or was cancelled, retry it with `POST /schedules/{schedule_id}/runs/{run_id}/retry`, where `run_id` is the scheduled-run record's `id`. The retry queues a brand-new background execution with `triggered_by` set to `retry`; it does not run inline and does not modify the original run.

<CodeGroup>
  ```bash cURL theme={null}
  # 1. Find the failed runs
  curl "https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/runs?status=failed" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"

  # 2. Retry one by its record id
  curl -X POST https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/runs/11111111-2222-3333-4444-555555555555/retry \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"
  ```

  ```python Python theme={null}
  failed = await client.schedules.list_runs(
      "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f", status="failed",
  )
  for r in failed.runs:
      result = await client.schedules.retry_run(
          "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f", r.id,
      )
      print("retried", result.original_run_id)
  ```

  ```javascript JavaScript theme={null}
  const { runs: failed } = await client.schedules.runs(
    "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
    { status: "failed" },
  );
  for (const r of failed) {
    const result = await client.schedules.retryRun(
      "8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f",
      r.id,
    );
    console.log("retried", result.original_run_id);
  }
  ```
</CodeGroup>

On success the response is `{ "message": "Retry scheduled", "original_run_id": "<id>" }`. Retrying a run whose status is anything other than `failed` or `cancelled` returns `400` with `{"detail": "Can only retry failed or cancelled runs. Current status: <status>"}`.

<Note>
  There is no public "run now" endpoint for a single schedule. To trigger an ad-hoc execution, either retry a finished run, or run the workflow directly through [Run via API](/workflow-builder/execution/api-endpoint). For the get-one-run endpoint and the full run schema, see the [reference run section](/workflow-builder/execution/schedule#run-history-and-statistics).
</Note>

## Pause or resume a schedule

To stop a schedule firing without deleting its history, pause it; bring it back with resume. While paused (`is_active: false`), the scheduler tick skips it.

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

  # Resume
  curl -X POST https://api.modulex.dev/schedules/8f1c2a40-1d3e-4b9a-9c77-1a2b3c4d5e6f/resume \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"
  ```

  ```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>

<Warning>
  Resume **restarts the clock**: it recomputes `next_run_at` from now rather than resuming from where the schedule left off. A paused daily schedule resumed at 14:00 computes its next firing from 14:00. If you need the original cadence to hold exactly, leave the schedule active and rely on its computed `next_run_at` instead of pausing. Deleting a schedule (`DELETE /schedules/{schedule_id}`) removes it **and all of its run history** and cannot be undone — pause instead when you want to keep history.
</Warning>

## Cost of a schedule

The schedule API itself — create, read, update, pause, resume, run history — does **not** consume credits. Credits are consumed when a scheduled firing **executes the workflow**, exactly as 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 accordingly, and review [Credits & metering](/billing/credits) and [Usage gating & limits](/billing/usage-gating) before scheduling a high-frequency, credit-heavy workflow.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Create returns 400 about a missing live deployment">
    The workflow has no live deployment yet. Deploy it first (see [Deploy & versions](/workflow-builder/execution/deploy)), then create the schedule. This is a `400`, not a `404`.
  </Accordion>

  <Accordion title="A teammate gets 403 just listing schedules">
    Schedule routes require the `owner` or `admin` role on every endpoint, reads included. A plain member cannot list, read, or manage schedules. Check the teammate's organization role — see [Roles & permissions](/security/roles-permissions).
  </Accordion>

  <Accordion title="The schedule never fires, or fires late">
    Confirm `is_active` is `true` (a paused schedule is skipped) and that `next_run_at` is in the future and in the expected UTC time. Remember the scheduler tick runs every 30–60 seconds, so firings land at or just after `next_run_at`, never before. Sub-minute cadences are best-effort.
  </Accordion>

  <Accordion title="Runs show status skipped">
    A `skipped` run means the firing was reached but could not execute — most often because the workflow lost its live deployment between scheduling and execution. Re-deploy the workflow.
  </Accordion>

  <Accordion title="The cron expression is rejected with 400">
    The expression must be a valid 5-field cron string (max 100 characters) that ModuleX can parse, and the `timezone` must be a valid IANA name. Re-check both against [Cron syntax](#cron-syntax).
  </Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
  <Card title="Schedules (reference)" icon="book" href="/workflow-builder/execution/schedule">
    The complete endpoint reference: every parameter, response field, and error.
  </Card>

  <Card title="Deploy & versions" icon="rocket" href="/workflow-builder/execution/deploy">
    Create the live deployment a schedule needs.
  </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="Run a workflow (REST + SDK)" icon="play" href="/guides/run-a-workflow">
    Authenticate, run a workflow, and stream the result.
  </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>
</CardGroup>
