PrismDraft SDK

Webhooks

A webhook endpoint is a URL that receives a signed POST each time something happens in a workspace, such as an article being published. This page covers registering an endpoint, verifying a delivery, how deliveries are retried, and the payload of every event.

Register an endpoint

pd.webhooks.create needs the integrations.create permission, which the Owner and Admin roles have and Editor does not. The signing secret is returned once, in this response; store it, because it cannot be read again.

const { endpoint, secret } = await pd.webhooks.create({
  workspaceId,
  body: {
    url: "https://example.com/prism-draft",
    description: "Rebuild the site",
    events: ["article.published", "article.updated", "article.unpublished"],
  },
});
console.log(endpoint.id, secret);
  • events lists at least one of the 14 events below. An endpoint receives only the events it subscribed to.
  • description is optional, up to 500 characters.
  • The URL is checked against private address ranges when you register it, and again on every delivery. A loopback or private address answers 400 (INVALID_REQUEST).
  • An endpoint cannot be edited. To change its URL or events, delete it and register a new one.

pd.webhooks.list (needs integrations.read) returns the endpoints, each endpoint's 50 most recent deliveries, and the catalog of event names. Pass deliveryStatus (pending, succeeded or failed) to filter the deliveries. pd.webhooks.delete (needs integrations.delete) removes an endpoint.

const { endpoints, deliveries } = await pd.webhooks.list({ workspaceId });
const failed = await pd.webhooks.list({ workspaceId, deliveryStatus: "failed" });
console.log(endpoints.length, deliveries.length, failed.deliveries.length);

A delivery

A delivery is a POST to your URL. The body is JSON with the event name and its data. These headers come with it:

Header Value
x-prism-draft-signature t=<unix seconds>,v1=<hex>: the signature, see Verify.
x-prism-draft-timestamp The same t, in unix seconds.
x-prism-draft-event The event name, as in the body.
x-prism-draft-delivery The delivery id. It is the same on every attempt of one delivery.
POST https://example.com/prism-draft
content-type: application/json
x-prism-draft-signature: t=1791500000,v1=f880984dbc63da8c7f54091dc1e69cd5d460d510b2670e1eaeb766a1ed79e29b
x-prism-draft-timestamp: 1791500000
x-prism-draft-event: article.updated
x-prism-draft-delivery: 7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f

{"event":"article.updated","data":{"articleId":"3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11","workspaceId":"0a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d","projectId":"b7e1c2d3-4a5f-4e6b-8c7d-9e0f1a2b3c4d","slug":"choosing-a-crm","status":"PUBLISHED","primaryLanguage":"en","translationGroupId":"c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f","title":"How to choose a CRM in 2026","changed":["title","slug"],"previousSlug":"how-to-choose-a-crm"}}

To see a real one, subscribe an endpoint to article.created and create an article.

Verify the signature

v1 is the HMAC-SHA256, in hex, of the string <t>.<rawBody>: the timestamp, a dot, then the request body bytes exactly as received. The key is the secret string as pd.webhooks.create returned it, used as UTF-8 text.

Verify against the raw body, before any JSON parsing. A body that was parsed and serialised again no longer matches, because whitespace and key order change. Refuse a request whose t is more than 300 seconds from your clock; that protects you from a replayed request, and PrismDraft leaves the check to the receiver.

constructWebhookEvent does both checks, parses the body, and returns the event typed by its name. This handler uses only the Fetch API's Request and Response, so it fits any framework that gives you one:

import {
  constructWebhookEvent,
  UnknownWebhookEventError,
  WebhookPayloadError,
} from "@prism-draft/sdk";

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text(); // raw, before any JSON parsing
  try {
    const event = await constructWebhookEvent({
      secret: process.env.PRISMDRAFT_WEBHOOK_SECRET!,
      rawBody,
      signature: request.headers.get("x-prism-draft-signature") ?? "",
    });
    if (event.event === "article.published") {
      console.log("published", event.data.slug);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    // An event from a newer API than this SDK: acknowledge it, or it is retried.
    if (error instanceof UnknownWebhookEventError) return new Response(null, { status: 204 });
    // Signed, but not an event: a bug on the sender's side or a body that was altered after signing.
    if (error instanceof WebhookPayloadError)
      return new Response("invalid payload", { status: 400 });
    return new Response("invalid signature", { status: 401 });
  }
}

event.event narrows event.data: inside the if, data has the fields of article.published.

constructWebhookEvent throws:

  • WebhookSignatureError for a missing, malformed, stale or wrong signature. A header with two t or two v1 fields is refused, and so is an empty secret.
  • WebhookPayloadError for a delivery whose signature is valid but whose body is not JSON (reason is "not_json") or is JSON that is not an { event, data } object with a string event (reason is "not_envelope"). It extends WebhookSignatureError, so a catch that tests for the base class still catches it; test for WebhookPayloadError first to answer 400 for it and 401 for the plain signature error, as the handler above does.
  • UnknownWebhookEventError for a correctly signed delivery of an event this SDK version does not know. Its event field holds the name.

