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
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • 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
    • Retrying Failed Jobs
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • 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
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
    • 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.HttpClient is the client half of the HTTP API. HttpScheduler is a full IScheduler implementation whose calls go over the wire, so an operator process, a control panel or a deployment script schedules jobs against a remote scheduler with the same code it would use against a local one.

dotnet add package Quartz.HttpClient

What it pairs with

The server has to be running the Quartz HTTP API, from Quartz.AspNetCore:

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

Two things must line up:

  • The path. The client's HttpClient.BaseAddress plus the API path must reach the endpoints. The simplest arrangement is a base address that already includes the API path.
  • The scheduler name. Every request names the scheduler it is for, and the name the client is registered with must be the remote scheduler's own SchedulerName. A mismatch is a 404, not a connection error.

BaseAddress must end in /; the constructor rejects one that does not, because relative endpoint paths would otherwise resolve against the wrong segment.

Registering the client

The recommended shape names an IHttpClientFactory client, so the handler is pooled and recycled:

builder.Services.AddHttpClient("quartz", client =>
{
    client.BaseAddress = new Uri("https://scheduler.example.com/quartz-api/");
    client.Timeout = TimeSpan.FromSeconds(30);
});

builder.Services.AddQuartzHttpClient(schedulerName: "MyScheduler", httpClientName: "quartz");

There are three overloads:

OverloadUse when
AddQuartzHttpClient(string schedulerName, string httpClientName, JsonSerializerOptions?)the client is registered with AddHttpClient — the normal case
AddQuartzHttpClient(string schedulerName, Func<IServiceProvider, HttpClient> createHttpClient, JsonSerializerOptions?)the client is assembled from other services, or from something the factory does not know about
AddQuartzHttpClient(Action<HttpClientOptions> configure)you want to set several things at once

HttpClientOptions carries SchedulerName, HttpClientName, CreateHttpClient and JsonSerializerOptions. Exactly one of HttpClientName and CreateHttpClient must be set; giving neither or both fails validation at registration, with the same OptionsValidationException every other Quartz options type throws.

CreateHttpClient runs once, when the scheduler is first resolved, and is handed the container. The client it returns belongs to whoever created it — the scheduler never disposes it. That is why the option is a factory rather than an HttpClient: an options object is bound, cached and shared, and a live client sitting in one has no owner.

Injecting it

A remote scheduler is registered exactly like a local one: keyed by its name, and unkeyed as well while it is the only scheduler in the container.

public sealed class OpsController(IScheduler scheduler);                          // one scheduler

Once a second scheduler joins the container, name the one you meant:

public sealed class OpsController([FromKeyedServices("MyScheduler")] IScheduler scheduler);
IScheduler reporting = provider.GetRequiredKeyedService<IScheduler>("reporting");

The unkeyed registration is TryAdd, so a second remote scheduler does not quietly take over what "the scheduler" means — with two of them, inject by key.

Beside a local scheduler

AddQuartz() registers the local default scheduler in the same unkeyed slot, so in a container that has both, call AddQuartz() first:

builder.Services.AddQuartz();                                   // owns GetRequiredService<IScheduler>()
builder.Services.AddQuartzHttpClient("MyScheduler", "quartz");  // reachable by name

The local scheduler then owns GetRequiredService<IScheduler>(), and the remote one is reached with GetRequiredKeyedService<IScheduler>("MyScheduler") or [FromKeyedServices("MyScheduler")] — which is where it always is, whichever order the two calls are written in.

The other order throws an InvalidOperationException at registration. Registration is first-wins, so AddQuartzHttpClient(...) followed by AddQuartz() would leave "the scheduler" meaning the remote one with nothing said about it, and a program that thought it held its own scheduler would be scheduling jobs in somebody else's process. A named local scheduler — AddQuartz("Local", …) — is keyed by its name and never wanted the unkeyed slot, so it can be registered on either side.

Changed in 4.x

Driving two remote schedulers used to need a marker interface of its own, implemented by a type emitted at runtime. The service key says the same thing without the reflection, so the generic AddQuartzHttpClient<TScheduler>() overloads are gone.

