# Jobs

> What a job is, the statuses it moves through, and how retries, delays, idempotency keys and callbacks work.

A **job** is one piece of work: a **name** that says what kind of work it is, like `charge-invoice`, and a JSON **payload** with what the worker needs, like `{"invoiceId": "inv_1042"}`. emit gives each job an **id**, a 26-character string such as `01M3QSGN0103P7EGVBX9D5NTSN`, and keeps it in a [space](/docs/concepts/spaces).

A worker claims jobs by name, so one space can hold many kinds of work, each served by the workers that know how to do it.

## Statuses

A job is always in one of five statuses.

| Status | What it means |
| --- | --- |
| :badge[pending]{tone=pending} | Waiting for a worker, or for a time: a delay, or the wait before a retry. |
| :badge[running]{tone=running} | A worker holds it and is working on it. |
| :badge[completed]{tone=completed} | The worker finished it. Its **result** is what the worker returned. |
| :badge[failed]{tone=failed} | It ran out of attempts, or a worker failed it for good. Its result is the last error. |
| :badge[cancelled]{tone=cancelled} | Someone cancelled it before it finished. |

The last three are final: a job that has finished never changes again.

```text
pending ──claimed──▶ running ──returned──▶ completed
   ▲                    │
   └── failed, with ────┤──── out of attempts, or failed for good ──▶ failed
       attempts left    │
anything unfinished ──cancelled──▶ cancelled
```

## Creating a job

Create a job with its name and payload. Everything else is optional.

:::code-group
```ts TypeScript
const { id, outcome } = await emit.jobs.create(
  "charge-invoice",
  { invoiceId: "inv_1042", amountCents: 4900 },
  {
    key: "charge-inv_1042", // a repeat returns this job instead of making another
    delayMs: 60_000,        // not before a minute from now
    maxAttempts: 5,         // the first try and up to four retries
    timeoutMs: 120_000,     // how long one attempt may take
  },
);
```
```go Go
created, err := c.Jobs.Create(ctx, emit.NewJob{
	Name:        "charge-invoice",
	Payload:     Invoice{ID: "inv_1042", AmountCents: 4900},
	Key:         "charge-inv_1042", // a repeat returns this job instead of making another
	DelayMs:     60_000,            // not before a minute from now
	MaxAttempts: 5,                 // the first try and up to four retries
	TimeoutMs:   120_000,           // how long one attempt may take
})
if err != nil {
	return err
}
log.Printf("job %s (%s)", created.ID, created.Outcome)
```
```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", "amountCents": 4900},
    "key": "charge-inv_1042",
    "delayMs": 60000,
    "maxAttempts": 5,
    "timeoutMs": 120000
  }'
```
:::

To create many jobs, send them in one call: `emit.jobs.createMany([...])` in TypeScript, `c.Jobs.CreateMany` in Go, or a JSON array to the same endpoint. The answers come back in the order you sent the jobs. One call with a hundred jobs is much faster than a hundred calls.

A payload is at most 4 MiB of JSON. For anything larger, store the data yourself and put its URL or key in the payload.

## Retries

A job gets **attempts**: four unless you or its space say otherwise, counting the first. When an attempt fails and attempts are left, the job goes back to pending and waits before it's tried again. The first wait is one second and each one after doubles, up to an hour. Change the policy for a space in its [settings](/docs/concepts/spaces#settings), or for one job with `backoffMs` (the first wait) and `backoff` (how the waits grow: `exponential`, `linear` or `constant`, with a ceiling and optional jitter).

What your handler does decides what happens to the job:

| The handler | The job |
| --- | --- |
| returns a value | completes, with that value as its result |
| throws, or returns an error | fails this attempt, and is retried while attempts are left |
| throws `Unrecoverable` | fails for good, whatever attempts are left |
| throws `RetryAfter` | fails this attempt, and is retried after the wait you give (at most a day) instead of the policy's |
| throws `Release` | goes back to the queue at once, and the attempt isn't counted |

