Skip to content
Docs

Handling Scheduled Runs

When a schedule fires, Edgenivo delivers a run to the schedule's target. This page shows how to write the code that receives the run for each target type.

TargetUse it whenRetriesAvailable for
FunctionYou want a private function that nobody else can call.NoStatic schedules
PathYou're migrating a Cron Job, or the route must stay public.NoStatic schedules
Queue topicFailed runs must be retried, or you need a payload or dynamic schedules.YesStatic and dynamic schedules

A function target runs a Edgenivo Function that has no public URL. Only Edgenivo's scheduler can invoke it, so you don't need to check a secret.

  1. Install the SDK:

    npm i @vercel/schedules
  2. Create the function and wrap your task in handleSchedule:

    api/cleanup.ts
    import { handleSchedule } from '@vercel/schedules';
     
    export const POST = handleSchedule(async (metadata, request) => {
      console.log(`${metadata.name} run ${metadata.executionId}`);
      await deleteExpiredRecords();
    });
  3. Add a schedule/v1beta trigger to the function and point a schedule at it in vercel.json:

    vercel.json
    {
      "functions": {
        "api/cleanup.ts": {
          "experimentalTriggers": [{ "type": "schedule/v1beta" }]
        }
      },
      "schedules": {
        "cleanup": {
          "schedule": "0 * * * *",
          "function": "api/cleanup"
        }
      }
    }

    The function value is the function's build output path, not its source filename. See Function identifiers. For a function in a service, also set service.

A function with the schedule/v1beta trigger can't also have a queue trigger.

handleSchedule reads the run's details from the request headers and passes them to your callback with the original Request:

  • If the request isn't a valid schedule run, it returns a 400 response and doesn't call your callback.
  • If your callback finishes, it returns a 200 response.
  • If your callback throws, the error propagates to your framework or runtime, which typically returns a 500 response.

The response status shows up in your function logs and the schedule's execution history. It doesn't affect the schedule, and Edgenivo doesn't retry the run.

The callback's first argument is ScheduleMetadata:

FieldTypeDescription
scheduleIdstringID of the schedule
executionIdstringID of this run. Stays the same if the run is delivered more than once.
namestringSchedule name
namespacestringSchedule namespace. Static schedules use default.
scheduledAtDateTime the expression matched. Doesn't include jitter, so the run can arrive later.
sourcestringstatic or dynamic
expressionstringThe cron expression, or the local datetime of a one-time schedule

To read the details without wrapping your handler, call parseScheduleEvent(request). It returns the same ScheduleMetadata, or throws a ScheduleEventParseError if the request isn't a schedule run.

Several schedules can target the same function. Use metadata.name to decide what to run:

api/maintenance.ts
import { handleSchedule } from '@vercel/schedules';
 
export const POST = handleSchedule(async (metadata) => {
  switch (metadata.name) {
    case 'hourly-cleanup':
      await deleteExpiredRecords();
      break;
    case 'nightly-report':
      await sendReport();
      break;
  }
});

A path target sends an HTTP request to a route on your deployment, like a Cron Job. The route is public, so anyone can call it. Protect it with a secret.

  1. Add a schedule with a path in vercel.json. Path schedules send a POST request by default. Set requestMethod to GET if your route handles GET:

    vercel.json
    {
      "schedules": {
        "daily-report": {
          "schedule": "0 9 * * *",
          "path": "/api/daily-report",
          "requestMethod": "GET"
        }
      }
    }
  2. Add a CRON_SECRET environment variable to your project. Use a random string of at least 16 characters. Edgenivo sends it in the Authorization header of every path request.

  3. Check the header in your route before doing any work:

    api/daily-report.ts
    export async function GET(request: Request) {
      const authHeader = request.headers.get('authorization');
      const cronSecret = process.env.CRON_SECRET;
     
      if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
        return new Response('Unauthorized', { status: 401 });
      }
     
      await sendReport();
      return new Response(null, { status: 200 });
    }

