Skip to content
Developer docs

Background jobs

Do something now outside the visitor's request, later, in order, or on a timetable, with retries. EzyStudio keeps every job, calls the site back when it is due, and shows the owner every run. App sites only.

How it works

your route ── sendJob() ──▶ EzyStudio (Site Key) ── keeps the job
                                    │  when due (now: ~1–2 s · later: on time, to the minute · schedules: each time)
                                    ▼
your site  ◀── signed POST /site-api/_platform/jobs ── EzyStudio ── answer: done · retry · failed
                                    │
                         the owner's Jobs tab: history, charts, run again
  • Your server code sends a job with the site's Site Key. Nothing to set up: the key and the delivery secret are set by EzyStudio.
  • EzyStudio keeps it until it is due. A job with no delay starts within about one to two seconds; a delayed one at its time, to the minute (a job due less than a minute ahead runs now); a schedule at each time in its time zone.
  • EzyStudio calls the site's own address, POST /site-api/_platform/jobs, signed with the site's own secret. The site runs the handler for the job's list and answers.
  • The answer decides: done, try again later (1 minute, 2 minutes, 10 minutes, then every hour, each a little later at random), or failed. A job that never works is kept under Didn't work, with its data, for the owner to run again.
  • The owner sees everything in the site's Jobs tab: what is coming up, history with every try, charts, pause and resume, run again.

Pick the kind

You needUseStarts
Do it now, outside the request (receipt, sync, processing)sendJob(url, list, data)within about 1–2 s
Do it later (a reminder, a follow-up){at} / {delay}at that time, up to 1 year ahead
Do it on a timetablebackground.jsonat each time, in its time zone
One at a time, in order (per customer, per order)"ordered": true + {group}in send order per group
Big work (an import)sendJobs()many small jobs, each retried alone, within the site's jobs-per-minute limit

Quick start (app template)

The app template already has everything. You write handlers in src/server/background.ts, list settings and schedules in background.json, and send from any Site API route. src/platform/background.ts is managed by EzyStudio: never edit it.

Send

src/server/routes/bookings.ts
import {sendJob, cancelJob} from '../../platform/background';

// now
await sendJob(c.req.url, 'receipts', {orderId: 981});
// later (≤ 1 year), safe to send twice thanks to the key
await sendJob(c.req.url, 'booking-reminders', {bookingId: 42}, {
  at: '2026-11-01T09:00:00Z', key: 'remind-42', label: 'Reminder · Ana, Sat 10:00',
});
// in order, per customer (ordered list)
await sendJob(c.req.url, 'payments', {paymentId: 7}, {group: 'customer-9'});
// cancel a waiting job
await cancelJob(c.req.url, {list: 'booking-reminders', key: 'remind-42'});

Handle

src/server/background.ts
import {defineBackground, DontRetry} from '../platform/background';

export const background = defineBackground({
  'booking-reminders': async ({bookingId}, job) => {
    // load by id, check it still applies, do the work, mark it done (safe to repeat)
  },
  payments: async ({paymentId}, job) => {
    if (!paymentId) throw new DontRetry('No payment id');   // fails now, no retries
  },
});
  • Return = done. Throw = try again later. DontRetry = failed now, with your message shown to the owner.
  • A job may run more than once (a lost answer, a retry): make handlers safe to repeat (check a column, use job.id, upsert).
  • Each run has 60 seconds. Never wait inside a handler: send another job with a delay, or split the work into more jobs.
  • A deployed site's server reaches only EzyStudio and its own database, so a job can do database work and platform calls, not call other websites.

Lists, ordered lists and schedules

background.json at the root of the site. EzyStudio checks it when the owner publishes: a bad file stops the publish with every problem listed. npm run platform -- jobs runs the same checks on your machine.

background.json
{
  "lists": {
    "booking-reminders": {"retries": 4, "concurrency": 5},
    "payments": {"ordered": true, "retries": 9}
  },
  "schedules": {
    "daily-summary": {"every": "day", "at": "07:00", "tz": "Europe/London"},
    "weekly-report": {"list": "reports", "every": "week", "day": 1, "at": "08:30"},
    "cleanup": {"list": "cleanup", "cron": "0 3 * * 0", "data": {"keepDays": 30}}
  }
}
KeyMeans
lists.<name>.retriesExtra tries after the first, 0 to 9 (default 4).
lists.<name>.concurrencyJobs of this list running at once, 1 to 100 (default: the site's limit).
lists.<name>.orderedOne job at a time per group, in send order. A job that is retrying holds its group; other groups go on.
schedules.<name>.everyminute, hour, day, weekday, week (with day 0 = Sunday … 6), month (the 1st), with at "HH:MM".
schedules.<name>.cronOr a 5-field cron line, with a day of the month or a weekday, not both.
schedules.<name>.tzAn IANA time zone (default UTC). Summer time is handled; avoid 01:00–03:00 local.
schedules.<name>.listThe list each run is sent to (default: the schedule's own name), with its data.

The owner can pause schedules and lists in the Jobs tab; a publish keeps their choice. A schedule removed from the file is deleted on the next publish. The plan sets how many schedules the account may have, all its sites together.

The Site API

The helpers call these with the Site Key. Any server can, with Authorization: Bearer <SITE_KEY>. The editor's key is refused (E_JOBS_EDITOR_KEY): in the editor Preview and on your machine the helpers run jobs in your own process instead.

POST/api/site/v1/jobs
POST /api/site/v1/jobs
Authorization: Bearer <SITE_KEY>
Content-Type: application/json

{"list": "booking-reminders", "data": {"bookingId": 42},
 "at": "2026-11-01T09:00:00Z", "key": "remind-42", "label": "Reminder · Ana"}

202 {"status": 202, "message": "Job accepted.",
     "data": {"id": "job_…", "list": "booking-reminders",
              "runAt": "2026-11-01T09:00:00.000Z", "deduplicated": false}}

Up to 100 at once as {"jobs": [...]}, accepted all or none; the answer's data is then {"jobs": [...]} too, one entry per job. Fields: list, data (JSON, 32 KB), at (ISO, in the past = now) or delay (seconds), key (dedupe: while that job waits or runs, and for 24 hours after it was sent; also how you cancel it; up to 128 characters), label (what the owner reads, up to 200), group (ordered lists only, up to 128).

POST/api/site/v1/jobs/cancel

{"id": "job_…"} or {"list": "…", "key": "…"}. Only a waiting job can be cancelled.

The answer's data is {"cancelled": true}: false when there is no such job, true again for a job already cancelled.

CodeStatusWhen
E_JOBS_BAD_LIST422The list name is not lower-case letters, digits and dashes (up to 63).
E_JOBS_PAYLOAD_TOO_LARGE413The data is over 32 KB. Send ids instead.
E_JOBS_BAD_TIME422at is not a date and time.
E_JOBS_TOO_FAR422More than a year ahead.
E_JOBS_KEY422key is empty or over 128 characters (E_JOBS_GROUP: the same for group; E_JOBS_LABEL: label over 200).
E_JOBS_TOO_MANY_LISTS422A new list past 100 per site. Reuse a list and put the variety in data.
No code422The body failed validation, for example more than 100 jobs in one call or delay not a number.
E_JOBS_RATE_LIMITED429The site added more jobs this minute than its limit (10 by default). Retry-After says when the minute turns. Nothing was created.
E_TOO_MANY_REQUESTS429More than 300 send calls a minute, or 600 Site API calls a minute. Batch with sendJobs.
E_JOBS_BACKLOG42950,000 jobs already wait in that list (E_JOBS_SITE_BACKLOG: 200,000 in the whole site): something is not running.
E_JOBS_PAUSED423The platform paused this site's jobs because something looked wrong (a flood of sends, almost every run failing). The owner resumes them in the Jobs tab.
E_JOBS_NOT_AN_APP409A static website has no background jobs.
E_JOBS_EDITOR_KEY403Sent with the editor's key: jobs run in your process there.
E_JOBS_NOT_CANCELLABLE409The job is running or finished.

Deliveries to your site

If you write your own handler instead of the template's (another framework), this is the whole contract.

POST https://<site>/site-api/_platform/jobs
content-type: application/json
user-agent: Site-Jobs/1.0
x-site-job-id: job_…
x-site-job-attempt: 1
x-site-signature: t=1792400000,v1=<hex HMAC-SHA256(SITE_JOBS_SECRET, "<t>.<raw body>")>

{"id": "job_…", "list": "booking-reminders", "attempt": 1, "maxAttempts": 5,
 "scheduledFor": "2026-11-01T09:00:00.000Z", "group": null, "data": {"bookingId": 42}}
  • Check the signature first: recompute the HMAC over <t>.<raw body> with SITE_JOBS_SECRET, compare in constant time, and refuse a t more than 5 minutes away (a replayed request). After the owner replaces the jobs secret, the header carries one v1 per secret until the site is published again: accept the delivery if any of them matches.
  • Answer 2xx when done. 5xx, 408 or no answer within 60 seconds: tried again. 429 or 503: tried again, after your Retry-After if you send one (up to an hour). Any other 4xx: failed now, and its {"error": "…"} is shown to the owner. 404 {"code": "no_handler"}: failed with "add a handler". A redirect fails the job: it is never followed.
  • Use id to make the work safe to repeat: the same job may be delivered again.

Limits

WhatLimit
Data per job32 KB
Delay or atup to 1 year
One run60 seconds
Tries per job1 to 10 per list (default 5)
Running at oncePer site, by plan: App 10, Business 50, up to 100 on agency plans. Each list may set less
Jobs added per minute10 per site by default; the owner can raise it to 50 in the Jobs tab, more on request. Retries, schedules and Run again don't count
Send calls per minute300
Waiting per list50,000
Waiting per site200,000
Lists per site100
Jobs per send100
Dedupe key windowWhile the job waits or runs, and 24 hours after it was sent
History kept14 days on App, 30 on Business and agency plans

In the editor and on your machine

Jobs run locally: sendJob runs the handler right away, in your own process, once, and logs what the live site would do (when, how many tries). Nothing is sent to EzyStudio and no key is needed. Schedules don't tick locally: test one by sending its list a job yourself.

Runs and plans
One run is one job started; its retries are free. App, Business and agency plans include runs every month. On App, past them, jobs wait until the month starts again. On Business and agency plans extra runs are billed at a published price, up to a monthly spend cap (by default $25, changed in the Jobs tab by whoever pays); at the cap new runs wait until it is raised or the month turns. The pricing page has the numbers.