Skip to content
Chapter 66Lesson 6

Waitpoints for callbacks and approvals

Trigger.dev waitpoints, durable pause tokens that park a run on a third-party callback, a human approval, or a batch of sub-jobs instead of polling.

Every wait so far ran on the clock: wait.for({ seconds: 2 }) counts off two seconds, wait.until(periodEnd) waits for a date you already hold. You set the alarm, and the run resumes when it rings.

Many workflows wait on something no clock can time. A partner’s render farm finishes in two minutes or two hours. An admin approves a refund in thirty seconds, or after lunch. A batch of twelve sub-jobs each reports back on its own schedule. You don’t know when the wait ends, only what will signal you, and the signal comes from outside the run.

With the tools so far, you’d poll: trigger the partner, then loop a short wait.for against a status endpoint until it flips. That burns run-minutes waking a worker every few seconds to hear “not yet,” opens a race window where a job finishing just after a poll waits until the next one, and assumes the partner exposes a status endpoint at all, which many don’t.

This lesson replaces the poll loop with the waitpoint , a durable pause the outside world completes instead of a clock you set. It covers the event-driven, human-in-the-loop work that justifies a job platform, and it reuses last lesson’s machinery: the run parks, the worker is freed, and the run survives a crash while parked. The only new part is who wakes it.

The three real-world shapes are one primitive seen from different angles, and that primitive has a four-beat lifecycle:

  1. Create a token. wait.createToken(...) returns a handle.
  2. Hand it out. Give whoever completes the token a way to do so: a URL, an id, or a Bearer token, depending on the completer.
  3. Park the run. await wait.forToken(...) is a checkpoint, so the worker is released and the run consumes nothing while it waits.
  4. Resume. An HTTP callback, an SDK call, or the timeout completes the token, and the run wakes up, possibly on a different worker, with the completion payload.

Parking on a token is a checkpoint , so a parked run isn’t a held thread or a busy worker; it’s a row in Trigger.dev’s database. It can park for six hours, survive a redeploy or the worker that started it being recycled, and resume the instant its token completes. Polling holds a worker hostage; parking lets it go.

The smallest version creates a token, then parks on it.

const token = await wait.createToken({ timeout: '1h' });
// hand token.url or token.publicAccessToken to whoever completes it
const result = await wait.forToken<{ approved: boolean }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('approval timed out');
}
const { approved } = result.output;

The handle has four fields. id (starting with waitpoint_) is how you refer to the token; url and publicAccessToken are the two ways someone else completes it; isCached matters for idempotency below.

const token = await wait.createToken({ timeout: '1h' });
// hand token.url or token.publicAccessToken to whoever completes it
const result = await wait.forToken<{ approved: boolean }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('approval timed out');
}
const { approved } = result.output;

Never skip timeout. It defaults to '10m', far short of a human approval or an hours-long partner job, which would hit the default and die. Set it explicitly, sized to the slowest completion you’ll accept.

const token = await wait.createToken({ timeout: '1h' });
// hand token.url or token.publicAccessToken to whoever completes it
const result = await wait.forToken<{ approved: boolean }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('approval timed out');
}
const { approved } = result.output;

wait.forToken<T>(token.id) parks the run; this line is the checkpoint. The generic <T> types result.output for free.

const token = await wait.createToken({ timeout: '1h' });
// hand token.url or token.publicAccessToken to whoever completes it
const result = await wait.forToken<{ approved: boolean }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('approval timed out');
}
const { approved } = result.output;

The result is { ok, output, error }. A forToken fails exactly one way, by timing out, so ok: false always means “timed out.” Either it completed and result.output holds your payload, or it timed out and AbortTaskRunError fails the run cleanly.

1 / 1

When a timeout should just fail the run, .unwrap() collapses the branch: const { approved } = (await wait.forToken<{ approved: boolean }>(token.id)).unwrap();. It returns the output on success and throws on timeout. Use .unwrap() when a timeout is fatal; branch on result.ok when a timeout means something, such as auto-reject, escalate, or notify.

Creation has one durable-model subtlety. If a task retries after creating a token but before parking, a naive createToken mints a new token and hands out the wrong URL. Pass idempotencyKey: ctx.run.id and the retry returns the same token with isCached: true. Idempotency lives on createToken, not forToken, and its key TTL defaults to '1h', far shorter than the 30-day default on tasks.trigger; size it up for waits that run longer.

