Social guides
Guides

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.

bash
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:

bash
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.

json
{
  "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:

verify-pcft-signature.ts
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:

receiver.ts
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 typeFires when
post.createdA post (text / link / image / quote / story) was created. Includes drafts and scheduled posts — check data.status.
post.updatedA post body / title / attributes was edited.
post.deletedA post was soft-deleted — including reposts deleted via either delete route. Use this to evict mirrored posts.
post.reaction.createdA reaction landed on a post.
repost.createdAn actor reposted a post (data.source_post_id points at the original).
comment.createdA comment was created (data.parent_id null for top-level).
comment.updatedA comment body was edited.
comment.deletedA comment was removed.
comment.reaction.createdA reaction landed on a comment.
edge.follow.createdAn actor followed another actor.
edge.follow.deletedAn actor unfollowed another actor.
flag.createdContent was reported to the moderation queue (data.auto_hidden tells you if it tripped the auto-hide threshold).
flag.updatedA flag was resolved (actioned / dismissed).
moderation.action.appliedA moderator applied an action (hide / remove / restore / warn / ban / dismiss) — audit-row granularity.
actor.updatedAn actor profile changed (update, or upsert on an existing actor).
dm.message.createdA 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.

bash
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/test sends a signed ping event 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/redeliver re-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:

bash
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.