Create EventBridge Scheduler Schedules With AWS SDK v3

A plain white wall clock with black hands against a grey wall

Photo by Pixabay on Pexels

To create a schedule with EventBridge Scheduler and AWS SDK v3, send CreateScheduleCommand from @aws-sdk/client-scheduler with a Name, a ScheduleExpression (cron(...), rate(...) or at(...)), a FlexibleTimeWindow and a Target holding the target ARN and an execution role ARN. Add ScheduleExpressionTimezone for local times and ActionAfterCompletion: "DELETE" for one-time schedules.

This guide is for Node.js and TypeScript developers who need code to run later or on a timetable: nightly jobs, per-customer reminders, stopping dev servers in the evening. EventBridge Scheduler is AWS’s recommended way to do that; the EventBridge docs now call scheduled rules a legacy feature.

You’ll get a working eventbridge scheduler example in AWS SDK v3 that creates cron, rate, one-time and universal-target schedules, the execution role trust policy, retry and dead-letter settings, costs and quotas, and fixes for the errors you’re likely to hit. The samples were type-checked with strict tsc against @aws-sdk/client-scheduler 3.1141.0 and run against aws-sdk-client-mock in September 2026.

EventBridge Scheduler or EventBridge rules?

EventBridge Scheduler Scheduled rules (legacy)
Time zones Any IANA time zone, with daylight saving handled UTC only
One-time schedules Yes, at(...), optionally self-deleting No
Targets One per schedule: templated targets or universal targets for over 6,000 API operations Several per rule, from the EventBridge target list
Event bus Not involved Default event bus only
Scale 10,000,000 schedules per Region by default, adjustable Counted against the event bus rules quota
Retries and DLQ Per schedule Per target

Use rules when you’re matching events, not the clock; the guide to sending events with EventBridge PutEvents in SDK v3 covers that side. For anything time-based, use Scheduler.

Prerequisites

  • Node.js 18 or later, a project with "type": "module", @aws-sdk/client-scheduler and tsx.
  • An execution role that Scheduler can assume, with permission to call your targets (below).
  • A standard SQS queue for failed deliveries. Scheduler doesn’t accept a FIFO queue as a DLQ. The guide to sending and receiving SQS messages with SDK v3 shows how to read it.

How to create an EventBridge Scheduler schedule with AWS SDK v3, step by step

  1. Create the execution roleTrust scheduler.amazonaws.com, then allow the target action, such as lambda:InvokeFunction.
  2. Choose a schedule groupGroups hold related schedules and their tags. Without GroupName, the schedule goes into default.
  3. Write the expressioncron has six fields including year; rate takes minutes, hours or days; at takes a local date and time.
  4. Set the time zone and windowScheduleExpressionTimezone for local times; FlexibleTimeWindow is required, { Mode: "OFF" } for exact timing.
  5. Define the targetArn, RoleArn, Input, plus RetryPolicy and DeadLetterConfig.
  6. Send CreateScheduleCommandIt returns the ScheduleArn. To change a schedule later, send its full definition with UpdateScheduleCommand.

Example: cron, rate, one-time and universal target schedules

schedules.ts

// schedules.ts: create (or update) EventBridge Scheduler schedules with AWS SDK v3.
// Usage: SCHEDULER_ROLE_ARN=arn:aws:iam::123456789012:role/SchedulerExecutionRole \
//        DLQ_ARN=arn:aws:sqs:us-east-1:123456789012:scheduler-dlq npx tsx schedules.ts
import {
  SchedulerClient,
  CreateScheduleCommand,
  CreateScheduleGroupCommand,
  GetScheduleCommand,
  GetScheduleGroupCommand,
  UpdateScheduleCommand,
  ResourceNotFoundException,
  type CreateScheduleCommandInput,
} from "@aws-sdk/client-scheduler";

const region = process.env.AWS_REGION ?? "us-east-1";
const account = process.env.ACCOUNT_ID ?? "123456789012";
const roleArn = process.env.SCHEDULER_ROLE_ARN ?? `arn:aws:iam::${account}:role/SchedulerExecutionRole`;
const dlqArn = process.env.DLQ_ARN ?? `arn:aws:sqs:${region}:${account}:scheduler-dlq`;
const group = "billing-jobs";
const scheduler = new SchedulerClient({ region });

