The modern link attribution platform. Loved by world-class marketing teams like Framer, Perplexity, Superhuman, Twilio, Buffer and more.
---
name: jobs-define-job
description: Add or migrate background jobs with defineJob, register them in jobLoaders, and dispatch via job.dispatch/dispatchBatch instead of qstash.publishJSON to a cron URL. Use when adding a QStash worker, converting a POST /api/cron route into a job, or when the user mentions defineJob, job handlers, /api/jobs/process, or background jobs.
---
# Background jobs: use defineJob
Payload-driven QStash work goes through `defineJob`. Do **not** add a new HTTP route under `/api/jobs` โ every job is executed by the existing [`apps/web/app/api/jobs/process/[jobName]/route.ts`](apps/web/app/api/jobs/process/[jobName]/route.ts).
Keep using `withCron` for Vercel GET schedules and for cron scanners that have no job envelope. See the `cron-use-with-cron` skill for those.
## File layout
```
apps/web/lib/jobs/
โโโ index.ts # defineJob โ do not edit unless changing the framework
โโโ registry.ts # jobLoaders โ register every new job here
โโโ send-jobs.ts # envelope + QStash request builder
โโโ handlers/
โโโ {name}-job.ts # one file per job
```
## 1. Create the handler
Add `apps/web/lib/jobs/handlers/{name}-job.ts`. The `name` must be kebab-case and end in `-job` (enforced by `jobNameSchema`: `/^[a-z][a-z0-9]*(-[a-z0-9]+)*-job$/`).
```ts
import * as z from "zod/v4";
import { defineJob } from "../index";
const inputSchema = z.object({
programId: z.string(),
partnerId: z.string(),
});
export const unbanPartnerJob = defineJob({
name: "unban-partner-job",
schema: inputSchema,
defaults: {
retries: 3, // optional; QStash retries on 5xx
// queue: "unban-partner", // optional; named QStash queue
// flowControl: { key: "unban-partner", parallelism: 20 },
},
async handle(input) {
// skip (permanent / not found) โ return (process route returns 2xx, QStash does not retry)
// transient failure โ throw (process route returns 500, QStash retries)
},
});
```
Export the const as camelCase `{name}Job` matching the kebab `name`.
Reference handlers:
- Simple skip/work: `unban-partner-job.ts`, `create-tremendous-campaign-job.ts`
- Self-pagination: `folder-deleted-job.ts`, `domain-deleted-job.ts`, `partner-search-sync-job.ts`
- `defaults.flowControl`: `partner-search-sync-job.ts`
## 2. Register it
Add a **static** `import()` in [`apps/web/lib/jobs/registry.ts`](apps/web/lib/jobs/registry.ts) `jobLoaders`. The object key must equal `defineJob({ name })`. Webpack code-splits each handler.
```ts
"unban-partner-job": () =>
import("./handlers/unban-partner-job").then((m) => m.unbanPartnerJob),
```
Do not register by editing the process route. `loadJob` throws if `job.name !==` the registry key.
## 3. Dispatch from call sites
Import the handler (not `qstash`) and call `dispatch` / `dispatchBatch`:
```ts
import { unbanPartnerJob } from "@/lib/jobs/handlers/unban-partner-job";
await unbanPartnerJob.dispatch(
{ workspaceId, programId, partnerId },
{ label: partnerId },
);
await folderDeletedJob.dispatchBatch(
folderIds.map((folderId) => ({ folderId })),
({ folderId }) => ({ label: folderId }),
);
```
Per-dispatch options merge over `defaults`: `delay`, `notBefore`, `deduplicationId`, `retries`, `queue`, `flowControl`, `label`.
Failed QStash publish is persisted to the jobs outbox automatically โ do not catch-and-swallow unless even the outbox persist failing must not fail the source mutation (see `queue-partner-search-sync.ts`).
Self-pagination: call `theJob.dispatch(nextPayload, { delay: 1 })` from `handle` (see `folder-deleted-job`, `partner-search-sync-job`).
Do not call `job.execute` from app code. That is only for the process route (and cron drain shims below).
## 4. Convert an existing cron worker
1. Move the `withCron` body into `handle`. Replace `logAndRespond("skipโฆ")` with `console.info` / `console.error` + `return`.
2. Register + switch every `qstash.publishJSON` / `enqueueJSON` / `enqueueBatchJobs` targeting that cron URL to `job.dispatch` / `dispatchBatch`.
3. Keep a thin POST shim at the old `/api/cron/...` URL that parses the **old** body (not the job envelope) and calls `job.execute(payload)`. That drains in-flight QStash messages. Do not add a shim for brand-new jobs.
4. Delete cron-only helpers that moved with the handler.
## Handle semantics
| Outcome | What to do | HTTP from process route | QStash |
| --- | --- | --- | --- |
| Work done | `return` | 200 | stop |
| Skip (not found, already done, env not configured) | `console.*` + `return` | 200 | stop |
| Bad payload | throw `ZodError` (schema.parse) | 200 | stop (non-retryable) |
| Transient failure | `throw` | 500 | retry |
Unknown job names and invalid envelopes also return 2xx so QStash does not retry forever.
## Do not use defineJob for
- Vercel GET crons in `apps/web/vercel.json`
- Scanners that only fan out work (the worker they enqueue can be a job)
- Importers that republish continuation state to the same URL
- `/api/cron/queue/retry` (job replay infrastructure)
- Path-param identity (`/api/cron/links/[linkId]/โฆ`) unless the id moves into the payload
- Outbound webhook forwarding and postbacks
## Do not
- Add a new `/api/jobs/...` route or a new `/api/cron/...` POST worker for payload-driven work.
- Call `qstash.publishJSON` / `enqueueJSON` / `enqueueBatchJobs` with `/api/jobs/process/...` โ use `dispatch`.
- Register jobs with a dynamic `import()` that webpack cannot statically analyze, or a key that differs from `defineJob({ name })`.
- Name a job without the `-job` suffix.
- Use `job.execute` at dispatch call sites.
- Run `pnpm build` after adding a job.