> ## Documentation Index
> Fetch the complete documentation index at: https://twenty-claude-cool-pascal-5ay683.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Background Jobs

> Hand long or rate-limited work to the Twenty workers by enqueuing another logic function run instead of doing everything inline.

A logic function run is capped by its `timeoutSeconds` (900 seconds maximum). Anything that can't finish in that window — a full re-sync, a per-record fan-out, a third-party API that rate-limits you — has to be split into smaller runs.

`enqueueJobs` does exactly that: it asks the Twenty workers to run one of your app's logic functions later, once per payload, each run in its own process with its own timeout budget. The caller returns immediately.

```text theme={null}
  ┌─────────────────┐  enqueueJobs(...)  ┌──────────────┐   ┌────────────────────┐
  │ Logic function  │ ─────────────────▶ │ Job queue    │──▶│ Logic function     │
  │ (returns now)   │                    │ (workers)    │   │ (fresh run/timeout)│
  └─────────────────┘                    └──────────────┘   └────────────────────┘
```

## Enqueue runs

Import `enqueueJobs` from `twenty-sdk/logic-function`, point it at the `universalIdentifier` of the logic function to run, and pass one payload per run to enqueue.

```ts src/logic-functions/sync-all-contacts.ts theme={null}
import { enqueueJobs } from 'twenty-sdk/logic-function';

await enqueueJobs({
  logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
  jobs: [{ payload: { page: 1 } }],
});
```

Each run receives its payload as the handler argument, exactly like any other trigger. The target must belong to the **same application** as the caller — enqueuing another app's function is rejected with `Logic function not found` and nothing is enqueued. A single call accepts up to `200` jobs.

<Note>
  `enqueueJobs` returns as soon as the jobs are accepted, not when they have run. It does not return the targets' results — have each target write what it produces to the [key-value store](/developers/extend/apps/logic/key-value-store) or to a workspace record if you need to read it back. To find out whether a run finished, poll its [job status](#poll-jobs).
</Note>

<Note>
  The older `enqueueJob` helper, which enqueues a single job per call, is deprecated. Use `enqueueJobs` with a one-element `jobs` list instead. The `payloads` argument is deprecated too — pass `jobs` instead, which takes the same payload per run and lets you name each one.
</Note>

## Poll jobs

`enqueueJobs` returns one `jobId` per enqueued run, in the same order as the jobs you passed. `getJobs` reads back where those runs got to, in one call.

```ts theme={null}
import { enqueueJobs, getJobs } from 'twenty-sdk/logic-function';

const { jobIds } = await enqueueJobs({
  logicFunctionUniversalIdentifier: ENRICH_COMPANY,
  jobs: [{ payload: { companyId: 'abc' } }],
});

const statuses = await getJobs(jobIds);
```

A single `getJobs` call reads at most `200` ids, the same ceiling `enqueueJobs` applies to a batch.

Each entry carries the id you asked for:

| Field          | What it holds                                                                               |
| -------------- | ------------------------------------------------------------------------------------------- |
| `jobId`        | The id you polled with.                                                                     |
| `state`        | `WAITING`, `PRIORITIZED`, `DELAYED`, `ACTIVE`, `WAITING_CHILDREN`, `COMPLETED` or `FAILED`. |
| `attemptsMade` | Queue attempts made so far, including the current one.                                      |
| `failedReason` | The error message of the last failed attempt, otherwise `null`.                             |
| `enqueuedAt`   | When the job was accepted, in epoch milliseconds.                                           |
| `startedAt`    | When a worker picked it up, `null` while it is still waiting.                               |
| `finishedAt`   | When it completed or failed, `null` while it is still running.                              |

A job that has not been picked up yet normally reads as `PRIORITIZED` rather than `WAITING`, because application jobs are queued at a fixed low priority. `COMPLETED` and `FAILED` are the terminal states. A poll loop should also give up on its own after a bounded number of attempts rather than waiting forever, since a run is only readable for as long as the queue retains it: 4 hours after completing, 7 days after failing, and in both cases only the most recent 1000 jobs in that state.

`getJobs` returns only the jobs it still holds, so an id that has been evicted — or never existed — is simply absent from the result rather than erroring the whole batch. Compare what you get back against what you asked for to spot those, and treat a missing id as "no longer known", which is not the same answer as "it never ran".

Statuses are scoped to the workspace the call is made from, so one workspace can never read another workspace's job.

## Choose your own job id

Pass `jobs` instead of `payloads` to name each run yourself. The id you pass is the one `getJobs` takes, so a caller can poll a job without having to store what `enqueueJobs` returned.

```ts theme={null}
await enqueueJobs({
  logicFunctionUniversalIdentifier: ENRICH_COMPANY,
  jobs: [{ payload: { companyId: 'abc' }, jobId: 'enrich-abc' }],
});
```

An id is at most 128 characters of letters, digits, `_`, `.` or `-`, and no two jobs in one call may share one. Pass either `payloads` or `jobs`, never both. When you omit `jobId`, Twenty generates one.

A `jobId` is also an idempotency key: enqueuing an id that the queue still holds is accepted but does **not** start a second run, and `getJobs` keeps reporting the first one. This makes a retried enqueue safe. Two caveats: the guarantee only lasts as long as the job is retained, so the same id can start a fresh run once the original has been evicted, and ids are shared by every app in a workspace, so prefix yours if a collision would matter.

## Job options

Options apply to every run in the batch.

| Option       | Default | Range                    | What it does                                                                                                                            |
| ------------ | ------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `retryLimit` | `0`     | `0`–`10`                 | Overall extra queue attempts. Application-requested retries are capped at `3`. Only raise this for handlers that are safe to run twice. |
| `delayMs`    | `0`     | `0`–`604800000` (7 days) | Wait this long before the runs become eligible.                                                                                         |

