Core concepts
Jobs
A job is one unit of background work: a task name, a JSON payload and a lifecycle Halyard tracks from the moment you create it until it settles.
On this page
What a job is
You create a job with halyard.jobs.create, naming a queue, a task and a payload. The queue decides how many jobs run at once; the task name decides which handler in your worker runs it; the payload is the input. Halyard stores the job durably before the API answers, so once you have an ID the work will not be lost.
Jobs are at-least-once: in rare cases, such as a worker losing its network mid-attempt, the same job can run twice. Write handlers so that running them again is harmless, and use idempotency keys to avoid creating duplicates in the first place.
Lifecycle
Every job moves through a small set of states. queued and running are active; succeeded, failed and cancelled are final and never change again.
Retry loop. When an attempt throws and attempts remain, the job goes back to queued with a backoff delay instead of moving to failed. A queued job can also be cancelled before it starts.
| Status | Meaning | Next states |
|---|---|---|
| queued | Waiting for its run_at time or a free slot on the queue. | running, cancelled |
| running | Claimed by a worker that holds a lease and sends heartbeats. | queued, succeeded, failed, cancelled |
| succeeded | The handler returned. Its return value is stored as result. | — |
| failed | The last attempt failed and no attempts remain, or the error was non-retryable. | — |
| cancelled | Cancelled through the API, the dashboard or a replace policy. | — |
The job object
The API returns the same shape from create, retrieve, list and in job.* events.
Attributes
idstringjob_.queuestringrequiredemails.taskstringrequiredsend-welcome-email.statusenumqueued, running, succeeded, failed, cancelled.payloadobjectrequiredpriorityintegerdefault: 50attemptsintegermax_attempts is the limit (default 8).resultobject | nullerrorobject | nulltype, message and a truncated stack.run_attimestamp | nulldelay or run_at on create.created_at · started_at · finished_attimestampduration_ms covers the last attempt.{ "id": "job_3Rk9Tq7Xw2", "object": "job", "queue": "emails", "task": "send-welcome-email", "status": "succeeded", "priority": 50, "payload": { "user_id": "usr_8KfQ2" }, "result": { "delivered": true }, "attempts": 1, "max_attempts": 8, "idempotency_key": "welcome-usr_8KfQ2", "run_at": null, "created_at": "2026-10-14T09:12:04.381Z", "started_at": "2026-10-14T09:12:04.402Z", "finished_at": "2026-10-14T09:12:04.814Z", "duration_ms": 412, "error": null}Payload limits
Keep payloads small: pass IDs and look data up in the handler, so the job always sees current data.
| Limit | Value | When exceeded |
|---|---|---|
| Payload size | 256 KB | 413 payload_too_large on create |
| Result size | 64 KB | Result truncated, job still succeeds |
| Task name | 1–64 chars, a–z 0–9 - _ . | 422 invalid_task_name |
| Log lines per attempt | 1,000 | Later lines dropped, a notice is added |
| Retention | 30 days | Job and logs deleted after finished_at |
Priorities
Within one queue, workers always claim the highest priority job that is ready. Jobs with equal priority run in creation order. Priority never moves a job to another queue, and it has no effect on queues that set priority: off.
| Name | Value | Use for |
|---|---|---|
| critical | 100 | Password resets, payment confirmations |
| high | 75 | User-facing work someone is waiting for |
| normal | 50 | Default for every job |
| low | 25 | Backfills, reports, cleanup |
await halyard.jobs.create({ queue: "emails", task: "send-password-reset", payload: { user_id: "usr_8KfQ2" }, priority: "critical", // or a number from 0 to 100});halyard.jobs.create( queue="emails", task="send-password-reset", payload={"user_id": "usr_8KfQ2"}, priority="critical", # or a number from 0 to 100)client.Jobs.Create(ctx, &halyard.JobParams{ Queue: "emails", Task: "send-password-reset", Payload: map[string]any{"user_id": "usr_8KfQ2"}, Priority: halyard.PriorityCritical,})Delayed jobs
Pass delay as a duration (90s, 30m, 2h, up to 365d) or run_at as a timestamp. The job is queued right away but is not claimed before that time. For work that repeats, use a schedule instead.
// Run in 30 minutesawait halyard.jobs.create({ queue: "emails", task: "send-onboarding-tips", payload: { user_id: "usr_8KfQ2" }, delay: "30m",}); // Run at an exact time (UTC)await halyard.jobs.create({ queue: "reports", task: "generate-invoice", payload: { customer_id: "cus_4Hd8Wk2Pq" }, run_at: "2026-11-01T06:00:00Z",});# Run in 30 minuteshalyard.jobs.create( queue="emails", task="send-onboarding-tips", payload={"user_id": "usr_8KfQ2"}, delay="30m",) # Run at an exact time (UTC)halyard.jobs.create( queue="reports", task="generate-invoice", payload={"customer_id": "cus_4Hd8Wk2Pq"}, run_at="2026-11-01T06:00:00Z",)Idempotency keys
Networks fail. If a create request times out you can't know whether the job was stored. Send an Idempotency-Key and retry freely: for 24 hours, any create with the same key returns the original job instead of creating a new one.
curl https://api.halyard.example/v3/jobs \ -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d" \ -H "Idempotency-Key: welcome-usr_8KfQ2" \ -H "Content-Type: application/json" \ -d '{ "queue": "emails", "task": "send-welcome-email", "payload": { "user_id": "usr_8KfQ2" } }'const job = await halyard.jobs.create( { queue: "emails", task: "send-welcome-email", payload: { user_id: "usr_8KfQ2" }, }, { idempotencyKey: `welcome-${userId}` },);job = halyard.jobs.create( queue="emails", task="send-welcome-email", payload={"user_id": "usr_8KfQ2"}, idempotency_key=f"welcome-{user_id}",)Choosing a key
- Derive it from the business event, like
welcome-usr_8KfQ2orinvoice-inv_77Q2, not a random UUID per attempt. - Keys are scoped to the project and environment, up to 255 characters.
- Reusing a key with a different payload returns
409 idempotency_conflict.
Cancelling a job
A queued job is cancelled at once and never runs. A running job is marked for cancellation: the worker is told on its next heartbeat and the job's abort signal fires. Finished jobs can't be cancelled and return 409 job_finished.
curl -X POST https://api.halyard.example/v3/jobs/job_8Vn4Hc2Lp6/cancel \ -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d"const job = await halyard.jobs.cancel("job_8Vn4Hc2Lp6");console.log(job.status); // cancelledjob = halyard.jobs.cancel("job_8Vn4Hc2Lp6")print(job.status) # cancelledLong-running handlers should check the signal between steps so cancellation takes effect quickly:
worker.task("resize-image", async (job) => { for (const size of [320, 768, 1440, 2560]) { if (job.signal.aborted) return; // cancelled while running await resize(job.payload.image_id, size); await job.heartbeat({ progress: size / 2560 }); }});