# Background jobs

Run long API calls and AI workflows in the background. 

## How it works

Click Deploy on the Jobs card. You get two values, `JOBS_API_URL` and `JOBS_API_KEY`. Keep the key on your server: it starts runs and signs what we send you.

1. Your server asks for a run with the URL to call, for example an endpoint of your own that generates a report or runs an AI workflow.
2. We answer at once with the run's `id` and status `queued`.
3. Our worker calls your URL and waits for the answer, for up to 5 minutes on Free and 60 on Pro.
4. The run ends as `succeeded` (a 2xx answer), `failed` or `cancelled`. Read it with its id, or give a `callbackUrl` and we POST the result to you.

## Start a run

*From your server*

```ts
const res = await fetch(`${process.env.JOBS_API_URL}/runs`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-api-key": process.env.JOBS_API_KEY!,
  },
  body: JSON.stringify({
    url: "https://api.example.com/generate-report",
    method: "POST",
    headers: { authorization: `Bearer ${process.env.MY_API_TOKEN}` },
    body: { reportId: 42 },
    timeoutSeconds: 300,
    retries: 3,
    callbackUrl: "https://example.com/api/jobs-webhook",
  }),
});
const { id, status, statusUrl } = await res.json(); // 202, status "queued"
```

| Field | Default | Meaning |
| --- | --- | --- |
| `url` | required | The http or https URL we call. Must be a public address. |
| `method` | `POST` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. |
| `headers` | none | Sent with the request. Stored encrypted. At most 30. |
| `body` | none | A string is sent as is; anything else as JSON. Up to 256 KB. |
| `timeoutSeconds` | 300 | Longest we wait for the full answer. Up to 300 on Free, 3600 on Pro. |
| `retries` | 3 | Extra tries after a network error, 429 or 5xx. 0 to 5. |
| `delaySeconds` | 0 | Start the run later, up to 7 days. |
| `callbackUrl` | dashboard webhook | Where we POST the finished run. Leave it out to use the webhook URL set in the dashboard; `null` sends none. |

> **Note:** A timeout or any other answer, such as 400, is final and not retried. If your workflow is not safe to run twice, set `retries: 0`.

## Poll for the result

```ts
async function waitForRun(id: string) {
  for (;;) {
    const res = await fetch(`${process.env.JOBS_API_URL}/runs/${id}`, {
      headers: { "x-api-key": process.env.JOBS_API_KEY! },
    });
    const run = await res.json();
    if (["succeeded", "failed", "cancelled"].includes(run.status)) return run;
    await new Promise((r) => setTimeout(r, 3000));
  }
}
```

*A finished run*

```json
{
  "id": "4b5dea73-bf6d-4a5d-94b6-833b80c9241c",
  "status": "succeeded",
  "method": "POST",
  "url": "https://api.example.com/generate-report",
  "attempts": 1,
  "maxAttempts": 4,
  "createdAt": "2026-10-21T09:00:00.000Z",
  "runAt": "2026-10-21T09:00:00.000Z",
  "startedAt": "2026-10-21T09:00:00.120Z",
  "finishedAt": "2026-10-21T09:02:14.900Z",
  "durationMs": 134780,
  "response": { "status": 200, "body": { "reportUrl": "https://..." }, "truncated": false },
  "error": null,
  "callback": { "url": "https://example.com/api/jobs-webhook", "status": "delivered", "attempts": 1, "error": null }
}
```

- `status` is `queued`, `running`, `succeeded`, `failed` or `cancelled`.
- `response.body` is parsed when your URL answered JSON, otherwise it is text. We keep the first 256 KB; `truncated` says when there was more.
- `error` explains a failure, such as `The URL answered 500.` or a timeout.
- `GET {JOBS_API_URL}/runs?limit=20&status=failed` lists recent runs, newest first.
- `POST {JOBS_API_URL}/runs/<id>/cancel` cancels a run. A queued run never starts; a running one is left to finish, but its result is dropped and no webhook is sent.

## Webhooks

With a `callbackUrl`, we POST the finished run there, with the same JSON as polling returns. Answer with any 2xx. Other answers are retried 6 times over about 15 minutes.

To get every run's result without passing `callbackUrl` each time, set a webhook URL under Jobs → Manage → Webhook in the dashboard. A run's own `callbackUrl` wins over it, and `callbackUrl: null` turns it off for one run. From that page you can also send a signed test event.

Every request we send, to your `url` and to your `callbackUrl`, has an `x-cloudhike-signature` header: `t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 of `<t>.<raw body>` with your `JOBS_API_KEY`. Check it, and refuse old timestamps, so nobody else can call your endpoints. Requests also carry `x-cloudhike-run-id`.

*Next.js route: app/api/jobs-webhook/route.ts*

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(body: string, header: string | null) {
  const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", process.env.JOBS_API_KEY!).update(`${t}.${body}`).digest("hex");
  return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

export async function POST(request: Request) {
  const body = await request.text();
  if (!verify(body, request.headers.get("x-cloudhike-signature"))) {
    return new Response("bad signature", { status: 401 });
  }
  const run = JSON.parse(body);
  // run.status, run.response.body ...
  return new Response("ok");
}
```

> **Warning:** Runs are delivered at least once. In rare cases, such as a worker restart mid-run, your URL can be called twice for the same run. Use `x-cloudhike-run-id` to skip a run you already handled.

## Limits

|  | Free | Pro |
| --- | --- | --- |
| Runs per 24 hours | 500 | No limit |
| Price | Free | 50,000 run-seconds a month per project included, then $0.01 per 1,000 run-seconds |
| Runs per minute | 30 | 600 |
| Runs in progress at once | 5 | 25 |
| Longest timeout | 5 minutes | 60 minutes |

- Over a limit, starting a run answers 429. Runs beyond the in-progress limit wait their turn.
- Runs and their results are kept for 7 days. The Runs page in the dashboard shows the last 100.
- URLs must be public: localhost, private networks and cloud metadata addresses are refused.
- Background jobs are free on Free, within its limits. On Pro, run time is the time we wait on your URL, in whole seconds per attempt (at least 1), so a retried run counts each attempt. It is charged from your credits every hour.

## Use with AI

Let your coding agent move slow work into background jobs.

```text
Read https://cloudhike.dev/docs/md/jobs. Move this app's slow API calls into CloudHike.dev background jobs: start a run with JOBS_API_URL and JOBS_API_KEY from server code, receive the result on a webhook route that verifies x-cloudhike-signature, and skip run ids already handled.
```
