Skip to main content
Webentor Studio

Help · Webhooks

Webhooks

A webhook tells another system when something happens in a project: a draft goes live, a build fails, a domain starts working. We send a signed JSON request to an address you choose. The signing follows Standard Webhooks, so its libraries verify our deliveries. Sign in to add one to a project.

Adding an endpoint

On the dashboard, open a project’s Settings → Notifications. Give the address and tick the events you want. Tick none to receive every event, including events added later. You need to be allowed to change the project’s settings.

With the API, POST /api/v1/projects/{project}/webhooks with {"url": "https://…", "events": ["draft.published"]}. The names a list may hold are in the catalogue below, and GET /api/v1/webhook-events answers the same list with each payload’s JSON Schema. A name that is not an event is refused.

  • The address must use HTTPS and be reachable from the public internet.
  • The answer carries the signing secret, once. Store it then: it is never shown again. An endpoint an assistant added shows its secret once on the dashboard instead, so it never reaches a conversation.
  • Each endpoint has its own secret. Add a separate endpoint per project, even when they share an address: every delivery names its project in project_id.

What a delivery looks like

A POST with a JSON body and three headers that identify and sign it:

POST /hooks/webentor HTTP/1.1
content-type: application/json
user-agent: Webentor-Cloud-Webhooks/1
webhook-id: msg_0196f2c4-7a10-7c3e-9a51-3f2a0d9b8e41
webhook-timestamp: 1763035200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
  • webhook-id is the message’s id. It is the same on every attempt and on a redelivery, so keep it to recognise a message you have already handled.
  • webhook-timestamp is when this attempt was sent, in seconds since 1970. When the event happened is timestamp in the body.
  • webhook-signature is one signature, v1, and base64, or two separated by a space for 24 hours after the secret was rotated.

The body is the same envelope for every event:

{
  "id": "msg_0196f2c4-7a10-7c3e-9a51-3f2a0d9b8e41",
  "type": "draft.published",
  "timestamp": "2026-11-13T12:00:00.000Z",
  "version": 1,
  "account_id": "0196a1b2-0000-7000-8000-000000000001",
  "project_id": "0196a1b2-0000-7000-8000-000000000002",
  "data": {
    "draft_id": "0196f2c4-0000-7000-8000-000000000003",
    "commit": "4f0c2e9…",
    "pages": 3,
    "hidden_pages": 0
  }
}
FieldTypeWhat it is
idstringThe message id, the same value as the `webhook-id` header. It does not change when a delivery is retried or sent again, so it is the key to deduplicate on.
typestringThe event’s name, for example `draft.published`.
timestampstringWhen the event happened, ISO 8601. Not when this attempt was sent: that is the `webhook-timestamp` header.
version1The version of this envelope.
account_idstringThe id of the team the event belongs to.
project_idstring or nullThe id of the project the event is about.
dataobjectThe event’s payload. Its shape depends on `type`.

Fields are only ever added, to the envelope and to an event’s data. Read the fields you need and ignore the rest; a receiver that refuses unknown fields breaks on the next one. version changes only for a change that is not additive.

A payload carries ids and a few values, never content: no page text, no comment, nothing a visitor typed into a form, no email address. Read what you need with the API and your own token.

Verifying a signature

Check every delivery before you act on it. Anybody can send a request to your address; only we can sign one with your secret. In any language:

  1. Take the body exactly as it arrived, as text. Parsing the JSON and writing it out again changes the bytes, and the signature no longer matches.
  2. Refuse the request when webhook-timestamp is more than five minutes from your clock. That stops somebody replaying an old delivery.
  3. Take the secret without its whsec_ prefix and decode it from base64. That is the key.
  4. Compute HMAC-SHA256 with that key over the id, a full stop, the timestamp, a full stop and the body: {webhook-id}.{webhook-timestamp}.{body}.
  5. Split webhook-signature on spaces. The delivery is genuine when one entry is v1, followed by your result in base64. Compare in constant time.

In Node, with nothing but its own crypto module:

import { createHmac, timingSafeEqual } from 'node:crypto';

