Files
picpeak/docs/_to-migrate/webhooks.md
T
Luca ddebd50d3f docs: slim README to a lean router, stage deep content for docs-site migration (#1001)
Phase 1 of the README slim / docs-migration plan in #1000.

README goes from 577 to ~191 lines: hero, one Quick Start, a Documentation
index, comparison table, tech stack and a table of contents. The deep inline
prose moves into a temporary docs/_to-migrate/ staging folder (webhooks,
storage backends, first-run setup, system requirements, roadmap) so README
links keep resolving until the docs-site pages are live.

Existing docs/*.md referenced by app code are deliberately left in place —
crm-disclaimers.md (frontend TSX, i18n, a backend route and migration),
fonts.md (server.js), accounting-inbound-invoices.md (Dockerfile) and
migration-to-org.md (UpdateNotification.tsx, MigrationBanner.tsx). Moving them
is a separate, code-touching change.

Verified before merge: merges cleanly against main with no conflicts; all 14
in-repo links resolve in the merged tree; no docs file is deleted or renamed;
and the registry-move notice from #995 survives the rewrite in condensed form,
keeping 'still responds but its tags are frozen at 2026-05-27' plus the
migration-to-org.md link. The fuller symptom explanation remains in that doc,
which the README links to.

Follow-up per #1000: port docs/_to-migrate/* into docs.picpeak.app, then flip
the README links and delete the staging folder.

Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
2026-08-10 08:16:13 +02:00

3.4 KiB

Webhooks

PicPeak POSTs event/photo lifecycle notifications to URLs you configure under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a per-webhook secret in the X-PicPeak-Signature header so receivers can verify the request really came from your PicPeak instance.

Event types

Event Fires when
event.created Gallery created (admin or API)
event.published Draft becomes live (is_draft: true → false) — also fires when an event is created with is_draft=false
event.archived Bulk-archive, manual archive, or auto-archive on expiry
event.expired Expiration checker marks the gallery inactive (fires before event.archived in the cascade)
photo.uploaded Admin upload, API upload, guest upload, or auto-import
photo.deleted Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from event.archived to avoid flooding)

Payload shape

{
  "id": "delivery-uuid",
  "type": "event.published",
  "created_at": "2026-04-28T05:25:00.000Z",
  "data": {
    "event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
  }
}

Also sent on every request:

  • X-PicPeak-SignatureHMAC-SHA256(secret, raw_body) as hex
  • X-PicPeak-Event — the event type (handy for routing without parsing the body)
  • X-PicPeak-Delivery — UUID for idempotency on the receiver side
  • User-Agent: PicPeak-Webhooks/1.0

Verifying signatures

Node.js

const crypto = require('crypto');
function verify(secret, rawBody, signature) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Python

import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

curl + openssl (one-liner for a quick replay)

SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH

Retries + observability

  • 2xx → success, recorded with latency
  • Non-2xx or network error → exponential backoff: 1m → 5m → 30m → 2h → 12h, max 5 attempts
  • After max attempts: status failed, surfaces in Settings → Webhooks → Deliveries with a "Replay" button
  • Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via WEBHOOK_DELIVERY_CONCURRENCY)
  • Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log

The deliveries page (/admin/webhooks/:id/deliveries) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.

SSRF protection

Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, .local/.internal hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).

For local development with a receiver on the same machine or docker network, set WEBHOOK_ALLOW_PRIVATE_URLS=true. Production deployments must leave this OFF.