Whoever has built their own Camunda connector knows this: Some are simple, but some have a long list of operations. You can already generate element templates for the Web Modeler with Camunda’s element-template-generator, which beats editing JSON by hand. Still, the classic connector function made me write a pile of routing and Jackson annotations for every new operation.
I stumbled over the Operation API after a hint from Simon Büttner (from Camunda’s awesome Connector Team). Once I tried it on a connector with many operations, it cut most of the glue code and made deserialization failures easier to spot. Camunda’s new CSV connector already uses this API as well.
This article is about how the Camunda Operation API for connectors removed that overhead. I show the classic workflow, why its errors are hard to read, and the updated approach that maps operations directly to methods.
How Multi Operation Templates Behave
Multi operation connectors show a dropdown in the Web Modeler, so the operation can be picked during modeling. Once you pick it, the input fields update in the UI, to match that operations input and hide the others. The connector logic behind the template needs to figure out how to deserialize the payload into a Java object, and route it to the correct business logic.
For this, the operation type is set as a task header in the modeler, and added to the process model’s XML. After deployment, the engine sends the payload together with the headers to the connector. After deserializing the payload into the correct request object, the business logic is executed accordingly.

The Classic Workflow And Its Costs
The classic pattern, when dealing with multiple operations, typically uses a single Operation type as input, plus a sealed hierarchy of subtypes. Jackson needs @JsonTypeInfo and @JsonSubTypes so it can deserialize a payload into the right subtype.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CreateTask.class, name = CreateTask.TYPE),
@JsonSubTypes.Type(value = CloseTask.class, name = CloseTask.TYPE)
})
public sealed interface Operation permits CreateTask, CloseTask {}The element template also needs a discriminator annotation so the template generator can render the operation dropdown and its conditional fields. Each new operation means adding a new class and updating the subtype list and template metadata.
// json type and subtype annotations go here
@TemplateDiscriminatorProperty(
label = "Operation",
group = "type",
name = "type",
defaultValue = CreateTask.TYPE,
description = "Select operation"
)
public sealed interface Operation permits CreateTask, CloseTask {}In practice that means touching several places for every new operation:
Add the new subtype class and its @TemplateSubType for the template generator.
Add the @TemplateProperty fields for the template generator.
Update the @JsonSubTypes list to register the subtype to Jackson.
Extend the Operation interface with a new class.
Regenerate element templates.
Extend the routing switch to call the new business logic.
Here are the payloads of the operations. One to create a task with a name, and one to close a task by its id:
@TemplateSubType(id = CreateTask.TYPE, label = "Create Task")
public record CreateTask(
@TemplateProperty(
id = "task.name",
label = "Task Name"
)
public String title
) implements Operation {
@JsonProperty(ignore=true)
static final String TYPE = "CREATE_TASK";
}@TemplateSubType(id = CloseTask.TYPE, label = "Close Task")
public record CloseTask(
@TemplateProperty(
id = "task.id",
label = "Task Id"
)
public String taskId
) implements Operation {
@JsonProperty(ignore=true)
static final String TYPE = "CLOSE_TASK";
}Your connector function then binds the input and routes to the right logic. This is also where the Jackson setup and the template discriminator meet the runtime. If either annotation setup is wrong, you get a runtime failure that is hard to localize.
@OutboundConnector(name = "TASKS", type = "com.example:tasks:1")
public class TaskConnector implements OutboundConnectorFunction {
@Override
public Object execute(OutboundConnectorContext context) {
Operation operation = context.bindVariables(Operation.class);
return switch (operation) {
case CreateTask create -> handleCreate(create);
case CloseTask close -> handleClose(close);
};
}
}This works, but the error handling is vague. If deserialization fails, you often get a generic message like “operation has invalid fields”. If you forget to add a subtype or a discriminator annotation, the connector fails and it is not obvious which operation broke.
The Operation API Workflow
With the Operation API, you implement OutboundConnectorProvider and annotate one method per operation. The runtime reads the operation header from the element template and calls the matching method. Inputs arrive already bound to the right type, so no manual routing and no subtype hierarchy.
This also removes the need for the Jackson subtype list and the template discriminator annotation, because the operation metadata comes from the annotated methods.
Adding a new operation is a smaller set of steps:
Create a new input class with
@TemplateProperty fields.Add a new @Operation method and call the new business logic.
Regenerate element templates.
This is what the connector provider now looks like:
@OutboundConnector(name = "TASKS", type = "com.example:tasks:2")
public class TaskConnector implements OutboundConnectorProvider {
@Operation(id = "create.task", name = "create task")
public TaskResult createTask(@Variable CreateTaskInput input) {
return handleCreate(input);
}
@Operation(id = "close.task", name = "close task")
public TaskResult closeTask(@Variable CloseTaskInput input) {
return handleClose(input);
}
}This is what the payload classes now look like:
public record CreateTaskInput(
@TemplateProperty(
id = "task.name",
label = "Task Name"
)
String taskName
) {}
public record CloseTaskInput(
@TemplateProperty(
id = "task.id",
label = "Task Id"
)
String taskId
) {}The element template generator uses those operations to build the dropdown and the conditional fields.
{
"id" : "operation",
"label" : "Operation",
"description" : "The operation to execute",
"value" : "createTask",
"group" : "operation",
"binding" : {
"key" : "operation",
"type" : "zeebe:taskHeader"
},
"type" : "Dropdown",
"choices" : [
{
"name" : "create task",
"value" : "create.task"
},
{
"name" : "close task",
"value" : "close.task"
}
]
}Bonus: Enums Turn Into Dropdowns!
With the latest template generator, I can now use enums for fields that need a fixed set of values. The template generator reads the enum and renders a dropdown automatically. That removes another manual step and keeps the template values aligned with the code, instead of copying strings that drift over time.
Before, you needed to duplicate the enum values in the dropdown choices:
@TemplateProperty(
id = "create.task.priority",
type = @TemplateProperty.PropertyType.Dropdown,
label = "Priority",
description = "Priority of the task",
choices = {
@TemplateProperty.DropdownPropertyChoice(value = "LOW", label = "Low"),
@TemplateProperty.DropdownPropertyChoice(value = "MEDIUM", label = "Medium"),
@TemplateProperty.DropdownPropertyChoice(value = "HIGH", label = "High")
},
defaultValue = "LOW"
)
String priority;Now, you set the type of your property to the target enum, and be done with it:
@TemplateProperty(
label = "Priority",
description = "Priority of the task",
defaultValue = "LOW"
)
Priority priority;
enum Priority {
@DropdownItem(order = 1, label = "1 - Low")
LOW,
@DropdownItem(order = 2, label = "2 - Medium")
MEDIUM,
@DropdownItem(order = 3, label = "3 - High")
HIGH
}Migration Notes For Operation API
Here are some notes of my findings, when migrating an existing connector:
If you migrate an existing connector, expect breaking changes in the template bindings. Classic bindings are operation.<variableName> or operation.<id>, while the Operation API generates <operationId>:<variableName> or <operationId>:<id>.
The group attribute on @TemplateProperty is ignored in this mode. Everything lands in the operation group in the generated template.
The new prefix reduces duplicate ids and lets you skip explicit ids. In the classic approach, two operations with a field taskId and no explicit id both become operation.taskId, which breaks generation.
You can finally structure request objects across packages. Sealed interfaces forced all operations into one package so the template generator could find them, but the Operation API does not have that constraint.
Routing is no longer your responsibility, so you need process tests with a connector runtime to verify it. Without a connector runtime, it is hard to validate the routing in isolation.
Enums must live in the request classes for now. You cannot reuse enums from a target API model yet, so you may have to duplicate them, and that may change later.
Summary
I still use the classic approach when a connector has a single operation and I do not need any routing. As soon as a connector grows beyond one operation, I switch to the Operation API.
The benefits are practical:
I can add a new operation without editing a subtype list or a big switch statement.
Errors point to the operation that failed to deserialize instead of a generic payload error.
I can delete a lot of Jackson boilerplate and avoid forgotten annotations.
The template generator produces the operation dropdown and input fields from the method annotations.
The whole setup is cleaner and less error prone.
That is a good tradeoff for connectors that keep growing and changing.

