Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Dashboard Agent
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Quartz.AspNetCore serves scheduler management endpoints from an ASP.NET Core application. This page covers the server and the wire format; to call the endpoints from .NET, see HTTP Client, whose HttpScheduler implements IScheduler over this contract.

Installation

Quartz.AspNetCore depends on Quartz, so one reference is enough:

dotnet add package Quartz.AspNetCore

Basic setup

Configure Quartz and enable the HTTP API:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.AddQuartz(q => { });
builder.Services.AddQuartzHttpApi();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
  • One set of endpoints serves every scheduler in the container; each request names its scheduler. So the API is added to the container, and there is no IQuartzBuilder form.
  • The order of the three calls does not matter: nothing is built until the service provider is.

Map endpoints:

WebApplication app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapQuartzHttpApi("/quartz-api").RequireAuthorization();

Quartz authorizes but does not authenticate. UseAuthentication needs a scheme the application registered, such as AddAuthentication(…).AddJwtBearer() or an API-key handler. Calling UseAuthentication() with no scheme registered fails at startup, resolving IAuthenticationSchemeProvider.

A mapping that says nothing about authorization does not start

The API has no authentication or authorization of its own, and its endpoints mutate schedulers, shutdown and clear included. A scheduled job's type is a string from the request, resolved later with Type.GetType against the host's probing path. With Quartz.Jobs there (Quartz.Plugins depends on it), the string can name NativeJob, which starts a process: an unauthenticated endpoint is remote code execution.

So app.MapQuartzHttpApi() with no authorization statement fails at startup, in IHostedLifecycleService.StartingAsync (before the server binds its listener). The message names the three statements:

  • app.MapQuartzHttpApi().RequireAuthorization() authorizes the whole API;
  • QuartzHttpApiOptions.SchedulerAuthorizationPolicy authorizes each scheduler; see Authorizing per scheduler;
  • app.MapQuartzHttpApi().AllowAnonymous() serves it to anyone, deliberately.

Also accepted: a non-null AuthorizationOptions.FallbackPolicy, or RequireAuthorization() on a MapGroup the API is mapped into (group metadata flows into the endpoints). An application that calls AddQuartzHttpApi() and maps nothing is not checked.

AllowAnonymous() on a group counts, and it wins. With app.MapGroup("/ops").AllowAnonymous(), the API mapped into it starts and serves the whole mutating API to anyone, even if the mapping also says RequireAuthorization(). ASP.NET Core's authorization middleware gives IAllowAnonymous precedence over any IAuthorizeData, in any order. The guard cannot tell a deliberate AllowAnonymous() from an inherited one.

Some mappings are checked a moment late. At StartingAsync the guard sees only endpoints reachable from the route builder Map* was called on, and a RouteGroupBuilder's endpoints do not yet carry the group's conventions. A MapGroup(...).MapQuartzHttpApi(), and anything mapped from Startup.Configure/UseEndpoints, is checked in StartedAsync instead. The host still stops, but an unauthorized API of that shape answers requests between the web host starting and the guard throwing. Map it on the application directly to avoid that window.

Where the API is served

The default is /quartz-api. Change it at the map site or at registration:

// at the map site, beside the application's other routes
app.MapQuartzHttpApi("/ops/api").RequireAuthorization();

// or at registration
builder.Services.AddQuartzHttpApi(options => options.ApiPath = "/ops/api");
  • If both are given, the pattern passed to MapQuartzHttpApi wins. The parameterless overload uses ApiPath.
  • A map-site pattern must start with /, the same rule as ApiPath.

Every endpoint

Seventy-one routes in four groups.

  • {ApiPath} is /quartz-api unless changed. {name} is the scheduler; every route but the first has one.
  • Every route with {name} is subject to SchedulerAuthorizationPolicy when set.
  • An unknown scheduler is 404 on every route.
  • Answers uses the response-shape conventions: empty is 200 with no body; { applied } is the one-flag form; { groups } / { jobs } / { triggers } are the group-matcher and key-set forms; paged is the paged envelope.

Schedulers — 22

MethodPathAnswers
GET{ApiPath}/schedulersEvery scheduler the container knows, built or only registered (below); names no scheduler, so it filters itself
GET{ApiPath}/schedulers/{name}The scheduler and its SchedulerMetadata
GET{ApiPath}/schedulers/{name}/context{ context }, every value as text
POST{ApiPath}/schedulers/{name}/startempty; ?delay=00:00:30 delays; 400 for a negative delay or a store-attached window
POST{ApiPath}/schedulers/{name}/standbyempty; 400 for a store-attached window
POST{ApiPath}/schedulers/{name}/shutdownempty; ?waitForJobsToComplete=true waits for running jobs; 400 for a store-attached window
POST{ApiPath}/schedulers/{name}/clearempty; deletes every job, trigger and calendar
POST{ApiPath}/schedulers/{name}/pause-allempty; optional reason body
POST{ApiPath}/schedulers/{name}/resume-allempty
GET{ApiPath}/schedulers/{name}/nodesThe cluster's nodes (below)
GET{ApiPath}/schedulers/{name}/eventsLive events as text/event-stream (below)
GET{ApiPath}/schedulers/{name}/history/executionsA page of what the scheduler ran (below)
GET{ApiPath}/schedulers/{name}/history/executions/{entryId}One execution with its captured log; 404 when there is no such row
GET{ApiPath}/schedulers/{name}/history/misfiresA page of missed firings
GET{ApiPath}/schedulers/{name}/history/misfires/count{ count } since ?since=
GET{ApiPath}/schedulers/{name}/history/job-statuspaged job run statuses
GET{ApiPath}/schedulers/{name}/history/job-status/{jobGroup}/{jobName}One job's run status; 404 when no run is recorded
POST{ApiPath}/schedulers/{name}/history/job-status/fetchThe run statuses of up to 1000 jobs; a read
GET{ApiPath}/schedulers/{name}/history/statisticsRuns per bucket, by result, with duration percentiles
GET{ApiPath}/schedulers/{name}/execution-limits{ limits, useTriggerGroupWhenUnset }; limits is null when nothing is limited. Keys are configuration's: a group, _, *, or a prefix such as tenant:*
POST{ApiPath}/schedulers/{name}/execution-limitsempty; replaces the whole set
DELETE{ApiPath}/schedulers/{name}/execution-limitsempty; same as posting an empty set

Jobs — 21

Every path below is prefixed {ApiPath}/schedulers/{name}.

MethodPathAnswers
GET…/jobspaged job headers
POST…/jobs/fetchWhole job details for a page of keys, at most 1000
GET…/jobs/{jobGroup}/{jobName}The job detail
GET…/jobs/{jobGroup}/{jobName}/exists{ exists }
GET…/jobs/{jobGroup}/{jobName}/triggersEvery trigger pointing at that job
GET…/jobs/fire-instancespaged fire instances (below)
POST…/jobs/{jobGroup}/{jobName}/pause{ applied }; optional reason body
POST…/jobs/pause{ groups }; group matcher in the query string; optional reason body
POST…/jobs/keys/pause{ jobs }; key set in the body, with an optional reason
POST…/jobs/{jobGroup}/{jobName}/resume{ applied }
POST…/jobs/resume{ groups }
POST…/jobs/keys/resume{ jobs }
POST…/jobs/{jobGroup}/{jobName}/triggerempty; fires the job now; body may carry a JobDataMap for this firing
POST…/jobs/{jobGroup}/{jobName}/interrupt{ applied }; every execution of the job
POST…/jobs/interrupt/{fireInstanceId}{ applied }; the one firing
DELETE…/jobs/{jobGroup}/{jobName}{ applied }
POST…/jobs/delete{ jobs }
POST…/jobs/delete-by-group{ jobs }; group matcher in the query string
POST…/jobsempty; adds the job; replace and storeNonDurableWhileAwaitingScheduling are body fields
GET…/jobs/groupspaged job groups; the four name* filters, paused
GET…/jobs/groups/{jobGroup}/paused{ paused, pause }

