Back to Docs

Webhooks

Subscribe a URL to Simple Product events. We POST you signed payloads when things happen in your workspace — releases publish, docs go live, drafts get created. Use it to send emails, revalidate caches, post to Slack, or trigger any other workflow.

Quick start

Create a subscription. Replace sk_ws_xxx with a secret API key from Settings → API Keys.

curl -X POST https://simpleproduct.dev/api/v1/webhooks \ -H "Authorization: Bearer sk_ws_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-service.example.com/webhooks/sp", "events": ["doc.published"] }'

The response includes a secret field — shown only once. Save it; you'll need it to verify incoming requests.

Event types

Current event types. Pass any subset in the events array when subscribing, or ["*"] to receive everything.

EventFires when
doc.createdA new document of any type is created (draft, blog, release, feedback, custom — any).
doc.updatedAn existing doc is updated — title, content, status, etc. Fires on every edit.
doc.publishedA doc's status changes to published. Includes releases, blog posts, and any doc with a published state.
doc.unpublishedA doc's status changes from published to something else.
doc.deletedA doc is deleted. Payload includes a snapshot of the now-gone doc (id, type, slug, title).
release.publishedConvenience alias — fires in addition to doc.published when the doc's type is release. Subscribe to this if you only care about releases.
feedback.createdNew customer feedback received (via the SDK widget, REST API, or in-app submission). Fires alongside doc.created; subscribe narrowly if you only care about feedback.
card.createdA card was added to a board. Payload includes boardId and stageId.
card.updatedA card's title, description, or assignee changed.
card.movedA card moved between stages (not for reorders within the same stage). Payload includes previousStageId so you can detect "moved to Done" etc.
person.createdA new contact/customer was created in your CRM.
org.createdA new organization was created in your CRM.

More event types will be added over time. Subscribe to only what you need — narrow event lists reduce noise to your service.

Payload format

Every webhook POST has the same envelope. Headers identify the event; the body is JSON.

POST /your/webhook/path HTTP/1.1 Content-Type: application/json X-SP-Signature: t=1779480000,v1=<hex-hmac-sha256> X-SP-Event-Id: evt_3f9a2b1c8d4e5f6a7b8c9d0e1f2a3b4c X-SP-Event-Type: doc.published User-Agent: SimpleProduct-Webhooks/1.0 { "id": "evt_3f9a2b1c8d4e5f6a7b8c9d0e1f2a3b4c", "type": "doc.published", "createdAt": 1779480000000, "workspaceId": "kn7…", "data": { "id": "doc_abc…", "type": "release", "slug": "v2-0-0", "title": "Version 2.0.0", "publishedAt": 1779480000000 } }

Payloads are thin by design. We send IDs and identifiers, not full content. Fetch fresh data via the public API in your handler — that way you always have the latest version, even if the doc was edited between events.

Verifying the signature

Every webhook is signed with the secret we issued when you created the subscription. Always verify before processing. Without verification, any caller who knows your URL could spoof events.

The X-SP-Signature header has the form t=<unix-ts>,v1=<hmac>. The HMAC is computed as HMAC-SHA256(secret, "{ts}.{raw-body}") with output as lowercase hex.

Node.js example

import { createHmac, timingSafeEqual } from "crypto"; const SP_WEBHOOK_SECRET = process.env.SP_WEBHOOK_SECRET!; const MAX_AGE_SECONDS = 300; // reject signatures older than 5min function verifySignature(rawBody: string, signatureHeader: string): boolean { // Parse "t=<ts>,v1=<hmac>" const parts = Object.fromEntries( signatureHeader.split(",").map((p) => p.split("=") as [string, string]) ); const timestamp = parseInt(parts.t, 10); const provided = parts.v1; if (!timestamp || !provided) return false; // Reject old signatures (prevents replay attacks) if (Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE_SECONDS) return false; // Recompute and compare in constant time const expected = createHmac("sha256", SP_WEBHOOK_SECRET) .update(`${timestamp}.${rawBody}`) .digest("hex"); const a = Buffer.from(provided, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && timingSafeEqual(a, b); } // In your handler: app.post("/webhooks/sp", (req, res) => { const signature = req.headers["x-sp-signature"] as string; if (!verifySignature(req.rawBody, signature)) { return res.status(401).end(); } // ... process event ... res.status(200).end(); });

Important: verify against the raw request body, not the parsed JSON. Re-serializing JSON can subtly change whitespace and break the signature. Use a middleware that preserves the raw body — for Express, express.raw(); for Next.js, read the request body as text before parsing.

Retries & delivery

We retry failed deliveries automatically. A delivery succeeds when your endpoint returns any 2xx status. Anything else (4xx, 5xx, timeout, network error) triggers a retry.

AttemptDelay after previous failure
1 (initial)
230 seconds
32 minutes
410 minutes
51 hour
66 hours
724 hours

