Workflows and webhooks in code
Like content types, a few other things you normally click together in the admin can also be written down in code:
- workflows, which do something automatically, for example send an email when a page is published,
- webhooks, which call another service when content changes, or let another service call your CMS,
- content templates, ready-made block layouts editors can start a page from,
- credential slots, named places for the passwords and API keys that workflows and webhooks use.
Writing them in code is useful when every installation of your CMS should have them (your laptop, a test server, the live server), or when you want them reviewed and versioned in Git. If you only need a workflow once, in one space, building it in the admin is simpler.
How it works
Section titled “How it works”Content types from code are simply read when the CMS starts. These four are different: they belong to a space, they have history (a workflow has runs, a webhook has a delivery log), and they are stored in the database. So you declare them in manablox.config.ts, and a separate command writes them into the database:
pnpm exec manablox syncsync compares what the config declares with what is in the database, in every space, and creates or updates what is needed. Run it after you change a declaration. A space created later in the admin gets everything declared for it at the moment it is created, without a sync.
A complete example
Section titled “A complete example”This example, for a company website with the page and teaser types from Content types in code, declares:
- a credential slot
site-deployfor the token of your hosting service, left empty so someone fills it in the admin, - an outgoing webhook
rebuild-sitethat tells your hosting service to rebuild the website whenever content is published or unpublished, - a workflow
tell-editors-on-publishthat emails the editors whenever a page goes live, - a content template
landing-pagewith one teaser block to start from.
In manablox.config.ts, first add the four define... helpers and ref to the names imported from @manablox/core at the top:
import { defineConfig, defineCredential, defineTemplate, defineWebhook, defineWorkflow, envBoolean, envList, envNumber, envOptional, envString, loggingConfigFromEnv, mailConfigFromEnv, ref, requireEnv, storageConfigFromEnv,} from '@manablox/core';Then add a resources section inside defineConfig({ ... }), for example right after logging: loggingConfigFromEnv(),:
resources: { credentials: [ // An empty slot: someone fills in the token in the admin. defineCredential({ slug: 'site-deploy', kind: 'bearer' }), ],
webhooks: [ defineWebhook({ slug: 'rebuild-site', direction: 'outgoing', url: 'https://deploy.example.com/hooks/rebuild', events: ['content.published', 'content.unpublished'], auth: { mode: 'bearer', credential: 'site-deploy' }, }), ],
workflows: [ defineWorkflow({ slug: 'tell-editors-on-publish', name: 'Tell the editors when a page goes live', enabled: true, trigger: { kind: 'event', events: ['content.published'], typeIds: [ref.contentType('page')], }, steps: [ { key: 'tell', action: 'email', config: { to: ['editors@example.com'], subject: 'Published: {{ content.title }}', body: '"{{ content.title }}" is live now.\n\n{{ url }}', }, }, ], }), ],
templates: [ defineTemplate({ slug: 'landing-page', title: 'Landing page', blocks: [ { blockId: '6f1c2a4e-8b3d-4c5e-9f7a-1b2c3d4e5f60', type: ref.contentType('teaser'), fields: { headline: 'Your headline here', style: 'dark' }, }, ], }), ], },Save the file, run pnpm typecheck to catch typos, and make sure the services are running (pnpm services:up). Then run the sync as shown below.
Running the sync
Section titled “Running the sync”- Preview what would change, without writing anything:
pnpm exec manablox sync --dry-run- Read the report. For the example above, in a space called
website, it looks like this:
manablox sync (dry run, nothing written): 1 space(s) + credential site-deploy [website] created - no values declared; slot is empty + webhook rebuild-site [website] created - its credential is empty, so it arrives off + workflow tell-editors-on-publish [website] created + template landing-page [website] created - 1 locale(s)- If that is what you want, run it for real:
pnpm exec manablox sync- The same report appears without “(dry run, nothing written)”. Reload the admin: the workflow is under Workflows, the webhook under Webhooks, the template under Templates and the credential under Settings > Credentials.
Each line of the report is one thing in one space. The sign at the start says what happens to it:
| Sign | Meaning |
|---|---|
+ | Created |
~ | Updated to match the config |
o | Switched off (a declaration was removed) or handed back to the editors |
- | Deleted (only with --prune) |
! | Skipped; the text after it says why |
Things that are already up to date are only counted, in a last line like 4 unchanged. With nothing to do the report says nothing to do.
Options
Section titled “Options”| Option | What it does |
|---|---|
--dry-run | Shows what would change and writes nothing. Ends with exit code 2 if anything would change, so a deploy script can stop and ask |
--space <name> | Only this space, by its technical name, for example --space website |
--prune | Deletes workflows and webhooks from code whose declaration you removed, instead of only switching them off |
--config <file> | Another config file than manablox.config.ts |
When to run it
Section titled “When to run it”- After you add, change or remove a declaration.
- After deploying a new version of your project to a server, right after the migration: first
migrate, thensync.
In a docker project the config is part of the image, so rebuild first (docker compose build, then docker compose up -d), then run the sync inside a container:
docker compose run --rm api syncIf you would rather not think about it, add apply: 'boot' at the top of the resources section. The CMS then syncs by itself every time it starts. The default is 'manual', which waits for you to run the command.
What sync will never do
Section titled “What sync will never do”sync is careful with what people changed in the admin:
- It never switches something back on.
enabledonly counts when a workflow or webhook is first created; after that, the on/off switch in the admin belongs to whoever runs the CMS. - It never overwrites a stored secret with nothing. A credential slot declared without values keeps whatever someone filled in.
- It does not delete unless you ask. A declaration you remove only switches the workflow or webhook off (so its run history survives).
--prunedeletes them. Credentials and templates are never deleted; they are handed back to the admin as normal, editable entries. - It does not take over names. If a workflow built in the admin already uses the same slug, the declaration is skipped and reported with
!.
In the admin
Section titled “In the admin”Everything written by sync carries a small “code” badge in the admin, and its editor is read-only. A few things stay in your hands there:
- the on/off switch of a workflow or webhook,
- filling in the secret of a credential slot,
- Clone on a workflow, which makes a normal, editable copy with no link to the config.
Credential slots
Section titled “Credential slots”The rule: the code declares the slot, the secret comes from somewhere else. There are two ways to fill a slot.
Leave it empty, as in the example, and fill it in the admin under Settings > Credentials. Until then, everything that uses the slot is created switched off; once the secret is filled in, switch it on yourself.
Or take the value from .env with requireEnv:
defineCredential({ slug: 'search', kind: 'apiKey', values: { header: 'X-Api-Key', key: requireEnv('SEARCH_API_KEY') },}),Then put SEARCH_API_KEY=... in .env. The value is encrypted when sync stores it, just like a value typed into the admin. Note that requireEnv stops the CMS at start when the variable is missing, on every machine that uses this config.
The kinds of credential and their values:
kind | Values |
|---|---|
apiKey | header (the header name), key, optional prefix |
bearer | token |
basic | username, password |
oauth2 | tokenUrl, clientId, clientSecret, refreshToken, optional scope |
smtp | url (for example smtps://user:pass@smtp.example.com:465), from |
signing | secret, a shared secret for signed webhooks |
custom | Any named values, for plugins |
Workflows
Section titled “Workflows”defineWorkflow takes:
| Key | What it means |
|---|---|
slug | The technical name. The workflow is found by it, so renaming the slug creates a new workflow and leaves the old one behind |
name | What the admin shows |
enabled | Whether it starts switched on. Default false. Only read when the workflow is first created |
spaces | '*' (the default) for every space, including future ones, or a list of technical names such as ['website'] |
trigger | When it runs: kind: 'event' with a list of events (content.created, content.updated, content.saved, content.published, content.unpublished, content.deleted), kind: 'schedule' with a cron time, or kind: 'webhook' with the slug of an incoming webhook |
steps | What it does, in order |
Each step has a key (lowercase letters, digits and _), which later steps use to read its result, for example {{ nodes.tell.messageId }}. A step normally runs after the one above it. The simplest step runs an action: action names it (email sends an email through your mail settings, http calls a web address), and config holds its settings, with the same names as the fields in the admin’s workflow editor. Steps can also branch on a condition, wait, or repeat for a list of items; build such a workflow in the admin first and use the admin’s config download (see Content types in code) to get it as code.
Texts like {{ content.title }} are placeholders the workflow fills in when it runs. {{ url }} is a link to the document in the admin.
Webhooks
Section titled “Webhooks”defineWebhook takes a slug, a direction and, depending on the direction:
- Outgoing (
direction: 'outgoing'): theurlto call and theeventsto call it for (empty means all). - Incoming (
direction: 'incoming'): themethodsit accepts (defaultPOST). Its address is made from the slug, so it is the same on every installation; the admin shows it under Webhooks.
auth protects the call, with a mode (hmac, token, basic, bearer, and for outgoing also oauth2) and the slug of the credential that holds the secret.
Content templates
Section titled “Content templates”defineTemplate writes a template document with a list of blocks.
slugandtitlename it.blocksis a list of blocks. Each has ablockId, which must be a unique id in UUID format (make one withnode -e "console.log(crypto.randomUUID())"), the blocktype, and thefieldsto fill in.manage: 'seed'(the default) writes the template once; after that it belongs to the editors andsyncleaves it alone.manage: 'managed'rewrites it on every sync when the declaration changed, and the admin does not allow editing it.
A space that does not have the block types a template uses skips it.
Referring to things by name: ref
Section titled “Referring to things by name: ref”The config cannot know the id a content type has in a space. ref writes a placeholder that sync turns into the right id in each space:
ref.contentType('page')for a content type,ref.credential('site-deploy'),ref.webhook('from-github')andref.template('landing-page')for the other declarations, by slug.
If a space has nothing by that name, that one declaration is skipped for that space and reported with !; the rest of the sync carries on.