Triggers — 23

MethodPathAnswers
GET…/triggerspaged trigger headers
POST…/triggers/fetchWhole triggers for a page of keys, at most 1000
GET…/triggers/{triggerGroup}/{triggerName}The trigger
GET…/triggers/{triggerGroup}/{triggerName}/exists{ exists }
GET…/triggers/{triggerGroup}/{triggerName}/state{ state, pause }; state is the TriggerState name
POST…/triggers/{triggerGroup}/{triggerName}/reset-from-error-state{ applied }
POST…/triggers/keys/reset-from-error-state{ triggers }
POST…/triggers/{triggerGroup}/{triggerName}/pause{ applied }; optional reason body
POST…/triggers/pause{ groups }; optional reason body
POST…/triggers/keys/pause{ triggers }; optional reason beside the keys
POST…/triggers/{triggerGroup}/{triggerName}/resume{ applied }
POST…/triggers/resume{ groups }
POST…/triggers/keys/resume{ triggers }
GET…/triggers/groupspaged trigger groups; the four name* filters, paused
GET…/triggers/groups/{triggerGroup}/paused{ paused, pause }
POST…/triggers/schedule{ firstFireTimeUtc }; one job and its trigger. A trigger alone may carry onConflict (Throw, Replace, Keep, KeepEarlier); the answer then adds outcome (Created, Replaced, Kept). See One-off job. May store it paused
POST…/triggers/schedule-multipleempty; several jobs and their triggers; may store them paused
POST…/triggers/{triggerGroup}/{triggerName}/unschedule{ applied }
POST…/triggers/unschedule{ triggers }
POST…/triggers/unschedule-by-group{ triggers }; group matcher in the query string
POST…/triggers/{triggerGroup}/{triggerName}/reschedule{ firstFireTimeUtc }; null when the trigger did not exist
POST…/triggers/{triggerGroup}/{triggerName}/update-details{ applied }; edits the trigger as a patch
POST…/triggers/{triggerGroup}/{triggerName}/backfillWhat it found and scheduled; below. From 4.3

Calendars — 5

MethodPathAnswers
GET…/calendarspaged calendar names
GET…/calendars/{calendarName}The calendar
GET…/calendars/{calendarName}/exists{ exists }
POST…/calendarsempty; adds or replaces; replace and updateTriggers are body fields
DELETE…/calendars/{calendarName}{ applied }

Why schedule-multiple is not schedule

POST …/triggers/schedule answers with one first fire time. Several jobs have several, so the plural form is a separate route with an empty body.

Names in a path

Names, groups, the scheduler name and fire instance ids go into the path percent-encoded. ASP.NET Core decodes the path before routing, so every character arrives as written, with two exceptions:

ValueThe server readsHttpScheduler
contains /, such as a/ba%2Fb: routing leaves %2F encodedrefuses it
. or ..nothing: the segment is removed, and another route answersrefuses it
  • A name spelled a%2Fb also arrives as a%2Fb, so the server cannot tell the two apart.
  • The refusal is an ArgumentException naming the value, its route parameter and the route. Nothing is sent.
  • Members that take a set of keys send them in the body, where any name works: PauseJobs(keys), ResumeJobs(keys), DeleteJobs(keys), UnscheduleJobs(keys), GetJobDetails(keys), GetTriggers(keys) and the trigger forms. See A whole set of keys in one call.
  • Matcher values in the query string, such as ?groupEquals=, are percent-encoded too, and any character works.

The scheduler listing carries registrations

GET {ApiPath}/schedulers lists every scheduler the container has registered or built, ordered by name. It is not paged.

[
  {
    "name": "acme",
    "schedulerInstanceId": null,
    "status": null,
    "origin": "Container",
    "target": null,
    "members": [],
    "lastSeenUtc": null
  },
  {
    "name": "core",
    "schedulerInstanceId": "web-01",
    "status": "Running",
    "origin": "Container",
    "target": null,
    "members": [],
    "lastSeenUtc": null
  }
]
  • status and schedulerInstanceId are both null for a registration nothing has built; listing does not build it. This is the only way to tell "this tenant has not started" from "no such tenant": the scheduler's own routes answer 404 for both.

  • origin says where the scheduler came from:

    originThe scheduler
    Containerregistered by AddQuartz() or AddQuartz(name, …)
    Runtimebound into the repository by hand, such as one built by QuartzSchedulerBuilder
    Remotean HttpScheduler from AddQuartzHttpClient; it runs in another process. target is its HttpClientOptions.Target, or null
    Windowa never-started scheduler over a store the dashboard attached; target names the store
    Agentreached through an agent that dialed in; target is the agent's name and lastSeenUtc when it last spoke (4.5)
    Clusterseveral targets fronting one cluster; target is a+b+c and members lists the targets (4.5)
  • target, members and lastSeenUtc were added in 4.2 and 4.5; a client built against the four original fields keeps reading the listing.

  • {name} in every other route is a bare scheduler name, which resolves to the scheduler reached through no target. A name held only by targets answers 404 naming them; the API does not address a targeted scheduler in 4.5.

Enums travel as names

Every enum on the wire (status, state, repeatIntervalUnit, a daysOfWeek entry, …) is spelled as its C# member name:

{ "status": "Running" }
{ "state": "Paused" }
  • The names are the contract and are stable across versions.
  • Numeric forms are still accepted on input, so an older client's ?state=1 works.
  • Query-string filters take a name too: ?state=Paused.
  • A name the server does not know, in a request, is 400.

A new name and an older client. A later release can add a member to an enum on the wire.

ClientA name it does not know
Quartz.HttpClient 4.4 or later, which a dashboard's HTTP target usesReads it: see A host newer than the client. A new name needs no hiding
Quartz.HttpClient 4.3 or earlierFails the whole call, or ends the event stream. A 4.4 host keeps its new names from it: Vetoed misfires are listed only when asked for
Your ownShould skip what it does not know, because a later host may send it

A job type is a name, and its two attribute flags may be absent

jobType is an assembly-qualified type name, treated as text. The server resolves neither incoming nor outgoing names; the type is loaded only when the job fires and when a store derives the job's attributes.

concurrentExecutionDisallowed and persistJobDataAfterExecution are therefore nullable:

  • stated (true / false): your value, overriding the type's attributes;
  • omitted or null: whatever [DisallowConcurrentExecution] and [PersistJobDataAfterExecution] on the type say, decided by the side that resolves the type.
{
  "job": {
    "name": "nightly", "group": "reports",
    "jobType": "Acme.Jobs.NightlyReport, Acme.Jobs",
    "durable": true,
    "jobDataMap": {}
  },
  "replace": true
}