// secret:  the endpoint's signing secret, as it was shown to you (whsec_...).
// headers: the request's headers, with lower-case names.
// body:    the raw request body as a string, before any JSON parsing.
export function verifyWebhook(secret, headers, body) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) throw new Error('Missing webhook headers');

  // Refuse a replay: the timestamp is when this attempt was sent.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 5 * 60)) throw new Error('Timestamp outside the tolerance');

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();

  // One signature, or two for a day after the secret was rotated.
  for (const candidate of signatures.split(' ')) {
    const [version, signature] = candidate.split(',');
    const given = Buffer.from(signature ?? '', 'base64');
    if (version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected)) {
      return JSON.parse(body);
    }
  }
  throw new Error('No matching signature');
}

The Standard Webhooks libraries do the same in many languages; pass them the secret and the raw body. A secret made before 13 November 2026 may hold - or _, which the code above reads and a strict library refuses. Rotate it for a new one, or decode it as base64url: the key is the same.

Retries, duplicates and the delivery log

  • A delivery succeeds when your endpoint answers with a 2xx status within ten seconds. Anything else is a failure: another status, no answer, a connection that fails. A redirect is not followed; change the endpoint to the address it redirects to.
  • A failed delivery is tried again, waiting longer each time (30 seconds, 60 seconds, 2 minutes, 4 minutes, 8 minutes, 16 minutes, 32 minutes, 60 minutes, then every hour), for a day after the first attempt. Then it is given up and shown as failed. Each endpoint is tried by itself: one that fails does not hold up or repeat the others.
  • The same message can arrive more than once, after a retry whose answer was lost or a redelivery. Keep the webhook-id values you have handled and skip one you have seen.
  • Messages are not in order. Use timestamp to order events, and read the current state with the API when order matters.
  • Answer quickly and do the work afterwards. A slow answer is a timeout, and a timeout is a retry.

Each endpoint keeps a log of what it was sent and what it answered, for 30 days: the status code, how long it took, and why a delivery failed. It is under the endpoint on the dashboard, and at GET /api/v1/projects/{project}/webhooks/{id}/deliveries. From there you can send a delivery again, and send a webhook.test event to try an endpoint without anything happening in the project.

An endpoint that has done nothing but fail for 3 days is switched off, and the people who manage the project’s settings are told. So is one that answers 410 Gone, at once. Nothing is sent to it until somebody switches it on again; what it missed can be sent again from its log.

To change the secret, rotate it. The old one keeps signing beside the new one for 24 hours, so the receiving end can switch without missing a delivery.

Events

Every event, with its payload, the fields of data. The date is the day it was first sent. New events are added here, and endpoints with no event list start receiving them. deploy.held belongs with the deployment.* events despite its prefix.

project.created

A project was created. A new project finished provisioning: its repository exists and it can be edited. Sent at the end of provisioning, not when it was asked for.

Since 2026-08-28

FieldTypeWhat it is
project_idstringThe id of the project.
slugstringThe project’s slug.

draft.created

A draft was started. A draft was opened on the project, by a person, an assistant, a push, a fork, an import, a rollback or an upgrade. `origin` says which.

Since 2026-08-28

FieldTypeWhat it is
draft_idstringThe id of the draft.
titlestringThe draft’s title.
via (optional)"adopted-from-push"Present when the draft was made from a branch somebody pushed.
origin"draft", "session", "assistant", "branch", "push", "fork", "import", "revert", "upgrade"How it was made: `draft` (started from the dashboard or the API), `session` (an editing session), `assistant` (an assistant’s line), `branch`, `push` (a pushed branch), `fork` (a fork of a session), `import` (a site import), `revert` (a rollback’s revert draft) or `upgrade` (a Webentor upgrade). Since 2026-11-13.

draft.submitted

A draft was sent for review. Somebody asked for a decision on a draft. `purpose` says which: `review` asks for a review, `publish` asks a person with the right to publish it.

Since 2026-08-28

FieldTypeWhat it is
draft_idstringThe id of the draft.
review_request_id (optional)stringThe id of the review request.
purpose"review", "publish"

draft.approved

A draft was approved. A reviewer approved a draft that was sent for review. The approval is of the version they read; a later save drops it. Not the reviewer’s note: read the draft’s comments with the API.

Since 2026-11-13

FieldTypeWhat it is
draft_idstringThe id of the draft.
review_request_idstring or null
reviewer_idstringThe id of the person who approved it.

draft.changes_requested

