Webhooks

Embed
Preview of the card
<a href="https://forumulate.com/guides/webhooks"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/4dcdeff6-3ce4-41fc-a151-d00b8654f815/social-card.svg?v=3e7ae33eec8b" alt="Webhooks" width="600" height="315"></a>

A webhook posts a JSON body to a URL of yours every time something happens in your organization. This page covers creating one, the full event list, and — the part that is easy to get wrong — verifying the signature.

Create one

POST /orgs/{orgId}/webhooks
{
  "url": "https://hooks.yourcompany.com/forumulate",
  "events": ["page.published", "topic.solved"]
}

Field

Required

Notes

url

yes

Up to 2048 characters.

events

yes

At least one, from the list below.

spaceId

no

Confine the webhook to one space. null for the whole organization.

libraryId

no

Confine it to one content library instead (content.* events).

format

no

json (default), slack or discord.

categoryIds

no

Up to 100 forum category ids — only fire for these.

tagIds

no

Up to 100 tags — only fire for these.

groupIds

no

Up to 100 group ids — only fire for these.

contentTypes

no

Up to 100 content type keys — content.* events for these only.

locales

no

Up to 100 locales — content.* events in these only.

kind

no

webhook (default) or deploy_hook. See "Deploy hooks".

bodyEncoding

no

json (default) or form. See "What exactly is signed".

signatureVersion

no

v1 (default for anything created now) or both.

There is no secret input. The secret is generated for you and returned once, on the 201:

{
  "secret": "whsec_…",          // this response only, never again
  "webhook": { "id": "…", "url": "…", "events": [...], … }
}

Store it when you create the webhook. If you lose it, POST /orgs/{orgId}/webhooks/{webhookId}/rotate-secret issues a new one and keeps the old one signing alongside it for a window, so you can deploy without dropping deliveries.

The other endpoints

Call

What it does

GET /orgs/{orgId}/webhooks

List them.

PATCH /orgs/{orgId}/webhooks/{webhookId}

Change url, events, filters, format.

DELETE /orgs/{orgId}/webhooks/{webhookId}

Remove it.

POST /orgs/{orgId}/webhooks/{webhookId}/ping

Send a synthetic ping delivery. The fastest way to test a verifier.

GET /orgs/{orgId}/webhooks/{webhookId}/deliveries

Recent attempts, with status and response.

POST /orgs/{orgId}/webhooks/{webhookId}/deliveries/{deliveryId}/replay

Send one again.

GET /orgs/{orgId}/webhooks/events

The event catalog with a sentence on each. Needs org.manage_webhooks; the list below needs nothing.

Every event

330 events. GET /orgs/{orgId}/webhooks/events returns the same list with a sentence on each, and the sentence names the payload's keys. Earlier versions of this page listed forty-three and left out every space type's own events.

Documentation

page.created            page.published          page.updated            page.deleted
item.stale              assertion.failed        change_request.merged
version.created         version.default_changed locale.created
collection.created      collection.updated      collection.deleted

Paid forum membership

membership.started      membership.renewed      membership.canceled     membership.ended

A member's paid access to a forum (or some of its categories) began, was paid for another period, was set to end at the period's close, or ended. The payload names the plan, the member and currentPeriodEnd; never a card or payment detail.

Comments, snippets, variables and templates (docs authoring)

comment.created         comment.moderated       comment.resolved        comment.deleted
reusable.created        reusable.updated        reusable.deleted
variable.set            variable.deleted
page_template.created   page_template.deleted
content_rule.created    content_rule.deleted

These say which thing changed, never its words: a comment.* payload carries commentId, pageId, spaceId, parentId, status, resolved and authorUserId, and no comment text or e-mail. reusable.* carries reusableId, name and orgWide but not the snippet's content, and variable.* carries key, type and orgWide but not the value. Read the words from the API when you need them. A snippet or variable that is organization-wide has spaceId: null and reaches only endpoints that are not confined to one space. reusable.updated and variable.set change every page that uses them without a page.published per page, so a search index or a static mirror should rebuild those pages when they arrive. Applying a suggestion sends comment.resolved with suggestionApplied: true. A content_rule.* event (the styleguide the docs agent checks change requests against) carries ruleId, isRegex, severity and enabled, never the pattern.

