Webhooks.
Keep an external mirror consistent without polling: every event is persisted the moment it happens, delivered asynchronously, signed, retried on a backoff schedule for ~32 hours, and fully auditable via the delivery log.
Delivery is at-least-once and strictly async — a slow subscriber can't slow a write, and a crashed pod can't lose an event. Deduplicate on the envelope id. Up to 10 subscribers per community, each with its own URL, secret, and event selection.
1
Register a subscriber
Webhooks are community-scoped. The URL must be https:// and publicly resolvable (private / link-local addresses are rejected on every delivery, not just at registration). Unknown event_types are rejected with a 400 naming the valid set — a typo like post.craeted can't silently register and never fire.
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.acme.com/webhooks/social",
"event_types": ["post.created", "post.deleted", "comment.created"]
}' \
"https://social.productcraft.co/v1/communities/<communityId>/webhooks"The response includes signing_secret once — store it. Every later read exposes only the last-4 hint. GET …/webhooks lists every subscriber including disabled rows, so an auto-disabled webhook stays discoverable. The catalog is also available at runtime, unauthenticated:
curl "https://social.productcraft.co/v1/webhook-event-types"
# → { "data": ["post.created", "post.updated", "post.deleted", ...] }2
The delivery envelope
Every delivery is an HTTPS POST with a JSON body in one envelope shape. id is stable across retries and manual redelivers — it's your dedupe key.
{
"id": "evt_5f0c…", // stable event id — dedupe on this
"type": "post.created", // one of the catalog below
"created_at": "2026-07-14T12:00:00.000Z",
"community_id": "cccccccc-…",
"data": { /* event-specific payload, e.g. post_id, actor_id, body */ }
}Respond with any 2xx within 15 seconds. Anything else — including a timeout — counts as a failed attempt and schedules a retry. Do the minimum in the handler (verify, enqueue, 200) and process heavy work asynchronously on your side.
3
Verify the signature
Never process an unverified delivery. Each POST carries an X-Pcft-Signature header: t=<unix seconds>,v1=<lowercase hex>. The value is HMAC-SHA256(key = signing secret, message = "<t>.<raw body>") — the body byte-for-byte as received, never re-serialized JSON. This is byte-identical to the Auth, Trawl and Waitlist webhook schemes (different header names), so one verifier function covers every ProductCraft product:
import { createHmac, timingSafeEqual } from 'node:crypto';
const DEFAULT_TOLERANCE_SECONDS = 5 * 60;
export function verifyPcftSignature(
signatureHeader: string, // the X-Pcft-Signature header value
rawBody: string, // request body, byte-for-byte as received
secret: string, // your signing secret (shown once at create/rotate)
toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
): boolean {
const parts = new Map(
signatureHeader.split(',').map((part) => {
const i = part.indexOf('=');
return [part.slice(0, i), part.slice(i + 1)] as const;
}),
);
const t = parts.get('t');
const v1 = parts.get('v1');
if (!t || !v1) return false;
// Replay protection: reject timestamps outside the tolerance window.
const timestamp = Number(t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds)
return false;
// HMAC-SHA256 over "<t>.<raw body>", hex-encoded.
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest();
const given = Buffer.from(v1, 'hex');
// Constant-time comparison.
return (
given.length === expected.length && timingSafeEqual(expected, given)
);
}The one integration bug to avoid: verification needs the raw body. A JSON parser that deserializes and re-serializes changes the bytes and every check fails — mount a raw parser on the webhook route:
import express from 'express';
import { verifyPcftSignature } from './verify-pcft-signature';
const app = express();
app.post(
'/webhooks/social',
express.raw({ type: 'application/json' }), // raw bytes, not parsed JSON
(req, res) => {
const rawBody = req.body.toString('utf8');
const ok = verifyPcftSignature(
req.header('x-pcft-signature') ?? '',
rawBody,
process.env.SOCIAL_WEBHOOK_SECRET!,
);
if (!ok) return res.status(401).end();
const event = JSON.parse(rawBody);
// At-least-once delivery — dedupe on the event id.
if (alreadyProcessed(event.id)) return res.status(200).end();
switch (event.type) {
case 'post.created':
mirrorPost(event.data);
break;
case 'post.deleted':
evictPost(event.data.post_id);
break;
// …
}
// 2xx within 15s; queue heavy work for later.
res.status(200).end();
},
);4
Event catalog
Sixteen event types today (live list: GET /v1/webhook-event-types). Each feature that ships new events adds them here — new types never fire for existing webhooks until you subscribe to them.
| Event type | Fires when |
|---|---|
| post.created | A post (text / link / image / quote / story) was created. Includes drafts and scheduled posts — check data.status. |
| post.updated | A post body / title / attributes was edited. |
| post.deleted | A post was soft-deleted — including reposts deleted via either delete route. Use this to evict mirrored posts. |
| post.reaction.created | A reaction landed on a post. |
| repost.created | An actor reposted a post (data.source_post_id points at the original). |
| comment.created | A comment was created (data.parent_id null for top-level). |
| comment.updated | A comment body was edited. |
| comment.deleted | A comment was removed. |
| comment.reaction.created | A reaction landed on a comment. |
| edge.follow.created | An actor followed another actor. |
| edge.follow.deleted | An actor unfollowed another actor. |
| flag.created | Content was reported to the moderation queue (data.auto_hidden tells you if it tripped the auto-hide threshold). |
| flag.updated | A flag was resolved (actioned / dismissed). |
| moderation.action.applied | A moderator applied an action (hide / remove / restore / warn / ban / dismiss) — audit-row granularity. |
| actor.updated | An actor profile changed (update, or upsert on an existing actor). |
| dm.message.created | A direct message was sent — the minimal DM push primitive (data.recipient_actor_ids lists everyone to notify). |
5
Retries
Delivery is fully decoupled from the originating write: the event is persisted in the same breath as the write, then a dispatch worker delivers it. A failed attempt (non-2xx, timeout, TLS or DNS error) retries on a fixed backoff schedule:
1m → 5m → 30m → 2h → 6h → 24h — 7 attempts over roughly 32 hours, then the delivery is marked exhausted. Every attempt appears in the delivery log with its real attempt_number, and the POST carries the same envelope (same id) each time.
A subscriber that's down for an afternoon misses nothing; one that's down for two days should reconcile by reading the API after recovery, using the delivery log to find the gap.
6
Debug with the delivery log
Every attempt — success or failure — is recorded: request body, response status, first KiB of the response body, error class, latency, attempt number.
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<communityId>/webhooks/<webhookId>/deliveries?limit=20"Two management routes close the loop (both async, 202):
- Test ping —
POST …/webhooks/:id/testsends a signedpingevent through the full pipeline (SSRF guard, TLS, signature), so you can validate your receiver before subscribing to real traffic. - Redeliver —
POST …/webhooks/:id/deliveries/:deliveryId/redeliverre-publishes a past delivery byte-identically from the stored request body (same event id — your dedupe sees a redelivery, not a new event). Use it after fixing a receiver bug.
7
Auto-disable and recovery
A webhook that fails 20 consecutive times, or fails continuously for 24 hours, is auto-disabled: disabled_at / disabled_reason are set and it stops receiving events (in-flight retries for it are canceled). It stays visible in GET …/webhooks so the outage is diagnosable.
Re-enable it once your endpoint is healthy again:
curl -X PATCH -H "Authorization: Bearer pcft_live_..." \
-H "Content-Type: application/json" \
-d '{ "disabled": false }' \
"https://social.productcraft.co/v1/communities/<communityId>/webhooks/<webhookId>"This clears the failure streak — no secret rotation required (rotating via POST …/rotate-secret also re-enables, if you want a fresh secret at the same time). Events that fired while the webhook was disabled are not queued for it; reconcile via the API, then rely on webhooks going forward. { "disabled": true } is the symmetric manual pause.
In-app notifications (the inbox in 08 · Notifications) and webhooks are complementary: the inbox is per-actor product UX; webhooks are your backend's system-level firehose.