The three families of wait differ on one axis: who completes the wait.

WaitWho completes itReach for it when…
wait.for / wait.untilthe clockyou know the delay or the deadline up front
triggerAndWait / batchTriggerAndWaita child taskthe runtime owns the wait and hands you the child’s typed result
wait.forTokenan external system or a humanyou create the token and hand it out; something outside your run signals back

If a clock tells you when to continue, or your own task produces the value, you don’t need a token. Reach for wait.forToken when the signal comes from outside your run: a third party, a person, or another part of your system holding the SDK. Building a completer by hand for something the runtime would have completed means you’ve picked the wrong row.

The base case is one run, one token, one completer:

Run parks on token
Waitpoint token durable pause
Completed by…
Third party (HTTP)Human (SDK call)Timeout
One run parks on one token, and any one of three completers wakes it: a third party's HTTP callback, a human's SDK-backed click, or the timeout.

The topology generalizes two ways: one token can unblock several runs, and, more useful, one run can wait on several completions at once. We build that second direction at the end of the lesson. Trigger.dev has no wait.forWaitpoint([...], { all }) API; the fan-in is built from batchTriggerAndWait, which you already have.

A task kicks off a long external job, a video transcode or a partner data import, that runs for minutes to hours on someone else’s infrastructure and reports completion by hitting a URL you give it.

Without waitpoints, receiving that callback means rebuilding your Stripe webhook stack for every partner: a public route, a row tying the partner’s job id to your run id, dedup for duplicate callbacks, and code to resume the right run. A waitpoint collapses all of it. The token’s url field is the callback URL, and completing it is the resume:

export const renderVideo = schemaTask({
id: 'render-video',
schema: z.object({ organizationId: z.uuid(), sourceUrl: z.url() }),
run: async ({ organizationId, sourceUrl }) => {
const token = await wait.createToken({ timeout: '6h' });
await fetch('https://api.partner.example/render', {
method: 'POST',
body: JSON.stringify({ source: sourceUrl, callbackUrl: token.url }),
});
const result = await wait.forToken<{ renderUrl: string }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('render callback timed out after 6h');
}
const db = tenantDb(organizationId);
await db.insert(renders).values({ url: result.output.renderUrl });
},
});

Size the timeout to the slowest completion you’ll accept. If the partner’s render takes four hours on a bad day, '6h' leaves headroom where '10m' would kill the run mid-render.

export const renderVideo = schemaTask({
id: 'render-video',
schema: z.object({ organizationId: z.uuid(), sourceUrl: z.url() }),
run: async ({ organizationId, sourceUrl }) => {
const token = await wait.createToken({ timeout: '6h' });
await fetch('https://api.partner.example/render', {
method: 'POST',
body: JSON.stringify({ source: sourceUrl, callbackUrl: token.url }),
});
const result = await wait.forToken<{ renderUrl: string }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('render callback timed out after 6h');
}
const db = tenantDb(organizationId);
await db.insert(renders).values({ url: result.output.renderUrl });
},
});

Hand the partner token.url: the server-to-server completion webhook, with no CORS headers, right for a backend calling from its own servers. Not token.id, an identifier rather than a URL, and not token.publicAccessToken, which is for browsers and comes up next. The wrong handle means the partner can’t complete the token, and the run dies on the timeout with no obvious cause.

export const renderVideo = schemaTask({
id: 'render-video',
schema: z.object({ organizationId: z.uuid(), sourceUrl: z.url() }),
run: async ({ organizationId, sourceUrl }) => {
const token = await wait.createToken({ timeout: '6h' });
await fetch('https://api.partner.example/render', {
method: 'POST',
body: JSON.stringify({ source: sourceUrl, callbackUrl: token.url }),
});
const result = await wait.forToken<{ renderUrl: string }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('render callback timed out after 6h');
}
const db = tenantDb(organizationId);
await db.insert(renders).values({ url: result.output.renderUrl });
},
});

Park on the token. The worker is freed for the entire wait, six hours if it comes to that, at zero cost, where a poll loop would wake a worker every ten seconds to ask “done yet?” and hear “no”.

