The CFO sees “we will cron it on a Lambda.” The staff engineer knows a SaaS product does not have one cron. It has a job per tenant: trial-end, invoice retry, report window, data export, pause, resume. EventBridge Scheduler SaaS job system is the product Rutagon builds for that: one-time and recurring schedules created per tenant, targeted at a queue or a Lambda you own, with isolation so tenant A cannot cancel tenant B. This is not EventBridge scheduled rules on a default bus. Scheduled rules do not scale to a create-per-customer API.
We have already shipped adjacent delivery plumbing as a custom AWS webhook delivery system. Jobs and webhooks meet at the worker. They are not the same scheduler. The platform sits on AWS cloud infrastructure we already know how to land in a mid-market account.
EventBridge Scheduler SaaS Job System, Not Scheduled Rules
EventBridge Scheduler is a separate scheduler service. You create a schedule resource with a one-time at() or a recurring cron() / rate(), an optional flexible time window, and a target. The target is typically SQS, Lambda, or Step Functions. Each schedule can carry an input payload. That payload is where tenant_id and job_type live — or, better, a pointer to a job row.
EventBridge rules with a schedule expression on a bus are a different product. They are fine for “run this one org-wide cleanup every night.” They are a poor tenant job store. You would create a rule per tenant, hit rule quotas, and fight bus policy. Scheduler was built for high counts of independent schedules. AWS describes the service in the EventBridge Scheduler user guide.
We do not quote a per-schedule or per-invocation price. Scheduler bills on invocations and you also pay the target. Open the current pricing page when you forecast. The design lever is not a rate table. It is whether you create a schedule per job or a single cron that scans a table. The scan pattern is cheap to operate at tiny scale and becomes a noisy-neighbor problem when every tenant’s due work sits in one query. Scheduler pushes due work into the target when it is due.
What We Build for Mid-Market SaaS
An API your product already has — “schedule export Friday 09:00 in the tenant timezone” — writes a row in DynamoDB and calls CreateSchedule. The schedule name is deterministic: export#{tenant}#{jobId} or a hash you can look up. The schedule group is per environment or per product, not per tenant, unless you have a compliance reason to split groups. Groups are for IAM and bulk disable, not for tenancy.
The target is SQS, not Lambda, when we want concurrency control and a dead letter. Scheduler invokes the queue. Workers pull with reserved concurrency per job type. A tenant who scheduled ten thousand one-time reminders cannot stampede the billing function. That is the same fair-queue instinct as webhook delivery.
One-time schedules delete themselves after fire if you set the completion action to delete. We still write a completion row. Recurring schedules stay until the tenant cancels or the subscription dies. Cancel is DeleteSchedule plus a status update. A billing job that only deletes the Dynamo row leaves a live schedule. That is the incident.
Timezone is a product requirement. Scheduler supports time zones on cron. We store the IANA zone on the tenant and pass it at create. UTC-only crons are how “09:00 local” becomes a support ticket.
Flexible windows matter for fan-out. If ten thousand tenants pick midnight UTC, you want a window so the queue does not ingest a cliff. We set the window on low-priority jobs. Charges that must hit a legal minute do not get a window.
Retries live on the queue, not in a second schedule. Scheduler has retry policies on the target. We still prefer SQS plus a worker that records attempts. Two retry systems will double-fire. Pick one owner.
IAM and Tenant Isolation
The product API role can scheduler:CreateSchedule, UpdateSchedule, and DeleteSchedule only on name prefixes or tags you own. It cannot pass an arbitrary IAM role to Scheduler. Execution roles are a short allowlist: scheduler-to-sqs-exports, scheduler-to-sqs-billing. The create path picks the role by job_type. That is confused-deputy prevention, the same class of control as a data perimeter.
Workers do not have Scheduler admin. They consume SQS and write results. A compromised worker should not delete every tenant’s schedules.
KMS for Scheduler is optional at the start. Encrypt the payload or keep the payload as a pointer (jobId) and load the body from DynamoDB in the worker. We prefer the pointer. The schedule input then has no customer body.
Audit is CloudTrail on Scheduler APIs plus your own job table. Who created a schedule is an identity question. What the job did to customer data is an application question. If those jobs write durable customer records, the restore story for that data is still AWS Backup restore testing, not a Scheduler checkbox.
Failure Modes We Design For
Create succeeds and Dynamo fails: you have an orphan schedule. The create path must be transactional in the application sense — write the row first as pending, create the schedule, then mark armed, with a reconciler that deletes schedules for rows stuck in pending.
Dynamo succeeds and Create fails: the user sees an error and retries. Names must be idempotent so a retry does not mint a second schedule.
A worker is down: SQS holds the invoke. Visibility timeout must exceed the job. A timeout that is too short redelivers while the first worker still runs. Exports then double.
A tenant is deleted: a reaper lists schedules by prefix or by tag and deletes them. Relying on “the API will 404” leaves paid invocations.
Clock skew and daylight-saving: IANA zones on the schedule, not a stored UTC offset. Offsets go stale.
A Create Path We Ship
def arm_job(tenant_id, job_id, when, tz, job_type):
put_item(status="pending", tenant_id=tenant_id, job_id=job_id)
name = f"{job_type}_{tenant_id}_{job_id}"
create_schedule(
name=name,
schedule_expression=f"at({when})",
timezone=tz,
target_arn=QUEUE[job_type],
role_arn=ROLE[job_type],
input={"job_id": job_id},
)
mark_armed(job_id, schedule_name=name)
The map QUEUE and ROLE is the allowlist. There is no “pass a role ARN from the request.”
What We Scope in an Engagement
We do not drop a Terraform module and leave. The SOW is the job types, the idempotency keys, the cancel and tenant-delete paths, the queue concurrency, the CloudWatch alarms on dead letters and failed CreateSchedule, and the runbook for orphaned schedules. Org-wide scheduled rules stay where they belong: one rule, one ops job, not this system.
If you already have EventBridge scheduled rules for product jobs, the migration is a dual-run: new tenants on Scheduler, old rules drained, then delete. We will not hide that as a rename.
Frequently Asked Questions
Can we use EventBridge scheduled rules instead of Scheduler for per-tenant jobs?
You can at very small tenant counts, but you will hit rule quotas, bus clutter, and IAM that is hard to prefix-scope. Scheduler exists so each job is a schedule resource with its own next-run time. We use scheduled rules for a handful of platform crons and Scheduler for the EventBridge Scheduler SaaS job system that tenants create through your API.
Should the schedule input include the full job payload?
Prefer a job id. Full payloads duplicate state and can put customer fields into a service you did not classify. The worker loads the row, checks the tenant is still active, and then runs. If the row says canceled, the worker no-ops even if the schedule still fired during a race.
How do we stop a canceled subscription from firing next month?
Delete the schedule in the same transaction path as the subscription status change, then verify with GetSchedule that the name is gone. A Dynamo status flag alone is not enough if a worker is down and a message is in flight. The worker must re-read status. The reaper is the backstop for schedules the cancel path missed.
Does Scheduler replace a workflow engine?
No. Scheduler decides when to start work, while Step Functions or your worker decides the steps, compensation, and human approval. A multi-step onboarding that waits on a customer action is a workflow with a schedule for the timeout, not a pile of unrelated crons.
If you want this job system scoped — job types, IAM, queues, cancel and delete paths, and the dual-run off scheduled rules — call 907-841-8407, email contact@rutagon.com, or use rutagon.com/contact.