Changes were asked for on a draft. A reviewer sent a draft back for changes. Not the reviewer’s note: read the draft’s comments with the API.

Since 2026-11-13

FieldTypeWhat it is
draft_idstringThe id of the draft.
review_request_idstring or null
reviewer_idstringThe id of the person who asked for changes.

review.comment_added

Somebody commented on a draft. A comment was added to a draft’s review. Never the comment’s text: read it with the API.

Since 2026-11-13

FieldTypeWhat it is
draft_idstringThe id of the draft.
comment_idstringThe id of the comment.
review_request_idstringThe id of the review the comment belongs to.
author_idstringThe id of the person who wrote it.

draft.scheduled

A draft was scheduled to go live. Somebody chose a time for a draft to be published. If it cannot go live then, `draft.schedule_failed` follows; if it can, `draft.published`.

Since 2026-11-13

FieldTypeWhat it is
draft_idstringThe id of the draft.
atstringWhen it is to be published, ISO 8601.
scheduled_bystringThe id of the person who scheduled it.

draft.merged

A draft was merged into another line. A draft’s changes were merged, into another line or into the live branch by the merge route. A publish is `draft.published`.

Since 2026-11-13

FieldTypeWhat it is
draft_idstringThe id of the draft that was merged.
intostring`live`, or the id of the draft it was merged into.
commitstringThe merge commit.

draft.discarded

A draft was discarded. A draft was closed without being published. Its branch is archived in the repository, not deleted.

Since 2026-11-13

FieldTypeWhat it is
draft_idstringThe id of the draft.

draft.published

A draft went live. A draft was merged into the live branch. The production build and deployment follow; `deployment.production` says when visitors see it.

Since 2026-08-28

FieldTypeWhat it is
draft_idstringThe id of the draft.
commitstringThe merge commit on the live branch.
pagesintegerHow many pages the draft changed.
hidden_pagesintegerHow many of those pages are hidden from the site.

draft.schedule_failed

A scheduled publish did not happen. A draft was scheduled to go live at a time, and could not. The schedule is cleared and the site is unchanged.

Since 2026-09-11

FieldTypeWhat it is
draft_idstringThe id of the draft.
reasonstringWhy, as a sentence.

code.published

Code was pushed to the live branch. A Git push moved the live branch and a production build was queued for it.

Since 2026-08-28

FieldTypeWhat it is
commitstringThe live branch’s new head.
branchstringThe live branch’s name.

import.finished

A site import finished. An import of an existing site ended. `status` says how; the imported pages are on the draft named here.

Since 2026-08-28

FieldTypeWhat it is
import_idstringThe id of the import.
draft_idstringThe id of the draft the pages were imported into.
status"succeeded", "partial", "failed"
pagesintegerPages imported.
replacedintegerPages that replaced an existing one.
mediaintegerMedia files imported.

build.succeeded

A build succeeded. A build finished without an error. Sent for every kind of build, not only the ones that go live: read `trigger` to tell them apart.

Since 2026-08-28

FieldTypeWhat it is
build_idstringThe id of the build.
kind"site", "preview", "editor_bundle", "lockfile"What was built: `site` (the project’s site; only `trigger: production` is then deployed), `preview` (a draft’s or a branch’s preview), `editor_bundle` (the editor, not the site) or `lockfile` (an upgrade’s lockfile, no site). Since 2026-11-13.
commit (optional)stringThe commit that was built. Absent only when the build stopped before it had one.
trigger (optional)stringWhy the build ran: `production`, `draft_preview`, `branch`, `manual`, `editor_bundle`, `editor_release`, `system` or `upgrade`. More may be added.
errorstring or nullWhat went wrong, cut to a length. `null` on a build that succeeded.
speculative (optional)truePresent on a preview built ahead of anybody asking for it. Nobody is waiting for its result.

build.failed

A build failed. A build ended with an error. Sent for every kind of build: read `trigger` to tell them apart.

Since 2026-08-28