export const renderVideo = schemaTask({
id: 'render-video',
schema: z.object({ organizationId: z.uuid(), sourceUrl: z.url() }),
run: async ({ organizationId, sourceUrl }) => {
const token = await wait.createToken({ timeout: '6h' });
await fetch('https://api.partner.example/render', {
method: 'POST',
body: JSON.stringify({ source: sourceUrl, callbackUrl: token.url }),
});
const result = await wait.forToken<{ renderUrl: string }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('render callback timed out after 6h');
}
const db = tenantDb(organizationId);
await db.insert(renders).values({ url: result.output.renderUrl });
},
});

The timeout branch is mandatory. A silent partner must not park your run forever; AbortTaskRunError fails it cleanly so onFailure and alerting fire instead of leaving a run stuck in “Waiting” for weeks.

export const renderVideo = schemaTask({
id: 'render-video',
schema: z.object({ organizationId: z.uuid(), sourceUrl: z.url() }),
run: async ({ organizationId, sourceUrl }) => {
const token = await wait.createToken({ timeout: '6h' });
await fetch('https://api.partner.example/render', {
method: 'POST',
body: JSON.stringify({ source: sourceUrl, callbackUrl: token.url }),
});
const result = await wait.forToken<{ renderUrl: string }>(token.id);
if (!result.ok) {
throw new AbortTaskRunError('render callback timed out after 6h');
}
const db = tenantDb(organizationId);
await db.insert(renders).values({ url: result.output.renderUrl });
},
});

Re-derive tenancy with tenantDb(organizationId) inside the body. A task inherits no auth context, so the org id rides in the payload and you scope from it.

1 / 1

You wrote no public route, no signature check, no processed_events table, no correlation row. The runtime owns the URL, its authentication, the dedup, and the resume; you handed the partner a one-shot resume button and parked on it.

A parked run shows as “Waiting” in the Trigger.dev dashboard with a live countdown, so a silent integration diagnoses at a glance: if it reached “Waiting,” the partner never called back; if it didn’t, the bug is yours, before the handoff.

Some operations have to wait for a person: a refund above a threshold, a destructive admin action, an immediate plan downgrade.

Neither easy option works. Run the work synchronously in the request and it dies waiting, since no request lives the hours an approver might take. Make it fire-and-forget and the decision can’t gate the action, only annotate it after the fact. A waitpoint joins the two: the task parks on a token, a human’s click completes it, and the task resumes carrying the decision.

The two ends of that token live in different files. The task creates the token and parks; a Server Action, triggered by the admin’s click, completes it.

export const processRefund = schemaTask({
id: 'process-refund',
schema: z.object({ organizationId: z.uuid(), refundId: z.uuid() }),
run: async ({ organizationId, refundId }) => {
const token = await wait.createToken({ timeout: '48h' });
const db = tenantDb(organizationId);
await db.insert(pendingApprovals).values({
refundId,
waitpointTokenId: token.id,
});
await notify(`Refund ${refundId} needs approval`);
const result = await wait.forToken<{ decision: 'approve' | 'reject' }>(
token.id,
);
if (!result.ok || result.output.decision === 'reject') {
await markRefundRejected(db, refundId);
return;
}
await issueRefund(db, refundId);
},
});

The run parks for up to 48 hours, consuming nothing. It writes a pending_approvals row carrying token.id so the admin UI can map an approval back to its token, notifies the approver, then parks. No worker is held, nothing polls. A timeout (!result.ok) and a reject share one branch; only an approve issues the refund.

wait.completeToken(tokenId, payload) is the programmatic completion path: where token.url goes to an external system, completeToken is for your own authenticated code. The click never reaches Trigger.dev directly. It goes through your Server Action, where requireOrgUser() runs first, so the person never touches the token. Your code does, on their behalf, once you’ve decided they’re allowed.

Never complete a token inside a transaction that can roll back

Section titled “Never complete a token inside a transaction that can roll back”

Completing a token is an external side effect. Once it runs, the resume is out of your database’s control: the runtime has already woken the parked run, and a rollback can’t undo that.

So picture completing the token inside a db.transaction, before it commits:

await db.transaction(async (tx) => {
await markRefunded(tx, refundId);
await wait.completeToken(tokenId, { decision: 'approve' });
if (await balanceTooLow(tx)) {
throw new Error('insufficient balance');
}
});