That request keeps the job's [DisallowConcurrentExecution]. Before 4.0 rc.1 the fields were plain booleans, so an omitted field stored false and a job declared unsafe to run concurrently was stored as safe.

A job whose type the answering process cannot resolve reports both flags as null, instead of a 500, for example on a node without the job's assembly.

Durations travel as TimeSpan

Every duration, in bodies and query strings, is a TimeSpan in its invariant form, for example "repeatIntervalTimeSpan": "120.02:30:59.9990000":

POST {ApiPath}/schedulers/{name}/start?delay=00:00:30

The scheduler context travels as text

GET {ApiPath}/schedulers/{name}/context returns every value as a string: a non-string value as its invariant text, a null as null.

{
  "context": {
    "activeFrom": "2025-06-01T12:30:00.0000000+00:00",
    "nothing": null,
    "retries": "4352",
    "tenant": "acme"
  }
}
  • Instants (DateTimeOffset, DateTime) use the round-trip "O" format, as elsewhere on the wire.
  • Entries are ordered by key, ordinally.
  • A client gets strings whatever the types were in the scheduler's process. A value whose type a caller must act on belongs in its own endpoint.

The scheduler context is not a secret store

Every entry is returned, falling back to Convert.ToString. For a record or struct with a compiler-generated ToString, that is every field. One authorized GET dumps the whole map, including any connection strings or API keys, and Jobs puts shared instances there. Any authorized caller can read the context, as with a job's data map. Keep secrets in IConfiguration, a key vault or the container, and put a name in the context if a job needs to find one.

Response-shape conventions

A 200 has a body only when the operation has something to report that the caller could not work out: a computed value, or whether it applied.

OperationAnswers
A read that found its target200 with the object
A read whose target does not exist404 with RFC 7807 problem details
A mutation that always acts200 with an empty body
A mutation that may be a no-op200 with { "applied": … }
…the same, for a group matcher or key set200 with what it applied to: { "groups": [ … ] }, { "jobs": [ … ] }, { "triggers": [ … ] }
A mutation that computed something200 with that value, e.g. { "firstFireTimeUtc": … }
Any operation on an unknown scheduler404
  • Always-acting mutations: AddJob, TriggerJob, PauseAll, ScheduleJobs, AddCalendar, Start, Standby, Shutdown, Clear, the execution-limit writes.
  • { "firstFireTimeUtc": … } comes from schedule and reschedule.
  • applied is the only flag: the entity existed and the operation changed it. An operation that cannot answer that about one entity is a key-set form and answers with keys.
  • A key-set delete or unschedule still acts on the keys it found on a partial hit, so it answers with those keys; a boolean could not tell "some missing" from "nothing happened".

Errors are one shape per kind

Every error is RFC 7807 problem details. A client-actionable error (every 400 and 404) carries type, title, status, detail and Quartz-ExceptionType, the exception the server raised:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "The scheduler has been shut down",
  "Quartz-ExceptionType": "SchedulerException"
}

A client maps Quartz exception names (SchedulerException, JobPersistenceException, ObjectAlreadyExistsException, …) back to typed exceptions and treats other values as opaque; HttpScheduler does this. Quartz-ExceptionStackTrace is added only when IncludeStackTraceInProblemDetails is on.

A 501 carries the same members, naming NotSupportedException: the history store keeps no run status.

A 500 carries neither Quartz-ExceptionType nor the exception's message, because a driver's message can name the server, database, login or constraint. detail is a fixed sentence; the real message is logged:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "detail": "The scheduler failed to handle the request. The failure is recorded in the server's log."
}

IncludeStackTraceInProblemDetails puts the message back beside the stack trace.

Warning

The 500 detail is a constant: match a client on the status code only.

One 400 has no body: a query parameter the framework cannot bind (?skip=not-a-number, ?includeTotalCount=maybe) is rejected before the endpoint. Anything the endpoint rejects (?skip=-1, ?take=lots, ?state=not-a-state, a job with no name, unparseable JSON) answers with problem details.

Listing endpoints are paged

Every listing (jobs, triggers, calendars, the two group listings) takes skip, take and includeTotalCount and returns a paged envelope:

{
  "items": [ /* ... */ ],
  "hasMore": true,
  "totalCount": 4213
}
ParameterDefaultNotes
take250 (PagedQuery.DefaultTake)?take=all (PagedQuery.All in code) asks for as many as the server allows
includeTotalCountfalsetotalCount is null unless requested; it costs a second query
  • hasMore is exact.
  • A count without rows: ?take=0&includeTotalCount=true, answered with the count query alone.

QuartzHttpApiOptions.MaxPageSize bounds one request, default 1000, the same as the bulk key fetch.

  • A number above the cap is a 400 naming the cap and the setting that raises it. That includes ?take=2147483647, the number behind the old sentinel.
  • all is capped, not refused. A listing under the cap answers as if there were no cap; hasMore says when it was cut. This keeps the 3.x-compatible listings (GetJobKeys and its neighbours) working through HttpScheduler, which throws on a truncated answer instead of returning a short list.
  • Set MaxPageSize to 0 for an export or migration that must take everything in one call.

take is a string in the OpenAPI document

It accepts a number or all, so the six listing operations describe take as "A page size, or \"all\" for everything up to MaxPageSize". A generated client takes a string: pass the number as text.

EndpointReturnsFilters (besides paging)
GET {ApiPath}/schedulers/{name}/jobsJob headers¹groupEquals, groupContains, groupStartsWith, groupEndsWith, and the four name* filters
GET {ApiPath}/schedulers/{name}/jobs/groupsJob groups: name, pausednameEquals, nameContains, nameStartsWith, nameEndsWith, paused
GET {ApiPath}/schedulers/{name}/triggersTrigger headers²the four group* and four name* filters, jobName + jobGroup (both or neither), calendarName, state
GET {ApiPath}/schedulers/{name}/triggers/groupsTrigger groups: name, pausednameEquals, nameContains, nameStartsWith, nameEndsWith, paused
GET {ApiPath}/schedulers/{name}/calendarsCalendar namesnameEquals, nameContains, nameStartsWith, nameEndsWith
GET {ApiPath}/schedulers/{name}/jobs/fire-instancesFire instances³the four group* and four name* filters (on the trigger), jobName + jobGroup (both or neither), schedulerInstanceId, state

¹ Key, description, jobType (the assembly-qualified name the detail body carries), durable, concurrent-execution-disallowed, persist-job-data, requests-recovery. ² Key, job key, description, trigger type, state, start/end/next/previous fire times, calendar name, priority, execution group, retry policy and attempt, and what the trigger is waiting for. ³ fireInstanceId, trigger key, job key (null while only reserved), schedulerInstanceId, state, fireTimeUtc, scheduledFireTimeUtc, executionGroup, and what the job last reported: progress (0–100) and progressMessage, both null until it reports.

  • Results are ordered by group, then name, on every page. Fire instances add the fire instance id, since one trigger can have several firings at once.
  • At most one name* filter per request; more is a 400.
  • Filter text is literal, not a wildcard: a calendar named 50% matches ?nameStartsWith=50%25.

Fire instances

