From 1e69d5ff71ac2d1d133b0e40637b437d7cc8bc4f Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Wed, 29 Apr 2026 20:32:20 +0200
Subject: [PATCH] feat(webhooks): enrich event.* payloads with customer contact
+ share_token (#341)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.
Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:
{ id, slug, event_name, event_type, event_date,
share_url, share_token,
customer_name, customer_email, customer_phone }
Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.
Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)
PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
null contract, and a Callout warning.
Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
---
backend/src/routes/adminEvents.js | 45 +++++++++++++++++--
backend/src/routes/events.js | 21 ++++++---
backend/src/routes/v1/events.js | 21 ++++++---
backend/src/services/archiveService.js | 18 +++++++-
backend/src/services/expirationChecker.js | 19 +++++++-
backend/src/services/webhookService.js | 24 ++++++++++
.../features/settings/tabs/WebhooksTab.tsx | 14 ++++++
7 files changed, 144 insertions(+), 18 deletions(-)
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index c1a6d0e7..fbf929fd 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -610,10 +610,26 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// Fire event.created webhook (#327). If the event is being published
// immediately (not a draft), event.published also fires below.
+ // Payload uses canonical event subject (#341) so receivers always see
+ // the same shape (id/slug/event_name + customer contact + share_*).
try {
const webhookService = require('../services/webhookService');
await webhookService.fire('event.created', {
- event: { id: eventId, slug, event_name, event_type, event_date, is_draft: parseBooleanInput(is_draft, true) },
+ event: {
+ ...webhookService.buildEventSubject({
+ id: eventId,
+ slug,
+ event_name,
+ event_type,
+ event_date,
+ share_url: shareUrl,
+ share_token: shareToken,
+ customer_name: customerName,
+ customer_email: customerEmail,
+ customer_phone: customerPhone,
+ }),
+ is_draft: parseBooleanInput(is_draft, true),
+ },
});
} catch (e) { /* webhookService.fire never throws but be defensive */ }
@@ -661,7 +677,18 @@ router.post('/', adminAuth, requirePermission('events.create'), [
try {
const webhookService = require('../services/webhookService');
await webhookService.fire('event.published', {
- event: { id: eventId, slug, event_name, share_url: shareUrl },
+ event: webhookService.buildEventSubject({
+ id: eventId,
+ slug,
+ event_name,
+ event_type,
+ event_date,
+ share_url: shareUrl,
+ share_token: shareToken,
+ customer_name: customerName,
+ customer_email: customerEmail,
+ customer_phone: customerPhone,
+ }),
});
} catch (e) { /* non-fatal */ }
}
@@ -902,11 +929,23 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
);
// Fire event.published webhook (#327) — draft → live transition.
+ // Canonical payload (#341): includes customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await webhookService.fire('event.published', {
- event: { id: parseInt(id, 10), slug: event.slug, event_name: event.event_name, share_url: shareUrl },
+ event: webhookService.buildEventSubject({
+ id: parseInt(id, 10),
+ slug: event.slug,
+ event_name: event.event_name,
+ event_type: event.event_type,
+ event_date: event.event_date,
+ share_url: shareUrl,
+ share_token: event.share_token,
+ customer_name: event.customer_name || event.host_name,
+ customer_email: event.customer_email || event.host_email,
+ customer_phone: event.customer_phone,
+ }),
});
} catch (e) { /* non-fatal */ }
diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js
index 6da5820e..a42942a9 100644
--- a/backend/src/routes/events.js
+++ b/backend/src/routes/events.js
@@ -191,15 +191,24 @@ router.post('/', adminAuth, [
});
// Webhook lifecycle (#327). Legacy public endpoint — events go live
- // immediately so created + published fire together.
+ // immediately so created + published fire together. Payload uses the
+ // canonical event subject (#341) — every event.* webhook now includes
+ // customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
- await webhookService.fire('event.created', {
- event: { id: eventId, slug, event_name, event_type, event_date, share_url: shareUrl },
- });
- await webhookService.fire('event.published', {
- event: { id: eventId, slug, event_name, share_url: shareUrl },
+ const eventSubject = webhookService.buildEventSubject({
+ id: eventId,
+ slug,
+ event_name,
+ event_type,
+ event_date,
+ share_url: shareUrl,
+ share_token: shareToken,
+ customer_name: customerName,
+ customer_email: customerEmail,
});
+ await webhookService.fire('event.created', { event: eventSubject });
+ await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.json({
diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js
index 8a760af0..c8aab9d7 100644
--- a/backend/src/routes/v1/events.js
+++ b/backend/src/routes/v1/events.js
@@ -183,15 +183,24 @@ router.post(
});
// Webhook lifecycle (#327). v1 events are not draft-aware, so they're
- // both created AND published in the same call.
+ // both created AND published in the same call. Canonical event
+ // subject (#341) — customer contact + share_token always included.
try {
const webhookService = require('../../services/webhookService');
- await webhookService.fire('event.created', {
- event: { id, slug, event_name, event_type, event_date, share_url: shareUrl },
- });
- await webhookService.fire('event.published', {
- event: { id, slug, event_name, share_url: shareUrl },
+ const eventSubject = webhookService.buildEventSubject({
+ id,
+ slug,
+ event_name,
+ event_type,
+ event_date,
+ share_url: shareUrl,
+ share_token: shareToken,
+ customer_name,
+ customer_email,
+ customer_phone,
});
+ await webhookService.fire('event.created', { event: eventSubject });
+ await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken });
diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js
index 8d17c1ab..fae51523 100644
--- a/backend/src/services/archiveService.js
+++ b/backend/src/services/archiveService.js
@@ -99,10 +99,26 @@ async function archiveEvent(event) {
// Fire event.archived webhook (#327). Receivers infer per-photo loss
// from this event — we deliberately do NOT fire photo.deleted for each
// archived photo to avoid flooding subscribers on bulk archives.
+ // Canonical event subject (#341) so the shape matches event.created /
+ // event.published / event.expired; archive_path is an event.archived-
+ // specific extra.
try {
const webhookService = require('./webhookService');
await webhookService.fire('event.archived', {
- event: { id: event.id, slug: event.slug, event_name: event.event_name, archive_path: archiveRelKey },
+ event: {
+ ...webhookService.buildEventSubject({
+ id: event.id,
+ slug: event.slug,
+ event_name: event.event_name,
+ event_type: event.event_type,
+ event_date: event.event_date,
+ share_token: event.share_token,
+ customer_name: event.customer_name || event.host_name,
+ customer_email: event.customer_email || event.host_email,
+ customer_phone: event.customer_phone,
+ }),
+ archive_path: archiveRelKey,
+ },
});
} catch (e) { /* non-fatal */ }
diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js
index 0e6ab41a..7103730c 100644
--- a/backend/src/services/expirationChecker.js
+++ b/backend/src/services/expirationChecker.js
@@ -86,11 +86,26 @@ async function handleExpiredEvent(event) {
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Fire event.expired BEFORE the cascading archive call so receivers
- // get the lifecycle in order (expired → archived).
+ // get the lifecycle in order (expired → archived). Canonical event
+ // subject (#341) so receivers see the same shape across all event.*
+ // types; expires_at retained as an event.expired-specific extra.
try {
const webhookService = require('./webhookService');
await webhookService.fire('event.expired', {
- event: { id: event.id, slug: event.slug, event_name: event.event_name, expires_at: event.expires_at },
+ event: {
+ ...webhookService.buildEventSubject({
+ id: event.id,
+ slug: event.slug,
+ event_name: event.event_name,
+ event_type: event.event_type,
+ event_date: event.event_date,
+ share_token: event.share_token,
+ customer_name: event.customer_name || event.host_name,
+ customer_email: event.customer_email || event.host_email,
+ customer_phone: event.customer_phone,
+ }),
+ expires_at: event.expires_at,
+ },
});
} catch (e) { /* non-fatal */ }
diff --git a/backend/src/services/webhookService.js b/backend/src/services/webhookService.js
index 3b3df20c..3dca6b02 100644
--- a/backend/src/services/webhookService.js
+++ b/backend/src/services/webhookService.js
@@ -210,6 +210,29 @@ function parseJsonField(value, fallback) {
try { return JSON.parse(value) ?? fallback; } catch { return fallback; }
}
+/**
+ * Canonical event sub-object for outbound webhooks (#341). Always returns
+ * the full key set so receivers don't have to handle "field missing vs
+ * field null" — pass whatever the caller has in scope, missing fields
+ * become null. Adding a new field here propagates to every event.* type
+ * payload at once.
+ */
+function buildEventSubject(input = {}) {
+ const e = input || {};
+ return {
+ id: e.id ?? null,
+ slug: e.slug ?? null,
+ event_name: e.event_name ?? null,
+ event_type: e.event_type ?? null,
+ event_date: e.event_date ?? null,
+ share_url: e.share_url ?? null,
+ share_token: e.share_token ?? null,
+ customer_name: e.customer_name ?? null,
+ customer_email: e.customer_email ?? null,
+ customer_phone: e.customer_phone ?? null,
+ };
+}
+
module.exports = {
fire,
generateSecret,
@@ -219,6 +242,7 @@ module.exports = {
renderTemplate,
validateTemplate,
getByPath,
+ buildEventSubject,
EVENT_TYPES,
SECRET_PREFIX,
};
diff --git a/frontend/src/features/settings/tabs/WebhooksTab.tsx b/frontend/src/features/settings/tabs/WebhooksTab.tsx
index 8ba8a3b0..4ce27d58 100644
--- a/frontend/src/features/settings/tabs/WebhooksTab.tsx
+++ b/frontend/src/features/settings/tabs/WebhooksTab.tsx
@@ -130,6 +130,20 @@ export const WebhooksTab: React.FC = () => {
{t('settings.webhooks.subtitle', 'POST event notifications to your URL the moment something happens — gallery published, photo uploaded, event archived, etc. Signed with HMAC-SHA256 in the X-PicPeak-Signature header.')}
+ {/* PII notice (#341). event.* payloads include customer contact
+ fields (name / email / phone) plus the share token. Make sure
+ admins know what flows to a webhook receiver before they wire
+ one up to a third-party automation tool. */}
+
+
+
+ {t(
+ 'settings.webhooks.piiNotice',
+ 'event.* payloads include customer contact info (name, email, phone) and the gallery share token if you have stored them. Only point webhooks at receivers you trust — they have everything needed to message the customer or open the gallery.'
+ )}
+
+
+
{justCreatedSecret && (