---
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. No HTTP (connector without a token, chat-only, upload page): you cannot run it now. Say so: the first real run is the test, they get an email if it fails, and they can see each run on the app's Overview at https://embarko.ai/app. Or ask for a deploy token (https://embarko.ai/app/tokens) and run it over HTTP if you can reach ship.embarko.ai.
8. If a run failed, fix the route and deploy again.
9. 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. Newest first, 20 a page; add ?page=2, ?outcome=failed or ?trigger=schedule. The reply has total and totalPages. The last 100 runs of each schedule are kept.
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 a webhook: PATCH the schedule with {"alertWebhookUrl": "https://..."}. The body is the JSON in the example, with no text field, so a Slack incoming webhook rejects it: point it at something that accepts any JSON.
7. The person can see every schedule and run without the API: the app's Overview at https://embarko.ai/app, Scheduled jobs.

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

```json
{
  "$schema": "https://embarko.ai/schema/embarko.json",
  "appId": "cmh1q2w3e0001abcdmyapp",
  "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":3,"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 fails 3 runs in a row, or has been failing for 6 hours with no successful run, whichever comes first. One more email when it works again. A single failed run that the next run fixes sends nothing.
- 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: claimed over HTTP, deployed through the connector, or uploaded in the dashboard. 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. |
| `permission_denied` | 403 | No | The person who made this token is not allowed to do this. The reply names the permission and their access to the app (or their role). Nothing changed. Tell the person (say_to_user.no_permission); do not try another way round it. |

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