:::code-group
```ts TypeScript
import { RetryAfter, Unrecoverable } from "@emit.run/sdk";

type Invoice = { invoiceId: string; amountCents: number };

emit.worker<Invoice>({ names: ["charge-invoice"] }, async (job) => {
  const response = await fetch("https://payments.example.com/v1/charges", {
    method: "POST",
    headers: { "content-type": "application/json", "idempotency-key": job.payload.invoiceId },
    body: JSON.stringify(job.payload),
    signal: job.signal, // stop if emit gives this job to another worker
  });
  if (response.status === 429) {
    const seconds = Number(response.headers.get("retry-after") ?? "30");
    throw new RetryAfter("rate limited by the payment provider", seconds * 1000);
  }
  if (response.status === 402) throw new Unrecoverable("card declined");
  if (!response.ok) throw new Error(`payment provider answered ${response.status}`);
  return await response.json(); // the job's result
});
```
```go Go
worker := c.Worker(ctx, emit.WorkerOptions{Names: []string{"charge-invoice"}}, func(ctx context.Context, job *emit.HeldJob) (any, error) {
	request, err := http.NewRequestWithContext(ctx, "POST", "https://payments.example.com/v1/charges", bytes.NewReader(job.Payload))
	if err != nil {
		return nil, emit.Unrecoverable(err)
	}
	request.Header.Set("Content-Type", "application/json")
	response, err := http.DefaultClient.Do(request)
	if err != nil {
		return nil, err // a network error: try again later
	}
	defer response.Body.Close()
	switch {
	case response.StatusCode == http.StatusTooManyRequests:
		seconds, err := strconv.Atoi(response.Header.Get("Retry-After"))
		if err != nil || seconds <= 0 {
			seconds = 30
		}
		return nil, emit.RetryAfter(errors.New("rate limited by the payment provider"), time.Duration(seconds)*time.Second)
	case response.StatusCode == http.StatusPaymentRequired:
		return nil, emit.Unrecoverable(errors.New("card declined"))
	case response.StatusCode >= 300:
		return nil, fmt.Errorf("payment provider answered %d", response.StatusCode)
	}
	var charge map[string]any
	if err := json.NewDecoder(response.Body).Decode(&charge); err != nil {
		return nil, err
	}
	return charge, nil // the job's result
})
```
```bash curl
# Fail this attempt and ask for the next one in 30 seconds:
curl -s "$EMIT_URL/v1/spaces/billing/jobs:report" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id": "01M3QSMMXQ00ZSGYA6VWM3NGQA", "attempt": 1, "fail": {"error": "rate limited", "retryAfterMs": 30000}}'

# Fail it for good:
curl -s "$EMIT_URL/v1/spaces/billing/jobs:report" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id": "01M3QSMMXQ00ZSGYA6VWM3NGQA", "attempt": 1, "fail": {"error": "card declined", "unrecoverable": true}}'
```
:::

An error message is kept up to 4 KiB. A result is at most 4 MiB of JSON: a larger one fails the attempt.

## Leases and timeouts

A worker holds a job under a **lease**, which starts as the job's **timeout**: five minutes unless you or its space say otherwise. The SDKs renew the lease while your handler runs, so a long job is fine as long as the worker is alive.