async function ensureGroup(name: string): Promise<void> {
  try {
    await scheduler.send(new GetScheduleGroupCommand({ Name: name }));
  } catch (err) {
    if (!(err instanceof ResourceNotFoundException)) throw err;
    await scheduler.send(new CreateScheduleGroupCommand({ Name: name }));
    console.log(`Created schedule group ${name}`);
  }
}

/** Creates the schedule, or replaces it completely if it already exists (UpdateSchedule overwrites every field). */
async function upsert(input: CreateScheduleCommandInput): Promise<string | undefined> {
  try {
    await scheduler.send(new GetScheduleCommand({ Name: input.Name, GroupName: input.GroupName }));
  } catch (err) {
    if (!(err instanceof ResourceNotFoundException)) throw err;
    const { ScheduleArn } = await scheduler.send(new CreateScheduleCommand(input));
    console.log(`Created ${ScheduleArn}`);
    return ScheduleArn;
  }
  const { ScheduleArn } = await scheduler.send(new UpdateScheduleCommand(input));
  console.log(`Updated ${ScheduleArn}`);
  return ScheduleArn;
}

// A Date becomes an at() expression by writing its UTC wall-clock time and setting the time zone to UTC.
const atUtc = (when: Date): string => `at(${when.toISOString().slice(0, 19)})`;

await ensureGroup(group);

// 1. Cron: every day at 02:30 New York time, within a 15-minute window, with retries and a DLQ.
await upsert({
  Name: "nightly-invoice-run",
  GroupName: group,
  ScheduleExpression: "cron(30 2 * * ? *)",
  ScheduleExpressionTimezone: "America/New_York",
  FlexibleTimeWindow: { Mode: "FLEXIBLE", MaximumWindowInMinutes: 15 },
  Target: {
    Arn: `arn:aws:lambda:${region}:${account}:function:generate-invoices`,
    RoleArn: roleArn,
    Input: JSON.stringify({ job: "nightly-invoices" }), // Lambda targets need well-formed JSON
    RetryPolicy: { MaximumRetryAttempts: 3, MaximumEventAgeInSeconds: 3600 },
    DeadLetterConfig: { Arn: dlqArn },
  },
});

// 2. Rate: every 5 minutes, send a message to an SQS queue.
await upsert({
  Name: "queue-heartbeat",
  GroupName: group,
  ScheduleExpression: "rate(5 minutes)",
  FlexibleTimeWindow: { Mode: "OFF" },
  Target: {
    Arn: `arn:aws:sqs:${region}:${account}:billing-heartbeat`,
    RoleArn: roleArn,
    Input: "heartbeat",
  },
});

// 3. One-time: remind a customer in 14 days, then delete the schedule so it doesn't count toward the quota.
const reminderAt = new Date(Date.now() + 14 * 86_400_000);
await upsert({
  Name: "trial-expiry-cus-4821",
  GroupName: group,
  ScheduleExpression: atUtc(reminderAt),
  ScheduleExpressionTimezone: "UTC",
  ActionAfterCompletion: "DELETE",
  FlexibleTimeWindow: { Mode: "OFF" },
  Target: {
    Arn: `arn:aws:sqs:${region}:${account}:billing-notifications`,
    RoleArn: roleArn,
    Input: JSON.stringify({ type: "trial-expiry", customerId: "cus-4821" }),
    DeadLetterConfig: { Arn: dlqArn },
  },
});

// 4. Universal target: stop development instances at 19:00 London time on weekdays.
await upsert({
  Name: "stop-dev-instances",
  GroupName: group,
  ScheduleExpression: "cron(0 19 ? * MON-FRI *)",
  ScheduleExpressionTimezone: "Europe/London",
  FlexibleTimeWindow: { Mode: "OFF" },
  Target: {
    Arn: "arn:aws:scheduler:::aws-sdk:ec2:stopInstances",
    RoleArn: roleArn,
    Input: JSON.stringify({ InstanceIds: ["i-0123456789abcdef0", "i-0fedcba9876543210"] }),
  },
});
Output

Created schedule group billing-jobs
Created arn:aws:scheduler:us-east-1:123456789012:schedule/billing-jobs/nightly-invoice-run
Updated arn:aws:scheduler:us-east-1:123456789012:schedule/billing-jobs/queue-heartbeat
Created arn:aws:scheduler:us-east-1:123456789012:schedule/billing-jobs/trial-expiry-cus-4821
Created arn:aws:scheduler:us-east-1:123456789012:schedule/billing-jobs/stop-dev-instances

