> For the complete documentation index, see [llms.txt](https://developer.vario-software.de/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developer.vario-software.de/documentation/webhooks/introduction.md).

# Introduction

React to events in VARIO Cloud instead of polling — registration, delivery, verification.

A webhook lets VARIO Cloud call **your** service when something happens in the ERP — a document is created, an article changes, stock moves. Instead of polling, you subscribe once and get notified.

Webhooks are also how scheduled work is triggered for Apps: a cron webhook is the same delivery mechanism, fired by a timer instead of a data change.

## Concepts

A subscription has two parts, together called a **destination**:

| Part               | Meaning                                                          |
| ------------------ | ---------------------------------------------------------------- |
| `destinationQueue` | The event, e.g. `article.update` or `sales_channel.create`       |
| `destinationOwner` | Who owns the queue. Defaults to `#vario#` — the ERP's own events |

Plus the `url` VARIO should call, and your `appIdentifier`.

{% hint style="warning" %}
**Always register with your `appIdentifier`.** It is what causes VARIO to send an `Authorization` header with the call. Without it, the delivery arrives unauthenticated — and an App built on the VARIO App Framework will reject it with `401`, because the framework verifies that header on every route.
{% endhint %}

## Discovering available events

You don't have to guess event names. Ask the ERP:

```
GET /cmn/system/app-message-webhook/destinations
```

It returns every queue the backend offers:

```json
[
  { "owner": "#vario#", "queue": "article.update", "description": "…" },
  { "owner": "#vario#", "queue": "sales_channel.create", "description": "…" }
]
```

All webhook endpoints live under your tenant base URL with the versioned prefix:

```
https://{subdomain}.vario.cloud/api/vario/community/{version}/cmn/system/app-message-webhook/…
```

→ See [REST API Introduction](/documentation/rest-api/introduction.md) for base URLs. `{version}` is `latest` unless you pin one.

{% hint style="info" %}
The destinations list is generated from the backend's definitions. For documents it contains **every possible combination** of category and transition — several thousand entries, and not all of them are ever emitted. A subscription to a topic that never fires registers successfully and simply stays silent. Verify against a real event before assuming your handler is broken.
{% endhint %}

## Registering

| Verb   | Path                                 | Purpose               |
| ------ | ------------------------------------ | --------------------- |
| `POST` | `…/app-message-webhook/register`     | Subscribe             |
| `POST` | `…/app-message-webhook/deregister`   | Unsubscribe           |
| `GET`  | `…/app-message-webhook/destinations` | List available queues |

Body for register and deregister:

```json
{
  "url": "https://my-app.example.com/api/webhooks/article.update",
  "destinationQueue": "article.update",
  "destinationOwner": "#vario#",
  "appIdentifier": "<your-app-identifier>"
}
```

`url` and `destinationQueue` are required; `destinationOwner` defaults to `#vario#`.

Only `http://`, `https://` and `ws://topic/` URLs are accepted. Keep URLs short — the stored columns are limited to 255 characters.

### From an App

The framework wraps this in migration helpers, which also prefix your path with the app's public domain:

```javascript
await methods.registerWebhook('sales_channel.create', '/api/webhooks/sales_channel.create');

// App-owned (cron) destinations need the owner:
await methods.registerWebhook('ORDER_IMPORT', '/api/cron-webhooks/ORDER_IMPORT', 'APP');
```

Register webhooks inside `always()` rather than `setMigration()`, and prefer `registerWebhookIfNotExists()` — that combination is idempotent and re-registers after a reinstall.

{% hint style="warning" %}
`registerWebhookIfNotExists()` takes **no owner argument**. For app-owned destinations such as cron webhooks you must call `registerWebhook(queue, url, 'APP')`, otherwise the subscription lands under `#vario#` — where the queue name is unknown and the request is rejected as invalid.
{% endhint %}

### Registration is idempotent

Registering the same combination twice does not create a duplicate: an active subscription is returned unchanged, and a previously removed or deactivated one is reactivated.

The identity is `appIdentifier` + `destinationOwner` + `destinationQueue` + **the full URL**. Change the URL and you get a *second* subscription — the old one keeps firing. This is the usual cause of handlers running twice.

## Event names

The grammar is dotted and lowercase:

```
<entity>[.<sub-entity>].<operation>
```

Examples: `article.update`, `article-price.create`, `category-tree.delete`, `sales_channel.stockChange`, `shipment.parcel.completed`.

Some events are **filtered**, taking a parameter in parentheses:

```
article(sales-channel-id=42).update
```

Substitute the value yourself when registering. The value must be a plain word — numeric ids work, values containing dashes do not match.

{% hint style="info" %}
For articles, the plain and the filtered event are **mutually exclusive** for a given change: a listing on a non-system sales channel fires the filtered form, a change on the system sales channel fires the plain `article.update`. Subscribing to both does not double-deliver, but subscribing to the wrong one delivers nothing.
{% endhint %}

Document events are composed from the document category and its transition:

```
document.<category>.<transition>
document.customer_delivery_document.order_to_delivery
```

## What your handler receives

A `POST` with `Content-Type: application/json` and these headers:

| Header                             | When                                                                        |
| ---------------------------------- | --------------------------------------------------------------------------- |
| `Authorization: Bearer <appToken>` | Only if the subscription has an `appIdentifier`                             |
| `x-user-id`                        | Only if a user triggered the event — absent for system and scheduled events |

The body is **small — normally just identifiers**, not the full entity:

| Event                                          | Body                                      |
| ---------------------------------------------- | ----------------------------------------- |
| `document.<category>.<transition>`             | `{ "documentId": … }`                     |
| `article.*`                                    | `{ "articleId": …, "listingId": …, … }`   |
| `account.*`                                    | `{ "id": … }`                             |
| `sales_channel.create` / `.update` / `.delete` | `{ "salesChannelId": … }`                 |
| `sales_channel.stockChange`                    | `{ "salesChannelId": …, "articleId": … }` |
| `shipment.parcel.completed`                    | `{ "shipmentId": …, "parcelId": … }`      |

Fetch what you need with [VQL](/documentation/fundamentals/vql.md) or the REST API. An event with no payload arrives as `{}`.

## Verifying the call

There is **no HMAC signature**. Authenticity comes from the bearer token, which is a JWT:

* signed with **ES256**,
* verifiable with the **JWK set** you received when [creating the App](/documentation/apps/create-an-app.md),
* whose `aud` claim equals your `appIdentifier`,
* with a valid `exp`.

Reject anything that fails those checks. If you build on the App Framework this already happens for every `/api/*` route — see [App Authentication](/documentation/apps/authentication.md).

## Delivery semantics

Read this section before you write a handler.

| Property  | Behaviour                                                         |
| --------- | ----------------------------------------------------------------- |
| Timeout   | **5 seconds**, for connect and response                           |
| Attempts  | **3**, then the delivery is permanently abandoned                 |
| Guarantee | **At least once**                                                 |
| Ordering  | **None** — do not assume events arrive in the order they happened |
| Backoff   | No fixed schedule; retries happen when the sender next runs       |

{% hint style="danger" %}
**Answer immediately, then do the work.** With a 5-second timeout, any real processing will exceed it — the delivery is recorded as failed and retried even though you handled it. Acknowledge with `200` first and continue asynchronously. The App Framework's webhook router does this for you.
{% endhint %}

Because delivery is at-least-once and a timeout cannot distinguish "not received" from "received and slow", **handlers must be idempotent.** Note that duplicate protection held in memory does not span processes — if you run more than one instance, you need a shared guard.

A retry only happens the next time the sender runs, which is triggered by new activity. On a quiet tenant a failed delivery can wait a long time. After the third failure it is never retried.

## Preventing infinite loops

If your app reacts to `article.update` by writing back to the article, that write triggers `article.update` again. Break the cycle with a header on **your** write:

```
x-vario-suppress-own-webhooks: true
```

This suppresses only **your own** subscriptions — other apps still receive the event.

{% hint style="warning" %}
Two things to know:

1. **Only the header's presence is checked, not its value.** Sending `x-vario-suppress-own-webhooks: false` still suppresses.
2. **VARIO must be able to identify you as the caller** — either from the authenticated app user, or from a `User-Agent` that is your app identifier. This is one reason the API insists on a `User-Agent` header, and why the framework sends your `appIdentifier` as its value.
   {% endhint %}

Bulk imports have a separate switch: an import rule set can suppress webhooks for the whole run.

## Cron webhooks

A cron webhook fires on a schedule instead of a data change. It needs **two** things:

1. A subscription with owner `APP` and your own queue name:

```javascript
await methods.registerWebhook('ORDER_IMPORT', '/api/cron-webhooks/ORDER_IMPORT', 'APP');
```

2. A scheduled task with the same destination and a cron expression.

Because the owner is not `#vario#`, the queue name is **not validated** — a typo registers happily and never fires.

{% hint style="warning" %}
Cron payloads can arrive **double-encoded**: the scheduled payload is stored as a JSON string and serialised again on dispatch. Handle both shapes:

```javascript
const payload = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
```

{% endhint %}

## Inspecting what happened

Subscriptions and deliveries are queryable with [VQL](/documentation/fundamentals/vql.md):

```sql
SELECT id, destinationQueue, url, state, createdAt
  FROM system.queryAppMessageWebhook
 WHERE appIdentifier = '<your-app-identifier>'
```

```sql
SELECT id, eventTopic, recipients.state, recipients.sentAttempts, recipients.response
  FROM system.queryAppMessageEntry
```

A subscription is `ACTIVE`, `DEACTIVATED` (switched off by a user), `REVOKED` (switched off by the platform) or `REMOVED` (deregistered).

A delivery is `QUEUED`, `SENT`, `FAILED` (will be retried), `ERRONEOUS` (gave up) or `INACTIVE`. Failed attempts store the error in `response` — that is where to look first when a handler "never gets called".

## When nothing arrives

Work through this in order:

1. **Is the App active for the tenant?** Events for inactive apps are dropped before a delivery record is even created — so you will find no failed deliveries either.
2. **Is the subscription there and `ACTIVE`?** Query `system.queryAppMessageWebhook`.
3. **Does the topic actually fire?** Especially for document events, the topic may be listed but never emitted.
4. **Are deliveries failing?** Query `system.queryAppMessageEntry` and read `recipients.response`.
5. **Are you returning `401`?** If the subscription lacks `appIdentifier`, no token is sent and the framework rejects the call.
6. **Are you too slow?** Anything over 5 seconds counts as failed.
7. **Did you suppress it yourself?** Check whether your own write set the suppression header.

## Related

* [REST API Introduction](/documentation/rest-api/introduction.md) — base URL and versioned paths
* [App Authentication](/documentation/apps/authentication.md) — verifying the inbound token
* [VQL](/documentation/fundamentals/vql.md) — fetching the entity behind an event
* [FAQ](/documentation/webhooks/faq.md)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://developer.vario-software.de/documentation/webhooks/introduction.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