GET {ApiPath}/schedulers/{name}/jobs/fire-instances lists firings, not job-execution contexts. It is store-backed, so with a persistent store it covers the whole cluster.

stateLists
none (default)running firings (Executing)
Anyeverything
Acquiredreservations
anything else400

Caveats for a UI:

  • A firing an ITriggerListener vetoes completes when the veto is applied. It can be listed only between the store recording the firing and the veto decision.
  • Elapsed time is your clock minus fireTimeUtc, written by the firing node's clock. With skewed clocks it can be negative; clamp at zero.
  • scheduledFireTimeUtc is the schedule the owning node recorded; after a misfire, the rescheduled time. Its gap to fireTimeUtc is not misfire lateness.

POST {ApiPath}/schedulers/{name}/jobs/interrupt/{fireInstanceId} interrupts one firing; POST …/jobs/{group}/{name}/interrupt interrupts every execution of the job. Both are node-local on the server: interrupt a firing owned by another node through that node.

To get whole objects for a page of headers, post the keys back:

  • POST {ApiPath}/schedulers/{name}/jobs/fetch: body is an array of { "name": …, "group": … }; returns job details;
  • POST {ApiPath}/schedulers/{name}/triggers/fetch: the same, returning triggers.

Missing keys are absent from the response. At most 1000 keys per call. These are the only non-GET routes that are reads, so ReadOnly serves them; see Serving reads only.

Changed in 4.x

Listings used to return bare arrays of keys, or { "names": [ … ] } for group and calendar listings. All now return the paged envelope. GET {ApiPath}/schedulers/{name}/triggers/groups/paused was removed; use GET {ApiPath}/schedulers/{name}/triggers/groups?paused=true.

Cluster nodes

GET {ApiPath}/schedulers/{name}/nodes returns the scheduler's nodes: the node that handled the request first, then the rest by instance id. It is not paged.

[
  {
    "instanceId": "web-01",
    "lastCheckInUtc": "2026-08-26T09:14:57+00:00",
    "checkInInterval": "00:00:15",
    "state": "Alive",
    "isCurrentNode": true
  },
  {
    "instanceId": "web-02",
    "lastCheckInUtc": "2026-08-26T09:09:12+00:00",
    "checkInInterval": "00:00:15",
    "state": "Failed",
    "isCurrentNode": false
  }
]
stateMeaning
AliveChecking in
OverdueMissed a check-in; nothing more
FailedThe store's recovery sweep will take over its in-flight work; then it is no longer listed
  • checkInInterval is a TimeSpan, the interval that node was configured with.
  • Both times are null when the store keeps no check-in history (in-memory store, or persistent without clustering). The answer is then one node: isCurrentNode: true, Alive, no times. null is not zero; do not fall back to DateTimeOffset.MinValue.
  • The verdicts are the answering node's, on its own clock; with skewed clocks two nodes can disagree.
  • Join to GET {ApiPath}/schedulers/{name}/jobs/fire-instances on schedulerInstanceId to see what each node runs.

The event stream

GET {ApiPath}/schedulers/{name}/events streams the scheduler's events as server-sent events, one frame per event, until the caller disconnects. Use it for a live view from another process: a dashboard, a terminal, a browser EventSource.

event: JobExecuted
data: {"kind":"JobExecuted","schedulerName":"QuartzScheduler","schedulerInstanceId":"web-01","occurredAtUtc":"2026-09-12T10:30:00+00:00","jobKey":{"name":"nightly","group":"reports"},"triggerKey":{"name":"at-midnight","group":"reports"},"fireInstanceId":"fire-1","fireTimeUtc":"2026-09-12T10:29:55+00:00","runTime":"00:00:01.5000000","vetoed":false,"exceptionMessage":"the job threw","status":null,"message":null,"cause":null}
id: 7

  • event: is the event's kind, so a reader can subscribe by kind without parsing: EventSource.addEventListener("JobExecuted", …).
  • data: is one line of the API's usual JSON.
  • id: counts this stream's frames. It is not a cursor: nothing is replayed.

Fourteen kinds:

KindWhat happenedCarries, beyond the common four
JobExecutinga job began runningjobKey, triggerKey, fireTimeUtc, fireInstanceId
JobExecuteda job finished, threw, or was vetoed before it ranthe same, plus runTime, vetoed, exceptionMessage
TriggerFireda trigger firedtriggerKey, jobKey, fireTimeUtc, fireInstanceId
TriggerCompletedits firing completedthe same
TriggerMisfireda firing was missed and the misfire instruction appliedtriggerKey, jobKey; no fireTimeUtc
TriggerPaused, TriggerResumeda trigger was paused or resumedtriggerKey
JobPaused, JobResumeda job, and every trigger that fires it, was paused or resumedjobKey
JobInterruptedone firing of a job was interruptedjobKey, fireInstanceId
TriggerInErrora trigger entered the error state; it will not fire until resettriggerKey
SchedulerStateChangedone node's scheduler entered a new lifecycle statestatus
SchedulerErrorthe scheduler handled an error itselfmessage, cause, and the triggerKey / jobKey where known
Heartbeatnothing; the connection is still opennothing; see below
  • Every event carries kind, the raising node's schedulerName and schedulerInstanceId, and occurredAtUtc. In a cluster each node raises its own events, so the instance id tells a local event from a peer's.
  • Every member is always present; facets a kind does not carry are null.
  • A later release may add a kind. Skip one you do not know; Quartz.HttpClient does from 4.4.

Heartbeats are not events. One is sent when the stream opens (which sends the response headers), then one whenever EventStreamHeartbeatInterval (fifteen seconds) passes with nothing to send. They keep intermediaries from closing an idle connection and let the reader tell a quiet scheduler from a dead socket. A reader should drop them; Quartz.HttpClient does.

Authorized once, when the stream opens. The route names {schedulerName}, so SchedulerAuthorizationPolicy runs before the handler. A refused caller gets 403 with problem details and no frames; an unknown scheduler gets 404. The open stream is not re-authorized: close it and reopen to re-check rights.

No replay, and no Last-Event-ID. A subscription gets only later events. After a reconnect, read the gap from the execution history.

A reverse proxy must not buffer this

A buffering proxy delivers frames in bursts, and an idle timeout shorter than the heartbeat keeps cutting the connection. For nginx, set proxy_buffering off; and a proxy_read_timeout above the heartbeat interval for this route. The response already sends Cache-Control: no-cache,no-store and Content-Encoding: identity.

From .NET, AddQuartzHttpClient registers a reader for this route that reconnects by itself; see Events.

Execution history

A job store holds what is scheduled. What happened (what ran, for how long, what it achieved, what was missed) is in the container's IExecutionHistoryStore, served by eight routes:

PathQueryAnswers
GET {ApiPath}/schedulers/{name}/history/executionsskip, take, includeTotalCount, schedulerInstanceId, jobContains, triggerContains, failedFinally, and the exact filtersA page of executions, newest first
GET {ApiPath}/schedulers/{name}/history/executions/{entryId}noneOne execution, log and input included
GET {ApiPath}/schedulers/{name}/history/misfiresskip, take, includeTotalCount, schedulerInstanceId, triggerContains, jobGroup + jobName, triggerGroup + triggerName, reasonsA page of misfires, newest first
GET {ApiPath}/schedulers/{name}/history/misfires/countsince: a DateTimeOffset, required{ "count": 3 }, Missed rows only
GET {ApiPath}/schedulers/{name}/history/job-statusskip, take, includeTotalCount, failingA page of run statuses
GET {ApiPath}/schedulers/{name}/history/job-status/{jobGroup}/{jobName}noneOne job's run status
POST {ApiPath}/schedulers/{name}/history/job-status/fetchbody: { "jobs": [ { "name", "group" } ] }The statuses of those jobs
GET {ApiPath}/schedulers/{name}/history/statisticsthe listing's filters, jobGroup alone, bucketRuns per bucket
{
  "items": [
    {
      "schedulerInstanceId": "web-01",
      "jobGroup": "billing",
      "jobName": "release-stale",
      "triggerGroup": "billing",
      "triggerName": "hourly",
      "firedAtUtc": "2026-08-26T00:00:00+00:00",
      "duration": "00:00:00.2500000",
      "succeeded": true,
      "exceptionMessage": null,
      "retryAttempt": 0,
      "retryScheduled": false,
      "entryId": "8c3f2a0e5b7d4f1a9e6c2d4b8a0f3e71",
      "log": null,
      "result": "Skipped",
      "summary": "no stale reservations",
      "metrics": { "scanned": 1200, "released": 0 },
      "manual": false,
      "fireInstanceId": "web-01-17",
      "input": null,
      "inputTooLarge": false
    }
  ],
  "hasMore": false,
  "totalCount": 1
}
  • Rows carry the node's id, not the scheduler name (the route has it). schedulerInstanceId narrows to one node.
  • entryId names the row for …/history/executions/{entryId}. log and input are null on every listing row; that route carries them, when the scheduler captures its jobs' logs and its history records inputs.
  • jobContains and triggerContains match a key's group, name, or group.name, case-insensitively.
  • failedFinally=true lists the failures that were not retried; false, everything else.
  • Paging uses the usual envelope, bounded by MaxPageSize.

What a run reported, from 4.4. See Job Outcomes.

MemberWhat
resultSucceeded, Failed, Cancelled or Skipped; null on a row written before 4.4
summaryThe job's one line, or null
metricsA JSON object, or null. HttpExecutionHistoryStore hands it back as the text the recorder wrote
manualtrue for a run TriggerJob asked for
fireInstanceIdThe firing's id, as on its span and log scope
inputThe run's input, on …/history/executions/{entryId} only; null when none was recorded
inputTooLargetrue when the input was over the history's cap and not kept

A 4.3 client ignores the seven members, and a 4.3 host sends none: its rows have no input.

To run a failure again with its input, POST …/jobs/{jobGroup}/{jobName}/trigger with input under the QRTZ_JOB_INPUT key of the body's JobDataMap. The dashboard does this through AddQuartzHttpClient.

Filtering by job, time and result

On …/history/executions, from 4.4 unless the row says otherwise:

ParameterMatches
jobGroup and jobNameOne job exactly. Both or neither; one alone is 400
triggerGroup and triggerNameOne trigger exactly. Both or neither; one alone is 400. From 4.6
fireInstanceIdOne firing exactly: ?fireInstanceId=web-01-17. From 4.6
firedFromFired at or after, a DateTimeOffset
firedBeforeFired before, exclusive
resultsA result in the set: ?results=Failed,Cancelled or ?results=Failed&results=Cancelled. A row before 4.4 matches Succeeded or Failed
  • A name that is no result is 400, naming the ones that are.
  • jobGroup and jobName, and triggerGroup and triggerName, narrow …/history/misfires too.
  • A host before 4.4 ignores these parameters, and one before 4.6 ignores the trigger and the firing; either answers every row. HttpExecutionHistoryStore reads the host's version from GET {ApiPath}/schedulers/{name} before its first filtered read, and throws NotSupportedException for an older host instead of sending the filter.

Vetoes are listed when asked for

…/history/misfires lists Missed and Overlap rows unless reasons names others:

GET /quartz-api/schedulers/QuartzScheduler/history/misfires?reasons=Missed,Overlap,Vetoed
  • A 4.3 client reads reason through an enum with no Vetoed, and one such row fails its whole listing. The default keeps that client working against a 4.4 host.
  • A 4.4 client names every reason it can read. HttpExecutionHistoryStore and the dashboard do.
  • …/history/misfires/count counts Missed rows only, as before.

Job run status

From 4.4. A status per job, folded from every execution the store records, so it outlives the rows. See Read a job's status.

{
  "job": { "name": "release-stale", "group": "billing" },
  "lastFiredAtUtc": "2026-08-26T12:00:00+00:00",
  "lastResult": "Failed",
  "lastDuration": "00:00:00.2500000",
  "lastSchedulerInstanceId": "web-01",
  "lastEntryId": "3f2a0e5b7d4f1a9e",
  "lastSummary": null,
  "lastSucceededAtUtc": "2026-08-26T11:00:00+00:00",
  "lastFailedAtUtc": "2026-08-26T12:00:00+00:00",
  "lastFailureMessage": "the job threw",
  "consecutiveFailures": 1,
  "runCount": 2,
  "failureCount": 1,
  "firstFiredAtUtc": "2026-08-26T11:00:00+00:00"
}
RouteAnswers
GET …/history/job-statusA page by job group, then name. ?failing=true lists consecutiveFailures > 0; false, the rest
GET …/history/job-status/{jobGroup}/{jobName}One status; 404 with problem details when no run is recorded
POST …/history/job-status/fetchThe statuses of the jobs named, at most 1000; a job with no recorded run is absent. A read, served when ReadOnly
CaseAnswer
The store keeps no status (an IDashboardHistoryStore of your own)501, Quartz-ExceptionType: NotSupportedException, the store's reason as detail
A host before 4.4404 without problem details: no such route

HttpExecutionHistoryStore raises NotSupportedException for both.

AddQuartzHttpApi() records the history. It calls AddQuartzExecutionHistory(), which installs one recorder into every scheduler in the container and keeps history in an in-memory store bounded by age and count, as the dashboard's always has. To record nothing:

services.AddQuartzExecutionHistory(options => options.MaxEntriesPerScheduler = 0);

A scheduler on a persistent store can keep history in the database.UsePersistentStore(store => store.UseExecutionHistory()) writes both feeds to QRTZ_EXECUTION_HISTORY and QRTZ_MISFIRE_HISTORY. They survive a restart, and a whole cluster writes one history, so the routes answer for every node.

Which history a route reads follows the dashboard's rule:

  • a named scheduler that called UseExecutionHistory(): its own database;
  • any other scheduler: the container's shared store;
  • a store-attached window: the database it is a window onto. If that store keeps no history, the routes answer 400 with the dashboard's explanation, not an empty page.

To keep history elsewhere, register your own IExecutionHistoryStore before AddQuartzHttpApi(). The shipped registration is a TryAdd, and UseExecutionHistory() replaces only the in-memory default. A dashboard in the same process reads the same store.

Run statistics

From 4.4. A scheduler's runs counted by result and timed, per bucket of fire time. See Count runs over time.

