Book a Demo

Cabinee External Task

Overview

Cabinee External Task Starter extends the External Task mechanism and adds:

  • Automatic extraction of extensionProperties from BPMN
  • Routing by connector / action / version
  • Task dispatching via Kafka
  • Result handling via Kafka
  • A plugin architecture for connectors
  • A Spring Boot starter with autoconfiguration

This makes it possible to build loosely coupled integration services without direct REST polling of the Process Engine.

Status on Camunda 8. The mechanism is implemented but disabled by default and is not battle-tested in production. To enable it, set camunda-eight-client.jobs.enabled: true and fill camunda-eight-client.jobs.types: with an empty type list the worker ends its cycle without activating anything. The Kafka message contract and connector implementations stay the same — only BPMN modeling and how jobs are obtained from the engine differ.


Architecture

In Camunda 8, external tasks are jobs. Jobs are not pushed by subscription; they are fetched by the JobActivationWorker component inside unibpm-app via REST API v2.

Camunda 8 (Zeebe)
      │
      │ (job created from zeebe:taskDefinition)
      ▼
JobActivationWorker (unibpm-app)
      │
      │ 1. POST /v2/jobs/activation for each type in jobs.types
      │ 2. Reads zeebe:taskHeaders -> connector/action/version
      │ 3. Resolves businessKey by processInstanceKey
      │ 4. Publishes a Job to Kafka
      ▼
Kafka Topic (jobs.{type}.{env})
      │
      ▼
unibpm-integration-runtime
      │
      │ Processes the task
      │ Publishes the result
      ▼
Kafka Topic (results)
      │
      ▼
unibpm-app
      │
      ▼
Camunda 8
(completion / error / failure)

If businessKey is not yet resolved at activation time, the job is returned to the engine via failJob with a 5-second delay and will be activated again — the event is not lost.


BPMN configuration

A Service Task is described with zeebe:taskDefinition, and routing properties with zeebe:taskHeaders:

<bpmn:serviceTask id="Activity_1h87cc2" name="Log Info">
  <bpmn:extensionElements>
    <zeebe:taskDefinition type="finance" />
    <zeebe:taskHeaders>
      <zeebe:header key="uni.connector" value="demo" />
      <zeebe:header key="uni.action" value="log.info" />
      <zeebe:header key="uni.version" value="v1" />
    </zeebe:taskHeaders>
  </bpmn:extensionElements>
</bpmn:serviceTask>

The value of zeebe:taskDefinition/@type is substituted into the Kafka topic pattern and must be listed in camunda-eight-client.jobs.types.

Job type list

A job type is a string chosen at modeling time. The same string is substituted into the Kafka topic name, so changing it requires updating connector configuration.

Each type is described by an object; max-jobs is set per type:

camunda-eight-client:
  jobs:
    enabled: true
    types:
      finance:
        timeout-ms: 300000
        max-jobs: 10
        fetch-variables: []
ParameterPurposeDefault value
timeout-msHow long the job is locked to the worker60000
max-jobsHow many jobs to fetch per request50
fetch-variablesVariables to load; empty list means allempty

The list is required: POST /v2/jobs/activation accepts one type per request, so the worker makes as many requests per cycle as there are types.

Required properties

PropertyPurpose
uni.connectorLogical integration group
uni.actionBusiness action
uni.versionHandler version

The combination of these three parameters uniquely identifies the handler.

If uni.connector or uni.action is missing, the job is not dispatched: the worker returns it to the engine with zero retries, and Camunda creates an incident. uni.version is optional.


Handler resolution

The handler is resolved by the triad:

connector + action + version

Example:

demo / log.info / v1

If no handler is found, the following exception is thrown:

IllegalStateException:
No handler found for connector/action/version: demo/log.info/v1

This is fail-fast behavior.


Implementing a connector

To create an integration handler, implement the interface:

public interface ExternalTaskHandler {

    String connector();

    String action();

    default String version() {
        return null;
    }

    ExternalTaskResult handle(ExternalTaskJob job)
            throws BpmnErrorException, ExternalTaskFailureException;
}

Example:

@Slf4j
@Component
public class DemoLogInfoHandler implements ExternalTaskHandler {

    @Override
    public String connector() {
        return "demo";
    }

    @Override
    public String action() {
        return "log.info";
    }

    @Override
    public String version() {
        return "v1";
    }

