Skip to content

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.

queuedStored and waiting for a free worker slot.
claimed
runningA worker holds the lease and runs the task.
settles
succeededHandler returned.
failedNo attempts left.
cancelledStopped by you.

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.

Every transition is recorded on the job as an attempt with timestamps, and published as a job.* event.
StatusMeaningNext states
queuedWaiting for its run_at time or a free slot on the queue.running, cancelled
runningClaimed by a worker that holds a lease and sends heartbeats.queued, succeeded, failed, cancelled
succeededThe handler returned. Its return value is stored as result.—
failedThe last attempt failed and no attempts remain, or the error was non-retryable.—
cancelledCancelled 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

idstring
Unique identifier, prefixed job_.
queuestringrequired
Name of the queue the job belongs to, for example emails.
taskstringrequired
Handler name your worker registered, for example send-welcome-email.
statusenum
One of queued, running, succeeded, failed, cancelled.
payloadobjectrequired
Your JSON input, returned unchanged. Up to 256 KB.
priorityintegerdefault: 50
0–100. Higher runs first within the queue.
attemptsinteger
Attempts started so far. max_attempts is the limit (default 8).
resultobject | null
The handler's return value once the job has succeeded. Up to 64 KB.
errorobject | null
The last attempt's error: type, message and a truncated stack.
run_attimestamp | null
Earliest time the job may start. Set by delay or run_at on create.
created_at · started_at · finished_attimestamp
ISO 8601 timestamps in UTC. duration_ms covers the last attempt.
job.json
{  "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.

LimitValueWhen exceeded
Payload size256 KB413 payload_too_large on create
Result size64 KBResult truncated, job still succeeds
Task name1–64 chars, a–z 0–9 - _ .422 invalid_task_name
Log lines per attempt1,000Later lines dropped, a notice is added
Retention30 daysJob 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.

NameValueUse for
critical100Password resets, payment confirmations
high75User-facing work someone is waiting for
normal50Default for every job
low25Backfills, 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});

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",});

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" } }'

Choosing a key

  • Derive it from the business event, like welcome-usr_8KfQ2 or invoice-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"

Long-running handlers should check the signal between steps so cancellation takes effect quickly:

src/tasks/resize-image.ts
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 });  }});

Suggested