Tasks API
A tasks instance holds queues. A task is one JSON payload. A push queue delivers each task to a function, a channel or a webhook, and retries it until the target accepts it or its attempts run out. A pull queue holds tasks for your own workers to lease.
Create a queue and add a task
Create a tasks instance — here jobs — in the console or with MCP's create_instance. Queues are created on its Queues tab, or over the API:
curl -X POST https://api.altengine.net/v1/tasks/jobs/queues \
-H "Authorization: Bearer $ALTENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"emails","target":{"function":"app/send-receipt"},"ratePerSecond":5}'
curl -X POST https://api.altengine.net/v1/tasks/jobs/queues/emails/tasks \
-H "Authorization: Bearer $ALTENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"payload":{"orderId":"o-1041"},"name":"receipt-o-1041","delay":60}'
# → 201 { "tasks": [{ "ok": true, "id": "0mvpn7bai3f9c2e81d4a7", "name": "receipt-o-1041",
# "state": "scheduled", "runAt": 1793042520000 }] }The function send-receipt in functions instance app is called a minute later. A task with no delay runs within about a second. Every instance has a queue named default.
From a function granted tasks, the same add is one call — answer the request now and do the slow work in the background:
await env.tasks.add({ instance: "jobs" }, "emails", { payload: { orderId }, name: `receipt-${orderId}` });
return Response.json({ ok: true });Queues
POST /v1/tasks/{instance}/queues creates one: {name, …settings}. PATCH /v1/tasks/{instance}/queues/{queue} changes only the settings you send.
| Setting | Default | Meaning |
|---|---|---|
mode | push | push delivers to a target. pull holds tasks for workers to lease, and has no target. |
target | none | Exactly one of {"function": "<functions-instance>/<function>"}, {"channel": "<channel-instance>/<channel>"} or {"webhook": "https://…"}. Instances are named in your own organization, and must exist when you set it. Naming a channel needs a write grant on that channel instance. A task's own target overrides it. |
ratePerSecond | 10 | Deliveries started per second, 0.01–100. |
maxConcurrent | 5 | Deliveries in flight at once, 1–50. |
deadlineSeconds | 600 | How long a function or webhook target is waited for, 15–840. Past it the attempt counts as failed and is retried. |
retry | see Retries | The retry policy for its tasks. |
deadLetterQueue | none | Another queue in this instance that gets a copy of every task that fails. See Dead-letter queues. |
paused | false | Stops delivery and leasing. Tasks keep arriving and wait. |
targetControl | true | Lets the target's answer steer its own retries. |
Across all its queues, an instance has at most 50 deliveries in flight.
Adding tasks
POST /v1/tasks/{instance}/queues/{queue}/tasks takes one task, an array, or {"tasks": [...]} — up to 100.
| Field | Meaning |
|---|---|
payload | Required. Any JSON up to 128 KB; null for none. Put larger data in a blob and pass its key. |
name | Optional. Makes the add idempotent. 1–500 characters of A–Z a–z 0–9 _ . : -. |
delay | Seconds from now. |
runAt | Epoch milliseconds or an ISO 8601 time. Give delay or runAt, not both; at most 30 days ahead. |
target | Overrides the queue's. Not allowed on a pull queue. A channel target needs a write grant on that channel instance. |
params | A JSON object up to 4 KB, handed to the target as task.params. |
deadlineSeconds | Overrides the queue's deadline for this task, 15–840. |
retry | This task's own retry policy. It replaces the queue's whole: fields it leaves out take the defaults, not the queue's values. |
Each result in tasks is {ok: true, id, name, state, runAt} or {ok: false, code: "TASK_EXISTS", id, name, message}, in request order. The call answers 201 when at least one task was added, and 409 when none was — the same results, plus an error naming the first refusal. A push queue with no target refuses a task that has none of its own (400 NO_TARGET), and nothing in that call is added.
Names make an add idempotent
A name already used in the queue is refused with TASK_EXISTS and the existing task's id — for as long as that task's record is kept, finished or not: retentionDays after it finished, 7 by default. Name a task after the thing it does (receipt-o-1041) and a retried request cannot add it twice.
What a target receives
A function and a webhook both get a POST with this body:
{ "task": { "id": "0mvpn7bai3f9c2e81d4a7", "queue": "emails", "name": "receipt-o-1041",
"attempt": 1, "maxAttempts": 10, "runAt": 1793042520000, "createdAt": 1793042460000,
"deadlineAt": 1793043120000, "source": "api", "depth": 0, "params": null,
"deadLetterOf": null },
"payload": { "orderId": "o-1041" } }| Target | Receives |
|---|---|
| Function | The body above, with x-ae-trigger: task, x-task-id, x-task-queue and x-task-attempt. Waited for until the deadline. Redirects are not followed. |
| Webhook | The body above and the same x-task-* headers, signed. Waited for until the deadline. Redirects are not followed. |
| Channel | A message {"type": "task", "task": {…}, "payload": …} on that channel. At most 32 KB as published. |
source says where the task came from: api, console, mcp, function, datastore, blob, mailbox, deadletter or rerun.
Delivery is at least once. A delivery cut off mid-way — the queue restarted while it ran — is retried, so a target can see the same task twice. Make the handler idempotent on task.id.
The deadline
A function or webhook target has until task.deadlineAt (epoch ms) to answer: the queue's deadlineSeconds after the attempt starts, 600 by default, or the task's own. Past it the attempt counts as failed and is retried. The target is not stopped: its slot stays taken and the retry waits until it returns, so maxConcurrent is never exceeded and a task never runs beside itself. The timed-out attempt records what the target finally answered. For work longer than 14 minutes, use a pull queue, whose worker extends its lease.
What the answer means
| Answer | Outcome |
|---|---|
2xx, or the channel message published | Done. The response body is kept as the task's result, up to 16 KB. |
408, 429, 5xx, no answer by the deadline, a network error | Retried with backoff. |
Any other 4xx | Failed at once. A payload the target refuses does not improve by retrying. |
3xx | Failed at once. |
| The function, its instance or the channel instance does not exist; a channel message over 32 KB | Failed at once. |
A target is looked up again on every attempt, so one deleted after the task was added fails it then.
export default {
async fetch(request, env) {
const { task, payload } = await request.json();
if (await alreadySent(task.id)) return new Response("dup"); // at-least-once
await sendReceipt(payload.orderId);
return new Response("sent"); // 2xx: done
},
};Verifying a webhook
Every webhook delivery carries x-altengine-timestamp (ms) and x-altengine-signature: v1=<hex>, an HMAC-SHA256 keyed with the instance's signing key — the whole string, whsec_ prefix included — over <timestamp>.<raw body>. Owners reveal and rotate the key in the instance's Settings, or with MCP's tasks_reveal_webhook_secret and tasks_rotate_webhook_secret.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(req, rawBody, secret) {
const ts = req.headers["x-altengine-timestamp"];
const want = "v1=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const got = req.headers["x-altengine-signature"] ?? "";
return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want))
&& Math.abs(Date.now() - Number(ts)) < 5 * 60_000; // reject replays
}Rotating takes effect on the next delivery — update the receiver straight away. A webhook URL is https, with no username or password, at most 2,048 characters.
Retries
| Field | Default | Range | Meaning |
|---|---|---|---|
maxAttempts | 10 | 1–100 | Attempts before the task fails, the first included. |
minBackoffSeconds | 10 | 1–86,400 | The wait after the first failed attempt. |
maxBackoffSeconds | 3,600 | 1–604,800 | The longest wait. At least minBackoffSeconds. |
maxDoublings | 8 | 0–16 | How many times the wait doubles before it grows linearly. |
maxAgeSeconds | 604,800 (7 days) | 0–2,592,000 | A task whose next attempt would fall later than this after it was added fails instead. 0 is no limit. |
After the nth failed attempt the wait is minBackoffSeconds × 2^(n−1). Past maxDoublings it grows by the last doubled step each time instead, and never exceeds maxBackoffSeconds. With the defaults: 10 s, 20 s, 40 s … 2,560 s before the tenth and last attempt.
Steering retries from the target
A function or webhook can change what happens next with headers on its answer:
// the record this task needs is not there yet: try again in a minute, without using an attempt
return new Response("waiting", { status: 503, headers: { "x-task-retry": "defer", "retry-after": "60" } });| Header | Effect |
|---|---|
Retry-After or x-task-retry-after | Seconds, or an HTTP date: when the next attempt runs, instead of the backoff. At most 30 days out. |
x-task-retry: never | Fail now, whatever the status and the attempts left. |
x-task-retry: retry | Count this as a failed attempt and retry — even a 2xx, or a 4xx that would otherwise fail the task. |
x-task-retry: defer | Run again at Retry-After (or after minBackoffSeconds) without spending an attempt, whatever the status. A task can be deferred 1,000 times. |
x-task-max-attempts: n | This task's attempt limit from now on, 1–100. |
maxAgeSeconds still ends a task that is retried or deferred past it. Set the queue's targetControl to false and these headers are ignored.
Watching and managing tasks
In the console, a tasks instance's Queues tab shows each queue's counts by state; the Tasks tab lists them by queue and state, opens one with its attempts, and runs, retries or cancels it. The same operations over the API:
| State | Meaning |
|---|---|
scheduled | Waiting for its time, or for its next attempt. |
running | Being delivered now. |
leased | Held by a pull worker. |
done, failed, cancelled | Finished. Kept for retentionDays (7 by default), then deleted. |
GET …/queues/{queue}/tasks lists one page without payloads. Filter with state or name; page with limit (50 by default, at most 200) and the cursor from the previous page — null is the last page. Scheduled, running and leased tasks come soonest first, finished ones latest first, and with no state newest first. GET …/tasks/{id} returns one task with its payload, result, and every attempt with its status and error. A queue's counts by state stop at 10,000; a state listed in capped has at least that many.
| To | Call | Does |
|---|---|---|
| Run now, retry or re-run | POST …/tasks/{id}/run | A scheduled task runs now. A failed or cancelled one is scheduled again with its attempts reset. A done one is run again as a new task with the same payload, params and target and no name; the call returns the new task. A running or leased task is refused (409). |
| Cancel | DELETE …/tasks/{id}, or POST …/tasks/cancel {"ids": [...]} (up to 200) | Cancels scheduled and leased tasks. A running delivery cannot be stopped. The batch form returns {cancelled, skipped}, each skipped id with its state. |
| Pause, resume | POST …/queues/{queue}/pause, /resume | Stops and restarts delivery. Nothing is lost. |
| Purge | POST …/queues/{queue}/purge | Deletes every task in the queue, in any state, and keeps the queue. A running delivery finishes and its outcome is dropped. Returns {purged, pending}; pending: true means the rest is deleted in the background. |
| Delete a queue | DELETE …/queues/{queue} | Deletes the queue and every task in it. default cannot be deleted, and a queue that is another's dead-letter queue is refused (409 QUEUE_IN_USE). |
Pull queues
A queue with "mode": "pull" never delivers. Your worker leases tasks, does the work, and acknowledges each one:
POST /v1/tasks/jobs/queues/thumbnails/lease
{ "count": 10, "leaseSeconds": 120 }
→ { "tasks": [{ "id": "0mvpn7bai…", "payload": { "key": "blob:9f3c…:7a1b…" }, "attempts": 1,
"leaseId": "c3a9…", … }] }
POST /v1/tasks/jobs/tasks/0mvpn7bai…/ack
{ "leaseId": "c3a9…", "result": { "width": 320 } }| Call | Body | Does |
|---|---|---|
POST …/queues/{queue}/lease | {count?, leaseSeconds?} | Leases up to count (1–100, default 1) due tasks for leaseSeconds (1–3,600, default 300). Each lease is an attempt. A paused queue returns none. |
POST …/tasks/{id}/ack | {leaseId, result?} | Done. result is kept up to 16 KB. |
POST …/tasks/{id}/extend | {leaseId, leaseSeconds} | Holds the lease longer, from now. |
POST …/tasks/{id}/release | {leaseId, delaySeconds?, error?} | Gives the task back to be leased again after delaySeconds. The attempt counts as failed; on the last one the task fails. |
A lease that runs out without an ack counts as a failed attempt, and the task is retried with backoff. Every call after that with the old leaseId answers 409 LEASE_LOST, so a slow worker cannot finish a task another worker now holds.
Dead-letter queues
Set a queue's deadLetterQueue to another queue in the same instance, which must already exist. When a task fails, a copy is added there: the same payload and params, no name, and task.deadLetterOf set to the failed task's id. The dead-letter queue delivers it to its own target with its own retry policy, or, as a pull queue, holds it for you to lease.
Triggers
Other services can add tasks as things happen. A trigger names a queue as <tasks-instance>/<queue>; the queue's target runs the task, at the queue's rate and with its retries.
| When | Configured on | Payload |
|---|---|---|
| A document is written | A datastore instance's triggers | datastore.write |
| A file is uploaded or deleted | A blob instance's triggers | blob.create, blob.delete |
| Mail arrives | A mailbox route with a queue action | The message, as a function receives it |
The queue must exist when the rule is saved, and must have a target or be a pull queue. A rule's params reach the target as task.params. A chain of triggers more than 8 deep — a function writing the document that triggered it — is cut.
From a function
Grant a function tasks and it gets env.tasks. Methods return the value REST wraps — add the array of results, get the task — and take the same bodies and limits.
| Method | Grant |
|---|---|
queues(t), getQueue(t, name), get(t, id), list(t, queue, {state, name, cursor, limit}) | read |
add(t, queue, tasks), run(t, id), cancel(t, ids) | write |
lease(t, queue, {count, leaseSeconds}), ack(t, id, leaseId, result?), extend(t, id, leaseId, leaseSeconds), release(t, id, leaseId, {delaySeconds, error}) | write |
createQueue(t, name, settings), updateQueue(t, name, settings), pauseQueue(t, name), resumeQueue(t, name), purgeQueue(t, name), deleteQueue(t, name) | full |
Endpoints and grants
Every call takes an organization API key with a grant on the instance. An end-user identity token is refused. Changing a queue needs full: its target runs every task with that target's own access.
| Endpoint | Grant | Does |
|---|---|---|
GET /v1/tasks/{instance} | read | The instance's name, retentionDays and queues. |
GET /v1/tasks/{instance}/queues | read | Every queue, with its settings and counts by state. |
POST /v1/tasks/{instance}/queues | full | Create a queue. |
GET /v1/tasks/{instance}/queues/{queue} | read | One queue. |
PATCH /v1/tasks/{instance}/queues/{queue} | full | Change the settings sent. |
DELETE /v1/tasks/{instance}/queues/{queue} | full | Delete the queue and its tasks. |
POST /v1/tasks/{instance}/queues/{queue}/pause, /resume | full | Stop or restart delivery. |
POST /v1/tasks/{instance}/queues/{queue}/purge | full | Delete every task in it. |
POST /v1/tasks/{instance}/queues/{queue}/tasks | write | Add up to 100 tasks. |
GET /v1/tasks/{instance}/queues/{queue}/tasks | read | List, by state or name. |
POST /v1/tasks/{instance}/queues/{queue}/lease | write | Lease from a pull queue. |
GET /v1/tasks/{instance}/tasks/{id} | read | One task, with its payload, result and attempts. |
DELETE /v1/tasks/{instance}/tasks/{id} | write | Cancel a scheduled or leased task. |
POST /v1/tasks/{instance}/tasks/cancel | write | Cancel up to 200: {"ids": [...]}. |
POST /v1/tasks/{instance}/tasks/{id}/run | write | Run now, retry, or re-run. |
POST /v1/tasks/{instance}/tasks/{id}/ack, /extend, /release | write | Finish, hold or give back a leased task. |
Limits and settings
| Limit or setting | Value |
|---|---|
| Queues | 50 per instance, default included. A name is 1–63 characters of a–z 0–9 _ -, starting with a letter or digit. |
| Tasks per add | 100, in a request body of at most 4 MB. |
| Payload | 128 KB as JSON. params: 4 KB. |
| Result kept | 16 KB of the target's answer. |
| Scheduled ahead | 30 days. |
| Delivery | Up to 100 a second and 50 at once per queue; 50 at once per instance. |
Waiting for an answer (deadlineSeconds) | 600 seconds by default, 15–840, per queue or per task. See The deadline. |
| Leases | 1–100 tasks per call, held 1–3,600 seconds (300 by default). |
| Deferrals | 1,000 per task. |
| Trigger chain | 8 deep. |
Keep finished tasks (retentionDays) | 7 days by default, 1–30. A task's name stays taken until then. |
Rate limit (rateLimit) | 6,000 API calls a minute per instance by default; a value you set can only lower it. Delivery is not affected by it. |
| Region | Chosen when you create the instance, and fixed after. |
Change retentionDays and rateLimit in the instance's Settings, or with MCP's patch_instance_config.
What it costs
Tasks added, deliveries to a channel or webhook target (every attempt), and the data the instance keeps, averaged over the month. A delivery to a function bills as a function invocation instead, and one to a channel also bills as a channel message. See pricing for the numbers.
Testing locally
The emulator serves the same API, env.tasks, triggers and MCP tools, and delivers a task within about a second of it falling due, logging every attempt to the terminal.
altengine dev # http://127.0.0.1:9191Where it differs:
- A tasks instance is created on first use.
- Purging a queue and deleting one finish at once;
pendingis alwaysfalse. - A trigger rule change applies immediately, not within about 30 seconds.
- A trigger event that cannot be queued is logged to the terminal, not shown as a console notice.
- Revealing and rotating the webhook key are not limited to owners, and MCP scopes are not checked.
From an AI agent
The MCP server exposes tasks directly: tasks_list_queues, tasks_set_queue (create or change), tasks_add, tasks_list, tasks_get, tasks_run, tasks_cancel, and an agent can be a pull worker itself with tasks_lease, tasks_ack and tasks_release. tasks_purge_queue and tasks_delete_queue need full plus confirm: true. The resource docs://tasks/guide covers targets, retries and triggers.