# SAP Cloud Application Programming Model, n8n plugin for Java
[](https://api.reuse.software/info/github.com/cap-java/cds-feature-n8n)
CAP Plugin to automatically trigger and interact with [n8n](https://n8n.io/) workflow automation tool - declaratively via `@n8n.process.start` annotations and programmatically via the
`n8n` service.
## Table of Contents
- [About this project](#about-this-project)
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [Connect to n8n](#connect-to-n8n)
- [Local n8n](#local-n8n)
- [Environment Variables](#environment-variables)
- [BTP Destination](#btp-destination)
- [Credential Resolution](#credential-resolution)
- [Webhook Requests](#webhook-requests)
- [Test Webhooks](#test-webhooks)
- [Annotations](#annotations)
- [Triggering a workflow](#triggering-a-workflow)
- [Conditions](#conditions)
- [Multiple Triggers](#multiple-triggers)
- [Programmatic API](#programmatic-api)
- [Trigger a Workflow](#trigger-a-workflow)
- [Delivery Behaviour](#delivery-behaviour)
- [Console Mode](#console-mode)
- [Tests](#tests)
- [Support, Feedback, Contributing](#support-feedback-contributing)
- [Security / Disclosure](#security--disclosure)
- [Code of Conduct](#code-of-conduct)
- [Licensing](#licensing)
## About this project
`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 to `POST`
- Reliable delivery via CAP persistent outbox with configurable retry
- Optional API key header (`X-N8N-API-KEY`) for authentication
## Requirements
- Java 21+
- CAP Java 5+
- Spring Boot 4+
- A running n8n instance
## Quick Start
Follow these steps to get a fully working local environment from scratch.
1. Build and install the plugin locally:
```zsh
mvn clean install
```
2. Add the dependency to your CAP Java application's `pom.xml`:
```xml
com.sap.cds
cds-feature-n8n
0.0.1
```
3. Annotate an entity in your CDS model:
```cds
annotate AdminService.Books with @n8n.process.start: [
{on: 'DELETE', path: 'book-deleted'}
];
```
4. Configure the n8n base URL in `application.yaml`:
```yaml
n8n:
base-url: ${N8N_BASE_URL:http://localhost:5678}
api-key: ${N8N_API_KEY:}
```
5. Start the sample app
```zsh
cd samples/bookshop/srv
mvn spring-boot:run
```
The 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.
6. Smoke test
1. In n8n, create a workflow with a **Webhook** node, path `book-deleted`, save (Cmd+S), click **"Listen for Test Event"**
2. Delete any book at `http://localhost:8080` → Admin → Books
3. n8n should show a green execution with `{ "ID": "...", "title": "...", "author_ID": "..." }`
> **Alternative (test mode):** If you prefer one-shot manual testing, set `n8n.use-test-webhook: true` in `application.yaml`, restart the app, then click **"Listen for Test Event"** in n8n instead of activating the workflow.
## Connect to n8n
### Local n8n
```zsh
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/n8n
```
Replace `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:5678` in a browser and create an owner account before proceeding.
### Environment Variables
Configure the n8n base URL and optional API key in `application.yaml`:
```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.
### BTP Destination
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`:
```yaml
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 auth
```
When `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:
```xml
com.sap.cloud.sdk.cloudplatform
cloudplatform-connectivity
```
### Credential Resolution
The plugin resolves connection details in this order:
1. `n8n.use-console: true` — console mode, takes precedence over all other configuration (see [Console Mode](#console-mode))
2. `n8n.destination` — BTP destination (takes priority over base-url)
3. `n8n.base-url` — explicit base URL in application.yaml
4. 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.
## Webhook Requests
### Test Webhooks
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`:
```yaml
n8n:
use-test-webhook: true # set to false (default) for production webhooks
```
## Annotations
Annotate 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.
### Triggering a workflow
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):**
```cds
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:** `GET` sends no request body — the payload is serialized as query parameters instead. All other methods send a JSON body.
**For custom actions:**
```cds
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:
```json
{
"ID": "abc123",
"title": "The Hobbit",
"stock": 42
}
```
When `inputs` is omitted, all direct 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:
```cds
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:
```json
{
"ID": "abc123",
"title": "The Hobbit",
"name": "Tolkien"
}
```
> **Note:**
> 1. 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.
> 2. Association paths are *not* resolved for `CREATE` events. For these, the plugin uses the raw request payload (the data as submitted), so association fields like `$self.author.name` will be `null`. Use direct FK fields (e.g. `$self.author_ID`) for `CREATE` triggers instead.
### Conditions
Add an `if` expression to a trigger entry to fire the webhook only when the condition is met:
```cds
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 `if` condition 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 Triggers
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.
## Programmatic API
### Trigger a Workflow
For cases where you need full control over when and what is sent, inject `N8nService` directly into any CAP event handler and call `.trigger()`:
```java
@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.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` 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.
## Delivery Behaviour
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`:
```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)
```
### Console Mode
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`.
```yaml
n8n:
use-console: true
```
No `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:
```java
@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 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`. |
## Tests
Run the unit tests from the project root:
```zsh
mvn test
```
To also generate a JaCoCo coverage report (unit tests only):
```zsh
mvn verify
```
To include the retry integration test in coverage:
```zsh
mvn verify -pl cds-feature-n8n -am -Dtest="N8nHandlerTest,N8nWebhookServiceRetryIT"
```
The report is written to `cds-feature-n8n/target/site/jacoco/index.html`.
### Integration tests
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:
```zsh
mvn test -pl cds-feature-n8n -am -Dtest=N8nWebhookServiceRetryIT
```
This test uses [WireMock](https://wiremock.org) 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.
### Testing with the sample application
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](#local-n8n).
**Quick start (assumes n8n is already running):**
```zsh
cd samples/bookshop/srv
mvn spring-boot:run
```
The 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](#local-n8n) for the full walkthrough. 404 → listener expired or workflow not saved. 403 → `X-N8N-API-KEY` mismatch.
## Support, Feedback, Contributing
This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-java/cds-feature-n8n/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](CONTRIBUTING.md).
## Security / Disclosure
If you find any bug that may be a security problem, please follow our instructions at [in our security policy](https://github.com/cap-java/cds-feature-n8n/security/policy) on how to report it. Please do not create GitHub issues for security-related doubts or problems.
## Code of Conduct
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](https://github.com/cap-java/.github/blob/main/CODE_OF_CONDUCT.md) at all times.
## Licensing
Copyright 2026 SAP SE or an SAP affiliate company and cds-feature-n8n contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-java/cds-feature-n8n).