Vercel Cron as the schedule default
Vercel Cron, the platform-native scheduler that fires a secured HTTP GET at your route handler so recurring work runs on a clock without a queue, a worker, or a second platform.
Every SaaS quietly accumulates a drawer full of jobs nobody clicks. Emailing the nightly digest. Rolling up last week’s usage into a summary row. Sweeping the trials that expired overnight. Reconciling your local billing state against what Stripe believes is true. Nobody triggers these: there’s no button, no request, no user sitting there waiting. They run on a clock.
In Inline, then after() you climbed the bottom of a ladder. A plain await blocks the response, and after() runs on the same invocation once the response has shipped. Both tiers share one trait: they need a request to hang off, because something has to call your code. But a digest that goes out at 9am has no caller. So the question for this lesson is the one that drawer of jobs forces: where does work that runs on a schedule live, and when does it stop fitting the simplest answer?
The answer, in one sentence: Vercel Cron is a scheduled HTTP GET to a route handler you already know how to write. There’s no queue, no worker, and no second platform, because the platform you deploy to already ships a scheduler. By the end of this lesson you’ll have a secured, idempotent trial-expiry sweep that you can test on your laptop with curl. Just as important, you’ll be able to name the exact moment a scheduled job outgrows Vercel Cron and has to climb to the next rung.
One boundary first, so you know what this lesson is not about. Every schedule here is static and anchored to UTC: the same expression for every tenant, fixed at deploy time. Schedules that differ per customer (“9am in their timezone”), and jobs too big or too fragile to finish in one function invocation, come in later lessons in this chapter. This is the static-schedule, fits-in-one-invocation tier, where most of your scheduled work actually lives.
How a cron job actually runs
Section titled “How a cron job actually runs”The word “cron” sounds like infrastructure, so let’s strip the magic out of it first. A Vercel cron is an external scheduler that does exactly one thing: at the cadence you declare, it makes a plain HTTP GET to a path in your project’s production deployment. The handler at that path runs as an ordinary serverless function invocation, with the same runtime, time limit, and logs as any other route. It does its work and returns. That is the entire mechanism.
The diagram below shows that round-trip in one glance. Reading the three boxes left to right: the scheduler fires on its cadence, sends a GET to your cron path, and your route handler runs as a single invocation and returns.
GET to a public
path you own. The handler runs as a normal invocation — and the secret rides
along on every request.
That picture is small, but three consequences fall straight out of it, and each one shapes the rest of the lesson. Because a cron is just an HTTP GET to a path:
- The path is a public URL. Anyone who can guess
/api/cron/sweep-trialscan send a GET to it. That’s a trust boundary, and it’s the next section. - The handler is bounded by the function time limit. It’s a serverless invocation like any other, so the same wall-clock cap applies: the 5-minute Hobby / 13-minute Pro wall from the last lesson. A job that runs long gets killed mid-execution.
- The request can arrive more than once. Network delivery isn’t perfect, so a scheduled GET can be delivered twice, or not at all. That forces idempotency, two sections down.
Two smaller facts ride along on the same mechanism, worth knowing so nothing surprises you in the logs. First, every cron request carries the user agent vercel-cron/1.0 and an x-vercel-cron-schedule header holding the exact expression that fired it, which helps when several schedules point at one path and you need to tell which one is running. Second, crons fire against your production deployment only, never a preview deploy and never your local next dev. That second fact is why the “test it locally” section at the end exists: you can’t wait for the scheduler to hit your laptop, so you call the route yourself.
One piece of vocabulary to pin down, since the whole lesson rests on it. A serverless function invocation is one run of your handler, created for a single request and killed when it returns. A cron handler is one of these, which is why it inherits every limit a normal route has.
The config and the handler file
Section titled “The config and the handler file”A cron is two artifacts that point at each other: a config entry that declares when, and a route handler that declares what. Seeing them side by side is the fastest way to understand the wiring, so here they are as two tabs of one unit.
{ "$schema": "https://openapi.vercel.sh/vercel.json", "crons": [{ "path": "/api/cron/sweep-trials", "schedule": "0 * * * *" }]}The schedule lives at the project root, in vercel.json. Each entry in crons maps one cron expression to one path, and Vercel hits that path with a GET at the declared cadence. 0 * * * * means every hour, on the hour. A sub-daily schedule like this is Pro-tier only, the first place that flag bites; the plan rules come later in the lesson. The $schema line is optional, but it gives you editor autocomplete and validation on the file.
export const GET = () => { // verify CRON_SECRET, then do the work return Response.json({ ok: true });};The handler lives at app/api/cron/sweep-trials/route.ts, since the path in vercel.json is the route folder. It’s an ordinary route handler: a named GET export, nothing special. This is just the shell; the verification and the actual work land in the next two sections. Notice it’s GET, not the default-export shape you use for pages. Route handlers always export their HTTP method by name.
The location convention is worth one line of discipline, because it pays you back in the logs. Use one folder per cron job and one route.ts per folder: app/api/cron/<name>/route.ts. Each job gets its own path, so it shows up grouped in your deploy config, and you can filter logs by requestPath:/api/cron/<name> to see exactly one job’s runs. Here’s how a project with two crons lays out.
Directoryapp/
Directoryapi/
Directorycron/
Directorysweep-trials/
- route.ts the trial-expiry sweep we build in this lesson
Directorydaily-digest/
- route.ts a sibling job — one folder each
A cron handler is a public door
Section titled “A cron handler is a public door”Look back at that topology diagram for a moment. The scheduler sends a GET to /api/cron/sweep-trials, but nothing in the wire stops you from sending that same GET. The path is public and unauthenticated, so anyone who finds it can trigger your trial sweep on demand, as often as they like. Before the handler does a single useful thing, it has to answer one question: did this request actually come from Vercel’s scheduler, or from a stranger who guessed the URL?
If that question feels familiar, it should: this is the third trust boundary the course has built. In Verify before parse you drew the first, the Stripe webhook, where an unauthenticated stranger hands you a body claiming a customer paid you. The Resend webhook in Resend bounces and complaints was the second. Both of those proved identity with an HMAC signature: the sender computes a keyed hash of the exact bytes, you recompute it, and they either match or the request is forged. A cron is different, because Vercel’s scheduler doesn’t sign a payload, and there’s no body to sign. Instead it uses a shared secret. Vercel automatically attaches Authorization: Bearer ${CRON_SECRET} to every cron request, and your handler’s job is to check that the bearer token matches the secret only you and Vercel know.
A bearer token is exactly what it sounds like: whoever bears the token is trusted. So the guard is a string comparison, but a careful one. Here’s the canonical shape, walked line by line.
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};The guard is the first thing in the handler. isFromVercelCron reads the Authorization header and checks it; if the check fails, the function returns immediately, before any database query, any logging of the request, or any work at all. This is the same verify-first posture as the Stripe webhook: the trust boundary is the very first decision the handler makes.
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};On a mismatch, or a missing header, return 401 and stop. Note the status: this is a 401, not the 400 the Stripe webhook returned, and that difference is deliberate. The prose below this code explains why. It’s the one place students reasonably expect 400 and are wrong.
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};Build the two strings to compare. header is whatever arrived, defaulting to an empty string if absent so that a missing header just fails the length check. expected is the literal Bearer prefix plus your secret. The comparison reads the secret from validated env, never from a bare process.env lookup.
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};Compare in constant time. timingSafeEqual always runs the full length of the buffers instead of bailing on the first mismatched byte, the same discipline from the Web Crypto lesson and the Stripe webhook. The length guard comes first because timingSafeEqual throws on length-mismatched inputs. This is a deliberate hardening over Vercel’s docs, which show a plain !==; the prose below makes the case.
Two decisions in that guard deserve to be said out loud, because each is a place a reviewer pushes back or a downstream agent “simplifies” the wrong way.
Return 401 here, not 400, and here’s why it differs from the webhook. Your instinct, fresh off the Stripe lesson, might be to return 400. Resist it, because the two boundaries are genuinely different. A webhook signature failure is a malformed proof: the request claims to be from Stripe and the cryptographic proof doesn’t check out. That’s a 400, and the 4xx tells a misconfigured but legitimate sender to stop retrying. A cron auth failure is missing or invalid identity on a private endpoint. There’s no legitimate third party out there retrying your cron URL; the only callers are Vercel with the right secret or an attacker, and neither needs to be politely told to back off. That’s a 401, the “I don’t know who you are” status, which is exactly what Vercel’s own documentation uses. File them as two different boundaries, not a contradiction: malformed proof returns 400, missing identity on a private door returns 401.
Compare in constant time, the course hardening. Vercel’s documentation shows a plain authHeader !== `Bearer ${secret}` string comparison, and to be honest, that’s defensible. A timing attack on a bearer token, which means measuring how long the comparison takes to guess the secret byte by byte, is low-severity and hard to pull off across a network. But the reflex you established at the Stripe boundary is to compare secrets in constant time, every time. It costs one helper and removes an entire class of attack you then never have to think about again. So we diverge from the docs deliberately: timingSafeEqual, length-checked first, on the Node runtime. If you ever see this guard “simplified” back to !== in a review, that’s a regression, not a cleanup.
The secret itself goes where every secret in this course goes: into your validated env, so a missing value fails the build instead of surfacing as a mysterious 401 in production.
server: { // ...existing server vars CRON_SECRET: z.string().min(16),},CRON_SECRET lives in your Vercel project’s environment variables and in your local .env, validated by the same @t3-oss/env-nextjs setup you’ve used since the Stripe keys. Vercel recommends a random string of at least 16 characters. It’s server-only, never bundled to the client and never written to a log. Guard it exactly like a session secret, because functionally it is one: it’s the single thing standing between the public internet and your scheduled jobs.
Delivery is best-effort: design for missed and duplicate runs
Section titled “Delivery is best-effort: design for missed and duplicate runs”You’ve wired the schedule and secured the door. Now comes the fact that shapes everything the handler does inside that door. It’s the one belief most people hold about cron that’s subtly wrong, and getting it right is the difference between a handler that quietly corrupts data and one that heals itself.
Here it is, stated precisely: Vercel cron delivery is best-effort. It’s not “exactly once,” and not even the clean “at-least-once” you might remember from the webhook lesson. Best-effort means the scheduler tries to deliver each run, and that attempt can fail in both directions:
- A run can be missed. A transient network error can stop the request from ever reaching your function. The run simply doesn’t happen, and no log is produced for it, so you won’t even see a failure. Your handler therefore cannot assume “I definitely ran an hour ago.”
- A run can be duplicated. Delivery can occasionally fire the same scheduled run more than once, seconds apart. Your handler cannot assume “this is the only time I’m running for this tick.”
A handler that ignores either of these breaks. If it assumes it always runs, a missed tick silently drops an hour of work on the floor. If it assumes it runs exactly once, a duplicate tick does the work twice: double emails, double charges, double rows.
One pattern satisfies both failure modes at once, and it’s the senior move for any scheduled job: make the handler idempotent and reconciliation-based. Don’t compute a delta from “the last time I ran,” because you don’t actually know when that was, or whether it happened. Instead, every run queries and processes all the outstanding work since the last successful completion, in a way that’s safe to run twice. Vercel’s own framing draws the line well: “set this account’s status to active” is safe to repeat, because running it twice leaves the status active. “Increment this account’s credit by 10” is not, because running it twice hands out 20.
Reconciliation is the word for that approach, and it handles both failures for free. A missed run is no problem: the next run reconciles everything still outstanding, including whatever the missed run would have done, so it catches up automatically. A duplicate run is no problem either: the second copy reconciles and finds nothing left to do, because the first copy already did it. You don’t write special cases for “missed” or “duplicate”; a reconciliation query makes them both non-events.
This connects directly to the deduplication discipline from Claim once, mutate once, and the connection is worth making sharp, because it tells you exactly when you need extra machinery and when you don’t. A cron job can have two kinds of effect:
- A SQL UPDATE on a predicate, like the trial sweep you’re about to build. Here the predicate is the idempotency. Re-running re-evaluates the same
WHEREclause, which now matches nothing because the first run already changed those rows. No dedup key is needed: the database is the source of truth and it converges. - An external side effect, like sending an email or charging a card. Repetition here is visible to a human: a second email lands in the inbox, a second charge hits the statement. The database can’t un-send an email. So these need the exact dedup-and-transact shape from the webhook chapter: a claim row under a unique key (a
cron:<name>:<yyyy-mm-dd>granularity is typical), written inside the same transaction as the work, so the second run finds the claim already taken and does nothing.
The one-line discriminator to carry forward: idempotent-by-predicate jobs need no key; user-visible-side-effect jobs need the claim. That single judgment is the most useful thing in this lesson, so let’s drill it before we build anything.
For each job below, decide whether it’s safe to run twice exactly as written, or whether it needs a dedup key to stop a duplicate run from doing visible damage. Watch the difference between changing a database row and doing something a human can see.
A best-effort scheduler can fire the same run twice. Sort each job by whether it survives a duplicate run as-is, or needs a dedup key to stay safe. Drag each item into the bucket it belongs to, then press Check.
UPDATE trials SET status='past_due' WHERE status='trialing' AND current_period_end < now()overdueINSERT one usage-rollup row for last weekThe “safe” column always has the same shape: the operation either filters on a predicate that the first run invalidates, or it recomputes a value from current state so a second run lands on the identical answer. The “needs a key” column always has the same shape too: the effect escapes the database, into an inbox, onto a statement, or as a new row, where it can’t be silently re-converged. When you write a cron handler, the first question is which column you’re in. The trial sweep you’ll build next is squarely in the safe column, and that’s no accident: it’s why it makes such a clean first cron.
Worked example: the trial-expiry sweep
Section titled “Worked example: the trial-expiry sweep”Time to build the whole thing, end to end. The job runs once an hour, finds every trial whose window has closed, moves it out of the trialing state, and records each change in the audit log. This is the chapter’s anchor for off-invocation work, and it’s the kind of job Vercel Cron was made for: it fits one invocation easily, it tolerates a missed or duplicated run, and it needs no retry.
Recall the plan_entitlements row you designed in Plan entitlements as a derived view. Each organization has exactly one entitlement row, and its status mirrors the Stripe subscription lifecycle: trialing, active, past_due, canceled, incomplete. A trial is simply a row with status = 'trialing', and the trial window ends at currentPeriodEnd. When that timestamp passes and the trial hasn’t converted to a paid active subscription, the row should leave trialing, because it’s no longer a live trial. We move it to past_due, the existing “the billing relationship needs attention” state your hasActiveAccess helper already warns on.
The heart of the handler is one UPDATE, and it is naturally reconciliation-based, which is exactly why it needs no dedup key.
const expired = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });Read what makes this safe. The WHERE matches only rows that are still trialing and whose period has already ended. The first run flips every matching row to past_due. A second run, whether it’s a duplicate delivery or just the next hourly tick, re-evaluates the same predicate, finds zero rows still trialing past their end (the first run already moved them), and updates nothing. The predicate is the idempotency: no claim table, no unique key, no extra machinery. Here’s the full handler, with each decision spotlit.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};The same verify-first guard from the previous section, factored into isFromVercelCron in @/lib/cron. Nothing below this line runs for a request that isn’t Vercel’s scheduler. The trust boundary is still the very first decision.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Compute the comparison instant once. The handler reads “now” through the course’s Temporal seam and converts to a Date only at the Drizzle boundary, the same domain-Temporal, Date-at-the-seam convention from the time chapter. The cron shape is the lesson here; treat the timestamp as established plumbing.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Open one transaction. The UPDATE and every audit write commit together or not at all, the same atomicity rule as every mutation in the course. Crucially, there’s no external call in this block: audit rows are database writes, so they belong inside tx.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};The predicate is the idempotency. status = 'trialing' AND current_period_end < now matches only live, already-expired trials. The first run flips them to past_due; a second run re-evaluates this exact WHERE, matches nothing, and is a clean no-op. This is the predicate-idempotent case made concrete: safe to run twice with no claim row.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};.returning() hands back the org IDs of exactly the rows that changed, giving you both the work and the receipt in one statement, the same shape as the lifecycle-aware UPDATE from the soft-delete chapter. The length of this array is the observable result the handler reports.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Write one audit row per expired org, inside the same transaction, but not through logAudit. That helper derives the actor and org from requireOrgUser() and headers(), and a cron has no session: no user, no request headers. So we insert into auditLogs directly, with actorUserId: null. This is the system actor from The append-only audit log, where a null actor is information, not a missing value: it records that a machine, not a person, expired this trial. The payload carries the provenance, naming which job acted, and the whole insert still rides inside tx, so the audit trail stays atomic with the change it records.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Return 200 with the count. { expired: 3 } is something you can read straight off the Vercel logs to confirm the sweep did what you expected on each tick. A best-effort scheduler doesn’t promise to call you, so the count in the log is your proof it ran.
Three points are worth pulling out of that handler, because each marks a place where the job could grow up and leave Vercel Cron behind.
No external calls inside the transaction. The audit writes are database rows, so they live happily inside tx. But suppose product wants to email every org whose trial just expired. That email send is an external call, so it must move outside the transaction, by the same rule that kept the invitation email out of the transaction in the last chapter. And here’s the tell: at one email per expired row, with potentially thousands of rows, that loop of sends is exactly the kind of work that blows past the function time limit. The moment this sweep needs to email, it stops being a pure UPDATE and becomes a fan-out, which is the threshold that bumps it to a real job runner. We’ll name that fork precisely in the next section; for now, notice that the shape of the job is what decides its home.
.returning() gives you both the work and the receipt. This is the same move as the lifecycle-aware UPDATE you wrote in Version columns and the honest 409. The UPDATE does the work, and .returning() tells you precisely which rows it touched. You don’t query before to find candidates and after to confirm; one statement mutates and reports, and the count it reports is what you log and what you’d alert on if it ever looked wrong.
The predicate carries the safety, so there’s no claim row. It’s worth restating, because it’s the whole reason this job is a clean cron: since the WHERE filters on status = 'trialing', a second run matches nothing and changes nothing. This is the predicate-idempotent case from the previous section, and it’s why the trial sweep needs none of the processed_events machinery the webhook handler did. A job that sent something would; a job that converges a database predicate doesn’t.
Cron expressions you will actually write
Section titled “Cron expressions you will actually write”You’ve written 0 * * * * twice now without fully unpacking it, so let’s do that. A cron expression is five fields, minute, hour, day-of-month, month, and day-of-week, and on Vercel it’s scoped tightly: no fancy extensions, evaluated in UTC, with a couple of genuine gotchas that cause real deploy failures. Here are the five you’ll actually reach for, with their plain-English meaning and the one flag that matters most.
0 9 * * * daily at 09:00 UTC — Hobby-OK (daily is the only Hobby cadence)0 0 * * 0 weekly, Sunday 00:00 UTC — Pro-only (0 = Sunday, numeric)0 0 1 * * monthly, the 1st at 00:00 UTC — Pro-only0 * * * * hourly, on the hour — Pro-only*/15 * * * * every 15 minutes — Pro-onlyThree rules about these expressions are load-bearing, and each one is a surprise that bites someone in production, so learn them by name.
UTC, always. Vercel evaluates every cron expression in UTC. There is no timezone field in the expression and no setting to change it. So 0 9 * * * is 9am UTC, which is 4am in New York, and it drifts by an hour relative to any local wall-clock twice a year when daylight-saving shifts. For UTC-anchored work, like the trial sweep or a reconciliation pass, this is exactly right, because “9am UTC every day” is a perfectly stable instant. But for a business-hours schedule, like “email the customer at 9am their local time,” a plain UTC cron is the wrong tool. That’s not a workaround you reach for; it’s a named threshold. Timezone-aware, per-tenant schedules are what Trigger.dev’s scheduled tasks solve, and that’s a later lesson in this chapter.
Numbers only, no aliases and no extensions. Vercel does not accept MON/SUN or JAN/DEC: Sunday is 0, December is 12, and that’s the only spelling. It also has no L/W/# step-extensions and no seconds field. If you’ve used a cron dialect that allowed 0 0 * * MON, that habit will fail to deploy here. Five numeric fields, nothing fancier.
Day-of-month and day-of-week are mutually exclusive. This is the subtle one. When you put a value in one of the two day fields, the other must be *. You cannot express “the 1st of the month and every Monday” in a single Vercel cron expression; it’s a deploy-time error, not a silent misfire. If you need both, that’s two separate cron entries.
Now the plan tier, stated once and plainly, because it’s the difference between a deploy that succeeds and one that fails outright. On the Hobby (free) tier, only once-per-day expressions deploy at all, anything sub-daily fails the build, and the job may fire anywhere within the hour you specified. On Pro and above, any frequency is allowed and the job fires within the specified minute. Both tiers allow up to 100 cron jobs per project. The course’s running app is a Pro-tier deployment, so every sub-daily example here is valid for it. If you’re on the free tier, know your cadence before you write the expression, or the deploy will reject it.
Where Vercel Cron stops, and what it costs
Section titled “Where Vercel Cron stops, and what it costs”Here’s the payoff of this whole lesson. A cron handler is a function invocation, which means it inherits every limit a function has, and each of those limits is a precise fork in the road that sends a job up to a heavier tier. Knowing them is what lets you defend “no, Vercel Cron is enough” and recognize the exact moment that stops being true. There are four gaps, each tied to the escalation it forces:
- No automatic retries. If your handler returns a 5xx, Vercel logs it and moves on; it will not re-invoke a failed cron. The run is simply gone until the next tick. Work that must survive a transient downstream outage on its own schedule, rather than wait an hour for the next run, needs a runtime that retries with backoff. That’s a job runner, the next escalation.
- The function-time wall. The handler is bounded by the same 5-minute Hobby / 13-minute Pro wall from the last lesson. A 50,000-user digest that emails recipients one at a time will hit that wall and die mid-send. When the work doesn’t fit one invocation, the cron’s role inverts: it stops doing the work and becomes the thing that enqueues the work, fanning it out to a job runner that has no such wall. The cron stays, but it shrinks to a trigger.
- Overlapping runs. If a job runs longer than its own interval, Vercel can start a second instance while the first is still running, leaving two copies racing. The mitigations are the obvious ones: make it faster, run it less often, or add a lock. But needing a distributed lock to keep your cron from racing itself is itself a signal that the work wants a real queue with concurrency control, not a scheduled GET.
- No fan-out, no pauses, no run timeline. There’s one invocation per tick. You can’t pause mid-job and resume, can’t wait on a callback, and can’t fan a single tick out into a thousand controlled child runs. And your observability is
console.logplus the Vercel logs view, which you stitch together by hand, rather than the run-by-run timeline a job runner gives you for free. That observability gap is part of the threshold calculus, not a footnote to it.
There’s a cost shape worth internalizing too, and it’s counterintuitive. Crons are metered as invocations plus compute, and frequency drives the bill far more than the work does. An every-minute schedule is 43,200 invocations a month; a daily one is 30. A once-per-minute “check if there’s anything to do” job that almost always finds nothing still costs more than a daily job that does real work. The takeaway: run a job no more often than its freshness requirement actually demands. If a digest only needs to be current to the hour, an hourly cron is right and a per-minute one is waste.
All of this rolls up into one decision, and the order you ask the questions in is the entire lesson. Walk it.
The platform default, and you’re done. A vercel.json entry, a secured GET handler, and idempotent reconciliation work. No second platform, no infra.
The work doesn’t fit one invocation. The next lesson names the five conditions that justify a real job runner, and this is the first of them.
The job must survive a transient failure on its schedule rather than wait an hour. Automatic exponential-backoff retries are a job-runner feature.
The cron stays, but it shrinks to a trigger that enqueues the real work. The fan-out, with concurrency control, runs on the job runner.
Per-tenant or business-hours-local schedules need a timezone-aware scheduler. A later lesson in this chapter builds them.
The lesson lives in the order of those questions, not in any single leaf. A senior doesn’t start at “I’ll use a job runner”; they start at “does this fit one invocation?” and only climb when a named property forces it. Every leaf that points away from Vercel Cron names the exact missing thing: it doesn’t fit the wall, it needs retries, it fans out, or it needs per-tenant timing. That precision is what lets you say “Vercel Cron is enough, and here’s why” with a straight face, and recognize, just as confidently, the one job in five that genuinely needs more. The full decision tree for the job runner itself is the next lesson’s; this walker is scoped to the schedule question alone.
Running and watching a cron locally
Section titled “Running and watching a cron locally”You’ve built it. How do you run it before it’s deployed? Here’s the constraint that shapes the whole loop: Vercel does not fire crons against next dev. There’s no vercel dev support either, because the scheduler only ever hits your production deployment. So you can’t sit and wait for a tick on your laptop. But you don’t need to, because the handler is just a route, so you call it yourself, exactly the way Vercel would.
curl -H "Authorization: Bearer $CRON_SECRET" \ http://localhost:3000/api/cron/sweep-trialsThat’s the whole development loop, and it’s a tight one: write the cron as a normal GET route, exercise it locally with curl carrying the bearer header, deploy, then watch the real scheduled invocations in Vercel’s Logs view filtered by requestPath:/api/cron/sweep-trials (or just hit the cron job’s “View Logs” button in the dashboard). A nice property falls out of the guard you wrote: a curl without the header should come back 401. That’s a five-second way to confirm your trust boundary actually works, because if a bare request gets a 200, your guard is broken.
Two watch-outs live naturally right here, because both are silent failures: the cron “ran,” nothing happened, and there’s no error to tell you.
Check your understanding
Section titled “Check your understanding”Here are two drills before the chapter climbs to a real job runner. The first targets the decisions that cost the most when you get them wrong.
You’re reviewing a teammate’s first Vercel cron. Select every statement that is correct about how it behaves and how it should be built.
CRON_SECRET should return 401, not the 400 the Stripe webhook returns — it’s missing identity on a private endpoint, not a malformed signature proof.*/15 * * * * schedule fails to deploy, because sub-daily cadences are Pro-only.WHERE status = 'trialing' predicate makes a second run match nothing and change nothing.0 9 * * * can be pinned to the customer’s local 9am.The five correct statements are the load-bearing decisions of the lesson. A cron auth failure is 401 (missing identity on a private door), deliberately different from the webhook’s 400 (malformed proof). Delivery is best-effort — it can miss and duplicate — so handlers reconcile outstanding work, which self-heals both. Vercel does not retry a 5xx cron. Hobby is daily-only; sub-daily expressions fail the deploy. And the trial sweep’s predicate is its idempotency, so no claim row is needed.
The two false statements are the traps. Vercel does not guarantee exactly-once — incrementing a counter per run without a key double-counts on a duplicate delivery. And cron expressions have no timezone field: they’re always UTC, which is exactly why per-tenant local-time schedules need a different tool.
The second drill makes you produce the three tokens this lesson hangs on, the cadence, the secret check, and the idempotency predicate, rather than just recognize them. Fill each blank.
Fill the cadence, the secret check, and the idempotency predicate — the three tokens this whole handler hangs on. (The guard uses `!==` here for brevity; the real handler compares in constant time.) Pick the right option from each dropdown, then press Check.
// vercel.json → "schedule": "___"
export const GET = async (request: Request) => { const expected = `Bearer ${___}`; if (request.headers.get('authorization') !== expected) { return new Response('Unauthorized', { status: 401 }); }
return tx .update(planEntitlements) .set({ status: 'past_due' }) .where(eq(planEntitlements.status, '___'));};That’s the tier-1 floor, and it’s a higher floor than most people expect from “just a cron.” You can declare a schedule in vercel.json, write a GET handler that proves the request came from Vercel before it does anything, shape the work so a best-effort scheduler firing it twice (or skipping it once) is a non-event, and name the precise property that would force the job up to a real runner. Most of your scheduled work will live and die right here, on the platform default, and now you can defend that choice.
Next comes the other side of the threshold. The next lesson, When Trigger.dev earns its weight, names the five conditions that justify reaching past the platform for a real job runner, the durability, the retries, the fan-out, and the pauses that a scheduled GET can’t give you, so that when you do climb that rung, you’re doing it for a named reason, not a reflex.
External resources
Section titled “External resources”The canonical reference — the vercel.json crons shape, the plan-tier cadence limits, and the production-only delivery behavior this lesson is built on.
Vercel's guide to the Authorization: Bearer CRON_SECRET pattern the trust boundary in this lesson hardens with a constant-time compare.
Translate any five-field cron expression into plain English as you type — the standard tool for sanity-checking a schedule before you ship it.