FieldTypeWhat it is
build_idstringThe id of the build.
kind"site", "preview", "editor_bundle", "lockfile"What was built: `site` (the project’s site; only `trigger: production` is then deployed), `preview` (a draft’s or a branch’s preview), `editor_bundle` (the editor, not the site) or `lockfile` (an upgrade’s lockfile, no site). Since 2026-11-13.
commit (optional)stringThe commit that was built. Absent only when the build stopped before it had one.
trigger (optional)stringWhy the build ran: `production`, `draft_preview`, `branch`, `manual`, `editor_bundle`, `editor_release`, `system` or `upgrade`. More may be added.
errorstring or nullWhat went wrong, cut to a length. `null` on a build that succeeded.
speculative (optional)truePresent on a preview built ahead of anybody asking for it. Nobody is waiting for its result.

deploy.held

A deployment was held back. A production build finished and was not deployed, for the reason given. The live site still serves the previous version. Part of the `deployment.*` family despite its prefix.

Since 2026-11-11

FieldTypeWhat it is
build_idstringThe id of the build that was not deployed.
reasonstringWhy, as a sentence.

deployment.production

A deployment went live. A production deployment succeeded: visitors now see this build. A rollback is one too, marked `rollback: true`, after its own `deployment.rolled_back`.

Since 2026-08-28

FieldTypeWhat it is
deployment_idstringThe id of the deployment.
providerstringThe hosting provider the deployment went to.
urlstring or nullWhere the deployment is served, when the provider said. Null when it failed before the provider answered.
build_idstringThe id of the build that was deployed.
warnings (optional)integerHow many things the provider left undone on a deployment that went live.
site_changed (optional)"yes", "unknown"On a failure: whether visitors may already see part of the new version.
error (optional)stringOn a failure: what went wrong, cut to a length. Since 2026-11-13.
rollback (optional)truePresent when this deployment put an earlier version back (a rollback). Since 2026-11-13.

deployment.failed

A deployment failed. A production deployment did not succeed: the provider refused it, or it failed before the provider answered. The live site still serves the previous version unless `site_changed` says otherwise.

Since 2026-08-28

FieldTypeWhat it is
deployment_idstringThe id of the deployment.
providerstringThe hosting provider the deployment went to.
urlstring or nullWhere the deployment is served, when the provider said. Null when it failed before the provider answered.
build_idstringThe id of the build that was deployed.
warnings (optional)integerHow many things the provider left undone on a deployment that went live.
site_changed (optional)"yes", "unknown"On a failure: whether visitors may already see part of the new version.
error (optional)stringOn a failure: what went wrong, cut to a length. Since 2026-11-13.
rollback (optional)truePresent when this deployment put an earlier version back (a rollback). Since 2026-11-13.

deployment.rolled_back

The live site was rolled back. Somebody put an earlier version back. Its deployments follow as `deployment.production` with `rollback: true`, and a revert draft is opened to carry the change into the live branch.

Since 2026-11-13

FieldTypeWhat it is
build_idstringThe id of the earlier build being put back.
commitstringThe commit of that build.
revert_draft_idstringThe id of the revert draft.

domain.verified

A domain started working. A domain’s DNS records checked out and it serves the project. Sent on the change only, not each time a working domain is checked again.

Since 2026-11-13

FieldTypeWhat it is
domain_idstringThe id of the domain.
hostnamestringThe hostname.
tlsstring or nullThe certificate’s state as the provider reported it, for example `active` or `pending`.

domain.failed

A domain stopped working. A domain that had been working stopped verifying: visitors on that hostname may not reach the site. Sent on the change only.

Since 2026-11-13

FieldTypeWhat it is
domain_idstringThe id of the domain.
hostnamestringThe hostname.
reasonstringWhy, as a sentence.

export.finished

An export finished. An export bundle of the project was made, or could not be. No download address: fetch it with the API and your own token.

Since 2026-11-13

FieldTypeWhat it is
export_idstringThe id of the export.
status"ready", "failed"
reason"requested", "pre-delete", "pre-account-delete"Why it was made: asked for, or the copy made before a project or a team is erased.
bytes (optional)integerIts size, when it is ready.
error (optional)stringWhy it failed.

project.deletion_scheduled

A project was scheduled for deletion. The project was taken offline to be erased on `purge_after`, unless somebody restores it before then. Nothing is sent after it is erased: its endpoints go with it.

Since 2026-11-13

FieldTypeWhat it is
project_idstringThe id of the project.
purge_afterstringWhen it is erased, ISO 8601.
export_idstringThe id of the export bundle made before the deletion.