If the worker dies or loses its connection, the lease runs out, the attempt counts as failed, and another worker gets the next attempt. So a crashed job is picked up again within its timeout. The first worker may also be alive but slow, and still running when the second starts. The SDKs tell it to stop (`job.signal` aborts in TypeScript; the handler's `ctx` is cancelled in Go), and emit ignores anything more it reports.

:::warning Make handlers safe to run twice
A job can run more than once: after a crash, a lost lease or a retry. Write each handler so that a second run does no harm. Pass an idempotency key to the services you call, as the payment example above does, or check whether the work is already done.
:::

## Delays and schedules

A job can wait before it's first handed out. Give either `delayMs`, a wait from now, or `runAtMs`, a time in Unix milliseconds. Either can be up to a year away. A delayed job is pending until its time, then queues like any other.

## Idempotency keys

A **key** makes a create safe to repeat. Create a job with a key that an earlier job in the space already has, and emit doesn't make a second job: it answers with the earlier job's id, and `outcome` says `duplicate` instead of `new` (`DUPLICATE` and `NEW` over HTTP).

Use a key whenever you might send the same create twice, for example when you retry after a timeout: `charge-inv_1042` for the charge of invoice 1042.

A key holds while its job is unfinished, and for a window after the job finishes. On emit cloud that window is 10 minutes. A Dedicated cluster can raise it to a day in its node settings. After the window, the same key makes a new job.

## Jobs nobody claims

A job that no worker claims for 7 days after it could first be claimed fails for good, with the error `not claimed within 7d`. It catches a typo in a job name, or a service you retired, instead of leaving jobs waiting forever. Change the window, or turn it off, with the space's [unclaimed setting](/docs/concepts/spaces#settings).

## Progress, logs and events

While it runs, a worker can report on the job:

- **Progress**: a percentage and a short message. Only the latest is kept.
- **Log lines**: a level (`trace` to `fatal`; upper case over HTTP) and a message of up to 4 KiB each, kept in order and read back a page at a time.
- **Events**: small JSON objects, up to 4 KiB each and 1,000 a job unless the space says otherwise, all kept in order.

:::code-group
```ts TypeScript
emit.worker<{ importId: string; rows: number }>({ names: ["import-contacts"] }, async (job) => {
  await job.log("info", "import started", { metadata: { rows: job.payload.rows } });
  for (let done = 0; done < job.payload.rows; done += 500) {
    // ...import the next 500 rows...
    await job.progress(Math.round((done / job.payload.rows) * 100), `${done} rows`);
  }
  await job.event({ stage: "imported" });
  return { imported: job.payload.rows };
});
```
```go Go
worker := c.Worker(ctx, emit.WorkerOptions{Names: []string{"import-contacts"}}, func(ctx context.Context, job *emit.HeldJob) (any, error) {
	var in struct {
		ImportID string `json:"importId"`
		Rows     int    `json:"rows"`
	}
	if err := job.Decode(&in); err != nil {
		return nil, emit.Unrecoverable(err)
	}
	if err := job.Log(ctx, emit.LevelInfo, "import started"); err != nil {
		return nil, err
	}
	for done := 0; done < in.Rows; done += 500 {
		// ...import the next 500 rows...
		if err := job.Progress(ctx, float64(done)*100/float64(in.Rows), fmt.Sprintf("%d rows", done)); err != nil {
			return nil, err
		}
	}
	if err := job.Event(ctx, map[string]string{"stage": "imported"}); err != nil {
		return nil, err
	}
	return map[string]int{"imported": in.Rows}, nil
})
```
```bash curl
curl -s "$EMIT_URL/v1/spaces/contacts/jobs:report" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "01M3QSMMXQ00ZSGYA6VWM3NGQA",
    "attempt": 1,
    "touch": {},
    "progress": {"percent": 40, "message": "2000 rows"},
    "lines": [{"level": "INFO", "message": "import started"}],
    "event": {"stage": "importing"}
  }'
```
:::

Read them back with `emit.jobs.get(id)` and `emit.jobs.lines(id)`, or follow the job as it changes with `emit.jobs.follow(id)`. A browser can follow a job too, with no API token: see [Show progress in a browser](/docs/guides/browser-progress).

## Cancelling

Cancelling a job that hasn't finished makes it `cancelled` at once. A worker that's running it finds out when it next renews the lease, and the SDKs then tell your handler to stop, as they do for a lost lease. Anything it reports afterwards is ignored. Cancelling needs a token with the `admin` scope.

:::code-group
```ts TypeScript
const [outcome] = await emit.jobs.cancel("01M3QSMMXQ00ZSGYA6VWM3NGQA");
// { status: "cancelled" }, or { rejection: { reason: "already_finished" } }
```
```go Go
outcomes, err := c.Jobs.Cancel(ctx, "01M3QSMMXQ00ZSGYA6VWM3NGQA")
```
```bash curl
curl -s "$EMIT_URL/v1/spaces/billing/jobs:cancel" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '["01M3QSMMXQ00ZSGYA6VWM3NGQA"]'
```
:::

## Callbacks

A **callback** is a webhook emit sends when a job finishes, however it finishes. Give the job a URL, and optionally headers to send with it:

:::code-group
```ts TypeScript
await emit.jobs.create("render-report", { reportId: "rep_881" }, {
  callback: {
    url: "https://api.example.com/webhooks/emit",
    headers: { "x-tenant": "acme" },
  },
});
```
```go Go
_, err := c.Jobs.Create(ctx, emit.NewJob{
	Name:     "render-report",
	Payload:  map[string]string{"reportId": "rep_881"},
	Callback: &emit.Callback{URL: "https://api.example.com/webhooks/emit", Headers: map[string]string{"x-tenant": "acme"}},
})
```
```bash curl
curl -s "$EMIT_URL/v1/spaces/reports/jobs" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "render-report",
    "payload": {"reportId": "rep_881"},
    "callback": {"url": "https://api.example.com/webhooks/emit", "headers": {"x-tenant": "acme"}}
  }'
```
:::

When the job ends, emit POSTs a JSON body to the URL:

```json
{
  "jobId": "01M3QSMMXQ00ZSGYA6VWM3NGQA",
  "space": "reports",
  "name": "render-report",
  "status": "completed",
  "attempt": 1,
  "timestamp": 1790726272524,
  "result": { "url": "https://files.example.com/rep_881.pdf" }
}
```

`status` is `completed`, `failed` or `cancelled`, and `result` is the job's result, or its last error. Answer with any 2xx status. Anything else, or no answer, is tried again: after 5 seconds, then doubling up to an hour between tries, for about a day.

The URL must be `http` or `https` and reachable from the internet. Header values can carry credentials, so emit never shows them again: reading the job back gives the header names with empty values.

### Checking the signature

Every callback carries an `Emit-Signature` header, signed with the space's **webhook secret**. A space you set up in the dashboard gets a secret, shown once. A space created by its first job has none until you set one: type a new secret in the space's settings, or send `webhookSecret` through the [API](/docs/api/spaces). Check the signature before you trust the body, against the body exactly as it arrived:

:::code-group
```ts TypeScript
import { verifySignature } from "@emit.run/sdk";

const webhookSecret = process.env.EMIT_WEBHOOK_SECRET!;

export async function handleEmitCallback(request: Request): Promise<Response> {
  const body = await request.text(); // the raw body: don't parse it first
  if (!(await verifySignature(webhookSecret, request.headers.get("emit-signature"), body))) {
    return new Response("invalid signature", { status: 401 });
  }
  const callback = JSON.parse(body) as { jobId: string; status: string; result: unknown };
  console.log(`job ${callback.jobId} ${callback.status}`);
  return new Response(null, { status: 204 });
}
```
```go Go
webhookSecret := os.Getenv("EMIT_WEBHOOK_SECRET")

http.HandleFunc("POST /webhooks/emit", func(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body) // the raw body: don't decode it first
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	if !emit.VerifySignature(webhookSecret, r.Header.Get("Emit-Signature"), string(body), 5*time.Minute) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	var callback struct {
		JobID  string `json:"jobId"`
		Status string `json:"status"`
	}
	if err := json.Unmarshal(body, &callback); err != nil {
		http.Error(w, "not JSON", http.StatusBadRequest)
		return
	}
	log.Printf("job %s %s", callback.JobID, callback.Status)
	w.WriteHeader(http.StatusNoContent)
})
```
```bash Shell
# The header is t=<Unix ms>,v1=<hex HMAC-SHA256 of "<t>.<raw body>", keyed with the webhook secret>.
# Given the header's t, and the body saved to body.json:
printf '%s.%s' "$t" "$(cat body.json)" | openssl dgst -sha256 -hmac "$EMIT_WEBHOOK_SECRET"
# Compare the hex digest with the header's v1, and reject a t more than a few minutes old.
```
:::

`verifySignature` and `VerifySignature` refuse a signature more than five minutes old, which stops an old request from being replayed. The TypeScript function takes a different tolerance, in milliseconds, as its fourth argument.

## When emit is busy

A space or a plan can cap the jobs in flight: jobs created but not yet finished. At the cap, emit holds new jobs back until workers bring the count down: it answers HTTP `429` with a `Retry-After` header, and the body's `retryAfterMs` says how long to wait. Nothing from that call is kept, so the same call is sent again after the wait. (A single batch larger than the cap is refused.)

The SDKs do this for you. A create tries again as emit says, for up to a minute, and only then returns the `429` as an error. Set how long it waits with `roomWaitMs` in TypeScript or `RoomWait` in Go. Without an SDK, send the same request again after the wait. With a `key` on each job, a repeat within the key's window won't make a job twice.

:::code-group
```ts TypeScript
const emit = new Emit({
  url: process.env.EMIT_URL!,
  token: process.env.EMIT_TOKEN!,
  space: "imports",
  roomWaitMs: 5 * 60_000, // wait up to five minutes for room
});
```
```go Go
c := emit.New(emit.Options{
	URL:      os.Getenv("EMIT_URL"),
	Token:    os.Getenv("EMIT_TOKEN"),
	Space:    "imports",
	RoomWait: 5 * time.Minute, // wait up to five minutes for room
})
```
```bash curl
curl -s -i "$EMIT_URL/v1/spaces/imports/jobs" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "import-row", "payload": {"importId": "imp_7", "row": 18}, "key": "imp_7-row-18"}'
# HTTP/1.1 429 Too Many Requests
# retry-after: 1
# {"code":"ResourceExhausted","message":"...","retryAfterMs":1000}
```
:::

Claims and reports aren't refused this way: they're what brings the count down.
