# Jobs

> Create, read, list, follow and cancel jobs over HTTP.

A job lives in a space, and every path here starts with it: `/v1/spaces/{space}/jobs`. See [Jobs](/docs/concepts/jobs) for what each field does in practice.

## Create jobs

`POST /v1/spaces/{space}/jobs` · scope `create`

Send one job, an array of jobs, or `{"jobs": [...]}`. The space is created by its first job.

:::fields
:::field name {type="string" required}
What kind of work it is. Workers claim jobs by name. At most 256 bytes.
:::
:::field payload {type="JSON"}
What the worker needs, as any JSON value. At most 4 MiB.
:::
:::field key {type="string"}
An idempotency key. A job with a key an earlier job in the space has isn't made again: the answer is the earlier job's id, with `outcome` `DUPLICATE`. A key holds while its job is unfinished, and for 10 minutes after it finishes.
:::
:::field delayMs {type="integer"}
Don't hand the job out before this many milliseconds from now. Up to a year.
:::
:::field runAtMs {type="integer"}
Don't hand the job out before this Unix time, in milliseconds. Up to a year from now.
:::
:::field maxAttempts {type="integer" default="the space's (4)"}
Tries in all, counting the first. From 1 to 100.
:::
:::field timeoutMs {type="integer" default="the space's (300000)"}
The lease a worker gets on each attempt, unless the worker asks for another.
:::
:::field backoffMs {type="integer" default="the space's (1000)"}
The wait after the first failure.
:::
:::field backoff {type="object"}
How the waits after that grow, over the space's policy field by field: `kind` (`EXPONENTIAL`, `LINEAR` or `CONSTANT`), `maxMs` (the longest wait, at most an hour), `multiplierPercent` (100 to 1000; 200 doubles), `jitter` (`NONE`, `FULL` or `EQUAL`).
:::
:::field callback {type="object"}
A webhook sent when the job finishes: `url`, and optional `headers`, an object of header names and values. See [Callbacks](/docs/concepts/jobs#callbacks).
:::
:::field singleton {type="string"}
Only one unfinished job with this value can exist in the space. A second create answers `DUPLICATE` with the open job's id. Once it finishes, the next create makes a new job. Not with `key`, and not in a FIFO space.
:::
:::field workflow {type="boolean"}
The job is a [workflow run](/docs/api/runs#start-a-run).
:::
:::

:::code-group
```bash curl
curl -s "$EMIT_URL/v1/spaces/billing/jobs" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[
    {"name": "charge-invoice", "payload": {"invoiceId": "inv_1042"}, "key": "charge-inv_1042"},
    {"name": "charge-invoice", "payload": {"invoiceId": "inv_1043"}, "key": "charge-inv_1043", "delayMs": 60000}
  ]'
```
```ts TypeScript
const created = await emit.jobs.createMany([
  { name: "charge-invoice", payload: { invoiceId: "inv_1042" }, key: "charge-inv_1042" },
  { name: "charge-invoice", payload: { invoiceId: "inv_1043" }, key: "charge-inv_1043", delayMs: 60_000 },
]);
```
```go Go
created, err := c.Jobs.CreateMany(ctx, []emit.NewJob{
	{Name: "charge-invoice", Payload: map[string]string{"invoiceId": "inv_1042"}, Key: "charge-inv_1042"},
	{Name: "charge-invoice", Payload: map[string]string{"invoiceId": "inv_1043"}, Key: "charge-inv_1043", DelayMs: 60_000},
})
```
:::

```json
{
  "created": [
    { "id": "01M3QSGN0G01ZVECFQ48X49EVM", "outcome": "NEW", "attempt": 0 },
    { "id": "01M3QSGN0G024F4C1PA48D5EVM", "outcome": "NEW", "attempt": 0 }
  ]
}
```

One entry per job, in the order sent. `outcome` is `NEW`, or `DUPLICATE` when the key or singleton already made a job, whose `id` this is. A space at its in-flight limit refuses the whole request with `429`: see [Errors](/docs/api/overview#errors).

## Get a job

`GET /v1/spaces/{space}/jobs/{id}` · scope `read`

```bash
curl -s "$EMIT_URL/v1/spaces/billing/jobs/01M3QSGN0G01ZVECFQ48X49EVM" -H "Authorization: Bearer $EMIT_TOKEN"
```

:::fields
:::field id {type="string"}
The job's id: 26 characters.
:::
:::field space {type="string"}
:::
:::field name {type="string"}
:::
:::field payload {type="JSON"}
:::
:::field status {type="string"}
`PENDING`, `RUNNING`, `COMPLETED`, `FAILED` or `CANCELLED`.
:::
:::field attempt {type="integer"}
The current or last attempt: 0 before the first claim.
:::
:::field maxAttempts {type="integer"}
:::
:::field result {type="JSON"}
What the worker completed it with, or the error of the last failure. `null` until then.
:::
:::field progress {type="object"}
The latest progress: `percent`, `message`, and `stages`, the same per named stage.
:::
:::field events {type="JSON[]"}
Every event the workers reported, in order.
:::
:::field createdAtMs {type="string"}
When it was created.
:::
:::field finishedAtMs {type="string"}
When it finished; `"0"` until then.
:::
:::field leaseUntilMs {type="string"}
While it runs, when the worker's lease ends.
:::
:::field notBeforeMs {type="string"}
While it waits for a time (a delay, or the wait before a retry), when that wait ends. `"0"` otherwise.
:::
:::field callback {type="object"}
Its callback, if it has one. Header values are shown empty.
:::
:::field flow {type="object"}
For a workflow run: what it's up to. See [Workflow runs](/docs/api/runs).
:::
:::

## List jobs

`GET /v1/spaces/{space}/jobs` · scope `read`

Newest first. Each entry is a summary: `id`, `name`, `status`, `attempt`, `maxAttempts`, `createdAtMs`, `finishedAtMs`, `leaseUntilMs`, `notBeforeMs` and `progress`. Get a job for its payload and result.

:::fields
:::field name {type="string"}
Only jobs of this name.
:::
:::field status {type="string"}
Only jobs in this status: `PENDING`, `RUNNING`, `COMPLETED`, `FAILED` or `CANCELLED`.
:::
:::field fromMs {type="integer"}
Only jobs created at or after this Unix time, in milliseconds.
:::
:::field toMs {type="integer"}
Only jobs created before this time.
:::
:::field limit {type="integer"}
At most this many; up to 1,000.
:::
:::field page {type="string"}
The `nextPage` of the page before.
:::
:::field runs {type="boolean"}
Only workflow runs, at most 100 a page. Each unfinished one has `run`: its node in the [run's tree](/docs/api/runs#read-a-run-s-tree), without children or notes. Needs emit 0.6.
:::
:::

:::code-group
```bash curl
curl -s "$EMIT_URL/v1/spaces/billing/jobs?name=charge-invoice&status=FAILED&limit=50" \
  -H "Authorization: Bearer $EMIT_TOKEN"
```
```ts TypeScript
const page = await emit.jobs.list({ name: "charge-invoice", status: "failed", limit: 50 });
```
```go Go
page, err := c.Jobs.List(ctx, emit.ListJobs{Name: "charge-invoice", Status: emit.Failed, Limit: 50})
```
:::

```json
{ "jobs": [{ "id": "01M3QSGN0103P7EGVBX9D5NTSN", "name": "charge-invoice", "status": "FAILED", "attempt": 5, "maxAttempts": 5, "createdAtMs": "1790726132737", "finishedAtMs": "1790726139553", "leaseUntilMs": "0", "notBeforeMs": "0" }], "nextPage": "" }
```

A page can be shorter than `limit`, even empty, and still have a `nextPage`: keep asking until `nextPage` is empty. A narrow time range is the cheapest way to look through a large history.

## Read a job's log lines

`GET /v1/spaces/{space}/jobs/{id}/lines` · scope `read`

:::fields
:::field minLevel {type="string"}
Only lines at this level or above: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR` or `FATAL`.
:::
:::field source {type="string"}
Only lines from this source. Notes have the source `note`.
:::
:::field after {type="string"}
The `cursor` of the last line you read, to go on from there.
:::
:::field limit {type="integer"}
At most this many; up to 1,000.
:::
:::

```bash
curl -s "$EMIT_URL/v1/spaces/billing/jobs/01M3QSGN0103P7EGVBX9D5NTSN/lines?minLevel=WARN" \
  -H "Authorization: Bearer $EMIT_TOKEN"
```

```json
{ "lines": [{ "cursor": "9.0", "attempt": 1, "receivedAtMs": "1790726139530", "line": { "level": "WARN", "message": "card issuer slow to answer", "source": "", "atMs": "0", "metadata": null } }] }
```

Lines come in the order they were written, from every attempt.

## Follow a job

`GET /v1/spaces/{space}/jobs/{id}/events` · scope `read`, or a follow token

A stream of the job's changes: server-sent events, or a WebSocket if the request asks to upgrade. Each message is one JSON event, with `atMs` and one of these:

| Event | When |
| --- | --- |
| `now` | First: the job as it is, as [Get a job](#get-a-job) shows it. |
| `claimed` | A worker took it: `id`, `attempt`, `name`, `leaseUntilMs`. |
| `progress` | New progress. |
| `event` | A new event. |
| `lines` | New log lines: their `count` and `cursor`, and the lines themselves with `?lines=true`. |
| `retrying` | An attempt failed and another is coming: `attempt`, `nextAtMs`, `error`. |
| `released` | A worker gave it back unfinished. |
| `finished` | Last: its `status` and `result`. The stream then ends. |

:::fields
:::field lines {type="boolean"}
`true` to send log lines themselves, not only that there are new ones.
:::
:::field tree {type="boolean"}
For a workflow run: send the whole run as a tree on every change instead. See [Read a run's tree](/docs/api/runs#read-a-run-s-tree).
:::
:::field token {type="string"}
A follow token, for a browser with no API token.
:::
:::

```bash
curl -s -N "$EMIT_URL/v1/spaces/billing/jobs/01M3QSGN0103P7EGVBX9D5NTSN/events" -H "Authorization: Bearer $EMIT_TOKEN"
```

```text
data: {"atMs":"1790726139614","now":{"id":"01M3QSGN0103P7EGVBX9D5NTSN","status":"RUNNING",...}}

data: {"atMs":"1790726139702","progress":{"percent":50.0,"message":"charging","stages":{}}}

data: {"atMs":"1790726140031","finished":{"status":"COMPLETED","result":{"chargeId":"ch_3PQ"}}}
```

When two changes land close together, a follower may see only the later one. Treat the stream as the job's latest state, not as an exact log.

## Make a follow token

`POST /v1/spaces/{space}/jobs/{id}:followToken` · scope `read`

A token that lets a browser [follow one job](/docs/guides/browser-progress) without an API token, for the time you ask.

:::fields
:::field expiresInMs {type="integer" required}
How long the token works: from a second to a year.
:::
:::

```bash
curl -s -X POST "$EMIT_URL/v1/spaces/billing/jobs/01M3QSGN0103P7EGVBX9D5NTSN:followToken" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expiresInMs": 3600000}'
# {"token":"t=1790729747943,v1=09360a4d2d6ccf9a..."}
```

## Cancel jobs

`POST /v1/spaces/{space}/jobs:cancel` · scope `admin`

Send an array of ids, or `{"ids": [...]}`. Add `"tree": true` to cancel workflow runs with everything they started.

:::code-group
```bash curl
curl -s "$EMIT_URL/v1/spaces/billing/jobs:cancel" \
  -H "Authorization: Bearer $EMIT_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '["01M3QSGN0G01ZVECFQ48X49EVM", "01M3QSGN0G024F4C1PA48D5EVM"]'
```
```ts TypeScript
const outcomes = await emit.jobs.cancel("01M3QSGN0G01ZVECFQ48X49EVM", "01M3QSGN0G024F4C1PA48D5EVM");
```
```go Go
outcomes, err := c.Jobs.Cancel(ctx, "01M3QSGN0G01ZVECFQ48X49EVM", "01M3QSGN0G024F4C1PA48D5EVM")
```
:::

```json
{ "outcomes": [{ "status": "CANCELLED" }, { "rejection": { "reason": "ALREADY_FINISHED", "message": "" } }] }
```

One outcome per id: the job's status now, or why it wasn't cancelled (`NOT_FOUND` or `ALREADY_FINISHED`).
