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 waitpoint lifecycle
Section titled “The waitpoint lifecycle”The three real-world shapes are one primitive seen from different angles, and that primitive has a four-beat lifecycle:
- Create a token.
wait.createToken(...)returns a handle. - 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.
- Park the run.
await wait.forToken(...)is a checkpoint, so the worker is released and the run consumes nothing while it waits. - 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 itconst 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 itconst 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 itconst 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 itconst 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.
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.
Which wait to use
Section titled “Which wait to use”The three families of wait differ on one axis: who completes the wait.
| Wait | Who completes it | Reach for it when… |
|---|---|---|
wait.for / wait.until | the clock | you know the delay or the deadline up front |
triggerAndWait / batchTriggerAndWait | a child task | the runtime owns the wait and hands you the child’s typed result |
wait.forToken | an external system or a human | you 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:
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.
Parking a run on a partner’s callback
Section titled “Parking a run on a partner’s callback”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.
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.
Pausing for human approval
Section titled “Pausing for human approval”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.
'use server';
export async function approveRefund( approvalId: string, decision: 'approve' | 'reject',) { const { orgId } = await requireOrgUser(); const db = tenantDb(orgId);
const approval = await getPendingApproval(db, approvalId); await wait.completeToken(approval.waitpointTokenId, { decision });
return { ok: true as const };}Completing the token is the resume. requireOrgUser() enforces that only an authorized member of this org can decide. The action looks up the pending_approvals row, then completeToken resumes the parked task on a possibly different worker, at the line after wait.forToken, carrying the decision. It returns the same Result shape as every Server Action you’ve written.
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.
await db.transaction(async (tx) => { await markRefunded(tx, refundId); if (await balanceTooLow(tx)) { throw new Error('insufficient balance'); }});await wait.completeToken(tokenId, { decision: 'approve' });Commit first, complete last. On a throw the transaction rolls back and completion never runs. Only after the commit succeeds do you complete the token, so the run resumes on state guaranteed to exist.
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.
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 });pending_approvals row carrying the token id. wait.forToken. wait.completeToken. Waiting for many sub-jobs to finish
Section titled “Waiting for many sub-jobs to finish”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.
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)));batchTriggerAndWait over the 200 payloads, each with its own idempotency key, is the shape for parallel children with one wait: the runtime owns a waitpoint per child, parks the parent on all of them at once, and returns a typed result array once they settle. wait.forWaitpoint with a multi-token arg does not exist in v4. The wait.for loop is the polling waste waitpoints exist to delete — a held-open worker waking every ten seconds to re-ask a question. The 200-token Promise.all runs, but raw tokens are for external completers; pointing them at your own tasks hand-manages 200 tokens the runtime would have managed for free.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.
| Completer | Hand it… |
|---|---|
| A server (a backend partner) | token.url, no CORS, server-to-server |
| A browser / client | token.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.
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.
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.
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.
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.Reveal card-by-card review
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.
Going deeper
Section titled “Going deeper”The waitpoint API moves fast, so trust the official docs over any tutorial, including this one, for the current shapes.
The canonical createToken / forToken / completeToken API reference — the source of truth for this version-volatile surface.
The third-party callback pattern end to end — handing token.url to a partner and resuming on their POST back.
The vendor-neutral primitive under a parked run — why suspend-and-resume survives crashes without holding a worker.