Webhooks
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 |
|---|---|---|
| yes | Up to 2048 characters. |
| yes | At least one, from the list below. |
| no | Confine the webhook to one space. |
| no | Confine it to one content library instead ( |
| no |
|
| no | Up to 100 forum category ids — only fire for these. |
| no | Up to 100 tags — only fire for these. |
| no | Up to 100 group ids — only fire for these. |
| no | Up to 100 content type keys — |
| no | Up to 100 locales — |
| no |
|
| no |
|
| no |
|
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 |
|---|---|
| List them. |
| Change url, events, filters, format. |
| Remove it. |
| Send a synthetic |
| Recent attempts, with status and response. |
| Send one again. |
| The event catalog with a sentence on each. Needs |
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.deletedPaid forum membership
membership.started membership.renewed membership.canceled membership.endedA 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.deletedThese 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.removedStructure
category.created category.updated category.deleted
tag.created tag.deletedModeration
reviewable.created reviewable.updated
user.suspended user.silenced user.anonymizedPeople 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.acceptedA 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.paidShop (product blocks and paid forms, through your own Stripe)
shop.order.paid shop.order.refundedform.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.reviewedStatus 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.removedstatus.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.removedcareers.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.removedThe 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_savedfires when an entry's draft fields are written. Nothing is live yet. It carriesfields(the names of the top-level fields the write touched, never their values) and the newetag, which is what a preview build or an editorial tool needs.content.entry.unscheduledfires when a scheduled publish is cancelled, so thecontent.entry.publishedthatcontent.entry.scheduledpromised is not coming.content.asset.*fires for the library's media. It carriesassetId,filename,mimeType,size,folderIdandassetTags, and never the file or its URL. A bulk edit sends onecontent.asset.updatedper asset. The MIME type is undermimeType, notcontentType, so a content-type filter never drops it.content.locale.*names the locale by its public code underlocale, so alocalesfilter applies to it. A change to the source locale or to the fallback chains iscontent.locale.updatedwithlocale: null, and it reaches every endpoint, because it can change what every locale delivers. A library's languages send these and notlocale.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.removedDomain 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_requestedtheme.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.removedAn 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.revokedsso.*, 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.disconnectedwebhook.* 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.disconnectedorg.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.deletedtranslation.* 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.deletedA 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_removedThese 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.createdping 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 |
|---|---|
| One or more signature elements. The format is below, and it is not what you expect. |
| Unix seconds, decimal, no padding. The same value that is inside the signature. |
|
|
| The delivery id. Use it to dedupe — a retry reuses it. |
|
|
|
|
The signature
The element format, and the trap in it
An element looks like this:
v1,sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08A 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 hextimestampis the decimal integer inx-forumulate-timestamp. Not milliseconds, not zero-padded..is one literal full stop.bodyis the raw bytes we POSTed, before any parsing. Read it before your framework consumes it — Express needsexpress.raw(), notexpress.json(). InbodyEncoding: "form"mode the signed bytes are the literal stringpayload=<url-encoded json>, not the JSON inside it. A receiver that decodes the form first can never verify.
Which elements arrive
| Elements |
|---|---|
|
|
| every |
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:
typeof header !== 'string'— a forged POST, or aslack-format endpoint, carries no signature header at all. Without the guard,.splitthrows aTypeErrorand your handler 500s, which we read as a failure and retry.The length comparison before
timingSafeEqual. Node'stimingSafeEqualraisesRangeError: Input buffers must have the same byte lengthon unequal lengths. Aboth-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.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 Falsehmac.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.eventsmust becontent.*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.
pingandreplaystart a build at once.Each build trigger is in the delivery log as
deploy.triggered, and the endpoint'sdeployobject carrieslastTriggeredAt,lastStatusCodeandlastError.GET /orgs/{orgId}/content/libraries/{libraryId}/deploy-statusanswers the same for one library, pluspendingUntilwhen 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}/pingThat 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.disabledgoes 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 |
|---|---|---|---|
| Get the deploy status in an organization |
|
Webhook
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List events in an organization (webhooks) |
| |
| List webhooks in an organization |
| |
| Create a webhook in an organization |
| |
| Update the webhook |
| |
| Delete the webhook |
| |
| List deliveries in an organization (webhooks) |
| |
| Replay the delivery |
| |
| Rotate secret in an organization (webhooks) |
| |
| Create the ping in an organization |
|
Webhook Settings
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List webhook settings in an organization |
| |
| Replace webhook settings in an organization |
|