toleranceSeconds changes the 300-second window. A value that is not a finite number of seconds, 0 or more, throws a RangeError.

verifyWebhookSignature takes the same input and returns a boolean, for when you parse the body yourself. WEBHOOK_EVENTS lists the event names this version knows, and WEBHOOK_SIGNATURE_HEADER is the signature header's name.

Unknown events

The API may add events after you install the SDK. A delivery of one the SDK does not list throws UnknownWebhookEventError once its signature has checked out. Answer 2xx and ignore it, as the handler above does. Answering with an error would record a failed delivery, and a 5xx would be retried and fail the same way each time.

Respond and retries

  • Answer with any 2xx to acknowledge. Respond quickly: an attempt is abandoned after 15 seconds. Do slow work after you answer.
  • A delivery is tried up to 6 times. The waits between attempts are 30 s, 1 min, 2 min, 4 min and 8 min, so PrismDraft gives up after about 15 minutes. The delivery then stays failed in the delivery list.
  • Retried: 5xx, 429, a network error and a timeout.
  • Not retried: any other non-2xx answer, 4xx above all. The endpoint rejected the payload, and sending it again will not change that. Redirects are followed up to three hops.
  • The timestamp and signature are made again on every attempt, so a retried delivery carries a fresh signature.
  • Ordering is not guaranteed, and an event can arrive more than once. Deduplicate on the x-prism-draft-delivery header, and read the article's current state with pd.articles.get rather than trusting that the last event you saw is the latest. Recipes shows a receiver built this way.

Events

Every body is { "event": "<name>", "data": { … } }. In the tables below, ids are UUID strings. A field marked optional may be missing from data; every other field is always sent.

article.created

An article was created: through the API or the web app, by duplicating one, or as a translation.

Field Type Notes
articleId string The new article.
projectId string The project it belongs to.
title string

article.generation.started

AI generation of an article was queued.

Field Type Notes
articleId string
jobId string The generation job.
estimatedCredits number The estimated credits for the run.

article.generation.completed

Generation finished and the article is ready for review.

Field Type Notes
articleId string
jobId string
consumedCredits number The credits the run consumed.

article.generation.failed

Generation failed. The article is back to DRAFT.

Field Type Notes
articleId string
jobId string
error string A description of the failure.

article.approved

A reviewer approved the article.

Field Type Notes
articleId string
reviewId string The review that recorded the decision.
previousStatus string The article's status before, such as NEEDS_REVIEW.

article.rejected

A reviewer sent the article back. Both a rejection and a request for changes send this event; decision tells them apart.

Field Type Notes
articleId string
reviewId string
decision string rejected or changes_requested.

article.published

The article was published. The same eight fields describe the article in article.updated and article.unpublished.

Field Type Notes
articleId string
workspaceId string
projectId string
slug string The article's current slug.
status string PUBLISHED.
primaryLanguage string A language tag such as en or pt-BR.
translationGroupId string Shared by the language versions of one piece.
title string

article.updated

A published article changed in a field a site would show. Nothing is sent for an article that is not published, or for a save that changed nothing. data has the eight fields of article.published and two more:

Field Type Notes
articleId string
workspaceId string
projectId string
slug string The slug after the change.
status string PUBLISHED.
primaryLanguage string
translationGroupId string
title string
changed string[] What changed: any of title, content, slug, categories, metaTitle, metaDescription, targetKeywords, secondaryKeywords.
previousSlug string Optional. Present when the slug changed.

Setting an article's categories sends an event for that article, with categories in changed. Renaming a category, changing its languages or deleting it sends one event per published article that carries it. A regenerated or replaced image counts as a change of content.

article.unpublished

A published article stopped being published, or was deleted. data has the eight fields of article.published and two more:

Field Type Notes
articleId string
workspaceId string
projectId string
slug string
status string Where the article went: DRAFT after an unpublish, CHANGES_REQUESTED after a rejection or a request for changes, DELETED after a deletion.
primaryLanguage string
translationGroupId string
title string
previousStatus string Always PUBLISHED.
reason string status_changed, or deleted when the article was deleted.

Deleting a draft sends nothing. Deleting a project or a workspace removes its articles without sending any event, and archiving a project leaves its articles published.

image.generated

An image was generated for an article.

Field Type Notes
articleId string
assetId string The new image.
type string hero or inline.

image.regenerated

An image was regenerated in place, or a hero was replaced by a URL or an upload.

Field Type Notes
articleId string
assetId string For a regeneration, the same asset id with a new publicUrl. For a replaced hero, a new asset id.
type string Optional. hero when a hero was replaced by a URL or an upload. Absent when the image was regenerated in place.

The event does not carry the URL. Read the current one from the article.

credits.purchased

A credit pack was paid for.

Field Type Notes
creditAmount number The credits added.
stripeEventId string The Stripe event that confirmed it.

credits.granted

Plan credits were granted for a paid invoice.

Field Type Notes
creditAmount number The credits granted.
stripeEventId string The Stripe event that confirmed it.

credits.consumed

A generation settled and its credits were consumed.

Field Type Notes
articleId string
jobId string
credits number The credits consumed.