vercel-schedules
Vercel Schedules guidance — run a private function, route, or Vercel Queues topic on a cron cadence or once at a set time. Covers static schedules in vercel.json, dynamic per-user schedules with @vercel/schedules, handleSchedule, the vercel schedules CLI, environments, jitter, and migrating from Cron Jobs. Use when adding scheduled or recurring work, one-time reminders, or replacing `crons`.
How do I install this agent skill?
npx skills add https://github.com/vercel/vercel-plugin --skill vercel-schedulesIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides guidance on using Vercel Schedules for recurring and one-time tasks. It covers static configuration in `vercel.json`, dynamic scheduling with the `@vercel/schedules` SDK, and security best practices like using private function targets and environment-based secrets. No security issues were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Vercel Schedules
You are an expert in Vercel Schedules, which runs code on a recurring cron cadence or once at a specific time.
Choose static or dynamic
| Static | Dynamic | |
|---|---|---|
| Defined in | vercel.json schedules (or Build Output API config.json) | @vercel/schedules SDK or vercel schedules CLI |
| Takes effect | When the deployment becomes current for its environment/branch | Immediately |
| Targets | function, path, or topic | topic only |
| One-time runs | No | Yes |
| Payload / namespace | No / always default | Optional JSON (≤ 16 KB; 8 KB with --local) / any namespace |
| Update or delete | Edit config and redeploy | SDK or CLI |
Use static for jobs that belong to the app (nightly cleanup, hourly sync). Use dynamic for jobs the app creates for users (a reminder per customer, a report at 09:00 in each tenant's timezone).
Choose a target
| Target | Use when | Retries |
|---|---|---|
function (default) | Any new static schedule. The handler has no public URL, and only Vercel's scheduler can invoke it, so there's no secret to manage. | No |
topic | Failed runs must be retried, you need a payload, or the schedule is dynamic. | Yes, via Queues |
path | Only when migrating an existing Cron Job and you don't want to change any code. | No |
Prefer function over path. A path target exposes your handler to the public internet, and its only protection is a static CRON_SECRET that you have to store, check on every request, and rotate. A function target has neither problem. Don't pick path for new schedules, and don't pick it because a route "already exists". Wrapping the route in handleSchedule and adding the trigger is a small change.
If a failed run must be retried, target a queue topic.
Quickstart: private function on a static schedule
npm i @vercel/schedules
// api/cleanup.ts
import { handleSchedule } from '@vercel/schedules';
export const POST = handleSchedule(async (metadata) => {
console.log(`${metadata.name} run ${metadata.executionId} for ${metadata.scheduledAt.toISOString()}`);
await deleteExpiredRecords();
});
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/cleanup.ts": {
"experimentalTriggers": [{ "type": "schedule/v1beta" }]
}
},
"schedules": {
"cleanup": {
"schedule": "0 * * * *",
"function": "api/cleanup"
}
}
}
Test locally, then deploy:
vercel dev # starts a local scheduler
vercel schedules invoke cleanup --local # second terminal: run it now
vercel deploy --prod
vercel schedules invoke cleanup # run once in production
Rules that break deployments
functionis the build output path relative to.vercel/output/functions, without.func— not the source filename.api/jobs/report.ts→api/jobs/report. When unsure, runvercel buildand look in.vercel/output/functions.- The target function must declare
{ "type": "schedule/v1beta" }, and it must be its only trigger. It can't also have aqueue/v2betatrigger. - Don't add routes or rewrites to a
schedule/v1betafunction. It is private by design. - For a function in a service, set
serviceplusfunctionrelative to.vercel/output/services/<name>/functions.vercel devcan't run these yet (DEV_SCHEDULE_SERVICE_FUNCTION_UNSUPPORTED). - Set exactly one of
function,path, ortopic. - A framework's Build Output API schedules and
vercel.jsonschedules with the same name fail withSCHEDULE_NAME_CONFLICT.
Static schedule properties
| Property | Notes |
|---|---|
schedule | Required. Five-field cron, evaluated in timezone. |
timezone | IANA name such as America/New_York. Defaults to UTC. Follows DST. |
jitter | Minutes, 1–15. Defaults to 1 on Pro/Enterprise. Hobby is always 15. |
function / service | Private function target (see rules above). |
path / requestMethod | Public route target, for code-free Cron Jobs migration only. Path must start with /. Method defaults to POST. |
topic | Vercel Queues topic. Letters, digits, _, -. |
The object key is the schedule name: 1–256 chars of ASCII letters, digits, ., _, -, starting with a letter or digit. Keep names stable. Renaming deletes the schedule and creates a new one with fresh state and history. On Hobby, a deployment can declare up to 100 static schedules. On every plan, the CLI rejects a vercel.json with more than 500 schedules entries (Vercel CLI 65.11+; older CLIs cap it at 100).
Cron syntax: digits, *, ,, -, / only. No MON/JAN names, and don't set both day-of-month and day-of-week.
Path targets (Cron Jobs migration only)
Use a path target only to move an existing Cron Job over without touching its route code. For anything else, use a function target. The route stays publicly reachable, so it must check CRON_SECRET on every request:
{
"schedules": {
"daily-report": { "schedule": "0 9 * * *", "path": "/api/daily-report", "requestMethod": "GET" }
}
}
// api/daily-report.ts
export async function GET(request: Request) {
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) {
return new Response('Unauthorized', { status: 401 });
}
await sendReport();
return new Response(null, { status: 200 });
}
CRON_SECRET should be a random string of at least 16 characters. handleSchedule also works on path routes (GET or POST), but it does not check CRON_SECRET, so keep the check. Function targets never receive authorization or Cron Job headers, so don't require them there.
Queue targets and dynamic schedules
The consumer is a normal Vercel Queues consumer (⤳ skill: queues):
// api/reminders.ts
import { handleCallback } from '@vercel/queue';
interface ScheduleQueueMessage {
scheduleId: string;
executionId: string;
name: string;
namespace: string;
scheduledAt: string; // ISO 8601, excludes jitter
source: 'static' | 'dynamic';
payload?: { userId: string };
}
export const POST = handleCallback(async (message: ScheduleQueueMessage) => {
await sendReminder(message.payload!.userId, message.executionId);
});
{
"functions": {
"api/reminders.ts": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "reminders" }]
}
}
}
Create schedules at runtime from app code. The SDK authenticates with the deployment's OIDC token and acts on that deployment's environment or branch:
import { Schedules, SchedulesApiError } from '@vercel/schedules';
// Recurring, per tenant
await Schedules.create({
namespace: `tenant-${tenantId}`,
name: 'daily-digest',
target: { topic: 'digests' },
expression: { type: 'cron', cron: '0 9 * * *' },
timezone: tenant.timezone,
payload: { tenantId },
});
// One-time, local date and time in `timezone` (no Z, no offset)
await Schedules.create({
name: `reminder-${reminderId}`,
target: { topic: 'reminders' },
expression: { type: 'single', at: '2026-11-01T09:00' },
timezone: 'America/New_York',
payload: { userId },
});
// Idempotent create
try {
await Schedules.create({ /* ... */ });
} catch (error) {
if (!(error instanceof SchedulesApiError && error.code === 'schedule_already_exists')) throw error;
}
Other methods: get, list (paginate with cursor), update (dynamic only, can't switch cron ↔ one-time), enable (optionally until), disable, invoke (run now), delete (dynamic only). Use one namespace per tenant so a tenant's schedules can be listed and cleaned up together.
Handlers must be idempotent
- A run can be delivered more than once, and queue targets retry.
executionIdis the same for every delivery of an occurrence. Record it and skip repeats. - After an outage, every unexpired occurrence is delivered, so a frequent schedule can fire several times in a row. If the work only makes sense near its scheduled time, compare
scheduledAtto now and return early. - Runs are independent. A slow run doesn't delay the next one, so runs can overlap. Guard shared resources.
- Function and path targets are not retried. A thrown error becomes a
500in execution history, and that's it.
Environments
Vercel keeps separate schedules for production, each custom environment, each Preview branch, and CLI deployments per user.
- Static schedules apply when a deployment becomes current, including promotions and rollbacks.
- New static schedules start active in production and inactive in Preview and custom environments. Enable them in the dashboard or with
vercel schedules enable <name> --git-branch <branch> --for 2h. - Enabled/disabled state persists across later deployments.
- The CLI defaults to production. Use
--environment,--git-branch,--user, or--localto pick another set.
Jitter and plan limits
| Hobby | Pro / Enterprise | |
|---|---|---|
| Jitter window | Always 15 min | 1–15 min, default 1 |
| Most frequent cron | Every 15 min | Every minute |
| Schedules per team | 1,000 | 10,000 / 100,000 |
The shortest interval between runs must be at least the jitter window (jitter_exceeds_cadence). On Hobby, omit jitter or set it to 15. Downgrading to Hobby deletes schedules that run more often than every 15 minutes or set jitter below 15.
Migrate from Cron Jobs
Replace each crons entry with a named schedules entry. Keep path and schedule, and add "requestMethod": "GET", because Cron Jobs send GET and path schedules default to POST. Remove crons in the same change, or the route runs twice. Existing CRON_SECRET checks and vercel-cron/1.0 headers keep working. Leave timezone unset to keep UTC run times. This is the only case where a path target makes sense. If you can change code, go straight to a private function target instead: wrap the handler in handleSchedule, add the schedule/v1beta trigger, replace path/requestMethod with function, drop the CRON_SECRET check, and keep the same name so the schedule keeps its state and history.
Never do this on Vercel
node-cron,setInterval, or any in-process scheduler. Functions aren't long-lived, so they never fire reliably.- A
pathtarget for a new schedule. It exposes the handler publicly and depends on a staticCRON_SECRET. Use afunctiontarget. - A
pathtarget with noCRON_SECRETcheck. Anyone on the internet can trigger it. - Renaming a schedule to "fix" it. That resets its state and history.
Manage and debug
vercel schedules list
vercel schedules inspect cleanup --json
vercel schedules executions list cleanup --since 7d
vercel schedules disable cleanup
vercel schedules enable cleanup --for 2h
vercel schedules invoke cleanup
vercel schedules create send-reminder --at 2026-11-01T09:00 --timezone America/New_York --topic reminders
In the dashboard, open the project and select Schedules to see next run times, execution history, and Run Schedule.
Common SDK error codes: invalid_cron_expression, invalid_jitter, jitter_exceeds_cadence, single_at_in_past, payload_too_large, static_schedule (tried to update/delete a static one), rate_limited (wait retryAfter seconds).
Related
⤳ skill: queuesfor queue consumers, retries, and idempotency⤳ skill: workflowfor multi-step durable logic triggered by a schedule⤳ skill: vercel-functionsfor function runtime and duration limits- Schedules docs
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/vercel/vercel-plugin/vercel-schedules">View vercel-schedules on skillZs</a>