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.
| Event | Fires when |
|---|---|
| doc.created | A new document of any type is created (draft, blog, release, feedback, custom — any). |
| doc.updated | An existing doc is updated — title, content, status, etc. Fires on every edit. |
| doc.published | A doc's status changes to published. Includes releases, blog posts, and any doc with a published state. |
| doc.unpublished | A doc's status changes from published to something else. |
| doc.deleted | A doc is deleted. Payload includes a snapshot of the now-gone doc (id, type, slug, title). |
| release.published | Convenience alias — fires in addition to doc.published when the doc's type is release. Subscribe to this if you only care about releases. |
| feedback.created | New 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.created | A card was added to a board. Payload includes boardId and stageId. |
| card.updated | A card's title, description, or assignee changed. |
| card.moved | A card moved between stages (not for reorders within the same stage). Payload includes previousStageId so you can detect "moved to Done" etc. |
| person.created | A new contact/customer was created in your CRM. |
| org.created | A 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.
| Attempt | Delay after previous failure |
|---|---|
| 1 (initial) | — |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 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 ContentInspect 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-Signaturebefore 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.