The output is from a run against mocked responses in which queue-heartbeat already existed, so it was updated instead of created. What each schedule shows:

  • nightly-invoice-run uses a cron expression in America/New_York. In cron, day-of-month and day-of-week can’t both be *; one must be ?. The flexible window lets Scheduler start it any time in the 15 minutes after 02:30, which spreads load when many schedules share a time.
  • queue-heartbeat is a rate schedule. Without a StartDate, a rate schedule starts invoking right away.
  • trial-expiry-cus-4821 is a one-time schedule. The AWS docs note that completed one-time schedules still count toward the schedule quota, and ActionAfterCompletion: "DELETE" removes it after it runs. Scheduler ignores StartDate and EndDate for one-time schedules.
  • stop-dev-instances is a universal target: arn:aws:scheduler:::aws-sdk:ec2:stopInstances with the API’s own parameters as JSON Input. Scheduler doesn’t validate that input when you create the schedule, so a typo only shows up as failed invocations in the DLQ. Read-only actions starting with get, describe, list and similar prefixes aren’t supported.

Time zone names come from the IANA Time Zone Database, such as Europe/London, not abbreviations like BST. Scheduler adjusts for daylight saving: when clocks go forward, a time that doesn’t exist that day is skipped; when they go back, the schedule runs once, not twice. rate(1 days) is always 24 hours, even on a 23- or 25-hour day.

Warning: UpdateScheduleCommand replaces the whole schedule. Any optional field you leave out, such as RetryPolicy or DeadLetterConfig, goes back to its default. That’s why upsert sends the full definition every time; if you edit one field, read the schedule with GetScheduleCommand first.

Invoking a Lambda function this way is the scheduled cousin of calling it directly, as in invoking a Lambda function with AWS SDK v3 in TypeScript. For workflows, starting a Step Functions execution with SDK v3 pairs well with a schedule.

Retries, dead-letter queues and failed invocations

RetryPolicy has two limits: MaximumRetryAttempts from 0 to 185 and MaximumEventAgeInSeconds from 60 to 86,400 (24 hours). Retries back off exponentially until either limit is reached. Setting them explicitly keeps the behavior visible in code; three attempts over an hour suits a nightly job better than a day of retries.

When retries run out, Scheduler sends a dead-letter event to the DeadLetterConfig queue. Its message attributes tell you what went wrong: ERROR_CODE and ERROR_MESSAGE from the target’s API, EXHAUSTED_RETRY_CONDITION (MaximumRetryAttempts or MaximumEventAgeInSeconds), RETRY_ATTEMPTS, SCHEDULED_TIME, SCHEDULE_ARN and TARGET_ARN. The execution role needs sqs:SendMessage on the DLQ.

A DLQ nobody reads is just a slower way to lose work. Alarm on its depth, and check your other queues with the script to find SQS queues without a dead-letter queue. Scheduler’s retries only cover delivery; if the function you invoke also handles events asynchronously, the script to find Lambda functions without a failure destination covers failures inside it.

Which IAM permissions does EventBridge Scheduler need?

Two roles are involved. The execution role is what Scheduler assumes to call the target. AWS’s confused-deputy guidance recommends limiting its trust policy with aws:SourceAccount and aws:SourceArn scoped to a schedule group:

scheduler-trust-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "scheduler.amazonaws.com" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "123456789012",
          "aws:SourceArn": "arn:aws:scheduler:us-east-1:123456789012:schedule-group/billing-jobs"
        }
      }
    }
  ]
}
scheduler-execution-permissions.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:generate-invoices"
    },
    {
      "Effect": "Allow",
      "Action": "sqs:SendMessage",
      "Resource": [
        "arn:aws:sqs:us-east-1:123456789012:billing-heartbeat",
        "arn:aws:sqs:us-east-1:123456789012:billing-notifications",
        "arn:aws:sqs:us-east-1:123456789012:scheduler-dlq"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "ec2:StopInstances",
      "Resource": [
        "arn:aws:ec2:us-east-1:123456789012:instance/i-0123456789abcdef0",
        "arn:aws:ec2:us-east-1:123456789012:instance/i-0fedcba9876543210"
      ]
    }
  ]
}

