Skip to content

Webhooks

A webhook is one system telling another that something happened. A space does both: incoming webhooks give another system a URL to call, and a call on one starts every workflow pointed at it; outgoing webhooks call a URL of yours when content changes.

Webhooks in the sidebar has a tab for each direction. They are the same thing pointed opposite ways, which is why they share a page: a name, a switch, a way of proving who is calling, and a log of every call that went over the endpoint.

The webhooks plugin

All of it is the webhooks plugin, @manablox/plugin-webhooks (plugin id webhooks). manablox create offers it in its feature choice (--webhooks, or webhooks in --features), and manablox plugin install webhooks adds it to an existing instance. An instance that does not load it has no webhooks: no Webhooks in the sidebar, no webhook procedures, no incoming address and no webhook section in a transfer.

The plugin enhances the workflows plugin: with both loaded, a call on an incoming endpoint starts and aborts the workflows pointed at it, and workflows get the webhook trigger and abort trigger. Each works without the other. Without the workflows plugin, see Without workflows.

To add it to an instance, add @manablox/plugin-webhooks to its dependencies, with the same version as the other @manablox packages, and load it in the management config’s plugins:

import { defineConfig } from '@manablox/core';
import { webhooksPlugin } from '@manablox/plugin-webhooks';
import { workflowsPlugin } from '@manablox/plugin-workflows';
export default defineConfig({
// ...
plugins: [
workflowsPlugin(),
// Outgoing webhooks, and incoming ones that start and stop workflows.
webhooksPlugin(),
],
});

webhooksPlugin() takes no options. The public delivery instance needs nothing: incoming calls arrive at the management server. Then run manablox migrate: the plugin’s baseline migration creates its tables, webhooks and webhooks_deliveries. Removing the plugin leaves the tables in place.

The plugin owns its permissions, the flag features.plugins.webhooks, limits.plugins.webhooks.count, rateLimits.plugins.webhooks.incoming and .outgoing and retention.plugins.webhooks.deliveriesDays (see Controls), the procedures under plugins.webhooks.* (list, get, create, update, setEnabled, delete, deliveries, retry, test), the webhooks:deliver job, its hooks, the error keys plugins.webhooks.* (see Error keys), the audit kind webhooks.webhook, the code resource kind webhooks.webhook (see Resources in code) and the webhooks section of /llms.txt. Its endpoints travel in a transfer as the plugin section webhooks.webhooks. Another plugin reaches the endpoint service with plugins.get('webhooks'), which answers { webhooks: WebhookService }.

Incoming

Create one and it gets a URL of the form:

https://your-api.example.com/plugins/webhooks/in/<space id>/<slug>

It is an address of the management server, not of the public delivery API. It takes no session or API key: the endpoint’s own way of proving who is calling decides. Calls are limited per client IP, 600 a minute by default (rateLimits.plugins.webhooks.incoming), before anything is looked up.

The slug comes from the name it was created with and never changes afterwards, so renaming the endpoint does not invalidate a URL you have already handed out. Copy it from the list, or from the trigger in the workflow editor.

An endpoint of a staging environment names the environment between the space and the slug, /plugins/webhooks/in/<space id>/staging/<slug>, and starts that environment’s workflows only. It answers 404 while the space’s environments feature is off.

Nothing runs on its own. An incoming endpoint is a doorway: a workflow whose trigger is on a webhook names the endpoint, and every enabled workflow that names it starts when a call arrives. Several workflows may hang off one endpoint, and each may filter on what arrived, so one URL from a service that sends a dozen kinds of event can feed a workflow per kind. A workflow can also name an endpoint in an abort trigger: a call to it then stops that workflow’s runs that are still going, before any new runs start. The list marks an endpoint no workflow is waiting on, since that is almost always a half-finished setup rather than a choice, and Connect to workflow on a row opens the new-workflow dialog with that endpoint already picked.

What arrived is available to every node of the run:

PlaceholderWhat it holds
{{ payload.body }}The body, parsed. An object when it was JSON, form fields when it was a form, { raw } otherwise.
{{ payload.query.name }}One value from the query string.
{{ payload.method }}The method the call used.
{{ headers.name }}A request header, lower-cased.
{{ webhook.name }}Which endpoint was called.

The endpoint answers 202 as soon as the runs are queued, with the delivery id, how many workflows it started (runs) and how many active runs it aborted (aborted). It does not wait for them: a caller that needs to know how a run went reads the run, or is called back in turn by an outgoing webhook at the end of it.

Bodies larger than 1 MB are refused, and only the methods the endpoint accepts get through. POST alone is the default. The body is read raw, so a signature covers the exact bytes that were sent.

Without workflows

Without the workflows plugin, an incoming call is still received, checked, logged and handed to the webhooks:received hook; it answers 202 with runs and aborted at 0. The page says that calls are received and logged but nothing runs them unless a plugin listens, and Connect to workflow is not offered. A plugin of your own can act on calls through the hook.

Outgoing

An outgoing endpoint is a URL and the content events it cares about: created, updated, saved, deleted, published, unpublished. Nothing chosen means every event. The body is JSON:

{ "event": "content.published", "payload": { "id": "...", "permalink": "..." }, "at": "..." }

The event is also in x-manablox-event, and each call carries its own id in x-manablox-delivery, so a receiver can recognise a repeat. Extra headers can be added per endpoint for anything else the far end wants.

Deliveries run in the background, one job per endpoint per event, so a save never waits for a call to go out and a slow endpoint holds nothing up. A call that fails is retried by the queue’s own backoff. Send test posts a made-up call so an endpoint can be proved before any content depends on it, and any call in the log can be sent again by hand.

Proving who is calling

Both directions ask the same question, and neither stores a secret beside the endpoint. Every mode names a credential in the space’s vault, the same vault workflow nodes use, and one can be created straight from the webhook form.

ModeCredentialIncomingOutgoing
NothingnoneAnyone who knows the URL may call itNo authentication is sent
SignatureSigning secretThe body is checked against the signature headerThe body is signed
Token in a headerAPI keyThe header the credential names is comparedThe header is sent
Username and passwordUsername and passwordAuthorization is comparedAuthorization is sent
Bearer tokenBearer tokenAuthorization is comparedAuthorization is sent
OAuth 2OAuth 2 refresh tokenNot availableA fresh access token per call

A signature is an HMAC over the exact bytes of the body, in sha256, sha1 or sha512, written as sha256=<hex>, as bare hex, or as base64. Manablox’s own format, and the default, is sha256=<hex> in X-Manablox-Signature; the workflow Call an API node signs its body the same way when it has a secret. That is how GitHub, Stripe and most other services sign theirs, so an incoming endpoint can usually be matched to whatever the sender already does. On the way in, both spellings of the same digest are accepted, so a service that sends bare hex where the endpoint says prefixed still gets through. Every comparison is constant-time.

The events an instance pushes to its control layer are signed in another format, t=<unix seconds>,v1=<hex> over the timestamp and the body, so a captured push cannot be replayed later; see Verifying the signature. Both are the same HMAC (signBody of @manablox/core/node), only what is signed and how it is written differ.

Leaving an incoming endpoint on nothing means anyone who learns the URL can start its workflows. It is there for an install where the endpoint is not reachable from outside; anywhere else, pick a mode.

Deleting a credential does not delete the endpoints that used it. They stay, and their next call fails saying so, rather than quietly going out unauthenticated or letting a stranger in.

The log

Every call is recorded, both directions, whether or not it was let through. A call turned away for a wrong signature is in the log as a 401 with the reason, which is the entry you actually want when an integration is silently doing nothing. An entry opens to show what was sent or received and the headers that came with it; the headers that carry the secret itself are not kept. An incoming entry says which workflow runs it started.

The newest 200 calls per endpoint are kept, and older ones are dropped as new ones arrive. retention.plugins.webhooks.deliveriesDays drops calls older than that many days too, in every environment; the 200 stay the ceiling.

Permissions

webhooks:read sees the endpoints and their logs; webhooks:write creates, edits, tests and switches them. Owners and admins hold both; editors hold webhooks:read. An incoming endpoint can start a workflow, and an outgoing one calls another system on behalf of the space, which is why writing one is not an editor’s permission by default.

The two permissions are the plugin’s, in the group Webhooks. Every change to an endpoint is an entry in Activity on webhooks.webhook (webhooks.webhook.create, update, setEnabled, delete, test, and received for an incoming call).

Hooks

The plugin declares two hooks. A handler of a before hook throws to refuse; a thrown ManabloxError keeps its key and status.

HookPayloadWhen
webhooks:beforeCreate{ spaceId, direction, name }Before an endpoint is created: by hand, by a space import (before anything is written), by a workflow import that creates the endpoints it names, and for code endpoints manablox sync creates, where a refused one is skipped and logged
webhooks:received{ webhook: { id, spaceId, environmentId, name, slug }, payload: { body, query, method }, headers }After an incoming call passed its checks, before the workflows plugin starts or aborts runs. Observe only: a handler’s error is logged, never answered to the caller. headers are lower-cased, without the ones that carry the secret

Their context is { manablox, spaceId }. The payload types come with @manablox/plugin-webhooks (WebhookReceived is the one of webhooks:received); a plugin that registers a handler imports the package so the hook names are known.

Moving a space

Webhooks travel with a space export, as the plugin section webhooks.webhooks (the core section webhooks in files before format 12, which still import), but the credential vault does not. An endpoint that authenticates therefore arrives switched off with nothing behind the mode it claims: give it a credential on the new instance and switch it back on. An endpoint set to nothing arrives as it was. Endpoints can be picked one by one, like content types.

Environments and snapshots

Endpoints belong to an environment. A new staging environment gets a copy of production’s endpoints, switched off, so a staging copy never answers or calls out until it is switched on. A promote never writes production’s endpoints: it matches staging’s endpoints to production’s by direction and slug, so a promoted workflow points at production’s endpoint of the same slug. A snapshot restored on the same instance switches back on the endpoints whose credential secret came back with it.