After 7 attempts (initial + 6 retries), the delivery is marked dead and we stop trying. You can see dead-lettered deliveries in the deliveries history (see below).

The same X-SP-Event-Idis sent on every retry of the same delivery. Use it to deduplicate on your side — if you've already processed that ID, return 200 and skip the work.

REST API reference

All endpoints require Authorization: Bearer sk_ws_* (a secret API key from Settings → API Keys). Publishable keys (pk_ws_*) cannot manage webhooks.

Create a subscription

POST /api/v1/webhooks { "url": "https://example.com/webhooks/sp", "events": ["doc.published", "doc.unpublished"] } // 201 Created { "data": { "id": "<subscription-id>", "url": "...", "events": ["doc.published", "doc.unpublished"], "active": true, "secret": "whsec_<32-byte-hex>", // shown ONCE — save it "createdAt": 1779480000000, "updatedAt": 1779480000000 } }

List subscriptions

GET /api/v1/webhooks // 200 OK — secrets redacted on read { "data": [ { "id": "...", "url": "...", "events": [...], "active": true, "createdAt": ..., "updatedAt": ... } ], "meta": { "count": 1 } }

Update a subscription

PATCH /api/v1/webhooks/<id> { "url": "https://new-url.example.com/webhooks/sp", // optional "events": ["doc.published"], // optional "active": false // optional, pause/resume }

Delete a subscription

DELETE /api/v1/webhooks/<id> // 204 No Content

Inspect delivery history

For debugging: see the last N deliveries for a subscription, including attempt count, response status, and a snippet of the response body.

GET /api/v1/webhooks/<id>/deliveries?limit=50 // 200 OK { "data": [ { "id": "...", "eventId": "evt_...", "eventType": "doc.published", "status": "delivered", // pending | delivered | failed | dead "attempts": 1, "responseStatus": 200, "responseBodySnippet": "ok", "lastAttemptAt": 1779480000000, "nextAttemptAt": null, "createdAt": 1779480000000 } ], "meta": { "count": 1 } }

Common patterns

Send an email when a release is published

Subscribe to doc.published, filter by data.type === "release", fetch the full content via the public docs API, send an email.

app.post("/webhooks/sp", async (req, res) => { if (!verifySignature(req.rawBody, req.headers["x-sp-signature"])) { return res.status(401).end(); } const event = JSON.parse(req.rawBody); if (event.type !== "doc.published") return res.status(200).end(); if (event.data.type !== "release") return res.status(200).end(); // Fetch the full release content (always-fresh) const release = await fetch( `https://simpleproduct.dev/api/v1/docs/public/${event.data.slug}?type=release` ).then((r) => r.json()); // Send via your email provider await sendEmail({ to: subscriberList, subject: `New release: ${release.title}`, html: renderMarkdownToHtml(release.content), }); res.status(200).end(); });

Revalidate a cache when a doc publishes

If you mirror Simple Product docs on your own site (Next.js ISR, edge cache, etc.), use doc.published and doc.unpublished to trigger surgical revalidation rather than polling.

app.post("/webhooks/sp", async (req, res) => { if (!verifySignature(req.rawBody, req.headers["x-sp-signature"])) { return res.status(401).end(); } const event = JSON.parse(req.rawBody); if (event.type === "doc.published" || event.type === "doc.unpublished") { // Next.js: revalidate the public path for this doc await fetch(`https://your-site.com/api/revalidate?path=/share/${event.data.slug}&secret=${process.env.REVALIDATE_SECRET}`); } res.status(200).end(); });

Best practices

  • Return 200 fast. Acknowledge the webhook quickly, then do the actual work (email send, cache revalidation, etc.) in a background job. Long handlers risk timeout retries.
  • Verify the signature. Without it, anyone who knows your URL can spoof events. Always check X-SP-Signature before processing.
  • Dedupe by X-SP-Event-Id. Retries reuse the same event ID. Track recently processed IDs in your DB or cache to avoid double-handling.
  • Fetch content fresh. Payloads have IDs, not bodies. Fetch the full doc via the public API in your handler — that's how you get the latest version, even if it was edited after the event fired.
  • Store secrets out of code. Webhook secrets go in environment variables / secret manager, never committed to git. Same handling as API keys.
  • Subscribe narrowly. Listing every event you don't need adds load to your service. Subscribe only to event types you act on.

Current limits

This is the first release of webhooks. Things that will improve over time:

  • • Web UI for managing subscriptions (today: REST API only)
  • • Server-side filtering by payload fields (today: subscribe by event type, filter in your handler)
  • • More event types — currently only doc events; card.*, feedback.*, release.* and others are on the roadmap
  • • Self-service secret rotation (today: delete + recreate the subscription)

Found a bug or want a new event type? Send us feedback.