Configuration reference: forumulate.yaml, version 2 (site manifest)

Embed
Preview of the card
<a href="https://forumulate.com/reference/config-forumulate-yaml-v2"><img src="https://forumulate.com/reference/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/8042f004-e3d9-445f-b7a4-b6c1f86836e6/pages/03b071de-0493-4316-a70c-874d8ad28a29/social-card.svg?v=3e7ae33eec8b" alt="Configuration reference: forumulate.yaml, version 2 (site manifest)" width="600" height="315"></a>

The whole-site document forumulate plan and forumulate apply read: a forumulate.yaml that says version: 2. It is the body of PUT /orgs/{orgId}/manifest plus what only a file can say (org, a pages entry that is a Markdown folder {dir, space}, about/contactForm given as a file name). A file with no version key is the publish manifest instead — see forumulate.yaml.

Where it is used: forumulate plan|apply [forumulate.yaml], and (without the file-only keys) PUT /orgs/{orgId}/manifest.

JSON Schema: https://forumulate.com/schemas/2026-12-01/forumulate-yaml.json — the same file as the publish manifest; version: 2 selects this shape. Check a folder with forumulate validate <dir>.

This page is generated from the schema the API validates with; it cannot describe a key the API does not take.

Casing

  • Every key in a JSON or YAML configuration document (site config, theme, forumulate.yaml, a category) is camelCase, exactly: primaryColor, footerColumns, headerLinks. primary_color and PrimaryColor are unknown keys, refused with the camelCase name in the hint. The one exception is two top-level blocks of a version-2 site manifest, which are snake_case: reader_auth (a list of sign-in configurations) and status_monitors; readerAuth is a different key there, the one-word shorthand readerAuth: atlas.

  • Front matter is the exception: a key is read in any casing (seo_title, seo-title, SeoTitle all mean seoTitle), and the names other tools use (description, sidebar_label, sidebar_position, image) are read as ours.

  • Enum values are lower-case ("wide", "system"), except space types and visibilities, which are upper-case ("DOCS", "PUBLIC").

  • Colours are #rrggbb (or #rgb); lengths that are numbers are CSS pixels.

Keys

Top-level keys and the keys one level inside them. [] marks “each item of a list”.

Key

Type

Required

What it is

$schema

string

brand

object

brand.colors

map

brand.faviconUrl

string

brand.fonts

map

brand.logoMarkUrl

string

brand.logoUrl

string

brand.name

string

brand.voice

string

connectors

object[]

connectors[].appearance

object

connectors[].config

map

connectors[].embedEnabled

boolean

connectors[].embedOptions

object

connectors[].externalRef

string

Your own name for this resource (printable ASCII, no spaces, ≤200). A repeat create naming a reference already held answers that resource (200, idempotent: true) instead of making a second one.

connectors[].id

string

connectors[].knowledgeEnabled

boolean

connectors[].knowledgeScopes

"chunks" | "search" | "ask" | "llms" | "questions" | "suggestions"[]

connectors[].metadata

map

Free-form flat tags (≤50 keys; values string, number, boolean or null), filterable on the list with ?metadata[key]=value. The older metadata.forgedeck_ref is still accepted as an alias of externalRef.

connectors[].providerId

string

yes

connectors[].space

string

yes

consent

object

consent.provider

string

yes

domains

object[]

domains[].alsoRedirectWww

boolean

Also answer www.<hostname> (same path) with a permanent redirect to this address, written into our proxy. Default false: a domain is registered for ONE hostname, so without it www.acme.com/docs reaches whatever else answers that name (often your own app, which 404s). Leave it off when your own app already redirects www; GET …/setup reports wwwNotCovered either way. Refused (422) on a hostname that already starts with www.. Point the www name’s DNS where the bare name points; for a whole-host domain we also request its certificate.

domains[].basePath

string

domains[].hostname

string

yes

domains[].id

string

domains[].ipAllowList

string[]

IP addresses and CIDR blocks that may READ this address — 203.0.113.7, 203.0.113.0/24, 2001:db8::/32. An empty list means anywhere, which is what every domain has by default. Enforced before a page is rendered, against the client address the edge resolved; an entry that is not an address or a block is a 422, because one that nothing can parse would never match and would lock out the world silently.

domains[].onUnverified

"hold" | "fail"

When state is verified or active and the ownership proof is not served yet: hold (the default) keeps the domain claimed, reports the step waiting: <what each proof method saw> and carries on with every other unit (the apply exits 0; re-apply once the file is served); fail stops the run at this domain, as before.

domains[].redirectTo

string | null

domains[].site

string

domains[].space

string

domains[].state

"claimed" | "verified" | "active"

How far apply takes the domain — a MINIMUM, forward only: a domain already further along is never walked back (an active domain declared verified stays active). claimed (the default): the domain is added and held; manual lists the ownership TXT record (and the .well-known file) to publish. verified: ownership is checked (verify with activate: false) and traffic is NOT switched — your own application keeps serving the address. active: verified, then activated — we start answering it. The steps run in order, verify → activate. When no proof is served yet, onUnverified decides: hold (the default) keeps the domain claimed, reports the step waiting with what each proof method saw, and carries on with every other unit — the next apply takes it forward; fail stops the run there. A plan reports the same waiting before anything is written. A step that just activated reports settling: true: routing takes about a minute and GET …/setup may answer pending (pendingReason: settling) meanwhile.

executor

object

executor.completionWebhook

object

executor.deletionMode

"erase" | "anonymize"

executor.ensureKey

boolean

executor.retention

object

force

boolean

mail

object

The organization’s own outbound mail: {provider, sender, apiKey: ${MAIL_API_KEY}, domain?, region?, baseUrl?, webhookSecret?} — the body of PUT /orgs/{orgId}/mail/provider. Status notices, newsletters and community mail then go out from sender through that provider. apiKey and webhookSecret are write-only: plan prints <write-only>, export leaves them out.

mail.apiKey

string

Write-only. Use ${ENV} (apiKey: ${MAIL_API_KEY}): substituted by the CLI, never printed by plan, never exported. Omit to keep the stored key (required the first time).

mail.baseUrl

string | null

The provider’s API origin, for a provider that runs at an address you choose (hermetica: default https://hermeticor.com; a self-hosted or regional install sets its own). Ignored for the others.

mail.domain

string | null

mail.provider

"brevo" | "sendgrid" | "postmark" | "mailgun" | "resend" | "hermetica"

yes

mail.region

"us" | "eu" | null

mail.sender

string | null

The From this organization’s mail goes out as through this provider ("Acme Status status@acme.com") — status notices, newsletters and community mail alike. Null: the organization’s From address (PATCH /orgs/{orgId}/email-from), else ours. Its domain must be verified at the provider (GET …/mail/provider/domain).

mail.webhookSecret

string | null

Write-only. The signing secret of the provider’s bounce webhook (hermetica: the endpoint secret behind Hermetica-Signature); once stored, an unsigned or wrongly signed call is refused. Null removes it; omit to keep it. The GET answers webhookSecretSet only.

org

string

orgSettings

map

Organization settings by registry key (GET /orgs/{orgId}/settings lists them), each PUT …/settings/{key}.

pages

object[]

privacy

object

privacy.executor

"cookiemunch"

yes

prune

boolean

readerAuth

"atlas" | object | object

How readers sign in, for the whole organization. Three forms: "atlas" — the Atlas reader provider, on the instance linked to this organization or, failing that, the live atlas_byo provider (whose order and settings are kept, so it plans as unchanged); {method: "atlas", instanceId?, frontendApi?, audience?} — the same, naming the instance (ins_…), a custom frontend-API domain, or an audience other than forumulate:<orgId>; {providers, signInUrl?, signOutUrl?} — the DECLARED registry: apply replaces the providers (a stored provider left out is removed) and the URLs. Do not combine with an organization-level reader_auth entry. Guide: https://forumulate.com/guides/reader-sign-in-with-your-app#change-a-spaces-sign-in-method.

readerIdentity

"space" | "org"

The org default reader-identity scope, space or org; shorthand for the reader.identity_scope org setting.

reader_auth

object[]

reader_auth[].approveNewMembers

boolean | null

reader_auth[].hostedSignup

boolean | null

reader_auth[].identityPolicy

object | null

Per-space override of the reader identity keys (reader.idp_email_collision, reader.idp_user_deleted, reader.idp_deletion_grace_days, reader.idp_user_banned). Each field is resolved on its own: space → org setting → default. Only meaningful on a space override.

reader_auth[].outboundSync

object | null

reader_auth[].providers

object[]

reader_auth[].signInUrl

string | null

Your own sign-in page (https). When set, the reader sign-in surface shows one "Sign in to continue" link to this URL instead of the provider buttons (an Atlas provider keeps its own sign-in page), with these query parameters appended: returnTo — the absolute URL of the page the reader was on (always set, replacing any returnTo already in the URL); space — the space slug; spaceId — the space id. Your page must read returnTo and send the reader back there after sign-in, for example https://app.example.com/sign-in receives https://app.example.com/sign-in?returnTo=https%3A%2F%2Fdocs.example.com%2Fguides%2Fintro&space=guides&spaceId=…. A {returnTo} placeholder anywhere in the URL (https://app.example.com/login?next={returnTo}) is replaced with the URL-encoded address instead, and returnTo is then not appended again. space/spaceId are only added when the URL does not already carry them. Configured once for the org, one page serves every space: send the reader on to /api/public/orgs/{orgId}/spaces/{spaceId}/visitor-oidc/start (or your own flow) using spaceId, then back to returnTo. returnTo is at most 2048 characters: a longer page address is sent without its query string and fragment, and failing that as the space root. Saving it answers warnings[] (sign_in_url_overrides_providers, signin_url_unreachable, sign_in_url_return_to_unconfirmed). null clears it.

reader_auth[].signOutUrl

string | null

reader_auth[].sourceOfTruth

"customer" | "forumulate" | null

reader_auth[].space

string

sites

object[]

sites[].config

map

Exactly what PATCH /orgs/{orgId}/sites/{siteId} takes as config, checked by that door.

sites[].configMode

"merge" | "replace"

sites[].id

string

sites[].name

string

sites[].slug

string

yes

sites[].spaces

string[]

sites[].subdomain

string | null

The site's platform subdomain label (acme → acme.<platform domain>), claimed on apply. null releases it; absent leaves it alone.

sources

object[]

sources[].content

string

sources[].kind

"url" | "text"

yes

sources[].spaceId

string | null

sources[].title

string

yes

sources[].url

string

spaces

object[]

spaces[].about

object | string

spaces[].authors

map

A BLOG space's author registry by key (PUT …/authors/{key} each); a byline names one as {author: <key>}.

spaces[].categories

object[]

spaces[].collections

object[]

spaces[].compare

object

A COMPARE catalog: the keys inline, and/or catalog: (a YAML/JSON file beside this one) and matrix: (CSV text, or the CLI reads it from a file).

spaces[].contactForm

object | string

spaces[].description

string

One sentence about the space (up to 300 characters): the description search engines and social cards show. Shorthand for seo.description (the same stored value, kept in step on every apply); naming both with different text is refused.

spaces[].frontDoor

map

spaces[].homeDoc

object | string

The space intro (PUT …/home-doc): {body, translations}, or a Markdown file whose .<code>.md twins beside it are its languages.

spaces[].id

string

spaces[].legalDocuments

object[]

A LEGAL space's documents: binds each page (by slug) as kind (POST …/legal/bind). version: bump re-editions only when the agreed words changed.

spaces[].locales

object[]

spaces[].monitors

object[]

spaces[].name

string

spaces[].openapi

object | object[]

An API reference (or several): url or spec (the document, or a file name the CLI reads), the collection by name, and the import's options (mode, slugPrefix, pages, …). Re-imported when the spec changes.

spaces[].piecePreset

string

spaces[].posts

object[]

A BLOG space's posts by slug: the blog door's fields, body as Markdown, status (published by default, published without mailing anyone), and translations per declared language.

spaces[].preset

string

One of this space type's named layouts, as published by GET /space-types. Unset means the recommended one, resolved at read — so an improvement to the default reaches every space that never chose. An unknown name for the type is a 422 that lists the valid ones.

spaces[].redirects

object[]

The whole table, replacing what is stored — except the rules the platform wrote itself when a page or a collection was renamed, which are kept unless your list claims the same from. Renaming a collection moves one address — its hub, /c/<slug> — and we write that rule for you; the pages filed under it are addressed by their own slug within the space and do not move.

spaces[].releases

object[] | object | object

spaces[].seed

boolean

Whether to create the starter structure this kind of space ships with. Left unset it follows who is asking: a console session gets it (an empty space is a worse first screen than an example one), a personal access token does not — an agent creating a space is about to write its own structure, and ours arrives as content it has to find and delete first.

spaces[].seo

object

spaces[].settings

object

Merged into the space (PATCH …/spaces/{id} {settings}), STRICT: an unknown key is refused by name. blog, forum and status are the type's own settings doors.

spaces[].shape

object

spaces[].slug

string

yes

spaces[].theme

object

spaces[].topics

object[]

A FORUM space's staff topics by slug: title, body (Markdown), category by name, tags, pin (true, or {pinned, global, banner, until}), featuredLink, poll, customization, who it credits (postAs, byline, bylines), createdAt (a backdate, applied when the topic is created) and translations. Members' topics are never touched.

spaces[].translations

map

spaces[].type

"DOCS" | "FORUM" | "BLOG" | "ROADMAP" | "CHANGELOG" | "STATUS" | "LEGAL" | "FAQ" | "CAREERS" | "CONTACT" | "EVENTS" | "COURSES" | "CUSTOM" | "CONTENT" | "ABOUT" | "COMPARE"

spaces[].visibility

"PUBLIC" | "PRIVATE"

status_monitors

object[]

status_monitors[].alertsEnabled

boolean

status_monitors[].allowGetPing

boolean

Heartbeat only: whether GET /status/ping/{monitorId}/{token} is accepted. A GET URL ends up in browser history, proxy logs and link previews, so it is off (405) for a monitor created without it; monitors created before it existed keep GET on. POST (path or header form) always works.

status_monitors[].audience

object

Who may see this on the public page: everyone (default), signedIn (any signed-in reader) or groups (a signed-in member of one of groups). Hidden rows are dropped by the API before anything is sent. The machine doors — summary.json, api/v2, the feeds, the badge and the widget — always answer as an anonymous reader.

status_monitors[].barDays

30 | 60 | 90 | null

Its own day strip length on the board; null follows the theme’s bars.days.

status_monitors[].bodyExpect

string | null

status_monitors[].bodyPath

string | null

status_monitors[].certExpiryDays

integer | null

status_monitors[].confirmRegions

integer

How many regions must see it failing before it reads down (capped at how many report). Default 1.

status_monitors[].declaredState

"operational" | "degraded" | "partial_outage" | "major_outage" | "maintenance" | null

A state a person declares, which wins over the checks until it is cleared with null. Every field of this body — this one, alertsEnabled and both thresholds included — is applied on create exactly as on PATCH.

status_monitors[].description

string | null

What the component is, in a sentence — shown to readers beside its name.

status_monitors[].display

"bars" | "status" | "hidden-unless-degraded"

bars (default) draws its day bars; status only its state; hidden-unless-degraded leaves it off the page while it is operational.

status_monitors[].expectStatus

integer | null

status_monitors[].failureThreshold

integer

status_monitors[].group

string | null

status_monitors[].headers

object[] | null

status_monitors[].id

string

status_monitors[].intervalSeconds

integer

Seconds between checks (http, clamped to 30–3600, default 60) or the longest wait for a ping (heartbeat, clamped to 60–604800, default 3600). PATCH clamps by the same rule.

status_monitors[].json

object | null

status_monitors[].keyword

string | null

status_monitors[].keywordAbsent

boolean

Invert keyword: the check FAILS while the term is in the body ("error", "maintenance mode") — Better Stack's "keyword absent". Default false.

status_monitors[].kind

"http" | "heartbeat" | "manual" | "space" | "statuspage" | "tcp" | "udp" | "icmp" | "dns" | "tls" | "smtp" | "imap" | "pop3" | "domain" | "browser" | "instatus" | "betterstack" | "feed" | "watchtower"

http (default) polls url; heartbeat waits for your job to ping; manual is probed by nothing — its state is declaredState (else operational) or what an open incident linked to it says; space rolls up another status page of this organization (targetSpaceId) on a hub page; statuspage mirrors a vendor’s public Statuspage (url, plus vendorComponent); watchtower mirrors one Watchtower monitor (watchtower), read every intervalSeconds from its status badge. Fixed at creation: a PATCH cannot turn one kind into another — delete and re-create instead.

status_monitors[].linkUrl

string | null

Where the component’s own docs or product live; its name links there.

status_monitors[].maxLatencyMs

integer | null

status_monitors[].method

"GET" | "HEAD" | "POST"

status_monitors[].metricThresholds

object | null

status_monitors[].name

string

yes

status_monitors[].paused

boolean

Stop probing without deleting: the row and its history stay on the page, no new checks are taken and no alert is sent, and the component reads unknown (or its declaredState) until it is resumed. alertsEnabled: false only mutes the alerts while the probe keeps running.

status_monitors[].pingSecretVersion

integer

status_monitors[].probe

object

What a protocol monitor probes (kinds tcp, udp, icmp, dns, tls, smtp, imap, pop3, domain, browser): host (+ port), a DNS record/resolver/expect, the days a certificate or registration must have left, a browser check’s url and selector. Validated per kind; every host must resolve to a public address.

status_monitors[].region

string | null

Where it runs ("EU", "US-East"). theme.status.components.groupBy: region groups by it.

status_monitors[].regions

string[]

Probe regions this component is checked from (GET …/status/regions). Empty: the primary checker only.

status_monitors[].requestBody

string | null

status_monitors[].requestHeaders

map | null

status_monitors[].showUptime

boolean

Show its uptime figure beside the strip. Default true.

status_monitors[].slug

string

Its stable address within the page (component page, per-component subscription), normalised to lowercase words joined by hyphens. Minted from the name on create when omitted; unique per page — a taken one is a 409. Renaming the component does not move it.

status_monitors[].space

string

yes

status_monitors[].startsOn

string | null

The day it started existing. Earlier days draw as "not yet" rather than as no data. Null: from its first check (a manual component: from the day it was created).

status_monitors[].successThreshold

integer

status_monitors[].targetSpaceId

string

Kind space: the status page it rolls up (state, uptime and days are that page’s).

status_monitors[].url

string | null

status_monitors[].vendorComponent

string | null

Kind statuspage: the vendor component’s id or exact name in its summary.json. Null: the vendor page’s overall indicator.

status_monitors[].visible

boolean

status_monitors[].watchtower

object

Kind watchtower: the monitor it mirrors. Its state follows the monitor within one interval; an inbound watchtower hook naming the same monitor opens its incidents on this component.

tokens

object[]

tokens[].capabilities

string[]

tokens[].expiresInDays

integer

tokens[].name

string

yes

tokens[].ref

string

yes

Your own name for this resource (printable ASCII, no spaces, ≤200). A repeat create naming a reference already held answers that resource (200, idempotent: true) instead of making a second one.

tokens[].scopeGrants

string[]

tokens[].scopes

string[]

yes

tokens[].spaces

string[]

translationOverrides

map

Interface strings per language, { <locale>: { <key>: <text> } } (PUT /orgs/{orgId}/translation-overrides each).

version

2

yes

webhooks

object[]

webhooks[].active

boolean

webhooks[].alertContacts

string[] | null

E-mail addresses told when this endpoint keeps failing: at 1 hour, at 24 hours, and when it is switched off. Up to 10. Omitted leaves them as they are; null or [] means the organization's owners.

webhooks[].alert_contacts

string[] | null

E-mail addresses told when this endpoint keeps failing: at 1 hour, at 24 hours, and when it is switched off. Up to 10. Omitted leaves them as they are; null or [] means the organization's owners.

webhooks[].bodyEncoding

"json" | "form"

How the delivery body is encoded. json (the default) POSTs application/json; form POSTs application/x-www-form-urlencoded, for a receiver that cannot parse JSON bodies.

webhooks[].categoryIds

string[] | null

Narrow delivery to events whose payload names one of these forum categories. Omitted or null means no filter — the endpoint hears the event wherever it happens.

webhooks[].contentTypes

string[] | null

Narrow content.* events to entries of these content types (by key). Omitted or null means no filter. A model change to the whole model is always delivered.

webhooks[].events

"page.created" | "page.published" | "page.updated" | "page.deleted" | "item.stale" | "assertion.failed" | "change_request.merged" | "version.created" | "version.default_changed" | "locale.created" | "comment.created" | "comment.moderated" | "comment.resolved" | "comment.deleted" | "content_rule.created" | "content_rule.deleted" | "reusable.created" | "reusable.updated" | "reusable.deleted" | "variable.set" | "variable.deleted" | "page_template.created" | "page_template.deleted" | "topic.created" | "topic.published" | "topic.edited" | "topic.deleted" | "topic.recovered" | "post.created" | "post.edited" | "post.deleted" | "post.recovered" | "post.flagged" | "topic.solved" | "topic.moved" | "reaction.created" | "reaction.removed" | "chat.message_created" | "notification.created" | "category.created" | "category.updated" | "category.deleted" | "tag.created" | "tag.deleted" | "reviewable.created" | "reviewable.updated" | "user.joined" | "user.suspended" | "user.promoted" | "user.silenced" | "user.anonymized" | "badge.granted" | "group.member_added" | "group.member_removed" | "group.updated" | "group.deleted" | "form.submitted" | "status.incident.created" | "status.incident.updated" | "status.incident.resolved" | "event.published" | "event.updated" | "event.cancelled" | "rsvp.created" | "rsvp.updated" | "rsvp.deleted" | "rsvp.checked_in" | "roadmap.status_changed" | "roadmap.merged" | "roadmap.shipped" | "course.enrolled" | "lesson.completed" | "quiz.passed" | "course.completed" | "certificate.issued" | "course.purchased" | "course.refunded" | "quiz.failed" | "quiz.review_needed" | "blog.membership.started" | "blog.membership.ended" | "blog.review_requested" | "blog.reviewed" | "application.created" | "application.stage_changed" | "application.offer_answered" | "role.opened" | "role.closed" | "contact.submitted" | "about.updated" | "compare.catalog.updated" | "compare.stale" | "release.review_requested" | "release.reviewed" | "membership.started" | "membership.renewed" | "membership.canceled" | "membership.ended" | "shop.order.paid" | "shop.order.refunded" | "form.paid" | "content.entry.published" | "content.entry.unpublished" | "content.entry.deleted" | "content.entry.scheduled" | "content.route.changed" | "content.release.published" | "content.model.changed" | "content.entry.draft_saved" | "content.entry.unscheduled" | "content.asset.uploaded" | "content.asset.updated" | "content.asset.deleted" | "content.locale.added" | "content.locale.updated" | "content.locale.removed" | "domain.added" | "domain.verified" | "domain.failed" | "domain.activated" | "domain.removed" | "site.created" | "site.updated" | "site.deleted" | "theme.published" | "theme.saved" | "theme.deleted" | "theme.component_saved" | "theme.component_deleted" | "theme.component_installed" | "theme.component_uninstalled" | "theme.listing_published" | "theme.listing_withdrawn" | "legal.published" | "legal.removed" | "legal.access_requested" | "legal.privacy_requested" | "space.created" | "space.updated" | "space.deleted" | "space.restored" | "space.purged" | "snapshot.created" | "snapshot.restored" | "snapshot.deleted" | "collection.created" | "collection.updated" | "collection.deleted" | "form.created" | "form.updated" | "form.deleted" | "token.created" | "token.rotated" | "token.revoked" | "pat.expiring" | "integration_token.expiring" | "partner_link.expiring" | "executor_key.expiring" | "service_account.created" | "service_account.updated" | "service_account.deleted" | "role.created" | "role.updated" | "role.deleted" | "invite.created" | "invite.revoked" | "invite.accepted" | "user.removed" | "user.role_changed" | "group.grant_added" | "group.grant_removed" | "share_link.created" | "share_link.revoked" | "webhook.created" | "webhook.updated" | "webhook.deleted" | "webhook.secret_rotated" | "webhook.disabled" | "extension.installed" | "extension.updated" | "extension.uninstalled" | "automation.created" | "automation.updated" | "automation.deleted" | "library.created" | "library.updated" | "library.deleted" | "library.member_added" | "library.member_removed" | "openapi.spec.registered" | "openapi.spec.synced" | "openapi.spec.removed" | "openapi.changed" | "sdk.published" | "sso.connection_created" | "sso.connection_updated" | "sso.connection_deleted" | "sso.enforcement_changed" | "scim.token_created" | "scim.token_rotated" | "scim.token_revoked" | "oauth_client.created" | "oauth_client.secret_rotated" | "oauth_client.deleted" | "group.created" | "badge_rule.created" | "badge_rule.updated" | "badge_rule.deleted" | "badge_rule.ran" | "session.revoked" | "org.updated" | "org.deleted" | "org.subscribed" | "org.subscription_changed" | "org.unsubscribed" | "payments.connected" | "payments.updated" | "payments.disconnected" | "email_template.saved" | "email_template.reset" | "mail.suppression_added" | "mail.suppression_removed" | "mail_provider.connected" | "mail_provider.disconnected" | "connector.installed" | "connector.updated" | "connector.uninstalled" | "connector.token_minted" | "connector.token_revoked" | "chat_provider.created" | "chat_provider.updated" | "chat_provider.deleted" | "embed_host.saved" | "embed_host.removed" | "embed_feed.created" | "embed_feed.updated" | "embed_feed.deleted" | "github_agent.connected" | "github_agent.disconnected" | "integration.installed" | "integration.updated" | "integration.uninstalled" | "cloudflare.connected" | "cloudflare.updated" | "cloudflare.disconnected" | "kb.changed" | "status.component.created" | "status.component.updated" | "status.component.status_changed" | "status.component.removed" | "status.metric.created" | "status.metric.updated" | "status.metric.removed" | "status.template.created" | "status.template.updated" | "status.template.removed" | "status.translation.saved" | "status.translation.removed" | "status.inbound.created" | "status.inbound.removed" | "status.inbound.rotated" | "status.monitor.ping_secret_rotated" | "careers.ats.connected" | "careers.ats.updated" | "careers.ats.disconnected" | "careers.ats.synced" | "form_template.created" | "form_template.updated" | "form_template.deleted" | "form_template.attached" | "ai.provider.connected" | "ai.provider.disconnected" | "ai.embedding_provider.connected" | "ai.embedding_provider.disconnected" | "ai.source.added" | "ai.source.removed" | "translation.created" | "translation.updated" | "translation.deleted" | "speaker.created" | "speaker.updated" | "speaker.deleted" | "event_session.created" | "event_session.updated" | "event_session.deleted" | "ticket_tier.created" | "ticket_tier.updated" | "ticket_tier.deleted" | "ticket_order.refunded" | "ticket_order.paid" | "ticket_order.requested" | "promo_code.created" | "promo_code.updated" | "promo_code.deleted" | "course_path.created" | "course_path.updated" | "course_path.deleted" | "publish.completed" | "page.live" | "asset.uploaded" | "asset.updated" | "asset.deleted" | "asset_folder.created" | "asset_folder.updated" | "asset_folder.deleted" | "avatar.updated" | "avatar.removed" | "custom_field.created" | "custom_field.updated" | "custom_field.deleted" | "custom_field.values_changed" | "reader_domain.configured" | "reader_domain.verified" | "reader_domain.failed" | "reader_domain.removed" | "shape.updated" | "site_preview.created" | "site_preview.revoked" | "theme.origin_removed" | "theme.origin_restored" | "theme.component_listing_published" | "theme.component_listing_withdrawn" | "canned_reply.created" | "canned_reply.updated" | "canned_reply.deleted" | "flag_type.created" | "flag_type.updated" | "flag_type.deleted" | "reaction_palette.updated" | "custom_reaction.saved" | "custom_reaction.deleted" | "custom_emoji.saved" | "custom_emoji.deleted" | "saved_search.created" | "saved_search.updated" | "saved_search.deleted" | "member_note.created" | "member_note.deleted" | "conversation.created" | "conversation.replied" | "conversation.participant_added" | "conversation.participant_removed" | "relationship.muted" | "relationship.unmuted" | "relationship.followed" | "relationship.unfollowed" | "screening.rule_added" | "screening.rule_removed" | "job.completed" | "webhook_endpoint.failing" | "webhook_endpoint.disabled" | "audit.event"[]

yes

The events this endpoint should hear, by name. At least one; GET /webhooks/events publishes the whole vocabulary with a sample payload for each.

webhooks[].externalRef

string

Your own name for this resource (printable ASCII, no spaces, ≤200). A repeat create naming a reference already held answers that resource (200, idempotent: true) instead of making a second one.

webhooks[].format

"json" | "slack" | "discord"

The body shape. json (the default) sends our own event payload; slack and discord send a message that platform renders natively, so an incoming-webhook URL from either can be pasted in with nothing in between.

webhooks[].groupIds

string[] | null

Narrow group, user and badge events to these groups. Omitted or null means no filter; it has no effect on events that name no group.

webhooks[].id

string

webhooks[].kind

"webhook" | "deploy_hook"

webhook (the default) delivers signed events. deploy_hook sends a plain POST with an EMPTY body (no signature, no payload) to a Vercel, Netlify, Cloudflare Pages or Seolyticize deploy-hook URL, on content.* events and the publish events page.published, page.live and topic.published only. Debounced: Vercel/Netlify/Cloudflare one call 30 s after the last change (at most 5 min after the first); Seolyticize one call 60 s after the last publish (at most 2 min after the first), per hook, so a 30-page apply is one call. The URL is write-only: reads answer its host and /********. Fixed at create.

webhooks[].locales

string[] | null

Narrow content.* events to entries in these locales (BCP-47 codes, or default for a space's source language). Omitted or null means no filter.

webhooks[].metadata

map

Free-form flat tags (≤50 keys; values string, number, boolean or null), filterable on the list with ?metadata[key]=value. The older metadata.forgedeck_ref is still accepted as an alias of externalRef.

webhooks[].ref

string

webhooks[].signatureVersion

"both" | "v1"

Which signature headers each delivery carries. v1 is the timestamped scheme alone and is the default for a NEW endpoint; both adds the legacy body-only sha256=, which is replayable forever and exists only for receivers not yet migrated.

webhooks[].space

string

webhooks[].tagIds

string[] | null

Narrow delivery to events whose payload carries one of these tags. Omitted or null means no filter. Tags are matched by name, not by id.

webhooks[].url

string

yes

Where deliveries are POSTed. Must be https outside development, and is called with a signature header your receiver verifies — see the webhooks guide for the scheme.

The Forumulate teamUpdated