Registration also binds the scheduler into the container's ISchedulerRepository, so it shows up in GetAllSchedulers, in the dashboard and in a locally hosted HTTP API. Under a host that happens at startup rather than on first injection; a container with no host stays exactly as lazy as it was.

Constructing one directly

No container needed:

using HttpClient http = new() { BaseAddress = new Uri("https://scheduler.example.com/quartz-api/") };
IScheduler scheduler = new HttpScheduler("MyScheduler", http);

await scheduler.TriggerJob(new JobKey("nightly-report", "reports"));

Authentication

The client carries no authentication of its own — it is an HttpClient, so whatever you would do for any other API works here:

builder.Services.AddHttpClient("quartz", client =>
    {
        client.BaseAddress = new Uri("https://scheduler.example.com/quartz-api/");
    })
    .AddHttpMessageHandler<BearerTokenHandler>()
    .AddStandardResilienceHandler();

Match it on the server with app.MapQuartzHttpApi("/quartz-api").RequireAuthorization(). The API is scheduler control — shutdown, delete, pause-all are all in it — so an unauthenticated endpoint is a remote kill switch.

Serialization must match the server

Both ends speak the Quartz wire format, which is System.Text.Json with Quartz's own converters. The client builds its options from a copy of whatever you pass in, adds those converters to the copy, and leaves your instance untouched — so sharing one JsonSerializerOptions across several clients is safe.

Custom trigger and calendar types need their serializers registered on both sides. The remote scheduler's registrations are invisible from this process, so the client cannot discover them:

SystemTextJsonSerializerRegistry registry = new();
registry.AddTriggerSerializer<MyTrigger>(new MyTriggerSerializer());

IScheduler scheduler = new HttpScheduler("MyScheduler", http, jsonSerializerOptions: null, registry);

Registering through the container instead — the same AddQuartz-side serializer registration the server uses — is picked up automatically, because AddQuartzHttpClient resolves the container-wide registry.

What travels, and what does not

The wire carries data, not objects. Three consequences are worth knowing before you build on this:

Job details are rebuilt. A JobDetailDto carries name, group, job type name, description, Durable, RequestsRecovery, ConcurrentExecutionDisallowed, PersistJobDataAfterExecution and the job data map. GetJobDetail reconstructs a standard job detail from those fields, so a custom IJobDetail implementation on the server comes back as the ordinary one and any behaviour that lived in your type stays on the server.

The job type is a name. It is the assembly-qualified type name as the server has it. The client treats it as text: it never resolves it, never loads an assembly for it and never probes for one, in either direction. So the client does not need the type to exist locally to list, pause, trigger, schedule or add a job — only to reason about the type itself, which is your own call to make.

The two attribute-derived flags can be absent. concurrentExecutionDisallowed and persistJobDataAfterExecution are nullable on the wire: a value means the sender stated it, null means "whatever [DisallowConcurrentExecution] / [PersistJobDataAfterExecution] on the type says". Omit them when adding a job and the side that resolves the type decides; state them and your value wins. A job whose type the answering process cannot resolve reports them as null rather than false, so reading such a job answers with what is known instead of failing.

Enums are names. status, state, repeatIntervalUnit, daysOfWeek — all of them travel as the C# member name, and the names are the contract. Numeric forms are still accepted on input, which is what keeps an older client working.

What is not supported remotely

Both throw NotSupportedException, with a message that names the member and says why. Neither is a missing route: both are things a process boundary makes impossible.

MemberWhy not
Contextthe scheduler context is a live object in the scheduler's own process; a copy fetched over HTTP could not be written back
ListenerManagerlisteners run in the process that executes jobs

Listeners are the important one: a TriggerListener registered on a client would never see anything, because nothing fires here. Register listeners where the scheduler actually runs.

Read scheduler-wide state from the endpoint (GET {apiPath}/schedulers/{name}/context) where you would have reached for Context.

Blocking members

IScheduler has three members that are properties rather than methods, and over HTTP two of them are a request:

SchedulerInstanceId and Status call the remote scheduler synchronously, blocking the calling thread for the round trip. SchedulerName is the one that is free — the client already knows it. Context is not in this list because it does not reach the remote scheduler at all; see above.

Status is one request for the whole lifecycle, where the IsStarted / InStandbyMode / IsShutdown it replaces were three requests to the same endpoint, each reading a different field of the same answer.

Do not touch the two properties on a request path. Both have an asynchronous twin on IScheduler — GetStatus() and GetSchedulerInstanceId() — which ask the same question in the same one request and await the answer instead of holding a thread while it arrives. These are the members to call:

SchedulerStatus status = await scheduler.GetStatus(cancellationToken);
string instanceId = await scheduler.GetSchedulerInstanceId(cancellationToken);

They are default interface members that answer the property, so a scheduler in this process reports exactly what it did before and pays nothing for the indirection; only a proxy overrides them. The properties stay, and stay blocking — IScheduler declares them, and a property cannot be awaited.

Quartz itself no longer reads either property off a scheduler in another process. Two places used to. ISchedulerRepository read Status under its lock on every lookup, to notice a scheduler that had shut down, so one unreachable target stalled every lookup in the process for as long as the client's timeout — including the HTTP API's own scheduler resolution. It skips a proxy now: unreachable is not shut down, and a proxy that had shut down names a scheduler this process cannot restart anyway. And the scheduler listing asks GetStatus() and GetSchedulerInstanceId() under a two-second deadline for the whole listing, reporting a target that does not answer as SchedulerStatus.Unknown with no instance id. Give the client a short Timeout regardless: every other read waits for it.

GetMetadata() answers both and the rest of the scheduler's details in one request, so prefer it where more than the status is wanted:

SchedulerMetadata metadata = await scheduler.GetMetadata(cancellationToken);

Its IsProxy is true for an HTTP scheduler, and the three type properties — SchedulerTypeName, JobStoreTypeName, ThreadPoolTypeName — are strings, not System.Type. That is what lets the metadata describe a remote scheduler whose types do not exist in this process.

History

AddQuartzHttpClient registers one more thing beside the scheduler: an IExecutionHistoryStore keyed by the scheduler's name, which reads what the target has run and what it has missed through the API's history routes. That is a different question from anything a job store answers — a job store holds what is scheduled — and it is why a dashboard fronting a scheduler over HTTP has a History page at all.

IExecutionHistoryStore history = provider.GetRequiredKeyedService<IExecutionHistoryStore>("QuartzScheduler");
PagedResult<ExecutionHistoryEntry> page = await history.QueryExecutions(new ExecutionHistoryQuery
{
    SchedulerName = "QuartzScheduler",
    JobContains = "nightly"
});

It reads and does not write: history is recorded where the jobs run, so AddExecution and AddMisfire raise NotSupportedException. So does every read when the target's API predates the history routes — it answers 404 for them, and "this target serves no history" is a fact a caller can render, where an exception about a missing route is not. The 404 that names an unknown scheduler is unaffected and still arrives as HttpClientException.

Paging and bulk fetch over the wire

The query family maps straight onto query-string parameters:

PagedResult<TriggerHeader> page = await scheduler.QueryTriggers(new TriggerQuery
{
    Group = GroupMatcher<TriggerKey>.GroupStartsWith("reporting-"),
    State = TriggerState.Error,
    Skip = 0,
    Take = 100,
    IncludeTotalCount = true,
}, cancellationToken);

Skip, Take and IncludeTotalCount become skip, take and includeTotalCount; matchers become groupStartsWith, nameEquals and their siblings. take defaults to 250 at both ends, so a client that leaves it unset gets the same page size the server would have chosen.

QueryFireInstances works the same way and is how a remote console shows what is running — across the whole cluster, since the listing is store-backed. QueryClusterNodes is its companion and takes no query at all: it reads GET …/schedulers/{name}/nodes and answers with the nodes themselves, the one that served the request first. "Current node" therefore means current on the server, not on the client — the client has no identity in the cluster — so the order arrives as the server chose it and is not re-sorted here.

Bulk fetch posts the keys back:

List<IJobDetail> details = await scheduler.GetJobDetails(keys, cancellationToken);

The endpoint accepts at most 1000 keys per call; page the keys if you have more.

Errors

What the server rejects arrives as the exception it named. The API's problem details carry the type the failure came from, and the client rebuilds it — so a catch written against a local scheduler fires the same way against a remote one. Eight names are mapped back:

The server raisedThe client rethrows
SchedulerExceptionSchedulerException
InvalidConfigurationExceptionInvalidConfigurationException
JobExecutionExceptionJobExecutionException
JobPersistenceExceptionJobPersistenceException
SchedulerConfigExceptionSchedulerConfigException
LockExceptionLockException
NoSuchDelegateExceptionNoSuchDelegateException
ObjectAlreadyExistsExceptionObjectAlreadyExistsException
anything elseHttpClientException

ObjectAlreadyExistsException is the one most code depends on: it is what ScheduleJob and AddJob raise for a duplicate, and catching it works over HTTP exactly as it does in process.

Everything else is an HttpClientException — a request the endpoint rejected before it reached a scheduler, a scheduler name the server does not hold, a response carrying no problem details, a body that could not be read. It derives from SchedulerException, so a single catch (SchedulerException) covers both halves, and it carries the RFC 7807 problem details in its message. Turning on QuartzHttpApiOptions.IncludeStackTraceInProblemDetails on the server puts the server's stack trace in there too — useful in development, and not something to ship.

A 500 is the exception. Its problem-details detail is one fixed sentence — "The scheduler failed to handle the request. The failure is recorded in the server's log." — rather than the exception's message, so an HttpClientException raised by a server fault says only that and points at the server's log. IncludeStackTraceInProblemDetails puts the message back.

The 3.x-compatible listings — GetJobKeys, GetTriggerKeys, GetCalendarNames, GetJobGroupNames, GetTriggerGroupNames, GetPausedTriggerGroups — ask the server for every match, and a server with QuartzHttpApiOptions.MaxPageSize set (it defaults to 1000) answers with at most that many. Below the cap they behave exactly as they always have; above it the client raises an HttpClientException naming MaxPageSize rather than handing back a page that would read as the whole store. Read a large listing with the Query* members and a Take of your own, or raise the cap on the server.

A 404 for a read is not an error: GetJobDetail and GetTrigger return null, exactly as a local scheduler would.

Security: what the server can and cannot make the client do

The client trusts the server for data, and for nothing else.

  • Names stay names. A job type name in a response is never resolved, so a server cannot choose an assembly simple name that your runtime then goes looking for — which would run a module initializer in whatever matched and steer any AssemblyResolve handler your application registered. Nothing in the client calls Type.GetType on a server-supplied string.
  • Error bodies are matched against a closed list. The server names the exception type in the problem details, and the client rebuilds one of eight known Quartz exceptions from that name. Anything else becomes an HttpClientException carrying the server's detail as text. No type is loaded and none is activated by name.
  • The transport is yours. Quartz takes an HttpClient you configured. TLS and certificate validation, redirect following (HttpClientHandler.AllowAutoRedirect, on by default), the response buffer cap (HttpClient.MaxResponseContentBufferSize) and the timeout are all settings on that client or its handler, and Quartz changes none of them. A server you do not control is a server whose responses you should bound: set MaxResponseContentBufferSize and a Timeout, and turn redirects off if your credentials travel in a header.
  • Credentials are yours too. Whatever DelegatingHandler or default header you attach goes on every request to that BaseAddress; see Authentication.

The API's own trust boundary is on the server side and is documented with it: see Production hardening.

See also

  • HTTP API — the server half, and the full endpoint and wire-format reference
  • Querying Jobs and Triggers — the query family these calls implement
  • Multiple Schedulers — naming and keying schedulers in one container
  • Dashboard — a scheduler registered this way rendered and driven from a browser
Help us by improving this page!
Last Updated: 9/11/26, 7:37 PM
Contributors: Marko Lahma, Claude Fable 5.1
Prev
HTTP API
Next
Dashboard