Jobs
Jobs are small tasks that the bpmn engine created by executing process instances. When an external job (job that cannot be handled by the engine itself) is created the token that activated it transitions into waiting state. Token then waits for the job to be completed by 3rd party application by calling API to complete job. This is the main point of interaction of business applications with bpmn engine.
Internal jobs
Internal jobs are jobs that run internally in the bpmn engine and do not require 3rd party applications for completion (either by calling REST or GRPC api).
They can be registered by using NewTaskHandler method on the bpmn engine.
If you are using the engine as a library you can register your handlers and they will be executed right away when the token hits the task node that can be handled by one of the registered handlers.
External jobs
External jobs are jobs that require interaction with one of the ZenBPM's public APIs (REST or GRPC) by a 3rd party application.
Working with external jobs
REST API
Should be reserved for simpler job loads (e.g. completing user task) and task types that do not have multiple instances of workers trying to complete them.
You can load the waiting jobs with getJobs endpoint with state=active and optionally jobType=mycooljobtype filter:
Get list of jobs on partitions
query Parameters
| processInstanceKey | integer <int64> Filter by process instance |
| jobType | string Example: jobType=service-task Filter by job type |
| assignee | string Filter by assignee |
| state | string (JobState) Enum: "active" "completed" "terminated" "failed" Example: state=active Filter by job state |
| page | integer <int32> >= 1 Default: 1 Example: page=1 Page number (1-based indexing) |
| size | integer <int32> [ 1 .. 100 ] Default: 10 Example: size=10 Number of items per page (max 100) |
| sortBy | string Enum: "createdAt" "key" "type" "state" Sort field |
| sortOrder | string Enum: "asc" "desc" Sort direction |
Responses
Response samples
- 200
- 400
- 405
- 500
- 502
{- "partitions": [
- {
- "partition": 1,
- "totalCount": 1,
- "items": [
- {
- "key": 4503599627370540,
- "elementId": "Task_ReviewApplication",
- "type": "user-task",
- "processInstanceKey": 4503599627370501,
- "state": "active",
- "createdAt": "2025-08-22T15:10:26Z",
- "assignee": "john.doe",
- "retries": 3,
- "inputVariables": {
- "formKey": "loan-review-form"
}
}
]
}, - {
- "partition": 2,
- "totalCount": 1,
- "items": [
- {
- "key": 9007199254740996,
- "elementId": "Task_SendNotification",
- "type": "service-task",
- "processInstanceKey": 9007199254740992,
- "state": "active",
- "createdAt": "2025-08-22T15:46:33Z",
- "retries": 3,
- "inputVariables": {
- "notificationType": "email",
- "recipient": "customer@example.com"
}
}
]
}
], - "page": 1,
- "size": 10,
- "count": 2,
- "totalCount": 2
}This endpoint will return a list of partitions and jobs of type mycooljobtype that are waiting to be completed. Pagination on this endpoint is applied per partition. This means that page 1 and size 10 will return 20 jobs on fully saturated 2 partition setup.
To complete the job and move the token to the next element you have to call completeJob endpoint. Completing a job completed before answers 201 again; a job terminated or failed meanwhile no longer waits for a worker and answers 409.
Complete a job
Completes the job with the given output variables and moves its token on.
The request is idempotent: a job which is already completed is answered with 201 again,
and its process instance is moved on if an earlier completion left it behind. A job which
was terminated (its instance cancelled, an interrupting boundary event) or failed with an
incident no longer waits for a worker and is answered with 409; nothing is changed.
path Parameters
| jobKey required | integer <int64> Example: 4503599627370540 |
Request Body schema: application/jsonrequired
| variables | object or null |
Responses
Request samples
- Payload
{- "variables": {
- "result": "success",
- "transactionId": "tx-789-xyz",
- "processedAt": "2025-08-22T15:15:30Z"
}
}Response samples
- 400
- 404
- 405
- 409
- 413
- 415
- 500
- 502
{- "code": "NOT_FOUND",
- "message": "Process instance with key 4503599627370501 not found"
}GRPC API
Provides more robust solution to executing heavy workloads and distributes them across multiple instances of clients that can perform the same type of work. GRPC API uses Job manager to distribute jobs between nodes and clients.
Job streaming can be initiated by calling grcp.Zenbpm JobStream procedure. This opens a bidirectional stream between client and one of the ZenBPM nodes. Client can provide its clientID that will be used to balance job distribution in metadata. If not provided one will be generated on the server.
First thing that a client should do is send a StreamSubscriptionRequest message with type of TYPE_SUBSCRIBE and required job_type. This message can be repeated to register multiple job types.
After the client register itself for job processing the server will start sending jobs that need to be processed to the client. Every delivered job is locked for the client it was sent to and will not be distributed to another client until that lock lapses (see Job locks).
When client finishes the work that had to be done to complete the job, client must send JobCompleteRequest message. This message will complete the job in the engine and move the token to next element. A completion is idempotent: a job completed before is answered as completed again. A completion the engine did not carry out is answered with an ErrorResult next to a WaitingJob carrying only the key, and its code says why: JOB_STREAM_ERROR_CODE_JOB_IN_TERMINAL_STATE (6) when the job was terminated or failed meanwhile, JOB_STREAM_ERROR_CODE_JOB_NOT_FOUND (5) when its partition has no job with that key, JOB_STREAM_ERROR_CODE_INVALID_REQUEST (4) for variables which do not decode, and JOB_STREAM_ERROR_CODE_LEADER_UNAVAILABLE (3) when the leader of the job's partition could not be reached or has just changed - the completion may have been recorded all the same, and repeating it is safe.
Keys of a partition the cluster does not have. A job key encodes the partition of its job. A key naming a partition the cluster does not have, such as partition 0, which is reserved for global resources, can never name a job. Once the cluster has formed all its partitions, a completion or failure of such a key is answered with JOB_STREAM_ERROR_CODE_JOB_NOT_FOUND (5), a lock extension with JOB_STREAM_ERROR_CODE_LOCK_NOT_HELD (1), and the REST endpoints routed by the key answer 404, or an empty page for a listing such as the jobs of a process instance; repeating the request gets the same answer. An endpoint which validates the key before routing it, such as the element statistics of a process instance, answers a key that is not positive, like -1, with 400 instead. While the cluster is still forming its partitions it cannot tell such a key from one of a partition about to be formed, and answers JOB_STREAM_ERROR_CODE_LEADER_UNAVAILABLE (3), or 502 over REST, as it does for a partition whose leader is changing.
If there is an error while executing the job logic the client should send JobFailRequest message. With an error_code it throws a BPMN error; without one it spends one of the job's retries, and only the failure which leaves no retries creates an incident (see Failures and retries).
If the work takes longer than the lock, the client sends a JobExtendLockRequest message before the lock lapses; the engine answers with a LockExtended message carrying the new deadline.
Task headers
A BPMN element can define static task headers — key/value pairs handed to the job worker together with the job, unchanged. They are authored with the taskHeaders extension element. Both the ZenBPM namespace and the Camunda Modeler / Zeebe namespace (zeebe:taskHeaders) are accepted, so a model exported from Modeler deploys as-is:
<bpmn:serviceTask id="charge">
<bpmn:extensionElements>
<zenbpm:taskDefinition type="payment-connector" />
<zenbpm:taskHeaders>
<zenbpm:header id="h1" key="url" value="https://example.com/charge" />
<zenbpm:header id="h2" key="method" value="POST" />
</zenbpm:taskHeaders>
</bpmn:extensionElements>
</bpmn:serviceTask>
Each header has an optional id and a key/value pair. The values are literal: they are not expression-evaluated, so a header cannot reference process variables. Headers belong to the BPMN element rather than to a single job, so every job the element creates carries the same headers.
Headers are persisted with the job (a JSON object in the job.headers column, '{}' when the element defines none) and exposed to whoever receives the job:
| Consumer | Field |
|---|---|
gRPC WaitingJob | headers map, read with job.GetHeaders() |
REST getJobs / getJob response | taskHeaders object, omitted when the element defines none (zenclient.Job.TaskHeaders) |
| In-engine / inline handlers | ActivatedJob.Headers(), nil when the element defines none |
Headers are part of the job payload captured at creation, not of the process variables: input mappings do not touch them and they are not written back on completion.
Job locks
A job delivered over the stream is reserved for the receiving client for the lock duration of the subscription it was delivered under. StreamSubscriptionRequest carries two optional settings per job type:
| Field | Meaning | Default | Cap |
|---|---|---|---|
lock_duration_ms | how long a delivered job of this type stays locked for this client | jobManager.defaultLockDurationMs (30 s) | jobManager.maxLockDurationMs (24 h) |
max_active_jobs | how many jobs of this type this client may hold at once | jobManager.defaultMaxActiveJobs (10) | jobManager.maxActiveJobsCap (1000) |
A value of 0 means the engine's default; a value above the cap is lowered to the cap, never rejected. Subscribing again to the same job type replaces the settings; jobs already delivered keep the deadline they were delivered with. The defaults and caps are configured in the jobManager section.
Lock deadline. Every WaitingJob carries lock_until, the unix millisecond on the clock of the partition leader at which its lock lapses. lock_until - now on the client is an estimate of the remaining time: it is exact only as far as the two clocks agree, and it does not see the time the delivery spent in transit. It is also conservative: the leader counts the lock from the moment the delivery left it, while the reported deadline was taken just before, so the real deadline is never earlier than the reported one. Keep the clocks synchronised (NTP), and renew with a safety margin, at the latest at half the remaining time, rather than just before the deadline. When the clocks cannot be trusted, count from the moment of receipt with the lock duration you subscribed with, which needs no clock comparison at all. The Go client exposes the deadline as job.GetLockUntil().
Lock extension. JobExtendLockRequest{key, lock_duration_ms} moves the deadline to now plus the duration (0 = the subscription's lock duration; capped by jobManager.maxLockDurationMs). The new deadline is relative to now, not added to the previous one, so a worker renewing every half lock duration keeps a stable lead. With delivery_token the request extends only the lock of that delivery: once the job was handed out again, as after the delivery's lock lapsed, it is refused as not held even when the next delivery went to the same client, so that a handler still at work on the lapsed delivery learns it lost the job instead of keeping the next delivery locked. Without it the request extends whatever lock the client holds on the job. The engine answers on the same stream:
LockExtended{key, lock_until}on success;ErrorResultwithcode=JOB_STREAM_ERROR_CODE_LOCK_NOT_HELD(1) when the lock lapsed, the job was completed or failed, it was never delivered to this client or no job has the key, orJOB_STREAM_ERROR_CODE_LOCK_HELD_BY_OTHER_CLIENT(2) when another client holds it. A refused extension leaves the job untouched. TheLockExtendednext to the error carries only the key.ErrorResultwithcode=JOB_STREAM_ERROR_CODE_LEADER_UNAVAILABLE(3) when the leader of the job's partition could not be reached or has just changed. Unlike a refusal, this leaves the outcome unconfirmed: a leader which lost the connection after applying the extension has moved the deadline already, and a leader change forgets the lock (see below). Retry the extension in a moment, as the RESTextendJobLockendpoint's 502 asks you to, and count on the deadline of the last confirmed answer only. The Go client reports it aszenclient.ErrLeaderUnavailable.
The Go client offers Worker.ExtendLock(ctx, jobKey, duration), WithLockDuration and WithMaxActiveJobs (through RegisterWorkerWithOptions and WithJobType); it does not renew locks by itself.
Active-job cap. The cap is counted per client and job type: a client holding its ten jobs of one type still receives jobs of every other type it subscribed to. The leader loads at most the sum of the free slots per round, and never more than its internal batch size of 300 jobs. Lowering the cap by subscribing again binds the next delivery, jobs already held stay held.
A leader also holds at most about 32,700 locked jobs across all its clients and job types, whatever the subscriptions add up to: every locked key is a parameter of the query which loads the next batch, and the database accepts 32,766 parameters per query. A leader at that bound logs a warning and delivers again as locks lapse or jobs complete.
The cap is enforced by each partition leader for the jobs of its own partitions. A cluster whose partitions are led by several nodes therefore lets a client hold up to the cap per leader; there is no cluster-wide budget. Size the cap for the number of partition leaders, or run the workers against a cluster with a single partition, until such a budget exists.
⚠️ Upgrade note: before locks became configurable, the ten-job cap was counted per client across all job types. A client subscribed to several job types may now receive more jobs in total than before. Set
max_active_jobsper type to restore the old total.
What a lock is not. The lock lives in memory on the partition leader. A leader change forgets every lock and the new leader redelivers the open jobs at once, so a handler must tolerate a second delivery of a job it is still working on after a failover; the failure of the earlier delivery no longer counts then (see Delivery tokens). Completion and failure are not bound to the lock holder: a job may be completed or failed by anybody who knows its key, including a REST client which never held the lock. A failure which names its delivery token releases the lock of that delivery, whoever reports it, and never the lock of another delivery: the late or repeated failure of an earlier delivery leaves the worker of the current one working, and the job is not handed out alongside. A failure without a token releases the lock only when the reporting client holds it: a client whose lock lapsed, and which reports its failure after the job was handed to another client, does not take the job from that client. A completion or failure refused because the job no longer waits releases the lock at once. Jobs fetched through the REST getJobs endpoint hold no lock at all.
Workers which receive jobs over the stream but send their commands over REST extend a lock with the extendJobLock endpoint, passing the client id of their stream:
Extend the lock of a job delivered over the gRPC job stream
Moves the lock deadline of a job to now plus lockDuration (absent: the lock duration of the
subscription the job was delivered under, capped by jobManager.maxLockDurationMs) and answers
the new deadline. clientId is the client id of the job stream the job was delivered to; a job
which is not locked at the moment (the lock lapsed, the job was completed or failed) or which is
locked for a different client answers 409, and the job itself is left untouched. With
deliveryToken only the lock of that delivery is extended: a lock the job holds for another
delivery, as after this delivery's lock lapsed and the job came back to the same client, answers
409 too. It exists for workers which receive jobs over the stream but send their commands over
REST. Jobs fetched through GET /jobs hold no lock and cannot extend one.
path Parameters
| jobKey required | integer <int64> Example: 4503599627370540 |
Request Body schema: application/jsonrequired
| clientId required | string The client id of the job stream the job was delivered to. |
| deliveryToken | integer <int64> >= 1 The |
| lockDuration | string ISO-8601 duration to lock the job for, counted from now. Absent means the subscription's lock duration. |
Responses
Request samples
- Payload
{- "clientId": "order-worker-1",
- "lockDuration": "PT5M"
}Response samples
- 200
- 400
- 404
- 405
- 409
- 413
- 415
- 500
- 502
{- "lockUntil": "2025-08-22T15:20:30Z"
}Such workers pass the client id of their stream to the failJob endpoint as well, as clientId. A REST failure without it leaves a stream lock standing: the job is handed out again only once the lock has lapsed, whatever retryBackoff asks for, although GET /v1/jobs/{key} shows no retryAt.
Failures and retries
A worker which cannot finish a job reports a failure: JobFailRequest on the stream, the failJob
REST endpoint, or a zenclient.WorkerError from a Go worker. What happens depends on whether the
failure carries an error code.
With an error code the failure is a BPMN error. A matching error boundary event or error event sub-process takes over and receives the failure's variables; without a match the job fails with an incident. Retries are neither consulted nor spent.
Without an error code (absent or empty) the failure is technical and spends one attempt of the job:
- Every job starts with the
retriesof itszenbpm:taskDefinition- a non-negative integer, or a FEEL expression starting with=evaluated when the job is created. Without the attribute it getsjobs.defaultRetries, which is1: one attempt, so the first failure creates an incident. - A failure sets the remaining retries to what the request names in
retries, or to one less than now.retriesmay be larger than the current value, a worker may top up, but never abovejobs.maxRetries; a negative value is refused with400.retriesand the backoff are validated in every request: an invalid value is refused even next to an error code, which otherwise ignores them. - While retries remain, the job stays
active, no incident is created and the process instance is untouched. The job is not handed out again - neither over the stream nor by the library'sActivateJobs- before itsretryAt, and is afterwards without anybody acting, within the job manager's polling interval of about a second, unless a stream lock still reserves it (see "What a lock is not" above).GET /v1/jobs?state=activestill lists it; itsretryAtsays why nobody holds it. The failure's variables are dropped, so the next attempt starts from the same input. - The failure which leaves no retries fails the job with an incident whose message names the
attempts,
<message> (job <key>: 3 attempts, retries exhausted), and whosejobKeyleads to the job. The failure's variables stay on the job as its output variables. - Resolving that incident starts a fresh series:
attemptsgoes back to0,retryAtis cleared and the retries of the task definition are evaluated again - unless an operator set new retries meanwhile (see below), which are kept together with theretryAtthe operator chose. Aretriesexpression which no longer evaluates refuses the resolution with409and changes nothing; the message names the error and the way out: correct the variables the expression reads and resolve again, or resolve again with the job's retries given (see below). Resolving the incident of an error code nothing caught does the same, so leftover retries do not carry over into the new series; retries an operator set before that incident are forgotten with the series they belonged to. An incident the job did not raise itself, such as one of a boundary event of its task, leaves the series alone when it is resolved: attempts, retries andretryAtstay as they were.
Delivery tokens
Every delivery over the stream carries a delivery_token. The job manager writes it before it
hands the job out and raises it with every delivery, a redelivery after a lapsed lock or a leader
change included, and it never goes back for a delivery which reached a worker, not even when an
incident of the job is resolved. A delivery which a completion, failure or retry update of its job,
or the resolution of an incident of the job, overtakes after its token was written but before it
was sent is not sent, and its token is taken back, so that the failure of the delivery before it is
judged against the delivery which really reached a worker. The same holds for the token of a
delivery which the stream to the worker's node did not accept; that stream is not used again in the
round, and the tokens of every delivery it did not take go back in one write. A delivery handed out
by a leader which issues no tokens, as during a rolling upgrade, reaches the worker without one, and
a request over the stream naming the token 0, which no delivery carries, names no delivery. A fail
request names the token of the delivery it belongs to - JobFailRequest.delivery_token on the
stream, deliveryToken on the REST endpoint - and takes it from that delivery. GET /v1/jobs/{key} shows the token of the job's latest delivery for
inspection, but it is served by any node and may lag behind, so it is no source for a failure; a
worker which never received the job over the stream sends no token. The Go client names it in every
failure it sends. With it a failure counts once, whatever it carries:
- The first failure of the job's latest delivery is recorded as described above.
- A repeat of a failure the job recorded changes nothing and is answered like the first:
204, or success on the stream. That holds after the job was handed out again, once the failure exhausted the retries or raised the incident of a BPMN error, and when the instance ended meanwhile. One exception: a BPMN error leaves no entry in the failure history, so once the incident it raised was resolved and the failure of a later delivery was recorded, its repeat is refused like the failure of a superseded delivery below. - A failure of a delivery which another delivery superseded before its failure was recorded - the
worker worked past its lock, or the leader changed, and the job was handed out again - changes
nothing and is refused:
409on the REST endpoint,JOB_STREAM_ERROR_CODE_JOB_IN_TERMINAL_STATEon the stream. It neither spends the attempt the newer delivery is working on nor creates an incident, triggers an error event or releases a lock while that delivery runs: the newer delivery decides. - A token below
1, or one never handed out, is refused with400/JOB_STREAM_ERROR_CODE_INVALID_REQUEST.
A fail request without a token is not idempotent: every failure without an error code the engine
receives spends an attempt, also one reported for a job waiting out its backoff, which nobody holds
at that moment, and a failure the worker of a lapsed lock reports late counts as well. A client
which repeats such a request after a timeout may spend two attempts for one failure. A failure
reported for a job which no longer waits for a worker - the repeat of a failure which exhausted the
retries, or a job completed or terminated meanwhile - changes nothing and is refused: 409 on the
REST endpoint, JOB_STREAM_ERROR_CODE_JOB_IN_TERMINAL_STATE on the stream. A failure without a
token counts for no delivery, so the worker of the delivery which runs at that moment can still
report its own failure.
Completions are not bound to a delivery: a worker whose lock lapsed may still complete the job, and the first completion ends it, as before.
A JobFailRequest on the stream which the engine did not record is answered with an ErrorResult
next to a WaitingJob carrying only the key. Its code says why:
JOB_STREAM_ERROR_CODE_INVALID_REQUEST (4) for negative retries, a negative backoff, a delivery
token never handed out or variables which do not decode, which the REST endpoint answers with
400 - the message names the field and the value; JOB_STREAM_ERROR_CODE_JOB_NOT_FOUND (5) when
the job's partition has no job with that key; JOB_STREAM_ERROR_CODE_JOB_IN_TERMINAL_STATE (6)
when the job no longer waits for a worker, or no longer for the delivery the failure names;
JOB_STREAM_ERROR_CODE_LEADER_UNAVAILABLE (3) when the leader of the job's partition could not
be reached or has just changed - the failure may have been recorded all the same. Repeating a
failure which names its delivery token changes nothing more; repeating one without a token spends a
second attempt, or, with an error code, is refused with code 6 once the first was recorded. Any
other error carries no code.
A job whose token is terminated during its backoff, by an interrupting boundary event or by cancelling the instance, is terminated like any active job.
Backoff
How long a failed job waits before it is handed out again is decided, most specific first, by:
- the
retry_backoff_msofJobFailRequestor theretryBackoffof the REST request (an ISO-8601 duration such asPT10S;0/PT0Smeans at once); - the
retryBackoffof thezenbpm:taskDefinition: one ISO-8601 duration (PT10S, a fixed delay) or a comma-separated list of them (PT10S,PT1M,PT10M), where the n-th failure waits the n-th entry and the last entry repeats -PT10S,PT20S,PT40S,PT80Sis an exponential backoff. A FEEL expression starting with=may produce such a string, a FEEL duration (=duration("PT10S")) or a list of duration strings and FEEL durations; a fraction of a second in a FEEL duration is dropped; jobs.defaultRetryBackoff,PT0Sunless configured.
Every backoff is capped by jobs.maxRetryBackoff (PT24H). Durations are hours, minutes, seconds,
days (24 hours) and weeks; years and months have no fixed length and are refused. The policy of the
task definition is fixed when the job is created, so redeploying the model does not change the
policy of a running job. A literal retries or retryBackoff which does not parse is refused when
the definition is deployed; an expression is checked when the job is created, and one which does
not evaluate to a valid value creates an incident there. A definition deployed before the engine
read these attributes is not checked again: it keeps loading whatever its literals say, and a
literal which does not parse creates an incident when a job is created, like an expression.
<zenbpm:taskDefinition type="charge-card" retries="4" retryBackoff="PT10S,PT1M,PT10M" />
What a job shows
| Field | Meaning |
|---|---|
retries | attempts left: failures without an error code the job may still report; the one which leaves none creates an incident |
attempts | failures without an error code since the job was created or its incident resolved |
retryAt | before this moment an active job is not handed out; absent when it is deliverable. A job which ended during its backoff keeps the value |
lastFailureMessage | message of the latest failure without an error code |
retryBackoff | the policy of the task definition; absent when the engine default applies |
deliveryToken | token of the latest delivery over the stream, 0 while the job was never handed out; for inspection, a worker takes the token of its failure from its delivery |
Every delivery over the stream carries retries, attempt - 1 for the first delivery of a
series and one more after every failure; two deliveries of one attempt, a lapsed lock or a leader
change, carry the same number - and its delivery_token, which differs for every delivery (see
Delivery tokens). The Go client exposes them as job.GetRetries(),
job.GetAttempt() and job.GetDeliveryToken().
Every failure without an error code is recorded: attempt, time, message, when the job was handed
out again and, on the failure which exhausted the retries, the incident key. The record keeps the
delivery token the failure named as well, which is how a repeat arriving after the job was handed
out again is told from a failure of a delivery another one superseded. The records are
deleted together with the process instance by the history cleanup. An unknown job answers 404, a
job which never failed an empty page. The page, like the job itself read with getJob, is served by
any node of the job's partition and is eventually consistent: read right after a failure it may not
show it yet, so a reader acting on it polls until it does.
List the failures of a job
Lists the failures without an error code a worker reported for the job, newest first: the attempt, when it failed, why, and when the job was handed out again. The failure which exhausted the retries carries the key of the incident it created. Failures with an error code are BPMN errors and are not listed.
path Parameters
| jobKey required | integer <int64> Example: 4503599627370540 |
query Parameters
| page | integer <int32> >= 1 Default: 1 Example: page=1 Page number (1-based indexing) |
| size | integer <int32> [ 1 .. 1000 ] Default: 10 Example: size=10 Number of items per page (max 1000) |
Responses
Response samples
- 200
- 400
- 404
- 405
- 500
- 502
{- "items": [
- {
- "key": 4503599627370560,
- "jobKey": 4503599627370540,
- "processInstanceKey": 4503599627370501,
- "attempt": 2,
- "failedAt": "2025-08-22T15:11:40Z",
- "retryAt": "2025-08-22T15:12:40Z",
- "message": "payment service unavailable"
}, - {
- "key": 4503599627370555,
- "jobKey": 4503599627370540,
- "processInstanceKey": 4503599627370501,
- "attempt": 1,
- "failedAt": "2025-08-22T15:11:20Z",
- "retryAt": "2025-08-22T15:11:30Z",
- "message": "payment service unavailable"
}
], - "page": 1,
- "size": 10,
- "count": 2,
- "totalCount": 2
}The engine logs one WARN per retried failure (job, type, instance, attempt and next delivery) and
one ERROR when the retries are exhausted. The metric jobs_retried counts the retried failures
and the histogram job_retry_backoff (ms) the backoffs applied, both by job type;
jobs_failed keeps counting only the failures which failed the job. Once a failure without an
error code is committed, its span carries zenbpm.job.failure.outcome (retry or incident),
zenbpm.job.attempt, zenbpm.job.retries (left after the failure) and, on a retry,
zenbpm.job.retry_backoff_ms; the worker's message is not a span attribute.
Setting the retries of a job
An operator sets the remaining retries of an active or failed job, and optionally when it is
handed out next; without retryAt a job waiting out its backoff is deliverable at once. The
incident of a failed job stays open: resolve it as usual, and the resolution then keeps the retries
set here and, while it still lies ahead, the retryAt. A completed or terminated job answers
409; a count below 1 or above jobs.maxRetries, or a retryAt later than now plus
jobs.maxRetryBackoff, 400 - the cap which lowers every backoff refuses a deadline it cannot
lower.
Set the remaining retries of a job
Sets the remaining retries of an active or failed job and when it is handed out next. An
absent retryAt makes a job waiting out a backoff deliverable at once. The incident of a
failed job stays open: resolving it then keeps the retries set here instead of restoring
the retries of the task definition, and a retryAt still ahead keeps the job from its
workers until then. To set the retries of a failed job and resolve its incident in one
request, give them to POST /v1/incidents/{incidentKey}/resolve instead.
path Parameters
| jobKey required | integer <int64> Example: 4503599627370540 |
Request Body schema: application/jsonrequired
| retries required | integer <int32> >= 1 Retries the job has from now on, at most |
| retryAt | string <date-time> When the job is handed out next. Absent, or in the past, means at once; later than now plus |
Responses
Request samples
- Payload
{- "retries": 3
}Response samples
- 400
- 404
- 405
- 409
- 413
- 415
- 500
- 502
{- "code": "NOT_FOUND",
- "message": "Process instance with key 4503599627370501 not found"
}Setting the retries while resolving the incident
To give a failed job new retries and resolve its incident in one request, send them in the
optional body of POST /v1/incidents/{incidentKey}/resolve: retries, and optionally retryAt,
which is accepted only together with retries. Both are checked as above, and set in the same
transaction that resolves the incident, so either both happen or neither does. The new series
starts with these retries instead of the task definition's, which are not evaluated, so this also
resolves an incident whose retries expression no longer evaluates. Retries given here win over
retries set for the job since it failed.
Retries given for an incident which leaves no job waiting, such as one of an expression or of an
event subprocess, are refused with 400, and the incident stays open. A job whose incident it did
not raise itself, such as one of a boundary event of its task, gets them as the endpoint above sets
them: its series goes on with the new retries, and a job the endpoint above refuses with 409 is
refused the same way. Without a body, the resolution works as before.
An incident which is resolved already answers 409 and changes nothing; retries in the body are
not applied. A client which repeats a resolution after a timeout can tell from this that an earlier
attempt, or somebody else, resolved it, and reads the job to see which retries it has.
Resolve an incident
Resolves an incident and continues the execution it stopped. The job the incident leaves
waiting, if any, starts a fresh series of attempts with the retries of its task definition,
evaluated again, and is handed out at once. Retries set for the job since its last series
ended, with POST /v1/jobs/{jobKey}/retries, are kept instead, together with their retryAt.
The body is optional. With retries the job gets these retries, and with retryAt it is
not handed out before then, in the same transaction that resolves the incident: either both
happen or neither does. The retries of the task definition are not evaluated then, so this is
also the way out when they no longer evaluate (409). retries and retryAt are checked as
POST /v1/jobs/{jobKey}/retries checks them. Retries given for an incident which leaves no
job waiting, such as one of an expression, are refused with 400; a job whose incident it
did not raise itself, such as one of a boundary event, keeps them as that endpoint would set
them.
path Parameters
| incidentKey required | integer <int64> Example: 4503599627370530 |
Request Body schema: application/jsonoptional
| retries | integer <int32> >= 1 Retries the job the incident leaves waiting has from now on, at most |
| retryAt | string <date-time> When that job is handed out next; only together with |
Responses
Request samples
- Payload
{- "retries": 3
}Response samples
- 400
- 404
- 405
- 409
- 413
- 415
- 500
- 502
{- "code": "NOT_FOUND",
- "message": "Process instance with key 4503599627370501 not found"
}What stays the worker's business
The engine cannot undo what a worker did before it failed, and does not try. A job keeps its key across attempts and its input variables do not change between them, so an effect which must happen once per job is made idempotent on the job key, or on a key of the business operation it performs: a worker which charged a card and then failed must find that charge again on its next attempt.
Neither the attempt nor the delivery_token of a delivery is a substitute. The attempt tells a
redelivery of the same attempt (a lapsed lock, a leader change) from a retry, within one series of
attempts; it is not unique over the life of a job, since resolving an incident starts a new series
at 1. The delivery token differs for every delivery, so keying an effect on it, or on the job key
and the attempt, would repeat the effect on every retry.
⚠️ Upgrade notes:
- A model whose
zenbpm:taskDefinitionalready carriesretries- the Camunda Modeler writes the attribute - now gets those retries: its first failure without an error code no longer creates an incident. Remove the attribute, or setretries="1", to keep the old behaviour.- A failure with an empty error code, which the REST API and the Go client sent when no code was given, is now a failure without an error code: it spends a retry instead of being caught by a catch-all error boundary event or error event sub-process. Send an error code to throw a BPMN error.
- Jobs created before the upgrade keep one retry, the default of the new column, even when their definition carries
retries; jobs created afterwards get the definition's. Set the retries of a running job with the endpoint above.- Failing or completing a job which no longer waits for a worker answers
409instead of500, and the stream answers the completion with a code. A worker using the Go client is not affected: it only logs the answer. A stream completion the leader refused used to be reported to the worker as a success; it is now reported as the error it was.- A worker which receives jobs over the stream and fails them over REST passes its stream's
clientIdwith the failure; without it the job still waits for its lock to lapse, whateverretryBackoffasks for.- For applications embedding the engine as a library:
Engine.JobFailByKeytakes the retries and the backoff of the failure and the delivery token it names as three more arguments, and a storage implementation has to provideFindJobFailures,HasJobFailureOfDeliveryandSaveJobFailure(storage.JobStorageReaderandstorage.JobStorageWriter), writeJob.DeliveryTokenwhen it inserts a job but keep the stored one when it saves a job it stored before, and keepJobFailure.DeliveryToken.
Job manager
Is a component that handles management of external jobs. Its main goal is to pull jobs that need to be processed out of the database and distribute them among clients connected to different ZenBPM nodes. Job manager handles two sides of the communication in the cluster. Connection originates (client) from any node in the ZenBPM cluster and targets partition leaders (server) in the cluster:
- client side: handles worker connections that are interested in performing work on different ob types.
- server side: pulls out jobs that need to be processed and distributes them using round robin among workers connected to client side.