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);
eventslists at least one of the 14 events below. An endpoint receives only the events it subscribed to.descriptionis 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:
WebhookSignatureErrorfor a missing, malformed, stale or wrong signature. A header with twotor twov1fields is refused, and so is an empty secret.WebhookPayloadErrorfor a delivery whose signature is valid but whose body is not JSON (reasonis"not_json") or is JSON that is not an{ event, data }object with a stringevent(reasonis"not_envelope"). It extendsWebhookSignatureError, so acatchthat tests for the base class still catches it; test forWebhookPayloadErrorfirst to answer 400 for it and 401 for the plain signature error, as the handler above does.UnknownWebhookEventErrorfor a correctly signed delivery of an event this SDK version does not know. Itseventfield 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
2xxto 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
failedin the delivery list. - Retried:
5xx,429, a network error and a timeout. - Not retried: any other non-2xx answer,
4xxabove 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-deliveryheader, and read the article's current state withpd.articles.getrather 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. |