    @Override
    public ExternalTaskResult handle(ExternalTaskJob job) {

        log.info("DEMO handler invoked. taskId={}, businessKey={}, activityId={}",
                job.getTaskId(),
                job.getBusinessKey(),
                job.getActivityId()
        );

        return ExternalTaskResult.builder()
                .taskId(job.getTaskId())
                .status(ExternalTaskResult.Status.COMPLETED)
                .variables(Map.of("demoHandled", true))
                .build();
    }
}

The handler must be a Spring Bean.


Kafka Topics

Jobs topic

Format:

{organization}.unibpm.external-task.jobs.{taskType}.{environment}

where {taskType} is the value of zeebe:taskDefinition/@type from the BPMN model.

Example:

reunico.unibpm.external-task.jobs.finance.prod

The pattern is set by camunda-eight-client.jobs.kafka-topics.jobs-pattern.

Results topic

Unlike the jobs topic, the results topic name is not built from a pattern; it is set in full by camunda-eight-client.jobs.kafka-topics.results:

reunico.unibpm.external-task.results

Next to it is the dead-letter topic — camunda-eight-client.jobs.kafka-topics.dlq, default reunico.unibpm.external-task.dlq.

Job publishing is implemented; the reverse path is not: no component is subscribed to the results topic, so the external-task result is not yet returned to the engine. This section describes the message contract, not a working end-to-end scenario.


Job contract

FieldTypeSource
taskIdStringjobKey
processInstanceIdStringprocessInstanceKey
businessKeyStringvalue of the businessKey variable
activityIdStringservice-task elementId
lockExpirationTimeInstantactivated job deadline
retriesIntegerremaining retries
connector, action, versionStringfrom zeebe:taskHeaders
variablesMapvariables available to the job
extensionPropertiesMapall zeebe:taskHeaders

Identifiers are numeric Zeebe keys. Example message:

{
  "taskId": "2251799813685569",
  "processInstanceId": "2251799813685560",
  "businessKey": "57a4781b-dd65-4154-bde6-5de26eafef21",
  "activityId": "Activity_1h87cc2",
  "lockExpirationTime": "2026-09-18T10:45:17Z",
  "retries": 3,
  "connector": "demo",
  "action": "log.info",
  "version": "v1",
  "variables": {
    "amount": 1000
  },
  "extensionProperties": {
    "uni.connector": "demo",
    "uni.action": "log.info",
    "uni.version": "v1"
  }
}

businessKey is the Cabinee ticket identifier. Handlers rely on it, not on taskId or processInstanceId: the latter are numeric engine keys and must not be parsed as UUIDs.


Result contract

Successful completion

{
  "taskId": "...",
  "status": "COMPLETED",
  "variables": {
    "result": "OK"
  }
}

BPMN Error

{
  "taskId": "...",
  "status": "BPMN_ERROR",
  "errorCode": "BUSINESS_ERROR",
  "errorMessage": "Validation failed"
}

Failure (technical error)

{
  "taskId": "...",
  "status": "FAILED",
  "failureMessage": "System error",
  "retries": 3,
  "retryDuration": 60000
}

Extracting task properties

Properties arrive with the activated job in customHeaders and are placed in the message as extensionProperties. No separate engine request is required.

The handler reads them like this:

@Override
public ExternalTaskResult handle(ExternalTaskJob job) {
    String action = job.getExtensionProperties().get("uni.action");
    ...
}

If the element has no zeebe:taskHeaders block, the field arrives empty.


Spring Boot Starter

The integration client is connected through the starter:

unibpm-external-task-client-starter

It automatically:

  • Registers a Kafka consumer
  • Creates a handler registry
  • Connects the dispatcher
  • Configures JSON serialization
  • Supports conditional autoconfiguration

Error handling

ScenarioBehavior
No handlerIllegalStateException
Handler threw RuntimeExceptionFAILED is sent
Handler threw BpmnErrorExceptionBPMN_ERROR is sent
Message cannot be deserializedConsumer error (ErrorHandlingDeserializer is recommended)
RedeliveryThe handler must be idempotent

Design principles

  • BPMN defines the integration contract
  • Kafka provides asynchronous decoupling
  • Handlers are stateless
  • Versioning provides backward compatibility
  • The engine does not depend on specific integrations
  • Integrations scale independently

Summary

Cabinee External Task Integration provides:

  • A clear contract
  • Scalability
  • Extensibility
  • Isolation of business context
  • A production-ready architecture