GET /quartz-api/schedulers/QuartzScheduler/history/statistics?jobGroup=billing&jobName=release-stale&firedFrom=2026-08-26T10:00:00Z&bucket=01:00:00
{
  "bucketSize": "01:00:00",
  "buckets": [
    {
      "startUtc": "2026-08-26T10:00:00+00:00",
      "runCount": 2,
      "succeededCount": 1,
      "failedCount": 1,
      "cancelledCount": 0,
      "skippedCount": 0,
      "p50Duration": "00:00:00.2000000",
      "p95Duration": "00:00:00.2900000",
      "maxDuration": "00:00:00.3000000"
    }
  ],
  "truncated": false
}
ParameterMatches
schedulerInstanceId, jobContains, triggerContains, failedFinally, triggerGroup + triggerName, fireInstanceId, firedFrom, firedBefore, resultsAs on …/history/executions
jobGroup and jobNameOne job exactly. jobGroup alone counts the group; jobName alone is 400
bucketA TimeSpan: 01:00:00 when absent, 1.00:00:00 for a day. Under 00:01:00 is 400
  • A bucket starts at a whole multiple of bucket: an hour on the hour, a day at midnight UTC. One with no run is left out.
  • truncated is true when the host's store stopped counting at its row limit; the shipped stores never do.
CaseAnswer
The store cannot count501, Quartz-ExceptionType: NotSupportedException
A host before 4.4404 without problem details: no such route

HttpExecutionHistoryStore reads the host's version first and does not ask a host before 4.4. It raises NotSupportedException for that host and for both answers above.

Pause and resume report what they did

Every single-key mutation that can be a no-op answers { "applied": … }:

  • POST …/jobs/{group}/{name}/pause, …/resume
  • POST …/triggers/{group}/{name}/pause, …/resume
  • POST …/triggers/{group}/{name}/reset-from-error-state
  • POST …/triggers/{group}/{name}/unschedule
  • POST …/jobs/{group}/{name}/interrupt, POST …/jobs/interrupt/{fireInstanceId}
  • DELETE …/jobs/{group}/{name}
  • DELETE …/calendars/{name}
{ "applied": true }

applied is false when the key does not exist or nothing changed (pausing a paused trigger, resuming one that was not paused, resetting one not in the error state). POST …/jobs/delete and POST …/triggers/unschedule take a key set and answer with keys, like every key-set form.

The group-matcher forms, POST …/jobs/pause, …/jobs/resume, …/triggers/pause and …/triggers/resume, are the wire form of PauseJobGroups, ResumeJobGroups, PauseTriggerGroups and ResumeTriggerGroups. They return the groups they recorded:

{ "groups": [ "reporting", "imports" ] }

The …/keys/pause and …/keys/resume routes beside them return the keys they moved.

A pause can say why

From 4.3. These routes take an optional body:

  • POST …/triggers/{group}/{name}/pause, …/triggers/pause
  • POST …/jobs/{group}/{name}/pause, …/jobs/pause
  • POST {ApiPath}/schedulers/{name}/pause-all
{ "reason": "vendor API is down until 18:00", "requestedBy": "alice" }
  • No body is the pause a 4.2 client makes. It calls the reasonless IScheduler member, records nothing, and does not name the caller.
  • Both members are optional. With a body, requestedBy left out is the authenticated user's name, the one the mutation audit logs.
  • A body that says nothing (no reason, no requester, nobody authenticated) is the reasonless pause too.

From 4.4, the key-set POST …/triggers/keys/pause and …/jobs/keys/pause take the same two members beside the keys:

{ "triggers": [ { "name": "nightly", "group": "reports" } ], "reason": "vendor API is down until 18:00" }
  • A key-set body with neither reason nor requestedBy is the reasonless pause, even from an authenticated caller. It is the body every 4.3 client sends, so it is never put in the caller's name.
  • With either, requestedBy left out is the authenticated user's name, as above.
  • A 4.3 host reads the keys and ignores the rest, so it pauses the set without the reason.

The record is read back as pause:

WhereMember
GET …/triggers/{group}/{name}/state"pause": the trigger's record while it is Paused, else null
GET …/triggers/groups/{group}/paused, …/jobs/groups/{group}/paused"pause": the group's record while it is paused, else null
Trigger listing header"pause": the trigger's own record, else null
{
  "state": "Paused",
  "pause": {
    "reason": "vendor API is down until 18:00",
    "requestedBy": "alice",
    "pausedAtUtc": "2031-06-17T10:00:00+00:00"
  }
}

pause is null for a pause that said nothing; in a record, either text may be null. A 4.2 client ignores pause; a 4.2 host ignores the body. See Pausing with a Reason.

A trigger can be scheduled paused

From 4.4. The …/triggers/schedule and …/triggers/schedule-multiple bodies take the members of ScheduleJobOptions that store the triggers paused, in the call that stores them:

POST /quartz-api/schedulers/core/triggers/schedule
Content-Type: application/json

{ "trigger": { … }, "job": { … }, "pauseReason": "awaiting approval", "pauseRequestedBy": "alice" }
MemberMeans
pausedStore the triggers paused. Either text sets it too
pauseReasonThe record's reason
pauseRequestedByThe record's requestedBy. Beside a reason, left out is the authenticated user's name
  • paused with neither text is the reasonless pause, even from an authenticated caller.
  • onConflict beside any of them is 400: ScheduleTrigger takes no options.
  • A 4.3 host ignores all three and stores the triggers unpaused. AddQuartzHttpClient reads the host's version first and throws NotSupportedException against one older than 4.4, having sent nothing.

See Scheduling a trigger paused.

A whole set of keys in one call

The key-set forms take many keys in one request (one round trip and one scheduling signal) and answer with the keys they applied to:

EndpointBodyAnswers
POST …/jobs/keys/pause, …/jobs/keys/resume{ "jobs": [ { "name": …, "group": … } ] }; a pause may add reason, requestedBy{ "jobs": [ … ] }
POST …/triggers/keys/pause, …/triggers/keys/resume{ "triggers": [ { "name": …, "group": … } ] }; a pause may add reason, requestedBy{ "triggers": [ … ] }
POST …/triggers/keys/reset-from-error-state{ "triggers": [ … ] }{ "triggers": [ … ] }
POST …/jobs/delete{ "jobs": [ { "name": …, "group": … } ] }{ "jobs": [ … ] }
POST …/triggers/unschedule{ "triggers": [ { "name": …, "group": … } ] }{ "triggers": [ … ] }
  • A key the operation did not apply to (names nothing, already paused, not in the error state) is absent from the answer, never an error.
  • The answer keeps the request's order. answer.length === request.length means every key applied; otherwise the list shows which did.
  • Pause, resume and reset are under keys/ because the collection-level pause and resume are the group-matcher forms. delete and unschedule keep the plain path; their group forms are delete-by-group and unschedule-by-group.
  • The server applies the whole set in one pass (one lock and one transaction on the ADO store) and signals the scheduling change once.
  • Listener events stay per key: one TriggerPaused / JobPaused / TriggerResumed / JobResumed / JobDeleted / JobUnscheduled per applied key, none for the rest. There is no key-set listener event: TriggersPaused(null) means every group, and a monitoring listener would read it as a total outage.

A whole group in one call

Delete by group to cancel everything sharing a trigger group (a saga, a tenant, a conversation) without listing keys first, which would leave a window for another node to add one.