```ts theme={null}
await enqueueJobs({
  logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
  jobs: [{ payload: { page: 1 } }],
  retryLimit: 3,
  delayMs: 60_000,
});
```

<Note>
  **Priority is not configurable yet.** Enqueued jobs always run at the lowest priority, so platform work is never delayed behind application jobs. Control over priority is coming soon.
</Note>

The queued run inherits the acting user of the function that enqueued it, so it acts with the same permissions.

## Retry a transient failure

Twenty does not retry every exception from application code. An ordinary thrown error is treated as a permanent failure. For a transient failure, a queued logic function can request up to three retries by throwing `RetryableLogicFunctionError`.

```ts theme={null}
import {
  type LogicFunctionExecutionContext,
  RetryableLogicFunctionError,
} from 'twenty-sdk/logic-function';

export const handler = async (
  _payload: unknown,
  { retryCount, maxRetries }: LogicFunctionExecutionContext,
) => {
  const response = await fetch('https://api.example.com/contacts');

  if (response.status === 429 || response.status >= 500) {
    throw new RetryableLogicFunctionError(
      `The contacts API is temporarily unavailable (${response.status}); retry ${retryCount} of ${maxRetries}`,
    );
  }
};
```

Throw `RetryableLogicFunctionError` directly when possible. If you extend it, do not replace its `name`: Twenty recognizes the serialized name `RetryableLogicFunctionError` across execution runtimes.

`retryCount` is `0` for the initial execution and increases only when application code requests a retry. `maxRetries` is at most `3` and can be lower when the queued job has a smaller overall retry limit. Platform failures do not increase `retryCount`, although they still consume the queue's overall safety budget.

The queue delays retry attempts with exponential backoff and jitter. The exact delay is intentionally not guaranteed, so application code should not depend on a retry happening at a precise time. Once `maxRetries` is reached, another `RetryableLogicFunctionError` is recorded as the final application failure without another execution.

<Warning>
  Retries re-run the whole handler and may happen after some side effects succeeded. Make the handler idempotent before requesting retries.
</Warning>

## Use it: page through a long sync

The classic shape is a function that enqueues *itself* with the next cursor. Each run does one page of work well inside its own timeout, and the chain stops when there is nothing left.

```ts src/logic-functions/sync-contacts-page.ts theme={null}
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs } from 'twenty-sdk/logic-function';

const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33';

const handler = async (params: { cursor?: string }) => {
  const { contacts, nextCursor } = await fetchContactsPage(params.cursor);

  await importContacts(contacts);

  if (nextCursor) {
    await enqueueJobs({
      logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
      jobs: [{ payload: { cursor: nextCursor } }],
      delayMs: 2_000,
    });
  }

  return { imported: contacts.length, done: !nextCursor };
};

export default defineLogicFunction({
  universalIdentifier: SYNC_CONTACTS_PAGE,
  name: 'sync-contacts-page',
  timeoutSeconds: 120,
  handler,
});
```

## Fan out per record

When the work is naturally per-item, enqueue one job per item in a single call and let the workers process them in parallel instead of looping inline.

```ts theme={null}
const companies = await listCompaniesToEnrich();

await enqueueJobs({
  logicFunctionUniversalIdentifier: ENRICH_COMPANY,
  jobs: companies.map((company) => ({ payload: { companyId: company.id } })),
  retryLimit: 2,
});
```

## Good practice for long-running work

Two rules cover almost every long job: **recurse instead of looping**, and **process a bounded chunk per run**.

A run that tries to do everything is the failure mode — it hits the timeout, and with a retry it starts the whole thing again from zero. Instead, size one chunk so it comfortably finishes inside `timeoutSeconds`, persist your position, and enqueue the next run.

```ts src/logic-functions/enrich-companies-batch.ts theme={null}
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs, kv } from 'twenty-sdk/logic-function';

const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10';
const CHUNK_SIZE = 50;

const handler = async (params: { offset?: number }) => {
  const offset = params.offset ?? 0;
  const companies = await listCompaniesToEnrich({
    offset,
    limit: CHUNK_SIZE,
  });

  for (const company of companies) {
    await enrichCompany(company);
  }

  await kv.set('enrich:progress', { offset: offset + companies.length });

  if (companies.length === CHUNK_SIZE) {
    await enqueueJobs({
      logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
      jobs: [{ payload: { offset: offset + CHUNK_SIZE } }],
    });
  }

  return { processed: companies.length, done: companies.length < CHUNK_SIZE };
};

export default defineLogicFunction({
  universalIdentifier: ENRICH_COMPANIES_BATCH,
  name: 'enrich-companies-batch',
  timeoutSeconds: 300,
  handler,
});
```

What makes this hold up:

* **Size the chunk from the slowest item, not the average.** `CHUNK_SIZE × worst-case item time` has to fit in `timeoutSeconds` with room to spare, or the tail of a chunk is lost when the run is cut off.
* **Make the terminating condition explicit.** Recurse only while a full chunk came back. A chain that stops on "no results" alone will keep going forever if the source ever returns a short page mid-way.
* **Persist progress before enqueuing the next run,** so a failed link restarts from the last completed chunk instead of the beginning.
* **Keep each chunk idempotent.** Reprocessing one chunk after a retry must not double-write — key writes on the record or external id you are processing.
* **Prefer a chunked chain over one giant fan-out** when the work hits a rate-limited third party: a chain with `delayMs` paces itself, whereas thousands of jobs enqueued at once all become eligible immediately.