Topics and posts (forum, blog, roadmap and changelog all sit on topics)

topic.created           topic.published         topic.edited
topic.deleted           topic.recovered         topic.solved            topic.moved
post.created            post.edited             post.deleted
post.recovered          post.flagged
reaction.created        reaction.removed

Structure

category.created        category.updated        category.deleted
tag.created             tag.deleted

Moderation

reviewable.created      reviewable.updated
user.suspended          user.silenced           user.anonymized

People and groups

user.joined             user.promoted           user.removed            user.role_changed
badge.granted
badge_rule.created      badge_rule.updated      badge_rule.deleted      badge_rule.ran
group.created
group.member_added      group.member_removed    group.updated           group.deleted
group.grant_added       group.grant_removed
invite.created          invite.revoked          invite.accepted

A group or a membership a SCIM directory changed carries source: 'scim'. A directory that re-sends its whole roster on every sync only produces events for the members who actually joined or left. A badge_rule.* payload never carries the rule's SQL.

Forms

form.created            form.updated            form.deleted            form.submitted
form.paid

Shop (product blocks and paid forms, through your own Stripe)

shop.order.paid         shop.order.refunded

form.paid is sent once a paid form's payment arrives; shop.order.paid and shop.order.refunded carry the order (orderId, the items as charged, the total and currency, and the buyer's email).

Sent when a reader sends one of your forms. It carries exactly formId, title (the form's name, the same key other events use for their subject's name), formName (the same value), submissionId, the answers (data), email, pageUrl and createdAt. It is not sent for submissions flagged as spam.

Space types (each type's own events)

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             application.created         contact.submitted
quiz.failed                 quiz.review_needed
release.review_requested    release.reviewed
application.stage_changed   application.offer_answered
blog.membership.started     blog.membership.ended
blog.review_requested       blog.reviewed

Status page resources (beside the incident events)

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.component.status_changed is the one to subscribe to if you mirror a page's components. Its source says why the component moved: declared (a person set or cleared its state, with previousDeclaredState), checks (its checks started or stopped failing, with checksPassing) or vendor (the vendor's own status page changed, with vendorState). It fires on the transition only, never on a repeat. A status.component.* payload carries componentId, name, slug, kind, group, visible and declaredState. It never carries the checked URL, the request headers or a heartbeat's ping URL, because any of them can hold a token. For the same reason a status.inbound.* payload names the inbound address by hookId and label, never by the address. Pushing metric points sends nothing; defining a metric, including by the first push to a new key, sends status.metric.created.

Careers, form templates and AI

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

careers.ats.synced fires after every ATS pass, hourly or from "Sync now", with status (ok or error) and the counts of roles created, updated, closed and reopened. The error text stays in the console. AI, embeddings and translation use your own provider keys, so ai.provider.* and ai.embedding_provider.* name the provider and the model and say whether the key changed (keyChanged). They never carry the key or its hint. ai.source.* never carries the source's URL or its text.

Content libraries (the headless CMS: content for your own website)

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

The entry events carry enough for your site to revalidate exactly what changed, with no second request:

{
  "libraryId": "…", // the content library; the envelope carries it too, in place of spaceId
  "contentType": "post",
  "entryId": "…",
  "slug": "launch",
  "oldSlug": null, // the previous slug when a live entry was renamed
  "locale": "en",
  "paths": ["/blog/news/launch"], // from routes first, else the type's previewUrl
  "oldPaths": [], // where it was, after a rename
  "tags": ["fm:entry:…", "fm:type:<libraryId>:post", "fm:route:<libraryId>:…"],
  "affectedTags": ["fm:entry:…"], // every entry that references this one
}

tags are the same cache tags the delivery API sends, so a Next.js receiver calls revalidateTag on tags and affectedTags and is done. affectedTags follows references backwards, through shared blocks too: publishing an author names every post that shows the author. A path the previewUrl template cannot render (a field it reads is empty) is left out rather than sent half-filled.

The contentTypes and locales filters apply to these events only, and the forum filters (categoryIds, tagIds, groupIds) never apply to them. A content.model.changed for the whole model (contentType: null) reaches every endpoint whatever its type filter.

The rest of the family is about the library around the entries:

  • content.entry.draft_saved fires when an entry's draft fields are written. Nothing is live yet. It carries fields (the names of the top-level fields the write touched, never their values) and the new etag, which is what a preview build or an editorial tool needs.

  • content.entry.unscheduled fires when a scheduled publish is cancelled, so the content.entry.published that content.entry.scheduled promised is not coming.

  • content.asset.* fires for the library's media. It carries assetId, filename, mimeType, size, folderId and assetTags, and never the file or its URL. A bulk edit sends one content.asset.updated per asset. The MIME type is under mimeType, not contentType, so a content-type filter never drops it.

  • content.locale.* names the locale by its public code under locale, so a locales filter applies to it. A change to the source locale or to the fallback chains is content.locale.updated with locale: null, and it reaches every endpoint, because it can change what every locale delivers. A library's languages send these and not locale.created, which is for docs spaces.

Spaces, sites and domains

space.created           space.updated           space.deleted
space.restored          space.purged
snapshot.created        snapshot.restored       snapshot.deleted
site.created            site.updated            site.deleted
domain.added            domain.verified         domain.failed
domain.activated        domain.removed
site_preview.created    site_preview.revoked
reader_domain.configured    reader_domain.verified
reader_domain.failed        reader_domain.removed

Domain verification is the case these exist for. A provisioning script used to add a hostname and then poll "is it verified yet?" for as long as DNS took. Now it subscribes to domain.verified and domain.failed and waits. Every domain.* payload carries domainId, hostname, basePath, which of spaceId, siteId or libraryId owns it, and verified/active flags. It never carries the verification token. domain.failed adds reasons, one sentence per proof method (TXT, file, meta tag), the same words the 400 gives. It is sent once per attempt. An allocated subdomain is proven and live the moment it exists, so it sends domain.added, domain.verified and domain.activated together.

space.* is not sent for a content library's hidden backing space. Libraries have library.*.

A site_preview.* payload names the link (previewId), its site, label, scope and expiry. It never carries the link or its token, which are shown once to whoever minted it. A rotation sends site_preview.revoked for the old link and site_preview.created for the new one, with rotatedFrom naming the old.

reader_domain.* follows the reader sign-in host (custom-domain reader SSO) the way domain.* follows a content address. reader_domain.configured carries the cnameTarget to publish. reader_domain.verified means the host is live, and reader_domain.failed is sent for each verification that did not make it live.

Themes and legal

theme.published             theme.saved                 theme.deleted
theme.component_saved       theme.component_deleted
theme.component_installed   theme.component_uninstalled
theme.listing_published     theme.listing_withdrawn
theme.component_listing_published   theme.component_listing_withdrawn
theme.origin_removed        theme.origin_restored
legal.published             legal.removed
legal.access_requested      legal.privacy_requested

theme.published fires when a space's or a site's live theme changes. Importing a bundle or applying a library theme only writes a draft, so the event fires when that draft is published. legal.published fires on a new edition readers may have to accept. It does not fire for an amendment or an identical republish. legal.access_requested fires when a reader asks for a gated trust-centre document (granted by NDA, auto-approved, or pending), and legal.privacy_requested when someone files a privacy request from the register — the two a CRM listens to. theme.origin_removed and theme.origin_restored follow the list of hosts a space's theme lets readers load fonts and styles from, which is a security setting rather than a look. Each carries spaceId, the origin and the allowed list after the change.

Files, fields and shapes

asset.uploaded          asset.updated           asset.deleted
asset_folder.created    asset_folder.updated    asset_folder.deleted
custom_field.created    custom_field.updated    custom_field.deleted
custom_field.values_changed
shape.updated
avatar.updated          avatar.removed

An asset.* payload names the file (assetId, filename, contentType, size, folderId) and its owner, spaceId or libraryId. It never carries the bytes or a download address. asset.deleted says why in reason: a member deleted it (deleted), or the nightly sweep removed an upload that nothing cites (unreferenced).

custom_field.values_changed names the entity and the field keys that were written, and who wrote them in source (admin, member or signup). It never carries the values, because a field can be private to staff. Read them from the API.

shape.updated fires when a space's shape override, one of its own kinds, or its default kind changes. changed says which.

An avatar belongs to the person, not to an organization. avatar.* is sent to every organization they are a member of, each delivery carrying only their userId.

Access (an audit trail: these name a credential, they never carry one)

token.created           token.rotated           token.revoked
service_account.created service_account.updated service_account.deleted
role.created            role.updated            role.deleted
share_link.created      share_link.revoked
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
session.revoked

sso.*, scim.* and oauth_client.* never carry an IdP certificate, a client secret or a SCIM token. session.revoked is sent when a member revokes a session from their devices list (one, or "everywhere else"), to every organization they belong to. Atlas owns sign-in, so a session Atlas ends on its own is not sent.

A token.* payload says which token, of what kind (pat, service_account, delivery_key or device_key), whose, with what scopes and until when. It never carries the token or its hash. A personal access token or a device key (the "my devices" list) is announced to the organizations it is confined to, or, if it is unconfined, to every organization its owner belongs to. A device key is announced because it carries API scopes like any other token.

Integrations

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
sdk.published
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

webhook.* never carries the endpoint URL, because a Slack or Discord URL is itself a credential. It never carries the secret either. webhook.disabled means an endpoint was switched off after its deliveries kept failing (see When an endpoint keeps failing); it adds consecutiveFailures, failingSince and lastStatusCode. A library.* event, like every content.* one, names the library by libraryId, never by a space id.

None of these carries a connector's config, an integration's config values, a feed URL, a GitHub token or a Cloudflare token. They say what changed, never the credential that made it work.

Organization, payments and mail

org.updated             org.deleted
payments.connected      payments.updated        payments.disconnected
email_template.saved    email_template.reset
mail.suppression_added  mail.suppression_removed
mail_provider.connected mail_provider.disconnected

org.deleted is the last event an organization's endpoints receive. The delete is soft until the retention purge, so the endpoints still exist to hear it. mail.suppression_* names the member by userId, never the address, and is sent only when an admin adds or lifts one on this organization's own list. The platform-wide list, fed by bounces and complaints, is not sent to any organization. payments.* and mail_provider.* carry flags (mode, keyChanged), never a key.

Publishing (translations, event programmes and tickets, learning paths)

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

translation.* covers a changelog release's, a blog post's and a forum topic's translations; kind says which, itemId is the piece and locale the language. ticket_order.refunded fires for a refund made in the console and for one made in Stripe's own dashboard; it never carries the buyer's e-mail or the payment id. ticket_order.paid fires once a paid order is settled (the tickets are issued); ticket_order.requested when one waits for a host's approval or on a sold-out tier's waitlist, before anything is charged — same payload. course_path.* is the organization's, so a hook confined to one space does not receive it.

Community administration (the tools a forum team keeps)

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
screening.rule_added    screening.rule_removed
member_note.created     member_note.deleted

A screening.* payload names a domain, IP range or URL rule by its pattern, but leaves out an exact email address, because that address is a person's.

Members (what one member does with another, and private conversations)

saved_search.created    saved_search.updated    saved_search.deleted
relationship.muted      relationship.unmuted
relationship.followed   relationship.unfollowed
conversation.created    conversation.replied
conversation.participant_added                  conversation.participant_removed

These payloads carry ids only. A saved search's query, a staff note's text and a private message's subject or body are never in a payload. They were written for a small audience, and a payload is stored in the delivery log and replayed to whoever holds the endpoint. An integration that is allowed to read more can fetch it through the API by id, under its own permissions.

Other

chat.message_created    notification.created

ping is delivered by POST …/ping but is not subscribable — you do not put it in events.

The headers on a delivery

Only a format: "json" webhook is signed. A slack or discord webhook posts the shape those services expect and carries none of these headers, because neither service verifies a signature.

Header

Value

x-forumulate-signature

One or more signature elements. The format is below, and it is not what you expect.

x-forumulate-timestamp

Unix seconds, decimal, no padding. The same value that is inside the signature.

x-forumulate-event

page.published, or ping.

x-forumulate-delivery

The delivery id. Use it to dedupe — a retry reuses it.

user-agent

Forumulate-Webhooks/1.0

content-type

application/json, or application/x-www-form-urlencoded in form mode.

The signature

The element format, and the trap in it

An element looks like this:

v1,sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

A single element contains a comma. The v1, is part of the element, not a separator between two of them.

The header is a list of elements joined with ", " — comma, then one space:

x-forumulate-signature: v1,sha256=9f86…, v1,sha256=5b41…, sha256=2cf2…

So header.split(',') is wrong. It gives you ['v1', 'sha256=9f86…', ' v1', …] and nothing will ever match. This is the single most common mistake in a Forumulate webhook receiver, and an earlier version of this page described the header as "comma-separated" without saying that.

Split on a comma followed by an element prefix:

const elements = header.split(/,\s*(?=v1,sha256=|sha256=)/);

That is also robust to an intermediary rejoining the field value with a bare comma, which HTTP permits.

What exactly is signed

v1:      HMAC-SHA256(secret, timestamp + "." + body)   → lowercase hex
legacy:  HMAC-SHA256(secret, body)                     → lowercase hex
  • timestamp is the decimal integer in x-forumulate-timestamp. Not milliseconds, not zero-padded.

  • . is one literal full stop.

  • body is the raw bytes we POSTed, before any parsing. Read it before your framework consumes it — Express needs express.raw(), not express.json(). In bodyEncoding: "form" mode the signed bytes are the literal string payload=<url-encoded json>, not the JSON inside it. A receiver that decodes the form first can never verify.

Which elements arrive

signatureVersion

Elements

v1 — the default for any webhook created now

v1,sha256=… only

both — the default on webhooks that predate the v1 scheme

every v1,sha256=… element, then every legacy sha256=… element

When the legacy element goes away

The body-only sha256= element stops being sent on 2027-10-05. After that day every delivery carries v1 entries only, whatever an endpoint's signatureVersion says.

Nothing about v1 changes on that date, and an endpoint already set to signatureVersion: v1 is unaffected — it has not been sent a legacy element since it was set. If your verifier checks that the header contains your computed v1 value, as the one below does, the date passes without you doing anything. The work it creates is only for a receiver that compares against the legacy element, and the fix is to compute v1 instead.

You can move an endpoint early, and should: PATCH …/webhooks/{id} with {"signatureVersion":"v1"} stops the legacy element immediately, which turns the deadline into a change you choose the day of.

During a secret rotation there are two elements per scheme: the new secret first, then the previous one. With both and a rotation in flight you get four:

v1,sha256=<new>, v1,sha256=<old>, sha256=<new>, sha256=<old>

Which is why a verifier must compare against every element and not just the first, and why the elements have different lengths — v1,sha256=… is 74 characters, bare sha256=… is 71.

The age check

Verify the timestamp as well as the HMAC, or a captured delivery can be replayed at you forever. The legacy signature does not cover the timestamp at all, which is precisely why it is being retired on 2027-10-05 (above).

Reject a delivery whose x-forumulate-timestamp is more than 300 seconds away from your own clock, in either direction. Nothing on our side enforces this; it is the receiver's job.

A verifier that works

Node

import crypto from 'node:crypto';

const MAX_AGE_SECONDS = 300;

/** Returns true only for a delivery this secret signed, recently. */
export function verify(headers, rawBody, secret) {
  const header = headers['x-forumulate-signature'];
  const ts = headers['x-forumulate-timestamp'];
  if (typeof header !== 'string' || typeof ts !== 'string') return false;

  // Age first: it is cheap, and it is the check a replay fails.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
  if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false;

  const v1 =
    'v1,sha256=' + crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  const legacy = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  // An element contains a comma, so split on a comma that STARTS an element.
  const elements = header.split(/,\s*(?=v1,sha256=|sha256=)/);

  // Compare lengths before timingSafeEqual: it THROWS on a length mismatch,
  // and the elements are deliberately different lengths.
  return elements.some((el) => {
    const expected = el.startsWith('v1,') ? v1 : legacy;
    if (el.length !== expected.length) return false;
    return crypto.timingSafeEqual(Buffer.from(el), Buffer.from(expected));
  });
}

Three things in there are not decoration:

  1. typeof header !== 'string' — a forged POST, or a slack-format endpoint, carries no signature header at all. Without the guard, .split throws a TypeError and your handler 500s, which we read as a failure and retry.

  2. The length comparison before timingSafeEqual. Node's timingSafeEqual raises RangeError: Input buffers must have the same byte length on unequal lengths. A both-mode endpoint sends a 71-character legacy element next to your 74-character v1 expectation, so without this guard the function throws on exactly the delivery it exists to reject.

  3. The age check, before any HMAC work.

Python

import hashlib, hmac, re, time

MAX_AGE_SECONDS = 300
_SPLIT = re.compile(r",\s*(?=v1,sha256=|sha256=)")

def verify(headers, raw_body: bytes, secret: str) -> bool:
    header = headers.get("x-forumulate-signature")
    ts = headers.get("x-forumulate-timestamp")
    if not header or not ts:
        return False
    try:
        if abs(int(time.time()) - int(ts)) > MAX_AGE_SECONDS:
            return False
    except ValueError:
        return False

    key = secret.encode()
    v1 = "v1,sha256=" + hmac.new(key, f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    legacy = "sha256=" + hmac.new(key, raw_body, hashlib.sha256).hexdigest()

    for el in _SPLIT.split(header):
        expected = v1 if el.startswith("v1,") else legacy
        if hmac.compare_digest(el, expected):
            return True
    return False

hmac.compare_digest handles unequal lengths without raising, so Python needs no length guard — but it still needs the right split.

PHP

<?php
const MAX_AGE_SECONDS = 300;

function forumulate_verify(array $headers, string $rawBody, string $secret): bool {
    $header = $headers['x-forumulate-signature'] ?? null;
    $ts     = $headers['x-forumulate-timestamp'] ?? null;
    if (!is_string($header) || !is_string($ts) || !ctype_digit($ts)) return false;
    if (abs(time() - (int) $ts) > MAX_AGE_SECONDS) return false;

    $v1     = 'v1,sha256=' . hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
    $legacy = 'sha256='    . hash_hmac('sha256', $rawBody, $secret);

    $elements = preg_split('/,\s*(?=v1,sha256=|sha256=)/', $header);
    foreach ($elements as $el) {
        $expected = str_starts_with($el, 'v1,') ? $v1 : $legacy;
        if (hash_equals($expected, $el)) return true;
    }
    return false;
}

Deploy hooks

A fully static site rebuilds instead of revalidating. Create an endpoint with "kind": "deploy_hook" and the deploy-hook URL your platform issued:

POST /orgs/{orgId}/webhooks
{
  "kind": "deploy_hook",
  "url": "https://api.netlify.com/build_hooks/5f3a…",
  "events": ["content.entry.published", "content.entry.unpublished", "content.entry.deleted"],
  "libraryId": "…"
}
  • The URL must be one Vercel (https://api.vercel.com/v1/integrations/deploy/…), Netlify (https://api.netlify.com/build_hooks/…) or Cloudflare Pages (https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/…) issued. Anything else is a 422: the POST is unsigned, so it is pinned to those hosts.

  • events must be content.* events.

  • Debounced. Changes in a content library are collected into one build, started 30 seconds after the last change (and never more than five minutes after the first, so a long import still builds). Five quick publishes are one build.

  • ping and replay start a build at once.

  • Each build trigger is in the delivery log as deploy.triggered, and the endpoint's deploy object carries lastTriggeredAt, lastStatusCode and lastError. GET /orgs/{orgId}/content/libraries/{libraryId}/deploy-status answers the same for one library, plus pendingUntil when a build is waiting; it needs only read access to the library (a library you cannot read is a 404) and never returns the hook's URL.

Test it

POST /orgs/{orgId}/webhooks/{webhookId}/ping

That sends a real delivery with x-forumulate-event: ping and a real signature. Then read GET /orgs/{orgId}/webhooks/{webhookId}/deliveries to see the status code your endpoint returned and the body it sent back — which is where a verifier that throws shows up as a 500 rather than a 401.

Retries and ordering

A delivery that does not get a 2xx is retried. x-forumulate-delivery is stable across the retries of one event, so dedupe on it rather than on the body.

Deliveries are not ordered. Two events a millisecond apart can arrive in either order, and a retry can arrive after a later event. Use the payload's own timestamps and ids, not arrival order, to decide what is current.

When an endpoint keeps failing

A delivery counts as failed once its retries are spent. Each endpoint keeps a count of failed deliveries in a row and the time the run started. Any delivery that gets a 2xx, a ping included, resets both.

When the run is long enough and old enough, the endpoint is switched off on its own. By default that is 20 failed deliveries in a row over at least 24 hours. Both conditions must hold, so a ten-minute outage on a busy organization does not switch anything off, and neither do two misses a day apart on a quiet one. When an endpoint is switched off:

  • it stops receiving deliveries, and its row in Webhooks shows Disabled with the reason and when it happened;

  • webhook.disabled goes to your other endpoints that subscribe to it (the disabled one does not get it);

  • the organization's owners get an e-mail that links to the webhook.

Fix the receiver, then press Re-enable on the endpoint (or send PATCH /orgs/{orgId}/webhooks/{webhookId} with {"active": true}). That clears the count and sends webhook.updated. Nothing that was missed is re-sent on its own; replay the deliveries you need from the delivery log.

Pausing an endpoint yourself is different: a paused endpoint has no reason or disabled time, and the sweep never touches it.

The thresholds are per organization. Change them under Webhooks → Endpoint health, or through the API:

GET /orgs/{orgId}/webhook-settings
PUT /orgs/{orgId}/webhook-settings
{"autoDisable": {"enabled": true, "failures": 50, "windowHours": 72}}

failures is 3 to 1000 and windowHours is 1 to 720. A key you leave out keeps its default. "enabled": false turns auto-disable off: failing endpoints then keep retrying every event until you pause them.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Deploy Status

Operation

Request

What it does

Shortcuts

DeployStatus_status

GET /orgs/{orgId}/content/libraries/{libraryId}/deploy-status

Get the deploy status in an organization

client.content.deployStatus.status()

Webhook

Operation

Request

What it does

Shortcuts

Webhook_events

GET /orgs/{orgId}/webhooks/events

List events in an organization (webhooks)

forumulate webhooks events<br>client.webhooks.events()

Webhook_list

GET /orgs/{orgId}/webhooks

List webhooks in an organization

forumulate webhooks list<br>client.webhooks.list()

Webhook_create

POST /orgs/{orgId}/webhooks

Create a webhook in an organization

forumulate webhooks create<br>client.webhooks.create()

Webhook_update

PATCH /orgs/{orgId}/webhooks/{webhookId}

Update the webhook

forumulate webhooks update<br>client.webhooks.update()

Webhook_remove

DELETE /orgs/{orgId}/webhooks/{webhookId}

Delete the webhook

forumulate webhooks delete<br>client.webhooks.remove()

Webhook_deliveries

GET /orgs/{orgId}/webhooks/{webhookId}/deliveries

List deliveries in an organization (webhooks)

forumulate webhooks deliveries<br>client.webhooks.deliveries()

Webhook_replay

POST /orgs/{orgId}/webhooks/{webhookId}/deliveries/{deliveryId}/replay

Replay the delivery

forumulate webhooks replay<br>client.webhooks.replay()

Webhook_rotate

POST /orgs/{orgId}/webhooks/{webhookId}/rotate-secret

Rotate secret in an organization (webhooks)

forumulate webhooks rotate-secret<br>client.webhooks.rotate()

Webhook_ping

POST /orgs/{orgId}/webhooks/{webhookId}/ping

Create the ping in an organization

forumulate webhooks test<br>client.webhooks.ping()

Webhook Settings

Operation

Request

What it does

Shortcuts

WebhookSettings_get

GET /orgs/{orgId}/webhook-settings

List webhook settings in an organization

client.webhooks.settings.get()

WebhookSettings_set

PUT /orgs/{orgId}/webhook-settings

Replace webhook settings in an organization

client.webhooks.settings.set()

The Forumulate teamUpdated