CAP Plugin to automatically trigger and interact with n8n workflow automation tool - declaratively via @n8n.process.start annotations and programmatically via the
n8n service.
- About this project
- Requirements
- Quick Start
- Connect to n8n
- Webhook Requests
- Annotations
- Programmatic API
- Delivery Behaviour
- Tests
- Support, Feedback, Contributing
- Security / Disclosure
- Code of Conduct
- Licensing
cds-feature-n8n is a Spring Boot auto-configuration plugin for CAP Java applications. It listens to CDS events (CREATE, DELETE, and custom actions) annotated with @n8n.process.start and fires HTTP requests to configured n8n webhook URLs — enabling you to trigger n8n workflows directly from your CAP service layer.
Features:
- Annotation-driven: no boilerplate code needed in your service handlers
- Supports entity CRUD events (CREATE, DELETE) and custom actions/functions
- Configurable HTTP method per trigger (
GET,POST,PUT,PATCH,DELETE,HEAD) — defaults toPOST - Reliable delivery via CAP persistent outbox with configurable retry
- Optional API key header (
X-N8N-API-KEY) for authentication
- Java 21+
- CAP Java 5+
- Spring Boot 4+
- A running n8n instance
Follow these steps to get a fully working local environment from scratch.
- Build and install the plugin locally:
mvn clean install- Add the dependency to your CAP Java application's
pom.xml:
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>cds-feature-n8n</artifactId>
<version>0.0.1</version>
</dependency>- Annotate an entity in your CDS model:
annotate AdminService.Books with @n8n.process.start: [
{on: 'DELETE', path: 'book-deleted'}
];- Configure the n8n base URL in
application.yaml:
n8n:
base-url: ${N8N_BASE_URL:http://localhost:5678}
api-key: ${N8N_API_KEY:}- Start the sample app
cd samples/bookshop/srv
mvn spring-boot:runThe app starts on http://localhost:8080 and points at http://localhost:5678/webhook by default. Any annotated CDS event will fire a webhook to your local n8n instance.
-
Smoke test
- In n8n, create a workflow with a Webhook node, path
book-deleted, save (Cmd+S), click "Listen for Test Event" - Delete any book at
http://localhost:8080→ Admin → Books - n8n should show a green execution with
{ "ID": "...", "title": "...", "author_ID": "..." }
- In n8n, create a workflow with a Webhook node, path
Alternative (test mode): If you prefer one-shot manual testing, set
n8n.use-test-webhook: trueinapplication.yaml, restart the app, then click "Listen for Test Event" in n8n instead of activating the workflow.
docker volume create n8n_data
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-e GENERIC_TIMEZONE="Europe/Berlin" \
-e TZ="Europe/Berlin" \
-e N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true \
-e N8N_RUNNERS_ENABLED=true \
-v n8n_data:/home/node/.n8n \
docker.n8n.io/n8nio/n8nReplace Europe/Berlin with your local timezone (e.g. America/New_York). The named volume n8n_data persists your workflows across container restarts. n8n will be available at http://localhost:5678.
First run only: open
http://localhost:5678in a browser and create an owner account before proceeding.
Configure the n8n base URL and optional API key in application.yaml:
n8n:
base-url: ${N8N_BASE_URL:http://localhost:5678}
api-key: ${N8N_API_KEY:}N8N_API_KEY is sent as X-N8N-API-KEY on every webhook request — this is the same header n8n uses for its public REST API.
Set N8N_API_KEY in your environment (or ~/.zshrc) and configure the n8n Webhook node with Authentication: Header Auth, Name: X-N8N-API-KEY, Value: same string.
Without it, n8n must have Authentication: None — otherwise it returns 403.
For production deployments — especially SAP managed n8n instances behind a proxy — you can configure a BTP destination instead of a plain base URL. The destination takes priority over base-url:
n8n:
destination: my-n8n-dest # BTP destination name (takes priority over base-url)
api-key: ${N8N_API_KEY:} # optional; sent as X-N8N-API-KEY in addition to any proxy authWhen destination is set, the plugin resolves it via the SAP Cloud SDK at startup. The destination's URL and auth headers (e.g. Authorization: Bearer … for OAuth2 destinations) are merged into every request. X-N8N-API-KEY is then added on top — so both the outer proxy auth and the n8n-level API key are sent.
The destination can also carry the API key as a custom property (URL.headers.X-N8N-API-KEY) instead of setting n8n.api-key — though n8n.api-key takes precedence if both are set.
To use destinations, add cloudplatform-connectivity to your application's dependencies:
<dependency>
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>cloudplatform-connectivity</artifactId>
</dependency>The plugin resolves connection details in this order:
n8n.use-console: true— console mode, takes precedence over all other configuration (see Console Mode)n8n.destination— BTP destination (takes priority over base-url)n8n.base-url— explicit base URL in application.yaml- Dev-only fallback:
http://localhost:5678(development profile only — throws at startup in any other profile)
n8n.api-key is independent of the above and is always sent as X-N8N-API-KEY when set.
The path value in each annotation is appended after /webhook to form the full webhook URL:
path: 'book-deleted' → http://localhost:5678/webhook/book-deleted
Test vs. production webhooks
| Mode | n8n URL | When to use |
|---|---|---|
| Production (default) | /webhook |
Workflows are active and handle every call |
| Test | /webhook-test |
One-off manual testing; requires clicking "Listen for Test Event" in the n8n UI each time, and cannot handle bulk calls |
Toggle test mode via use-test-webhook in application.yaml:
n8n:
use-test-webhook: true # set to false (default) for production webhooksAnnotate entities or actions in your CDS model with @n8n.process.start. No additional Java code is needed — the plugin detects the annotation and fires the webhook automatically.
Each trigger entry supports the following properties:
| Property | Required | Description |
|---|---|---|
on |
yes | Event name — CREATE, READ, UPDATE, DELETE, or the action name |
path |
yes | Appended to n8n.base-url + /webhook (or /webhook-test if use-test-webhook: true in application.yaml) to form the full webhook URL |
method |
no | HTTP method to use when calling the n8n webhook — one of GET, POST, PUT, PATCH, DELETE, HEAD. Defaults to POST. Must match the method configured on the n8n Webhook node. |
inputs |
no | Fields to include in the payload; defaults to all direct entity attributes when omitted |
Entity events (CRUD):
annotate AdminService.Books with @n8n.process.start: [
{on: 'DELETE', path: 'book-deleted', inputs: [$self.ID, $self.title, $self.stock]},
{on: 'UPDATE', path: 'book-updated', method: 'PUT', inputs: [$self.ID, $self.title]}
];An invalid value (e.g. method: 'YOLO') causes the application to fail at startup with a descriptive error:
IllegalStateException: @n8n.process.start[0] on entity 'MyService.Books' has invalid 'method' value 'YOLO'.
Allowed values: GET, POST, PUT, PATCH, DELETE, HEAD.
Note:
GETsends no request body — the payload is serialized as query parameters instead. All other methods send a JSON body.
For custom actions:
annotate CatalogService.submitOrder with @n8n.process.start.on: 'submitOrder'
@n8n.process.start.path: 'order-submitted';When the annotated event fires, the plugin posts the selected inputs fields as a flat JSON object to the configured webhook URL. For example, with the inputs list above:
{
"ID": "abc123",
"title": "The Hobbit",
"stock": 42
}When inputs is omitted, all scalar fields of the entity are included in the payload. Specify inputs explicitly to limit which fields are sent — useful to avoid exposing sensitive or large fields.
Association fields can be included using dot notation — the plugin issues a single expanded query to fetch the associated data:
annotate AdminService.Books with @n8n.process.start: [
{on: 'DELETE', path: 'book-deleted', inputs: [$self.ID, $self.title, $self.author.name]}
];This produces a payload with the leaf field name as the key:
{
"ID": "abc123",
"title": "The Hobbit",
"name": "Tolkien"
}Note:
- Only one level of association traversal is supported (
$self.author.name). Deeper paths ($self.author.address.city) are skipped with a warning. This is a known limitation compared to the Node.js plugin — contributions welcome.- Association paths are not resolved for
CREATEevents. For these, the plugin uses the raw request payload (the data as submitted), so association fields like$self.author.namewill benull. Use scalar FK fields (e.g.$self.author_ID) forCREATEtriggers instead.
Add an if expression to a trigger entry to fire the webhook only when the condition is met:
annotate AdminService.Books with @n8n.process.start: [
{on: 'DELETE', path: 'book-deleted', if: (stock = 0), inputs: [$self.ID, $self.title, $self.author_ID]},
{on: 'UPDATE', path: 'book-updated', inputs: [$self.ID, $self.title]},
{on: 'UPDATE', path: 'book-low-stock', inputs: [$self.ID, $self.title, $self.stock],
if: (stock < 10)}
];Only deletes where stock is 0 fire book-deleted; every update fires book-updated; only updates that bring stock below 10 also fire book-low-stock — e.g. to trigger a reorder workflow in n8n.
The if property is optional. When present, the webhook fires only when the condition evaluates to true against the entity row:
| Property | Required | Description |
|---|---|---|
if |
no | CDS expression — webhook fires only when the condition is true |
Supported operators: =, ==, !=, <>, <, <=, >, >=, in, like, between, is null, is not null, not, and, or.
Note: The
ifcondition is evaluated in application code against the entity row available at the time the event fires — it is not pushed to the database. This means it uses the same data the plugin already has: the CQN payload for CREATE, and the prefetched row for UPDATE and DELETE. Complex expressions involving subqueries or navigation paths that aren't part of the prefetched columns will not work.
Multiple trigger entries for the same event on the same entity are supported — all matching entries fire. This allows you to route to different n8n workflows from one event, optionally with different if conditions.
For cases where you need full control over when and what is sent, inject N8nService directly into any CAP event handler and call .trigger():
@Component
@ServiceName(AdminService_.CDS_NAME)
public class AdminServiceHandler implements EventHandler {
@Autowired
private N8nService n8nService;
@After(event = CqnService.EVENT_CREATE, entity = "AdminService.Books")
public void afterCreateBook(List<Books> books) {
books.forEach(book -> n8nService.trigger("book-created", Map.of(
"ID", book.getId(),
"title", book.getTitle()
))); // defaults to POST when no HTTP Method is specified
}
}The first argument to .trigger() is the webhook path — appended after /webhook to form the full URL (e.g. "book-created" → http://localhost:5678/webhook/book-created). The second argument is the payload — any Map<String, Object> you choose to send. The third argument is the HTTP method — pass POST, PUT, PATCH, DELETE, GET, or HEAD. It must match the method configured on the n8n Webhook node. If you omit the third argument, POST is used by default.
Note: The annotation-based and programmatic approaches are independent. You can use both in the same application, but take care not to fire duplicate webhooks for the same event.
The plugin retries failed webhook calls only on network-level errors — when n8n is unreachable (connection refused, timeout). HTTP error responses are not retried:
| Response | Meaning | Retried? |
|---|---|---|
| Network error / timeout | n8n is down or unreachable | Yes |
| 5xx | n8n responded but the workflow itself failed | No |
| 4xx | Misconfiguration (wrong URL, bad auth) | No |
Retry behavior is managed by the CAP persistent outbox. Configure it under cds.outbox.services.N8nOutbox in your application.yaml:
cds:
outbox:
services:
N8nOutbox:
maxAttempts: 10 # total attempts before the message is marked as failed
ordered: true # process messages in submission order (default: true)The plugin ships a built-in console mode for local development and CI environments where no n8n instance is available. When enabled, webhook calls are logged instead of sent via the specified HTTP Method — the app behaves normally but never makes an HTTP request to n8n. Delivery is synchronous and skips the persistent outbox entirely, so no rows are written to cds_outbox_Messages.
n8n:
use-console: trueNo base-url is needed. Console mode takes precedence over all other configuration. You'll see:
INFO ConsoleN8NWebhookService - [console-n8n-service]: would POST /webhook/book-deleted
Inject ConsoleN8NWebhookService to assert on webhook calls without a real n8n instance:
@SpringBootTest
@TestPropertySource(properties = "n8n.use-console=true")
class MyServiceTest {
@Autowired
ConsoleN8NWebhookService consoleWebhookService;
@Test
void deleteBook_triggersWebhook() {
// ... trigger a delete ...
assertThat(consoleWebhookService.getExecutions()).hasSize(1);
Map<String, Object> exec = consoleWebhookService.getExecutions().get(0);
assertThat(exec.get("path")).isEqualTo("book-deleted");
assertThat(exec.get("status")).isEqualTo("success");
}
}Each execution record contains: id, path, method, payload, startedAt, finishedAt, status.
If n8n.use-console is false (the default) and n8n.base-url is not set:
| Profile | Behaviour |
|---|---|
development |
Warns at startup and falls back to http://localhost:5678. HTTP calls fail gracefully if n8n is not running — the outbox retries with backoff. |
| any other | Throws IllegalStateException at startup: set N8N_BASE_URL or use n8n.use-console=true. |
Run the unit tests from the project root:
mvn testTo also generate a JaCoCo coverage report (unit tests only):
mvn verifyTo include the retry integration test in coverage:
mvn verify -pl cds-feature-n8n -am -Dtest="N8nHandlerTest,N8nWebhookServiceRetryIT"The report is written to cds-feature-n8n/target/site/jacoco/index.html.
The retry integration test (N8nWebhookServiceRetryIT) is excluded from the default mvn test run because Maven Surefire skips *IT.java classes by default. Run it explicitly:
mvn test -pl cds-feature-n8n -am -Dtest=N8nWebhookServiceRetryITThis test uses WireMock to start a local HTTP server that stands in for n8n, verifying that the retry logic fires the webhook up to three times before giving up.
The samples/bookshop directory contains a complete CAP bookshop app that demonstrates the plugin with real webhook triggers.
For a full walkthrough including starting n8n locally in Docker, see Local n8n.
Quick start (assumes n8n is already running):
cd samples/bookshop/srv
mvn spring-boot:runThe sample configures three webhook triggers on AdminService.Books: DELETE with if: (stock = 0) fires book-deleted; every UPDATE fires book-updated; and updates that bring stock below 10 also fire book-low-stock. See Local n8n for the full walkthrough. 404 → listener expired or workflow not saved. 403 → X-N8N-API-KEY mismatch.
This project is open to feature requests/suggestions, bug reports etc. via GitHub issues. Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our Contribution Guidelines.
If you find any bug that may be a security problem, please follow our instructions at in our security policy on how to report it. Please do not create GitHub issues for security-related doubts or problems.
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its Code of Conduct at all times.
Copyright 2026 SAP SE or an SAP affiliate company and cds-feature-n8n contributors. Please see our LICENSE for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available via the REUSE tool.