The code that creates schedules needs the Scheduler actions and iam:PassRole for the execution role, limited to Scheduler with iam:PassedToService:

schedule-deployer-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["scheduler:GetScheduleGroup", "scheduler:CreateScheduleGroup"],
      "Resource": "arn:aws:scheduler:us-east-1:123456789012:schedule-group/billing-jobs"
    },
    {
      "Effect": "Allow",
      "Action": ["scheduler:GetSchedule", "scheduler:CreateSchedule", "scheduler:UpdateSchedule"],
      "Resource": "arn:aws:scheduler:us-east-1:123456789012:schedule/billing-jobs/*"
    },
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::123456789012:role/SchedulerExecutionRole",
      "Condition": { "StringEquals": { "iam:PassedToService": "scheduler.amazonaws.com" } }
    }
  ]
}

Trust policies work the same way as in assuming an IAM role with STS in SDK v3, with a service as the principal. Check both documents with how to review an IAM policy for least privilege, or generate a starting point from your code with the IAM policy generator for TypeScript.

What do EventBridge Scheduler schedules cost, and what are the quotas?

As of September 2026, the AWS Price List for EventBridge (published 24 September 2026) shows the first 14,000,000 scheduled invocations each month free and $1.00 per million after that in US East (N. Virginia). Worked example: 50,000 hourly schedules make 50,000 × 730 = 36,500,000 invocations a month, so (36.5 − 14) × $1.00 = $22.50.

Quota (us-east-1 defaults) Value
Schedules per Region 10,000,000, adjustable; completed one-time schedules count
Schedule groups per Region 500
CreateSchedule requests 5,000 per second (250 in Regions outside the main list)
Invocations 1,000 per second; above that, invocations are delayed, not dropped
Target Input size 256 KB, not adjustable
Reads and writes on one schedule 10 per second

Bulk creation above the request quota returns ThrottlingException; the SDK retries it, and the guide to AWS SDK v3 retries and timeouts shows how to raise the attempt count for a backfill.

Troubleshooting and limits

Scheduler’s API reference lists these exceptions, all exported by @aws-sdk/client-scheduler:

  • ValidationException: a malformed expression (five cron fields instead of six, * in both day fields), a name with characters outside [0-9a-zA-Z-_.] or a missing FlexibleTimeWindow.
  • ResourceNotFoundException: the group doesn’t exist, or GetSchedule looked in default because you left out GroupName.
  • ConflictException: the API reference describes it as a change that could leave the resource in an inconsistent state; retry once other changes to the schedule or group finish.
  • ServiceQuotaExceededException: too many schedules or groups; delete completed one-time schedules.
  • AccessDenied on create: usually iam:PassRole. See troubleshooting AWS IAM access denied errors.
  • Schedule created, target never runs: check the DLQ attributes; the execution role’s trust or permission policy is the usual cause.

Limits to know: invocations have 60-second precision, so a 01:00 schedule fires between 01:00:00 and 01:00:59; each schedule has one target; and deleting a schedule group deletes every schedule in it. To test the code above without AWS, mock SchedulerClient as in mocking AWS SDK v3 in unit tests.

Frequently asked questions

How do I schedule a Lambda function with AWS SDK v3?

Send CreateScheduleCommand with the function ARN as Target.Arn, an execution role that allows lambda:InvokeFunction, a JSON Input and a cron or rate expression.

Can EventBridge Scheduler run a job only once?

Yes. Use an at(yyyy-mm-ddThh:mm:ss) expression with ScheduleExpressionTimezone, and set ActionAfterCompletion to DELETE so the finished schedule doesn’t count toward your quota.

What time zone does EventBridge Scheduler use?

UTC unless you set ScheduleExpressionTimezone to an IANA name such as America/New_York. Cron and one-time schedules then follow local time, including daylight saving changes.

Does UpdateSchedule change only the fields I send?

No. It replaces the whole schedule, resetting omitted optional fields to defaults. Read the schedule with GetSchedule and send the complete definition.

How much does EventBridge Scheduler cost?

In US East (N. Virginia), the first 14 million invocations a month are free and each further million costs $1.00, according to the AWS Price List as of September 2026.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud