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 need | Use | Starts |
|---|---|---|
| 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 timetable | background.json | at 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
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
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.
{
"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}}
}
}| Key | Means |
|---|---|
lists.<name>.retries | Extra tries after the first, 0 to 9 (default 4). |
lists.<name>.concurrency | Jobs of this list running at once, 1 to 100 (default: the site's limit). |
lists.<name>.ordered | One job at a time per group, in send order. A job that is retrying holds its group; other groups go on. |
schedules.<name>.every | minute, hour, day, weekday, week (with day 0 = Sunday … 6), month (the 1st), with at "HH:MM". |
schedules.<name>.cron | Or a 5-field cron line, with a day of the month or a weekday, not both. |
schedules.<name>.tz | An IANA time zone (default UTC). Summer time is handled; avoid 01:00–03:00 local. |
schedules.<name>.list | The 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.
/api/site/v1/jobsPOST /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).
/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.
| Code | Status | When |
|---|---|---|
E_JOBS_BAD_LIST | 422 | The list name is not lower-case letters, digits and dashes (up to 63). |
E_JOBS_PAYLOAD_TOO_LARGE | 413 | The data is over 32 KB. Send ids instead. |
E_JOBS_BAD_TIME | 422 | at is not a date and time. |
E_JOBS_TOO_FAR | 422 | More than a year ahead. |
E_JOBS_KEY | 422 | key is empty or over 128 characters (E_JOBS_GROUP: the same for group; E_JOBS_LABEL: label over 200). |
E_JOBS_TOO_MANY_LISTS | 422 | A new list past 100 per site. Reuse a list and put the variety in data. |
| No code | 422 | The body failed validation, for example more than 100 jobs in one call or delay not a number. |
E_JOBS_RATE_LIMITED | 429 | The 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_REQUESTS | 429 | More than 300 send calls a minute, or 600 Site API calls a minute. Batch with sendJobs. |
E_JOBS_BACKLOG | 429 | 50,000 jobs already wait in that list (E_JOBS_SITE_BACKLOG: 200,000 in the whole site): something is not running. |
E_JOBS_PAUSED | 423 | The 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_APP | 409 | A static website has no background jobs. |
E_JOBS_EDITOR_KEY | 403 | Sent with the editor's key: jobs run in your process there. |
E_JOBS_NOT_CANCELLABLE | 409 | The 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>withSITE_JOBS_SECRET, compare in constant time, and refuse atmore than 5 minutes away (a replayed request). After the owner replaces the jobs secret, the header carries onev1per secret until the site is published again: accept the delivery if any of them matches. - Answer
2xxwhen done.5xx,408or no answer within 60 seconds: tried again.429or503: tried again, after yourRetry-Afterif you send one (up to an hour). Any other4xx: 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
idto make the work safe to repeat: the same job may be delivered again.
Limits
| What | Limit |
|---|---|
| Data per job | 32 KB |
| Delay or at | up to 1 year |
| One run | 60 seconds |
| Tries per job | 1 to 10 per list (default 5) |
| Running at once | Per site, by plan: App 10, Business 50, up to 100 on agency plans. Each list may set less |
| Jobs added per minute | 10 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 minute | 300 |
| Waiting per list | 50,000 |
| Waiting per site | 200,000 |
| Lists per site | 100 |
| Jobs per send | 100 |
| Dedupe key window | While the job waits or runs, and 24 hours after it was sent |
| History kept | 14 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.