Docs / Getting started
Core concepts: jobs, schedules and runs
Precise definitions of jobs, schedules, runs and attempts, including the retry backoff formula, idempotency, concurrency and pagination.
Last updated March 11, 2026
#The object model
Tend has four core objects, and most confusion in the first week comes from mixing up two of them. A job is a unit of work you define once. A schedule is a recurring definition that creates jobs over time. A run is one execution of a job. An attempt is one HTTP delivery within a run. Jobs and schedules are things you create. Runs and attempts are things that happen.
| Object | ID prefix | You create it? | Description |
|---|---|---|---|
| Job | job_ | Yes | One-off unit of work, immediate, delayed, or produced by a schedule. |
| Schedule | sch_ | Yes | Cron-driven definition that fires jobs on a timetable. |
| Run | run_ | No | A single execution of a job, with a final status. |
| Attempt | att_ | No | One HTTP request to your endpoint, within a run. |
The relationships run in one direction: a schedule produces jobs, a job produces runs, and a run contains attempts. Deleting a schedule does not delete the runs it already produced. Those age out according to your plan's log retention.
sch_01J8Q5BN2VK6M1RHW3D0AYTC8F (schedule: nightly-usage-report)
+-- job_01J8R0T5MCE4XWQ9H2NBZ7VKAP (fired 2026-03-11 02:00 MST)
+-- run_01J8R0T5P1F6D3YQ8ZKX2WMNBC
+-- att_01J8R0T5P2A... (attempt 1: 503)
+-- att_01J8R0T9G7B... (attempt 2: 200)#Jobs
A job carries a target url, a JSON payload, and timing. The payload can be at most 256 KB after JSON encoding. Larger requests fail with 413 payload_too_large, and the standard advice applies: store the big thing somewhere else and send its ID.
Timing is set one of three ways: omit it to run as soon as possible, pass delay_seconds to run after an offset, or pass run_at with an RFC 3339 timestamp. A run_at more than 60 seconds in the past returns 422 run_at_in_past. Small clock skew is tolerated. Yesterday is not.
curl -X POST https://api.tendcomputer.com/v2/jobs \
-H "Authorization: Bearer $TEND_API_KEY" \
-H "Idempotency-Key: invoice-9917-reminder" \
-H "Content-Type: application/json" \
-d '{
"url": "https://billing.example-shop.dev/hooks/remind",
"run_at": "2026-03-18T09:00:00-06:00",
"payload": { "invoice_id": 9917 },
"region": "us-west",
"retry": { "max_attempts": 8, "base_seconds": 10, "cap_seconds": 1800 }
}'job = client.jobs.create(
url="https://billing.example-shop.dev/hooks/remind",
run_at="2026-03-18T09:00:00-06:00",
payload={"invoice_id": 9917},
region="us-west",
retry={"max_attempts": 8, "base_seconds": 10, "cap_seconds": 1800},
idempotency_key="invoice-9917-reminder",
)const job = await client.jobs.create(
{
url: "https://billing.example-shop.dev/hooks/remind",
run_at: "2026-03-18T09:00:00-06:00",
payload: { invoice_id: 9917 },
region: "us-west",
retry: { max_attempts: 8, base_seconds: 10, cap_seconds: 1800 },
},
{ idempotencyKey: "invoice-9917-reminder" }
);A job in scheduled status can be cancelled. Once a run is in flight it is allowed to finish, and cancellation prevents any further retries. A run may execute for at most 15 minutes before Tend marks the attempt as timed out.
#Schedules
A schedule holds a five-field cron expression and an IANA timezone, and produces a job at each firing. Because timezone handling lives in the schedule, a job set for 0 2 * * * in America/Denver fires at 02:00 local time both before and after daylight-saving changes, rather than drifting by an hour twice a year.
- Minimum interval: 30 seconds. Anything tighter returns
422 interval_too_short. - Names are unique per environment. A duplicate returns
409 schedule_name_taken. - Limits depend on plan: 10 active schedules on Hobby, 250 on Pro, 5,000 on Scale.
- Pausing a schedule stops new firings without deleting its history or configuration.
# Pause a schedule
curl -X PATCH https://api.tendcomputer.com/v2/schedules/sch_01J8Q5BN2VK6M1RHW3D0AYTC8F \
-H "Authorization: Bearer $TEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"paused": true}'client.schedules.update(
"sch_01J8Q5BN2VK6M1RHW3D0AYTC8F",
paused=True,
)await client.schedules.update("sch_01J8Q5BN2VK6M1RHW3D0AYTC8F", {
paused: true,
});#Runs and attempts
When a job comes due, Tend creates a run. The run makes one or more attempts, and its final status reflects the last one. A 2xx response inside the 15-second webhook timeout is a successful attempt. Anything else (a 4xx, a 5xx, a timeout, a connection error) is a failed attempt and, if attempts remain, schedules the next one.
| Run status | Meaning |
|---|---|
pending | Due, waiting for a concurrency slot. |
running | An attempt is in flight. |
retrying | The last attempt failed; the next one is scheduled. |
succeeded | An attempt returned 2xx. |
failed | All attempts were used without a 2xx response. |
cancelled | The job was cancelled before a run completed. |
Run logs are retained for a period that depends on your plan: 3 days on Hobby, 30 days on Pro, and 90 days on Scale. Enterprise projects can keep logs for up to 365 days, with optional export to your own storage. After the retention window, run and attempt records are deleted, while billing counts are retained.
#Retries and backoff
Each job gets 5 attempts by default and can be raised to a maximum of 25 through retry.max_attempts. The delay between attempts uses exponential backoff with full jitter: the delay before retry n is a random value between 0 and min(cap, base * 2^n), where base is 10 seconds and cap is 3600 seconds by default.
Full jitter matters more than it looks. Plain exponential backoff makes a thousand failed jobs all retry at the same instant, which is a good way to knock over an endpoint that is already struggling. Randomizing the delay across the whole window spreads the load out. The consequence is that an individual delay can be very short, so do not assume retries are spaced evenly.
| Retry n | Upper bound of delay (base 10 s, cap 3600 s) |
|---|---|
| 1 | 20 seconds |
| 2 | 40 seconds |
| 3 | 80 seconds |
| 4 | 160 seconds |
| 5 | 320 seconds |
| 8 | 2,560 seconds |
| 9 | 3,600 seconds (cap reached) |
Both base_seconds and cap_seconds can be overridden per job. The cap must fall between 1 and 86,400 seconds. The example below retries quickly for a latency-sensitive job and gives up sooner.
{
"url": "https://app.example-shop.dev/hooks/sync-inventory",
"payload": { "sku": "TND-2231" },
"retry": {
"max_attempts": 6,
"base_seconds": 2,
"cap_seconds": 120
}
}#Concurrency, pagination and webhook delivery
Concurrency limits cap how many runs execute at the same time in a project: 5 on Hobby, 50 on Pro, 500 on Scale, and 2,000 on Enterprise with dedicated pools available. Runs beyond the limit wait in pending and start as slots free up, so a burst of work is absorbed instead of rejected.
List endpoints use cursor-based pagination. Pass limit (default 50, maximum 200) and the cursor value returned in the previous response's next_cursor field. When next_cursor is null, you have reached the end. The SDKs iterate pages for you.
curl "https://api.tendcomputer.com/v2/runs?limit=200&cursor=eyJpZCI6InJ1bl8wMUo4UjAifQ" \
-H "Authorization: Bearer $TEND_API_KEY"# Automatic pagination: iterates every page for you
for run in client.runs.list(limit=200, status="failed"):
print(run.id, run.attempts, run.response_status)// Async iterator fetches pages as needed
for await (const run of client.runs.list({ limit: 200, status: "failed" })) {
console.log(run.id, run.attempts, run.response_status);
}Webhook deliveries of job results carry a Tend-Signature header. If your endpoint is down, failed deliveries are retried for up to 48 hours before being marked undeliverable, and the run remains visible in your logs either way. Because delivery is at-least-once, key your handler on the run ID and treat duplicates as a normal occurrence.