The token is already completed when the transaction rolls back. If balanceTooLow throws, the update is undone but the completion is not, because it left your database entirely. The parked task has already woken and is acting on a refund the database says never happened. You find this bug through a confused support ticket, not a stack trace.

This is the after-commit rule behind your Resend sends and Stripe calls: external side effects go after the commit, never inside it. If the work it depends on might roll back, complete the token only once that work has landed.

A token completes exactly once. A second completeToken for the same token, from a double-click or a retried Server Action, is a no-op, so the completion needs no dedup table on your side. The completion is idempotent for free; the work the resume kicks off is not. If the resumed task triggers downstream jobs or sends an email, those still carry the idempotency keys from the last lesson.

Order the steps of the refund-approval flow, from the task spawning to the run resuming. Drag the items into the correct order, then press Check.

trigger/process-refund.ts
const token = await wait.createToken({ timeout: '48h' });
const result = await wait.forToken<{ decision: 'approve' | 'reject' }>(token.id);
// app/refunds/actions.ts ('use server')
await wait.completeToken(approval.waitpointTokenId, { decision });
The task creates the waitpoint token with a 48-hour timeout.
The task writes the pending_approvals row carrying the token id.
The task notifies the approver and parks on wait.forToken.
An admin clicks “Approve” in the dashboard.
The Server Action looks up the token id and calls wait.completeToken.
The task resumes and applies the decision after committing.

Fan-in is the opposite of fan-out: spawn N units of work, resume only when all N finish. A task fans out (export each report section, resize each uploaded image, process each imported row), and a final step runs after every child completes.

There is no wait.forWaitpoint([t1, t2, t3], { all }), so the tool for parallel children with one wait is batchTriggerAndWait. It creates and manages a waitpoint per child internally, and hands back a typed array of results once they all settle.

const results = await sectionTask.batchTriggerAndWait(
sections.map((s) => ({
payload: { organizationId, sectionId: s.id },
options: {
idempotencyKey: await idempotencyKeys.create([s.id, 'section'], {
scope: 'run',
}),
},
})),
);
const failures = results.runs.filter((r) => !r.ok);
metadata.set('failedSections', failures.length);

batchTriggerAndWait parks the parent on all the children at once: one checkpoint, the worker freed, and the parent resumes when the last child settles.

const results = await sectionTask.batchTriggerAndWait(
sections.map((s) => ({
payload: { organizationId, sectionId: s.id },
options: {
idempotencyKey: await idempotencyKeys.create([s.id, 'section'], {
scope: 'run',
}),
},
})),
);
const failures = results.runs.filter((r) => !r.ok);
metadata.set('failedSections', failures.length);

Each child carries a per-child key: the section id makes it unique, and scope: 'run' namespaces it against the parent run id. If the parent retries, it re-issues the same keys, so children that already finished return their cached result instead of running twice.

const results = await sectionTask.batchTriggerAndWait(
sections.map((s) => ({
payload: { organizationId, sectionId: s.id },
options: {
idempotencyKey: await idempotencyKeys.create([s.id, 'section'], {
scope: 'run',
}),
},
})),
);
const failures = results.runs.filter((r) => !r.ok);
metadata.set('failedSections', failures.length);

The batch resolving means every child settled, not that every child succeeded. Each entry in results.runs is { ok, output } (with error when !ok), so inspect failures per child.

const results = await sectionTask.batchTriggerAndWait(
sections.map((s) => ({
payload: { organizationId, sectionId: s.id },
options: {
idempotencyKey: await idempotencyKeys.create([s.id, 'section'], {
scope: 'run',
}),
},
})),
);
const failures = results.runs.filter((r) => !r.ok);
metadata.set('failedSections', failures.length);

metadata.set(...) writes live progress, the “47 of 200” channel the dashboard and in-app inspector render.

1 / 1

When does a raw token still beat batchTriggerAndWait? It comes down to the lesson’s one axis: who completes the work. When the parallel work is your own tasks, use batchTriggerAndWait, since the runtime owns those waits. When each unit is completed by an external system or human (twelve partner jobs calling back, three approvers signalling), create N raw tokens, hand each out, and wait on all of them:

const tokens = await Promise.all(
partners.map(() => wait.createToken({ timeout: '6h' })),
);
// hand each token.url to its partner…
const results = await Promise.all(tokens.map((t) => wait.forToken(t.id)));

Promise.all over forToken parks on several checkpoints at once. The batch path is the one you’ll write most days; this shape is the escape hatch for completers that live outside your system.

A task fans out 200 image-resize sub-jobs — every one of them your own Trigger.dev task — and must run its final step only after all 200 have settled. Which one is correct in Trigger.dev v4?

await wait.forWaitpoint(tokenIds, { all: true });
await resizeTask.batchTriggerAndWait(
jobs.map((j) => ({
payload: { organizationId, imageId: j.id },
options: {
idempotencyKey: await idempotencyKeys.create([j.id, 'resize'], {
scope: 'run',
}),
},
})),
);
while ((await getDoneCount(db)) < 200) {
await wait.for({ seconds: 10 });
}
const tokens = await Promise.all(
jobs.map(() => wait.createToken({ timeout: '1h' })),
);
await Promise.all(tokens.map((t) => wait.forToken(t.id)));

Where waitpoints fail: timeouts, leaks, and rollbacks

Section titled “Where waitpoints fail: timeouts, leaks, and rollbacks”

Five failure modes, each a symptom, cause, and fix.

The forever-parked run. Symptom: runs pile up in “Waiting” and never leave, leaking concurrency seats and run-minutes. Cause: no timeout (it defaulted to ten minutes), or one so long the run outlives any legitimate completion. Fix: size every token’s timeout to the slowest acceptable completion, and make the !result.ok branch real code, not a TODO.

The wrong handle to a third party. Symptom: the partner can’t complete the token, and your run dies on its timeout with nothing useful in the logs. Cause: you handed over token.id (an identifier, not a URL) or token.publicAccessToken (a browser Bearer token) where the partner needed a server-to-server callback URL. Fix: match the handle to the completer.

CompleterHand it…
A server (a backend partner)token.url, no CORS, server-to-server
A browser / clienttoken.publicAccessToken, a Bearer token on the CORS-enabled completion endpoint

Completion inside a transaction that can roll back. Symptom: the task resumes acting on state the database rolled back. Cause: wait.completeToken called inside a db.transaction that later throws. Fix: commit first, complete last — the side effect goes after the commit, never inside it.

A token where the clock or a child task was the answer. Symptom: you hand-build a completer the runtime would have run for free. Cause: wait.forToken for a known delay (that’s wait.for) or for your own child’s result (that’s triggerAndWait / batchTriggerAndWait). Fix: let “who completes it” pick the row above.

Assuming a token is many-shot, or a resolved batch means success. Symptom: you complete one token repeatedly, or trust a resolved batch to mean every child succeeded. Cause: over-reading the guarantees. Fix: a token is one-shot — the second completion is a silent no-op; a resolved batch means every child settled, not succeeded, so check per-child ok every time.

Each claim is about a waitpoint guarantee you just read. One wrong assumption here is a silent production bug. Mark each statement True or False.

Completing the same token twice resumes the parked run twice — once per wait.completeToken call.

False. A token is one-shot: it resumes the run exactly once. A second wait.completeToken — from a double-click, a retried Server Action, an impatient admin — is a silent no-op, so the callback needs no dedup table on your side.

A token created with no explicit timeout waits indefinitely until something completes it.

False. With no explicit timeout it defaults to '10m', so the run dies on the default while a real approval or partner job is still in flight. Set the timeout on every token, sized to the slowest completion you’ll accept.

After batchTriggerAndWait resolves you must still inspect each entry’s ok, because resolving means every child settled, not that every child succeeded.

True. Resolving means every child settled; some may have failed. Each results.runs entry is { ok, output } (with error when !ok), so you filter the failures explicitly.

token.url is the right handle to hand a browser client so it can complete the token directly from JavaScript.

False. token.url is the server-to-server webhook and carries no CORS headers, so a browser can’t call it. A browser gets token.publicAccessToken as a Bearer token on the CORS-enabled completion endpoint instead.

A waitpoint is a durable token the outside world completes — by callback, SDK call, or timeout — exactly once, never inside a transaction that can roll back. The next lesson maps the app’s real workloads onto it.

The waitpoint API moves fast, so trust the official docs over any tutorial, including this one, for the current shapes.