---
topic: schedules
connector: "deploy_app with embarko.json among the files. Running them once now is HTTP only, with a deploy token; the person can create one at https://embarko.ai/app/tokens"
api: "https://ship.embarko.ai/apps (embarko.json in the upload)"
needs_human: "Only their time zone, if you cannot tell it."
requires: [claim]
---

[← Guide home](https://embarko.ai/docs)

# Run something on a timer

**On this page:** Steps, Change, pause or delete, Examples, Fields, What the app receives, Run outcomes, Failure alerts, Limits, Errors, What to tell the person

> Plain words unless the person is a developer. Never invent a link, name or code. [Rules](https://embarko.ai/docs/prerequisites) Needs first: [claim](https://embarko.ai/docs/claim) (over HTTP only).

List the schedules in the app's embarko.json file and Embarko calls those routes of the app on time, for example GET /tasks/daily-digest every weekday at 9:00. The job is normal app code in that route. Embarko keeps a record of each run, and emails the owner if a schedule starts failing.

**Connector:** deploy_app with embarko.json among the files. Running them once now is HTTP only, with a deploy token; the person can create one at https://embarko.ai/app/tokens

**Done when:** Each schedule ran once with outcome "ok", and the person knows when it runs next.

**Steps**

1. Put the job in a GET route of the app, for example /tasks/daily-digest. Answer 2xx when it worked.
2. First thing in that route: answer 401 unless the Authorization header is "Bearer " plus the CRON_SECRET env var. Embarko creates CRON_SECRET for the app; you never set or see it.
3. Save anything the job must remember under DATA_DIR, not in memory: memory is not kept between runs.
4. List the job under "schedules" in embarko.json, in the app's top folder (see the first example and embarko-json).
5. Deploy as usual, any way. A bad schedule refuses the deploy before it builds; the error names the entry, e.g. "Schedule 1: ...".
6. Check the test run. The skill runs each schedule once when the app is live and prints ok or the error. Over HTTP: POST links.runSchedules from the deploy reply, then poll each statusUrl until terminal is true.
7. If a run failed, fix the route and deploy again.
8. Tell the person when it runs, in their time zone.

**Change, pause or delete**

1. Change: edit embarko.json and deploy again. The list is the app's full set. A schedule keeps its history while its path stays the same.
2. Remove one: delete it from the list and deploy. Remove all: "schedules": [].
3. No embarko.json, or one without a "schedules" key: the schedules stay as they are.
4. See runs: GET https://ship.embarko.ai/api/apps/{app-name}/schedules, then each schedule's links.runs.
5. Pause one without a deploy: PATCH https://ship.embarko.ai/api/apps/{app-name}/schedules/{schedule-id} with {"enabled": false}. The next deploy that lists it turns it back on.
6. Failure alerts to Slack or another webhook: PATCH the schedule with {"alertWebhookUrl": "https://..."}.

**Example: embarko.json, in the app's top folder**

```json
{
  "$schema": "https://embarko.ai/schema/embarko.json",
  "schedules": [
    {
      "cron": "0 9 * * 1-5",
      "path": "/tasks/daily-digest",
      "timezone": "Asia/Kolkata",
      "name": "daily digest"
    }
  ]
}
```

Next to package.json (or requirements.txt, go.mod...). Only the top folder is read.

**Example: Deploy with a schedule (skill)**

```bash
./scripts/deploy.sh ./my-app my-app
```

Write embarko.json first. When the app is live the script prints a line per schedule, for example "/tasks/daily-digest: ok (200) Digest sent to 12 people", and any warnings. It exits 1 if a test run failed.

**Example: Deploy with a schedule (HTTP) → 202**

```bash
tar -czf /tmp/app.tar.gz --exclude=node_modules --exclude=.git --exclude='.env*' -C ./my-app .
curl -sS -X POST 'https://ship.embarko.ai/apps' --oauth2-bearer $DEPLOY_TOKEN -H 'X-App-Name: my-app' -H 'X-Agent-Name: {your-name}' -F 'source=@/tmp/app.tar.gz'
```

The normal deploy. embarko.json travels inside the .tar.gz; in a JSON upload it is one of the files.

Reply 202:

```json
{"success":true,"status":"building","app":{"name":"my-app","kind":"service","claimed":true},"schedules":{"declared":1,"appliesWhen":"live"},"next":"Building: poll links.status every 10 seconds until it says \"live\", \"failed\" or \"crashed\". Once live, its schedule runs on time; POST links.runSchedules (deploy token) to run it once now, and tell the user it works only when each run's outcome is \"ok\".","links":{"status":"https://ship.embarko.ai/apps/my-app/status","feedback":"https://ship.embarko.ai/apps/my-app/customer-query","runSchedules":"https://ship.embarko.ai/api/apps/my-app/schedules/run"}}
```

**Example: Run them once now (HTTP) → 202, then poll**

```bash
curl -sS -X POST 'https://ship.embarko.ai/api/apps/my-app/schedules/run' --oauth2-bearer $DEPLOY_TOKEN
curl -sS 'https://ship.embarko.ai{statusUrl}' --oauth2-bearer $DEPLOY_TOKEN
```

Poll each statusUrl every 5 seconds until terminal is true. Done looks like {"outcome": "ok", "httpStatus": 200, "responseExcerpt": "Digest sent to 12 people", "terminal": true}.

Reply 202:

```json
{"runs":[{"scheduleId":"cmgv2k8w0000108l4schd7x2a","path":"/tasks/daily-digest","id":"cmgv2m1qa000308l4run9f3kd","trigger":"manual","outcome":"running","terminal":false,"httpStatus":null,"error":null,"responseExcerpt":null,"startedAt":"2026-10-17T10:01:00.000Z","finishedAt":null,"durationMs":null,"statusUrl":"/api/apps/my-app/schedules/cmgv2k8w0000108l4schd7x2a/runs/cmgv2m1qa000308l4run9f3kd"}]}
```

**Example: The route, with the CRON_SECRET check (Node, Express)**

```js
app.get('/tasks/daily-digest', async (req, res) => {
  if (req.get('authorization') !== 'Bearer ' + process.env.CRON_SECRET) return res.sendStatus(401);
  const sent = await sendDailyDigests(); // keep progress under DATA_DIR
  res.send('Digest sent to ' + sent + ' people'); // kept as responseExcerpt
});
```

Any other language: compare the Authorization header with "Bearer " plus CRON_SECRET and answer 401 if they differ. For work longer than 5 minutes, answer 202 at once and carry on in the background.

**Example: Alert webhook payload**

```json
{"event":"schedule.failed","app":{"name":"my-app","url":"https://my-app.embarko.app"},"schedule":{"id":"cmgv2k8w0000108l4schd7x2a","name":"daily digest","cron":"0 9 * * 1-5","timezone":"Asia/Kolkata","path":"/tasks/daily-digest"},"run":{"id":"cmgv3a7pe000508l4run2h6qz","trigger":"schedule","outcome":"failed","terminal":true,"httpStatus":500,"error":"HTTP 500","responseExcerpt":"Error: SMTP login failed","startedAt":"2026-10-19T03:30:00.412Z","finishedAt":"2026-10-19T03:30:01.224Z","durationMs":812},"consecutiveFailures":1,"sentAt":"2026-10-19T03:30:01.300Z"}
```

event is schedule.failed or schedule.recovered.

**Fields**

- `cron`: Required. Five fields: minute hour day-of-month month day-of-week. "0 9 * * 1-5" is 9:00 on weekdays. @hourly, @daily, @weekly, @monthly and @yearly also work.
- `path`: Required. The route to call, starting with /. A query string is allowed: /tasks/cleanup?days=30. One schedule per path.
- `timezone`: IANA name, such as Asia/Kolkata or Europe/London. Default UTC. Daylight saving is handled.
- `name`: Optional, up to 100 characters. Shown in alert emails.

**What the app receives**

- GET {path} on the app's own address, at each due time.
- Authorization: Bearer {CRON_SECRET}.
- X-Embarko-Schedule-Id and X-Embarko-Run-Id headers, and a User-Agent starting with Embarko-Scheduler/1.0.

**Run outcomes**

- `running`: The call is in progress. Keep polling.
- `ok`: Final. The app answered 2xx.
- `failed`: Final. Another status, no answer, a redirect, or no full answer within 5 minutes. error says which; responseExcerpt has the start of the app's reply.
- `skipped`: Final. The previous run was still going when this one was due.

**Failure alerts**

- The app's owner gets one email when a schedule starts failing, and one when it works again. Not one per failed run.
- Test runs count too: a failing test run can send that email.
- With alertWebhookUrl set, the same two events also go there as JSON (see the example).

**Limits**

- Up to 5 schedules per app, at most once every 5 minutes each.
- Each call has 5 minutes to finish.
- Redirects are not followed and count as failed.
- The first 2KB of each reply and the last 100 runs of each schedule are kept.
- A time missed during an Embarko outage runs once when it can. Missed times are not replayed.
- Needs a kept app and a deploy token. A temporary app deploys, but its schedules are not applied; the reply says so.
- Static sites cannot have schedules. A static site deploys, with the warning schedules_on_static_site.
- Schedules apply only when the deploy succeeds.
- embarko.json is the source of truth: a schedule paused or changed through the API is set back by the next deploy whose embarko.json lists it.
- A deploy with no schedules in embarko.json whose code lists a timer library (node-cron, node-schedule, APScheduler, rufus-scheduler...) goes ahead with the warning in_process_timer, and quotes any timing it found in the code. Move those jobs here.
- If the app has schedules and the upload declares none, the reply warns schedules_unchanged: they were kept.

**Errors**

| Code | Status | Retry | What to do |
| --- | --- | --- | --- |
| `invalid_embarko_json` | 400 | No | The file is not valid JSON (the message gives line and column), not one object, "schedules" is not a list, or it is over 64 KB. Fix it and deploy again. |
| `secrets_in_embarko_json` | 400 | No | The file looks like it holds a secret. Remove it; use an env var. The route checks CRON_SECRET, which Embarko creates. |
| `invalid_schedules` | 400 | No | An entry in "schedules" has the wrong shape: cron or path missing, or a field of the wrong type. The message names it, e.g. (0.path: Required). Fix it and deploy again. |
| `invalid_cron` | 400 | No | Use five fields (minute hour day-of-month month day-of-week), or @daily and the like. |
| `schedule_too_frequent` | 400 | No | Two runs would be less than 5 minutes apart. Space them out. |
| `invalid_timezone` | 400 | No | Use an IANA name such as UTC or Asia/Kolkata. |
| `invalid_path` | 400 | No | A path on the app starting with /, with no host, spaces or #. |
| `duplicate_schedule_path` | 400 | No | Two schedules call the same path. Give each its own route. |
| `schedule_limit_reached` | 400 | No | More than 5 schedules. Combine some jobs into one route. |
| `no_schedules` | 404 | Yes, once | On run-now right after going live: wait 5 seconds and try again. Still 404: the deploy's embarko.json listed no schedules. |
| `run_in_progress` | 409 | Later | That schedule's last run has not finished. Poll it, then try again. |
| `invalid_alert_webhook` | 400 | No | alertWebhookUrl must be a public https:// address. |
| `not_supported_for_static` | 409 | No | A static site has no server to call. Deploy it as a server app under a new name. |

**What to tell the person**

- done: "Done: every weekday at 9:00 your app sends the digest. I ran it once just now and it worked. If it ever stops working, Embarko emails you."

**Next:** Write the route with the CRON_SECRET check, list it in embarko.json, deploy, and check the test run.

**Related:** [embarko-json](https://embarko.ai/docs/embarko-json) · [deploy](https://embarko.ai/docs/deploy) · [logs](https://embarko.ai/docs/logs) · [static-sites](https://embarko.ai/docs/static-sites)

---

[Guide home](https://embarko.ai/docs) · JSON: add .json