Path requests include the same headers as Cron Job requests, plus the schedule headers that function targets receive. That means:

  • A route that already handles a Cron Job keeps working.
  • You can wrap a path route in handleSchedule to read ScheduleMetadata. handleSchedule accepts both GET and POST. It doesn't check CRON_SECRET, so keep the check.

See Request headers for the full list.

A queue target publishes a message to a Edgenivo Queues topic each time the schedule fires. A consumer on that topic processes the message. If the consumer fails, the queue retries it.

Queue topics are the only target for dynamic schedules, and the only target that supports a payload.

  1. Install the Queues and Schedules SDKs:

    npm i @vercel/queue @vercel/schedules
  2. Create a consumer with handleCallback:

    api/daily-reports.ts
    import { handleCallback } from '@vercel/queue';
    import type { ScheduleQueueMessage } from '@vercel/schedules';
     
    type ReportPayload = { reportType: string };
     
    export const POST = handleCallback<ScheduleQueueMessage<ReportPayload>>(
      async (message) => {
        console.log(`Running ${message.name}, scheduled for ${message.scheduledAt}`);
        await generateReport(message.payload?.reportType ?? 'summary');
      },
    );

    ScheduleQueueMessage types the message. Its parameter types the payload.

  3. Subscribe the consumer to a topic and point a schedule at the same topic:

    vercel.json
    {
      "functions": {
        "api/daily-reports.ts": {
          "experimentalTriggers": [
            { "type": "queue/v2beta", "topic": "daily-reports" }
          ]
        }
      },
      "schedules": {
        "daily-report": {
          "schedule": "0 9 * * *",
          "topic": "daily-reports"
        }
      }
    }

To create a queue schedule at runtime instead, use Schedules.create with target: { topic: 'daily-reports' }.

The message body is a JSON object with the schedule's details at the top level:

{
  "scheduleId": "sch_abc123",
  "executionId": "schx_def456",
  "name": "daily-report",
  "namespace": "default",
  "scheduledAt": "2026-09-15T09:00:00.000Z",
  "source": "dynamic",
  "payload": {
    "reportType": "summary"
  }
}
FieldTypeDescription
scheduleIdstringID of the schedule
executionIdstringID of this run. Stays the same if the run is delivered more than once.
namestringSchedule name
namespacestringSchedule namespace
scheduledAtstringISO 8601 time the expression matched. Doesn't include jitter.
source"static" or "dynamic"How the schedule was defined
payloadunknownThe payload you set on a dynamic schedule. Omitted when the schedule has no payload. Static schedules never include it.

A run can arrive more than once, and a delayed schedule can catch up with several runs in a row. Queue targets also retry failed deliveries. Write your handler so a repeated run doesn't repeat its side effects.

The execution ID is the same for every delivery of a run, so you can use it to detect a run you've already processed. Function targets get it as metadata.executionId, and queue targets get it as message.executionId.

Function and path targets receive a request with an empty body. The run's details are in the headers. handleSchedule and parseScheduleEvent read these for you.

HeaderSent toValue
ce-specversionFunction and path1.0
ce-typeFunction and pathcom.vercel.schedule.v1beta
ce-sourceFunction and path/schedule/{namespace}/{name}
ce-idFunction and pathExecution ID of this run
ce-vssscheduleidFunction and pathSchedule ID
ce-vssschedulenameFunction and pathSchedule name
ce-vssnamespaceFunction and pathSchedule namespace
ce-vssscheduledatFunction and pathISO 8601 time the expression matched, without jitter
ce-vssschedulesourceFunction and pathstatic or dynamic
ce-vssexpressionFunction and pathThe cron expression, or the local datetime of a one-time schedule
user-agentPath onlyvercel-cron/1.0
x-vercel-cron-schedulePath onlyThe cron expression
authorizationPath onlyBearer <CRON_SECRET>, when you set CRON_SECRET

Function targets don't receive authorization or the Cron Job headers. Don't require them in a function handler.


Was this helpful?

supported.