EndpointSelects byAnswers
POST …/jobs/delete-by-group?groupEquals=, ?groupStartsWith=, ?groupEndsWith=, ?groupContains={ "jobs": [ … ] }
POST …/triggers/unschedule-by-groupthe same four{ "triggers": [ … ] }
  • The parameters are those of …/jobs/pause and …/triggers/pause. None given means every group.
  • The answer is the removed keys, not group names.
  • A non-durable job left with no triggers is removed with them but not named: an unschedule answers with triggers.
  • The group is resolved inside the lock that empties it, and the scheduling change is signalled once.
  • Listener events are per key: one JobDeleted or JobUnscheduled per removed key, none for an empty group.

Editing a trigger in place

POST …/triggers/{triggerGroup}/{triggerName}/update-details changes a trigger's metadata and settings without rescheduling it. Fire times, fire count and state are unchanged; only the fields in the body change. It is the wire form of IScheduler.UpdateTriggerDetails. It answers { "applied": … }, false (not 404) when no trigger has the key, as for every single-trigger mutation.

The body is a patch: an omitted member is left alone; a member present as null is cleared. Sending every member as null clears them all.

{
  "description": "nightly export",
  "priority": 8,
  "calendarName": null
}

This sets the description and priority, removes the calendar, and leaves job data, misfire instruction, node pin, execution group, retry policy and overlap policy unchanged.

MemberTypeMeaning
descriptionstring or nullThe trigger's description
prioritynumberThe trigger's priority
jobDataMapobject or nullReplaces the trigger's job data; null empties it
calendarNamestring or nullCalendar to observe (must exist); null removes it
misfireInstructionnumberMisfire instruction code, as in a trigger body
misfireInstructionFamilystring or nullThe schedule family of the code; see below
preferredNodestring or nullNode pin: null clears, "*" requests an automatic pin, else a scheduler instance id
preferredNodeAutoboolSent with preferredNode: whether the pin was assigned automatically
executionGroupstring or nullThe execution group whose thread limit applies; null leaves every group
retryPolicystring or nullThe retry policy in stored form, e.g. "fixed;3;00:00:30"; "none" never retries; null removes the trigger's own, so it inherits the job type's or the scheduler's
overlapPolicystring or nullThe overlap policy by name: Skip, BufferOne, CancelPrevious, AllowAll, Default; null is Default. From 4.3

Name the misfire instruction's family. misfireInstructionFamily is Simple, Cron, CalendarInterval, DailyTimeInterval or Recurrence. The same number means a different policy per family (2 is do nothing for cron, reschedule now with existing repeat count for simple), so the scheduler refuses a code aimed at another family. Omit the family to skip that check; that is the only way to set a code on a custom trigger type, which belongs to none of the five.

A calendar name or a misfire instruction changes firing

Unlike the other fields, these change when the trigger fires. Neither recomputes fire times; the new value applies at the next scheduling evaluation.

A continuation cannot be edited here: what a trigger waits for is set when it is scheduled, and changing it is a reschedule.

Backfilling a trigger

From 4.3. POST …/triggers/{triggerGroup}/{triggerName}/backfill schedules one firing per slot the trigger had in [from, to). It runs Backfill on the host.

{
  "from": "2026-09-09T00:00:00+00:00",
  "to": "2026-09-09T03:00:00+00:00",
  "maxSlots": 10,
  "spacing": "00:00:30",
  "executionGroup": "backfills"
}

from and to are required; the rest are optional, with BackfillOptions' defaults. The answer:

{
  "slotsFound": 3,
  "scheduled": 2,
  "alreadyScheduled": 1,
  "firstSlot": "2026-09-09T00:00:00+00:00",
  "lastSlot": "2026-09-09T02:00:00+00:00",
  "triggers": [
    { "name": "hourly@2026-09-09T00:00:00Z", "group": "backfill:reports" },
    { "name": "hourly@2026-09-09T02:00:00Z", "group": "backfill:reports" }
  ]
}
CaseAnswer
No such trigger404
from or to missing400
to not after from400
to after now on the host's clock400
More slots than maxSlots400
An option out of range400
ReadOnly403

A 400 schedules nothing and carries the refusal as detail, with Quartz-ExceptionType: BadHttpRequestException. A success is audited once, as BackfillTrigger, however many slots it stored.

Backfill on an HttpScheduler is this one request. The host's refusals arrive as ArgumentException, with the same words, and a missing trigger as ObjectDoesNotExistException, as in process. A host older than 4.3 answers the route 404 without problem details; HttpScheduler then backfills through the routes that host has, a request per slot.

Overlap policies

From 4.3. A trigger's overlap policy travels by name:

WhereMember
Trigger body"overlapPolicy": "Skip", beside continuationCondition; absent for Default
Listing header"overlapPolicy", always present
update-detailsoverlapPolicy, above
Misfire history"reason": Missed, or Overlap for a firing Skip dropped; from 4.4, Vetoed when asked for

A 4.2 client ignores the members, and a 4.2 host ignores overlapPolicy in a trigger body it is sent.

Continuations

A continuation is a trigger that waits for another trigger's firing. It travels wherever a trigger does: in a trigger body, in the listing header, and through POST …/schedule-job.

In a trigger body (what GET …/triggers/{group}/{name} returns and schedule-job accepts), three members sit beside retryPolicy:

{
  "continuesAfterTriggerName": "import",
  "continuesAfterTriggerGroup": "nightly",
  "continuationCondition": 6
}

continuationCondition is the stored integer there: 1 on success, 2 on failure, 4 on cancellation, 8 on veto, 15 however it ends. All three are null on a trigger that waits for nothing.

A listing header uses the same members, with the condition as names, like every enum in a body the API composes:

{
  "name": "reconcile", "group": "nightly",
  "state": "Awaiting",
  "continuesAfterTriggerName": "import",
  "continuesAfterTriggerGroup": "nightly",
  "continuationCondition": "OnFailure, OnCancellation"
}

?state=Awaiting lists waiting triggers; GET …/triggers/{group}/{name}/state answers { "state": "Awaiting" } for one.

Awaiting is a state a 4.1 client does not know

A 4.1 client reading a trigger listing or state from a 4.2 host meets "Awaiting" and throws, as a 4.0 client did with SchedulerOrigin.Remote from a 4.1 host. It only happens once continuations are scheduled, so follow the rolling-upgrade order: migrate, roll every node and client, then schedule continuations.

Configuration options

QuartzHttpApiOptions:

OptionDefaultWhat it does
ApiPath/quartz-apiBase path of every endpoint; see Where the API is served
IncludeStackTraceInProblemDetailsfalseAdds Quartz-ExceptionStackTrace to errors, and a 500's real message to detail
MaxPageSize1000Most items one paged request returns; 0 is unbounded; see Listing endpoints are paged
ReadOnlyfalseRefuses every mutating route with 403; see Serving reads only
SchedulerAuthorizationPolicynonePolicy for every route naming a scheduler, evaluated against it; see Authorizing per scheduler
IsJobTypeAllowednonePredicate over a request's job type name; refused is 403; see Narrowing which job types may be named
EventStreamHeartbeatInterval00:00:15Idle time before the event stream sends a Heartbeat
  • Set EventStreamHeartbeatInterval below the idle read timeout of any proxy in front of the API: nginx's proxy_read_timeout defaults to 60 seconds, Azure's front doors to 90.
  • There is one set of options per process, not per scheduler. Calling services.AddQuartzHttpApi(configure) twice configures the same options; the last callback wins for settings both touch.

