webhooks
The methods of pd.webhooks. Inputs and outputs are described in Conventions; every method can also fail as described in Errors.
Methods
pd.webhooks.list(input, init?)
Webhook endpoints, recent deliveries, and the catalog of events
GET /api/workspaces/{workspaceId}/webhooks
Requires integrations.read. Returns the workspace's webhook endpoints (never their secret), the 50 most recent deliveries across them with the outcome of the latest attempt, and events, the catalog of event names an endpoint can subscribe to. A delivery is tried up to 6 times; deliveryStatus narrows the list, so failures are found even when many newer deliveries succeeded.
Input
| Name | In | Type | Required | Description |
|---|---|---|---|---|
workspaceId |
path | string (uuid) |
yes | Id of the workspace the request works in. The caller must be a member of it, or hold an API key issued by it. For anyone else, an unknown id and an id they do not belong to both answer 404 RESOURCE_NOT_FOUND, so membership cannot be probed. |
deliveryStatus |
query | "pending" | "succeeded" | "failed" |
no | Return only deliveries with this status: pending (queued, not yet attempted), succeeded (an attempt got a 2xx) or failed (the latest attempt failed). Omit for every status. The 50 most recent matching deliveries are returned. |
init |
init | { signal?: AbortSignal; timeoutMs?: number } |
no | The second argument: signal aborts the call, timeoutMs overrides the client's per-attempt timeout. |
Returns 200 OK
| Field | Type | Required | Description |
|---|---|---|---|
endpoints |
object[] |
yes | The workspace's webhook endpoints. The signing secret is never listed. |
endpoints[].id |
string (uuid) |
yes | Webhook endpoint id. |
endpoints[].url |
string (uri) |
yes | Address that receives the signed POST for each subscribed event. |
endpoints[].events |
string[] |
yes | Event names the endpoint subscribed to, as chosen when it was registered. |
endpoints[].active |
boolean |
yes | Whether new events are delivered to the endpoint. Endpoints are created active; deliveries are only queued for active ones. |
endpoints[].createdAt |
string (date-time) |
yes | When the endpoint was registered. |
endpoints[].description |
string |
yes | Free-text note given when the endpoint was registered; empty when none. |
deliveries |
object[] |
yes | The 50 most recent deliveries across all endpoints, newest first, filtered by deliveryStatus when it is given. |
deliveries[].id |
string (uuid) |
yes | Delivery id; sent as the x-prism-draft-delivery header on every attempt. |
deliveries[].endpointId |
string (uuid) |
yes | Endpoint the delivery was sent to. |
deliveries[].eventType |
string |
yes | Name of the event delivered, one of the events catalog. |
deliveries[].status |
"pending" | "succeeded" | "failed" |
yes | pending: queued, not yet attempted. succeeded: an attempt got a 2xx answer. failed: the latest attempt failed; a retry follows unless the failure was not retryable (for example a 4xx other than 429) or attempts reached the limit of 6. |
deliveries[].attempts |
integer |
yes | Attempts made so far: 0 while pending, at most 6. |
deliveries[].responseStatus |
integer | null |
yes | HTTP status the endpoint answered on the latest attempt. Null when it has not been attempted or no answer came (network error, timeout). |
deliveries[].errorMessage |
string | null |
yes | Why the latest attempt failed, such as Endpoint responded 500 or the network error. Null when it succeeded or has not been attempted. |
deliveries[].deliveredAt |
string (date-time) | null |
yes | When the successful attempt completed. Null until the delivery succeeds. |
deliveries[].createdAt |
string (date-time) |
yes | When the delivery was queued. |
events |
("article.created" | "article.generation.started" | "article.generation.completed" | "article.generation.failed" | "article.approved" | "article.rejected" | "article.published" | "article.updated" | "article.unpublished" | "image.generated" | "image.regenerated" | "credits.purchased" | "credits.granted" | "credits.consumed")[] |
yes | The catalog of event names an endpoint can subscribe to. |
Errors 400 invalid input, 401 missing or invalid key, 403 the key's roles lack the permission, 404 not found, 429 rate limited or out of quota. See Errors.
Example
import { PrismDraft } from "@prism-draft/sdk";
const pd = new PrismDraft({ apiKey: process.env.PRISMDRAFT_API_KEY! });
const result = await pd.webhooks.list({
workspaceId: "3f0c5a1e-8b2d-4c6e-9a7f-1d2e3f4a5b6c",
});
console.log(result);
pd.webhooks.create(input, init?)
Register a webhook endpoint
POST /api/workspaces/{workspaceId}/webhooks
Requires integrations.create; answers 201 and records a webhooks.created audit entry. Registers a URL that receives a signed POST for each subscribed event. The signing secret is returned once, in this response, like an API key. The URL is checked against private address ranges before it is stored, and again on every delivery.
Request
The body is JSON with an envelope of the event name and its data:
{ "event": "article.updated", "data": { } }
Headers:
x-prism-draft-signature:t=<unix seconds>,v1=<hex>x-prism-draft-timestamp: the samet, in unix secondsx-prism-draft-event: the event name, as in the envelopex-prism-draft-delivery: the delivery id. It is the same on every attempt of one delivery.
Verifying the signature
v1 is an HMAC-SHA256, hex encoded, keyed with the secret as returned (the UTF-8 string, not decoded from base64) over the string ${t}.${rawBody}: the timestamp, a dot, then the raw request body bytes exactly as received. Do not parse and re-serialise the JSON before checking: any change of whitespace or key order changes the signature. Compare in constant time.
Replay window: reject a request when |now - t| is more than 300 seconds. PrismDraft does not enforce this; the receiver does. The timestamp is set again on every attempt, so a retried delivery carries a fresh signature.
import { createHmac, timingSafeEqual } from "node:crypto";
// `secret` is the string returned when the endpoint was created, used as is.
// `rawBody` is the request body exactly as received (a Buffer or string);
// never JSON.parse and re-stringify it first.
export function verifyPrismDraft(secret, rawBody, signatureHeader, toleranceSeconds = 300) {
const fields = Object.fromEntries(
signatureHeader.split(",").map((part) => {
const at = part.indexOf("=");
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
}),
);
const t = Number(fields.t);
if (!Number.isInteger(t) || !fields.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const provided = Buffer.from(fields.v1, "hex");
return provided.length === expected.length && timingSafeEqual(provided, expected);
}
Delivery and retries
- Any
2xxresponse is success. Respond quickly: an attempt is abandoned after 15 seconds. - A delivery is tried up to 6 times. The waits between attempts are 30 s, 1 min, 2 min, 4 min, 8 min, so a delivery is given up on after about 15 minutes and stays
failedin the delivery list. - Retried:
5xx,429, a network error and a timeout. - Not retried: any other non-2xx response,
4xxabove all: the endpoint rejected the payload, and sending it again will not change that. Redirects are followed up to three hops. - Ordering is not guaranteed, and an event can arrive more than once. Deduplicate on the delivery id header, and read the article's current state rather than assuming the last event you saw is the latest.
Events and their data
article.created: An article was created, by hand, by duplication or by a translation.
{
"event": "article.created",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"projectId": "b7e1c2d3-4a5f-4e6b-8c7d-9e0f1a2b3c4d",
"title": "How to choose a CRM"
}
}
article.generation.started: AI generation of an article was queued.
{
"event": "article.generation.started",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"jobId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
"estimatedCredits": 12
}
}
article.generation.completed: Generation finished and the article is ready for review.
{
"event": "article.generation.completed",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"jobId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
"consumedCredits": 11
}
}
article.generation.failed: Generation failed; the article is back to DRAFT.
{
"event": "article.generation.failed",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"jobId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
"error": "The text provider did not answer"
}
}
article.approved: A reviewer approved the article.
{
"event": "article.approved",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"reviewId": "8d9e0f1a-2b3c-4d4e-9f5a-6b7c8d9e0f1a",
"previousStatus": "NEEDS_REVIEW"
}
}
article.rejected: A reviewer sent the article back. decision is rejected or changes_requested; both send this event.
{
"event": "article.rejected",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"reviewId": "8d9e0f1a-2b3c-4d4e-9f5a-6b7c8d9e0f1a",
"decision": "changes_requested"
}
}
article.published: The article was published.
{
"event": "article.published",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"workspaceId": "0a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d",
"projectId": "b7e1c2d3-4a5f-4e6b-8c7d-9e0f1a2b3c4d",
"slug": "how-to-choose-a-crm",
"status": "PUBLISHED",
"primaryLanguage": "en",
"translationGroupId": "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"title": "How to choose a CRM"
}
}
article.updated: A PUBLISHED article changed in a field a site would show. changed lists which; previousSlug is present when the slug changed. Nothing is sent for an article that is not published, or for a save that changed nothing.
{
"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",
"content"
],
"previousSlug": "how-to-choose-a-crm"
}
}
article.unpublished: A PUBLISHED article left that status (reason is status_changed; status is where it went: DRAFT after an unpublish, CHANGES_REQUESTED after a rejection or a change request) or was deleted (reason is deleted, and status is DELETED).
{
"event": "article.unpublished",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"workspaceId": "0a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d",
"projectId": "b7e1c2d3-4a5f-4e6b-8c7d-9e0f1a2b3c4d",
"slug": "how-to-choose-a-crm",
"status": "CHANGES_REQUESTED",
"primaryLanguage": "en",
"translationGroupId": "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"title": "How to choose a CRM",
"previousStatus": "PUBLISHED",
"reason": "status_changed"
}
}
image.generated: An image was generated for an article. type is hero or inline.
{
"event": "image.generated",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"assetId": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"type": "hero"
}
}
image.regenerated: An image was regenerated (same asset id, new publicUrl; data is articleId and assetId), or a hero was replaced by a URL or an upload (new asset id, new publicUrl, and the added field type: "hero", as in the example). Read the current URL from the article.
{
"event": "image.regenerated",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"assetId": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"type": "hero"
}
}
credits.purchased: A credit pack was paid for.
{
"event": "credits.purchased",
"data": {
"creditAmount": 500,
"stripeEventId": "evt_1QxExample"
}
}
credits.granted: Plan credits were granted for a paid invoice.
{
"event": "credits.granted",
"data": {
"creditAmount": 1000,
"stripeEventId": "evt_1QyExample"
}
}
credits.consumed: A generation settled and its credits were consumed.
{
"event": "credits.consumed",
"data": {
"articleId": "3f0c5a1e-7d2b-4c6a-9b1e-2a4d6f8c0e11",
"jobId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
"credits": 11
}
}
Input
| Name | In | Type | Required | Description |
|---|---|---|---|---|
workspaceId |
path | string (uuid) |
yes | Id of the workspace the request works in. The caller must be a member of it, or hold an API key issued by it. For anyone else, an unknown id and an id they do not belong to both answer 404 RESOURCE_NOT_FOUND, so membership cannot be probed. |
body |
body | object |
yes | |
body.url |
body | string (uri) |
yes | Address that receives a signed POST for each subscribed event. It must not be a private or internal address (400 otherwise); that is checked again on every delivery. |
body.description |
body | string |
no | Free-text note about the endpoint, up to 500 characters. Stored as empty when omitted. |
body.events |
body | ("article.created" | "article.generation.started" | "article.generation.completed" | "article.generation.failed" | "article.approved" | "article.rejected" | "article.published" | "article.updated" | "article.unpublished" | "image.generated" | "image.regenerated" | "credits.purchased" | "credits.granted" | "credits.consumed")[] |
yes | Event names the endpoint receives, at least one. Allowed values are the events catalog returned by webhooks.list; each is described, with an example payload, in the description of this operation. |
init |
init | { signal?: AbortSignal; timeoutMs?: number } |
no | The second argument: signal aborts the call, timeoutMs overrides the client's per-attempt timeout. |
Returns 201 Created
| Field | Type | Required | Description |
|---|---|---|---|
endpoint |
object |
yes | The registered endpoint, without its secret. |
endpoint.id |
string (uuid) |
yes | Webhook endpoint id. |
endpoint.url |
string (uri) |
yes | Address that receives the signed POST for each subscribed event. |
endpoint.events |
string[] |
yes | Event names the endpoint subscribed to, as chosen when it was registered. |
endpoint.active |
boolean |
yes | Whether new events are delivered to the endpoint. Endpoints are created active; deliveries are only queued for active ones. |
endpoint.createdAt |
string (date-time) |
yes | When the endpoint was registered. |
secret |
string |
yes | The signing secret. Returned only by this call, never listed again: store it now, it cannot be read back. |
Errors 400 invalid input, 401 missing or invalid key, 403 the key's roles lack the permission, 404 not found, 429 rate limited or out of quota. See Errors.
Example
import { PrismDraft } from "@prism-draft/sdk";
const pd = new PrismDraft({ apiKey: process.env.PRISMDRAFT_API_KEY! });
const result = await pd.webhooks.create({
workspaceId: "3f0c5a1e-8b2d-4c6e-9a7f-1d2e3f4a5b6c",
body: {
url: "https://example.com",
events: ["article.created"],
},
});
console.log(result);
pd.webhooks.delete(input, init?)
Remove a webhook endpoint
DELETE /api/workspaces/{workspaceId}/webhooks/{endpointId}
Requires integrations.delete; an unknown endpoint, or one of another workspace, answers 404. The endpoint's delivery log goes with it, and deliveries still waiting to be retried are not sent. Records a webhooks.deleted audit entry.
Input
| Name | In | Type | Required | Description |
|---|---|---|---|---|
workspaceId |
path | string (uuid) |
yes | Id of the workspace the request works in. The caller must be a member of it, or hold an API key issued by it. For anyone else, an unknown id and an id they do not belong to both answer 404 RESOURCE_NOT_FOUND, so membership cannot be probed. |
endpointId |
path | string (uuid) |
yes | Id of the webhook endpoint, from webhooks.list. |
init |
init | { signal?: AbortSignal; timeoutMs?: number } |
no | The second argument: signal aborts the call, timeoutMs overrides the client's per-attempt timeout. |
Returns 200 OK
| Field | Type | Required | Description |
|---|---|---|---|
ok |
boolean |
yes | Always true; a failure is an error response. |
Errors 400 invalid input, 401 missing or invalid key, 403 the key's roles lack the permission, 404 not found, 429 rate limited or out of quota. See Errors.
Example
import { PrismDraft } from "@prism-draft/sdk";
const pd = new PrismDraft({ apiKey: process.env.PRISMDRAFT_API_KEY! });
const result = await pd.webhooks.delete({
workspaceId: "3f0c5a1e-8b2d-4c6e-9a7f-1d2e3f4a5b6c",
endpointId: "3f0c5a1e-8b2d-4c6e-9a7f-1d2e3f4a5b6c",
});
console.log(result);