PrismDraft SDK

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 same t, in unix seconds
  • x-prism-draft-event: the event name, as in the envelope
  • x-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 2xx response 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 failed in the delivery list.
  • Retried: 5xx, 429, a network error and a timeout.
  • Not retried: any other non-2xx response, 4xx above 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);