Serving reads only

ReadOnly = true refuses every route that changes something:

services.AddQuartzHttpApi(options => options.ReadOnly = true);
  • A refusal is 403 with problem details reading The Quartz HTTP API is configured as read-only. It is decided before the handler: no body is read and no scheduler looked up, so refusals reveal nothing about which schedulers exist. It carries no Quartz-ExceptionType.
  • Use it where the API only feeds a dashboard, a monitoring tool or a report. Route-level authorization would have to name thirty-odd routes.
  • Mutation is decided per route, not per verb. The fetches, POST {ApiPath}/schedulers/{name}/jobs/fetch, POST …/triggers/fetch and POST …/history/job-status/fetch, are reads and are served. Every other non-GET is refused, including pause, resume, interrupt and reset-from-error-state.
  • It binds only this API. The scheduler keeps firing, and a dashboard mapped beside it has its own QuartzDashboardOptions.ReadOnly. A dashboard fronting this API from another process is bound by this setting and reports its refusal.

Authorizing per scheduler

RequireAuthorization(...) on the mapping covers the whole API, which fits when every caller may reach every scheduler. Otherwise, set SchedulerAuthorizationPolicy. Each request is then evaluated as IAuthorizationService.AuthorizeAsync(user, new SchedulerResource(name), policy) against the route's {schedulerName}.

  • A refused caller gets 403 with problem details, decided before the scheduler lookup, so a 404 only answers names the caller may ask about.
  • GET {ApiPath}/schedulers is filtered to the schedulers the caller may act on.
  • The application writes one AuthorizationHandler<TRequirement, SchedulerResource>. QuartzDashboardOptions.SchedulerAuthorizationPolicy uses the same resource, so one handler serves both. Worked example: Multi-tenancy.
  • Setting it without authorization services in the container fails at startup.
  • It authorizes and never authenticates: an anonymous caller gets whatever the policy says (a 403 if refused). Keep RequireAuthorization() on the mapped group to challenge with a 401 first.

Narrowing which job types may be named

Add-job, schedule and schedule-multiple take a job type name in the body. An authorized caller can name any IJob, including NativeJob (which starts the executable its job data names) when Quartz.Jobs is on the probing path. IsJobTypeAllowed restricts the names:

builder.Services.AddQuartzHttpApi(options =>
{
    // The predicate sees the job type name exactly as the request spelled it. A namespace
    // prefix therefore covers every spelling of the same type - with or without the
    // assembly's version, culture and public key token.
    options.IsJobTypeAllowed = jobType => jobType.StartsWith("Acme.Jobs.", StringComparison.Ordinal);
});
  • A refused name is 403 with problem details whose detail names only the requested type.
  • The refusal is per request: schedule-multiple stores none of its batch if one job is refused.
  • Each refusal is logged as event 9005 at Warning.
  • The predicate sees a name, not a Type. Nothing resolves it first, which would be the assembly probe this API avoids (A job type is a name).
  • One type has several spellings: Acme.Jobs.Nightly, Acme.Jobs, or with Version, Culture and PublicKeyToken. A namespace prefix covers them all; a set of exact strings is easy to get wrong.
  • A malformed name is still a 400, not a 403.
  • It is one predicate per process and does not receive the scheduler name, so it cannot allow a type for one scheduler only. The dashboard has its own QuartzDashboardOptions.IsJobTypeAllowed, configured separately.

Calling it from .NET

Quartz.HttpClient is the client half of this contract. Its HttpScheduler implements IScheduler over these endpoints:

dotnet add package Quartz.HttpClient
IScheduler scheduler = new HttpScheduler("MyScheduler", httpClient);
await scheduler.TriggerJob(new JobKey("nightly-report"));

With a container, register it and inject IScheduler, naming the IHttpClientFactory client that carries the base address and authentication.

The base address is the site root plus ApiPath, ending with /. A base address of the site root alone answers 404 on every call; one without the trailing slash is refused by the HttpScheduler constructor.

// The base address is the site root *plus the API path*, and it must end with "/"
builder.Services.AddHttpClient("quartz", client => client.BaseAddress = new Uri("https://scheduler.example.com/quartz-api/"));
builder.Services.AddQuartzHttpClient(schedulerName: "MyScheduler", httpClientName: "quartz");

Any HTTP client can speak this wire format. HTTP Client covers registration, authentication, serializer matching and what does not travel.

The wire contract is source-generated

The API's bodies are a closed set, described by a source-generated JsonSerializerContext shared by the server and Quartz.HttpClient: schedulers, job details, trigger pages, problem details and every request body. A new body must be added there.

Three things stay open:

  • ITrigger and ICalendar are read and written by converters that consult the scheduler's serializer registry, so custom trigger and calendar types can travel;
  • the values in a JobDataMap are whatever the application stored.

The generated contract is asked first and reflection second, so reflection covers only the payload, never the contract.

The server writes its errors from generated ProblemDetails metadata, behind reflection, so a trimmed or native AOT host answers them without it. A native canary runs the server and the client on every pull request; see Publishing Trimmed and Native AOT.

Production hardening

  • Require authentication and authorization on MapQuartzHttpApi(). Startup refuses a mapping that states nothing, but also accepts AllowAnonymous(): do not use it just to silence the error.
  • Do not expose this API or the dashboard to a network you would not give a shell. A job's type is a string from the request, and Quartz.Jobs puts NativeJob within reach. Set IsJobTypeAllowed wherever the deployment's job types are known in advance; see Narrowing which job types may be named.
  • Keep IncludeStackTraceInProblemDetails off in production: it returns the stack trace and a 500's real message.
  • Restrict mutating operations (schedule, delete, pause/resume, shutdown) to trusted operator roles. Quartz has no per-operation permissions: a caller who passes authorization is trusted with the whole API, including every job's data map. SchedulerAuthorizationPolicy limits which schedulers, IsJobTypeAllowed which job types; anything finer belongs in the policy or a gateway.
  • Every successful mutation is logged at Information as event 9007: "Api user {User} performed {Operation} on scheduler {SchedulerName}: {Route}".
    • {User} is HttpContext.User.Identity.Name, or (anonymous); authenticate the API for it to mean anything.
    • {Operation} is the endpoint's name; {Route} is the request path, which spells the target key.
    • One line per request, after the handler, for 2xx answers only. A refusal is 9005; a failure is 9003 or 9004.
    • It is this process's log. API actions are not in the dashboard's Action Log, and dashboard actions are not here. All events: Log Events.
  • Set ReadOnly where nothing should write through the API, such as a process that only feeds a dashboard, monitoring or reports; see Serving reads only.
  • Leave MaxPageSize set, so one request cannot load an unbounded result.
  • In a cluster, API calls are scheduler control operations with cluster-wide effect.
  • There is no rate limiting. ASP.NET Core's rate limiter middleware applies as to any endpoint; Quartz configures none.
Help us by improving this page!
Last Updated: 10/18/26, 9:21 AM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
ASP.NET Core Integration
Next
HTTP Client