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:
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.
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.
Discovering available events
You don't have to guess event names. Ask the ERP:
GET /cmn/system/app-message-webhook/destinationsIt returns every queue the backend offers:
[
{ "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:
→ See REST API Introduction for base URLs. {version} is latest unless you pin one.
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.
Registering
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:
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:
Register webhooks inside always() rather than setMigration(), and prefer registerWebhookIfNotExists() — that combination is idempotent and re-registers after a reinstall.
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.
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:
Examples: article.update, article-price.create, category-tree.delete, sales_channel.stockChange, shipment.parcel.completed.
Some events are filtered, taking a parameter in parentheses:
Substitute the value yourself when registering. The value must be a plain word — numeric ids work, values containing dashes do not match.
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.
Document events are composed from the document category and its transition:
What your handler receives
A POST with Content-Type: application/json and these headers:
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:
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 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,
whose
audclaim equals yourappIdentifier,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.
Delivery semantics
Read this section before you write a handler.
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
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.
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:
This suppresses only your own subscriptions — other apps still receive the event.
Two things to know:
Only the header's presence is checked, not its value. Sending
x-vario-suppress-own-webhooks: falsestill suppresses.VARIO must be able to identify you as the caller — either from the authenticated app user, or from a
User-Agentthat is your app identifier. This is one reason the API insists on aUser-Agentheader, and why the framework sends yourappIdentifieras its value.
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:
A subscription with owner
APPand your own queue name:
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.
Cron payloads can arrive double-encoded: the scheduled payload is stored as a JSON string and serialised again on dispatch. Handle both shapes:
Inspecting what happened
Subscriptions and deliveries are queryable with VQL:
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:
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.
Is the subscription there and
ACTIVE? Querysystem.queryAppMessageWebhook.Does the topic actually fire? Especially for document events, the topic may be listed but never emitted.
Are deliveries failing? Query
system.queryAppMessageEntryand readrecipients.response.Are you returning
401? If the subscription lacksappIdentifier, no token is sent and the framework rejects the call.Are you too slow? Anything over 5 seconds counts as failed.
Did you suppress it yourself? Check whether your own write set the suppression header.
Related
REST API Introduction — base URL and versioned paths
App Authentication — verifying the inbound token
VQL — fetching the entity behind an event
FAQ
Last updated
Was this helpful?