@hogsend/engine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/container.ts +80 -8
  4. package/src/destinations/define-destination.ts +104 -0
  5. package/src/destinations/presets/index.ts +94 -0
  6. package/src/destinations/presets/posthog.ts +71 -0
  7. package/src/destinations/presets/segment.ts +75 -0
  8. package/src/destinations/presets/slack.ts +66 -0
  9. package/src/destinations/presets/webhook.ts +37 -0
  10. package/src/destinations/registry-singleton.ts +78 -0
  11. package/src/env.ts +40 -0
  12. package/src/index.ts +59 -1
  13. package/src/journeys/define-journey.ts +26 -3
  14. package/src/journeys/journey-context.ts +1 -17
  15. package/src/lib/analytics-singleton.ts +7 -0
  16. package/src/lib/bucket-emit.ts +45 -0
  17. package/src/lib/contacts.ts +28 -6
  18. package/src/lib/mailer.ts +102 -0
  19. package/src/lib/outbound.ts +223 -0
  20. package/src/lib/preferences.ts +31 -0
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +45 -3
  23. package/src/lib/tracking-events.ts +77 -10
  24. package/src/lib/webhook-signing.ts +152 -0
  25. package/src/routes/admin/contacts.ts +43 -3
  26. package/src/routes/admin/index.ts +2 -0
  27. package/src/routes/admin/webhooks.ts +557 -0
  28. package/src/routes/contacts/index.ts +48 -5
  29. package/src/routes/lists/index.ts +41 -5
  30. package/src/routes/tracking/click.ts +58 -22
  31. package/src/routes/tracking/open.ts +53 -22
  32. package/src/routes/webhooks/sources.ts +69 -10
  33. package/src/webhook-sources/define-webhook-source.ts +57 -5
  34. package/src/webhook-sources/presets/clerk.ts +185 -0
  35. package/src/webhook-sources/presets/index.ts +80 -0
  36. package/src/webhook-sources/presets/segment.ts +120 -0
  37. package/src/webhook-sources/presets/stripe.ts +147 -0
  38. package/src/webhook-sources/presets/supabase.ts +131 -0
  39. package/src/webhook-sources/verify.ts +172 -0
  40. package/src/worker.ts +6 -0
  41. package/src/workflows/deliver-webhook.ts +484 -0
@@ -1,7 +1,14 @@
1
+ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
1
2
  import type { Database } from "@hogsend/db";
2
3
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
4
  import type { AppEnv } from "../../app.js";
4
- import { resolveOrCreateContact } from "../../lib/contacts.js";
5
+ import {
6
+ resolveContact,
7
+ resolveOrCreateContact,
8
+ serializeContact,
9
+ } from "../../lib/contacts.js";
10
+ import type { Logger } from "../../lib/logger.js";
11
+ import { emitOutbound } from "../../lib/outbound.js";
5
12
  import { applyListMembership } from "../../lib/preferences.js";
6
13
  import { errorSchema } from "../../lib/schemas.js";
7
14
  import { getListRegistry } from "../../lists/registry-singleton.js";
@@ -122,6 +129,8 @@ const unsubscribeRoute = createRoute({
122
129
  */
123
130
  async function applyListSubscription(opts: {
124
131
  db: Database;
132
+ hatchet: HatchetClient;
133
+ logger: Logger;
125
134
  id: string;
126
135
  email?: string;
127
136
  userId?: string;
@@ -132,7 +141,7 @@ async function applyListSubscription(opts: {
132
141
  | { kind: "failed"; message: string }
133
142
  | { kind: "ok" }
134
143
  > {
135
- const { db, id, email, userId, subscribed } = opts;
144
+ const { db, hatchet, logger, id, email, userId, subscribed } = opts;
136
145
 
137
146
  if (!getListRegistry().has(id)) {
138
147
  return { kind: "unknown_list" };
@@ -143,7 +152,30 @@ async function applyListSubscription(opts: {
143
152
  }
144
153
 
145
154
  try {
146
- await resolveOrCreateContact({ db, userId, email });
155
+ const { id: contactId, created } = await resolveOrCreateContact({
156
+ db,
157
+ userId,
158
+ email,
159
+ });
160
+
161
+ // INTENT-LAYER outbound emit (decision #3): the lists route emits
162
+ // `contact.created` ONLY on first creation (a list flip is not a contact
163
+ // property delta, so no `contact.updated`). Fire-and-forget after a read-back.
164
+ if (created) {
165
+ void resolveContact({ db, id: contactId })
166
+ .then((row) => {
167
+ if (!row) return;
168
+ return emitOutbound({
169
+ db,
170
+ hatchet,
171
+ logger,
172
+ event: "contact.created",
173
+ payload: serializeContact(row),
174
+ });
175
+ })
176
+ .catch(logger.warn);
177
+ }
178
+
147
179
  await applyListMembership({
148
180
  db,
149
181
  userId,
@@ -175,12 +207,14 @@ export const listsRouter = new OpenAPIHono<AppEnv>()
175
207
  return c.json({ lists }, 200);
176
208
  })
177
209
  .openapi(subscribeRoute, async (c) => {
178
- const { db } = c.get("container");
210
+ const { db, hatchet, logger } = c.get("container");
179
211
  const { id } = c.req.valid("param");
180
212
  const { email, userId } = c.req.valid("json");
181
213
 
182
214
  const result = await applyListSubscription({
183
215
  db,
216
+ hatchet,
217
+ logger,
184
218
  id,
185
219
  email,
186
220
  userId,
@@ -198,12 +232,14 @@ export const listsRouter = new OpenAPIHono<AppEnv>()
198
232
  return c.json({ list: id, subscribed: true as const }, 200);
199
233
  })
200
234
  .openapi(unsubscribeRoute, async (c) => {
201
- const { db } = c.get("container");
235
+ const { db, hatchet, logger } = c.get("container");
202
236
  const { id } = c.req.valid("param");
203
237
  const { email, userId } = c.req.valid("json");
204
238
 
205
239
  const result = await applyListSubscription({
206
240
  db,
241
+ hatchet,
242
+ logger,
207
243
  id,
208
244
  email,
209
245
  userId,
@@ -2,8 +2,12 @@ import { emailSends, linkClicks, trackedLinks } from "@hogsend/db";
2
2
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
3
  import { and, eq, isNull, sql } from "drizzle-orm";
4
4
  import type { AppEnv } from "../../app.js";
5
+ import { emitOutbound } from "../../lib/outbound.js";
5
6
  import { EMAIL_LINK_CLICKED } from "../../lib/tracking-event-names.js";
6
- import { pushTrackingEvent } from "../../lib/tracking-events.js";
7
+ import {
8
+ pushTrackingEvent,
9
+ resolveEmailSendContext,
10
+ } from "../../lib/tracking-events.js";
7
11
 
8
12
  const clickRoute = createRoute({
9
13
  method: "get",
@@ -48,6 +52,10 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
48
52
  null;
49
53
  const userAgent = c.req.header("user-agent") ?? null;
50
54
 
55
+ // First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
56
+ // exactly once (the first click), which is the row-level state we keep. The
57
+ // outbound emit is NO LONGER gated on this — every destination must receive
58
+ // EVERY click (owner decision 1), so the emit below fires per-hit.
51
59
  await Promise.all([
52
60
  db.insert(linkClicks).values({
53
61
  trackedLinkId: link.id,
@@ -75,28 +83,56 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
75
83
  ),
76
84
  ]);
77
85
 
78
- const {
79
- hatchet,
80
- registry,
81
- logger,
82
- analytics: posthog,
83
- } = c.get("container");
86
+ const { hatchet, registry, logger } = c.get("container");
84
87
 
85
- pushTrackingEvent({
86
- db,
87
- hatchet,
88
- registry,
89
- logger,
90
- posthog,
91
- event: EMAIL_LINK_CLICKED,
92
- emailSendId: link.emailSendId,
93
- properties: { linkUrl: link.originalUrl, linkId: link.id },
94
- }).catch((err) => {
95
- logger.warn("Failed to push click tracking event", {
96
- linkId: link.id,
97
- error: err instanceof Error ? err.message : String(err),
98
- });
99
- });
88
+ // Resolve the send context ONCE (off the response path) and feed both the
89
+ // re-ingest and the PER-HIT outbound emit — avoiding a duplicate
90
+ // `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
91
+ // NULL dedupe key is distinct in Postgres, so every click creates a fresh
92
+ // delivery to every subscribed destination (per-hit, not first-touch).
93
+ const emailSendId = link.emailSendId;
94
+ void resolveEmailSendContext(db, emailSendId)
95
+ .then(async (ctx) => {
96
+ await pushTrackingEvent({
97
+ db,
98
+ hatchet,
99
+ registry,
100
+ logger,
101
+ event: EMAIL_LINK_CLICKED,
102
+ emailSendId,
103
+ properties: { linkUrl: link.originalUrl, linkId: link.id },
104
+ resolvedContext: ctx,
105
+ }).catch((err) => {
106
+ logger.warn("Failed to push click tracking event", {
107
+ linkId: link.id,
108
+ error: err instanceof Error ? err.message : String(err),
109
+ });
110
+ });
111
+
112
+ // Only emit when the send-context resolved. A missing emailSends row
113
+ // (orphaned tracked link / deleted send) has no userId or recipient to
114
+ // attribute, and a keyed destination (PostHog) would otherwise receive
115
+ // an empty distinct_id. A normal click always resolves a non-null userId.
116
+ if (ctx) {
117
+ await emitOutbound({
118
+ db,
119
+ hatchet,
120
+ logger,
121
+ event: "email.clicked",
122
+ payload: {
123
+ emailSendId,
124
+ resendId: ctx.resendId ?? null,
125
+ templateKey: ctx.templateKey ?? null,
126
+ userId: ctx.userId ?? null,
127
+ to: ctx.to ?? ctx.userEmail ?? "",
128
+ at: new Date().toISOString(),
129
+ linkUrl: link.originalUrl,
130
+ linkId: link.id,
131
+ },
132
+ });
133
+ }
134
+ })
135
+ .catch(logger.warn);
100
136
 
101
137
  return c.redirect(link.originalUrl, 302);
102
138
  },
@@ -2,8 +2,12 @@ import { emailSends } from "@hogsend/db";
2
2
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
3
  import { and, eq, isNull } from "drizzle-orm";
4
4
  import type { AppEnv } from "../../app.js";
5
+ import { emitOutbound } from "../../lib/outbound.js";
5
6
  import { EMAIL_OPENED } from "../../lib/tracking-event-names.js";
6
- import { pushTrackingEvent } from "../../lib/tracking-events.js";
7
+ import {
8
+ pushTrackingEvent,
9
+ resolveEmailSendContext,
10
+ } from "../../lib/tracking-events.js";
7
11
 
8
12
  const TRANSPARENT_GIF = Buffer.from(
9
13
  "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
@@ -29,8 +33,12 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
29
33
  openRoute,
30
34
  async (c) => {
31
35
  const { id } = c.req.valid("param");
32
- const { db } = c.get("container");
36
+ const { db, hatchet, registry, logger } = c.get("container");
33
37
 
38
+ // First-touch state UPDATE: the `WHERE openedAt IS NULL` sets `openedAt`
39
+ // exactly once (the first open), which is the row-level state we keep. The
40
+ // outbound emit is NO LONGER gated on this — every destination must receive
41
+ // EVERY open (owner decision 1), so the emit below fires per-hit.
34
42
  await db
35
43
  .update(emailSends)
36
44
  .set({
@@ -39,27 +47,50 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
39
47
  })
40
48
  .where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)));
41
49
 
42
- const {
43
- hatchet,
44
- registry,
45
- logger,
46
- analytics: posthog,
47
- } = c.get("container");
50
+ // Resolve the send context ONCE (off the response path) and feed both the
51
+ // re-ingest and the PER-HIT outbound emit — avoiding a duplicate
52
+ // `resolveEmailSendContext` read on the pixel hot path. NO `dedupeKey`: a
53
+ // NULL dedupe key is distinct in Postgres, so every open creates a fresh
54
+ // delivery to every subscribed destination (per-hit, not first-touch).
55
+ void resolveEmailSendContext(db, id)
56
+ .then(async (ctx) => {
57
+ await pushTrackingEvent({
58
+ db,
59
+ hatchet,
60
+ registry,
61
+ logger,
62
+ event: EMAIL_OPENED,
63
+ emailSendId: id,
64
+ resolvedContext: ctx,
65
+ }).catch((err) => {
66
+ logger.warn("Failed to push open tracking event", {
67
+ emailSendId: id,
68
+ error: err instanceof Error ? err.message : String(err),
69
+ });
70
+ });
48
71
 
49
- pushTrackingEvent({
50
- db,
51
- hatchet,
52
- registry,
53
- logger,
54
- posthog,
55
- event: EMAIL_OPENED,
56
- emailSendId: id,
57
- }).catch((err) => {
58
- logger.warn("Failed to push open tracking event", {
59
- emailSendId: id,
60
- error: err instanceof Error ? err.message : String(err),
61
- });
62
- });
72
+ // Only emit when the send-context resolved. A missing emailSends row
73
+ // (orphaned tracked pixel / deleted send) has no userId or recipient to
74
+ // attribute, and a keyed destination (PostHog) would otherwise receive
75
+ // an empty distinct_id. A normal open always resolves a non-null userId.
76
+ if (ctx) {
77
+ await emitOutbound({
78
+ db,
79
+ hatchet,
80
+ logger,
81
+ event: "email.opened",
82
+ payload: {
83
+ emailSendId: id,
84
+ resendId: ctx.resendId ?? null,
85
+ templateKey: ctx.templateKey ?? null,
86
+ userId: ctx.userId ?? null,
87
+ to: ctx.to ?? ctx.userEmail ?? "",
88
+ at: new Date().toISOString(),
89
+ },
90
+ });
91
+ }
92
+ })
93
+ .catch(logger.warn);
63
94
 
64
95
  return c.body(TRANSPARENT_GIF, 200, {
65
96
  "Content-Type": "image/gif",
@@ -2,6 +2,7 @@ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
3
  import { ingestEvent } from "../../lib/ingestion.js";
4
4
  import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
5
+ import { verifySignature } from "../../webhook-sources/verify.js";
5
6
 
6
7
  export function registerWebhookSourceRoutes(
7
8
  app: OpenAPIHono<AppEnv>,
@@ -52,22 +53,75 @@ export function registerWebhookSourceRoutes(
52
53
 
53
54
  const { db, logger, env, registry, hatchet } = c.get("container");
54
55
 
55
- // Auth is enforced only when the source's secret is configured. An
56
- // unconfigured source is treated as open (parity with the pre-engine route).
56
+ // Read the body ONCE as the EXACT received bytes signature schemes verify
57
+ // over these bytes, so we must not re-stringify. JSON.parse only AFTER auth.
58
+ const rawBody = await c.req.text();
59
+ const headers: Record<string, string> = {};
60
+ for (const [key, value] of c.req.raw.headers.entries()) {
61
+ headers[key.toLowerCase()] = value;
62
+ }
63
+
57
64
  const secret = env[source.auth.envKey as keyof typeof env] as
58
65
  | string
59
66
  | undefined;
60
- if (secret) {
61
- const provided =
62
- c.req.header(source.auth.header) ??
63
- c.req.header("authorization")?.replace("Bearer ", "");
64
67
 
65
- if (provided !== secret) {
66
- return c.json({ error: "Invalid webhook secret" }, 401);
68
+ if (source.auth.type === "signature") {
69
+ // Signature sources FAIL CLOSED: an unset secret is a 401, never an open
70
+ // pass-through (deliberate divergence from the "match" variant).
71
+ if (!secret) {
72
+ logger.warn("Webhook signature secret not configured", {
73
+ source: sourceId,
74
+ });
75
+ return c.json({ error: "Webhook signature not configured" }, 401);
76
+ }
77
+
78
+ const auth = source.auth;
79
+ let verified = false;
80
+
81
+ if (auth.verify) {
82
+ verified = await auth.verify({ rawBody, headers, secret });
83
+ } else {
84
+ verified = verifySignature(
85
+ auth.scheme,
86
+ { rawBody, headers, secret },
87
+ auth.header,
88
+ );
89
+ }
90
+
91
+ // Optional plain shared-secret fallback (e.g. Supabase's
92
+ // `x-supabase-webhook-secret`) when the signature headers are absent.
93
+ if (!verified && auth.fallbackMatchHeader) {
94
+ const provided = headers[auth.fallbackMatchHeader.toLowerCase()];
95
+ verified = provided === secret;
96
+ }
97
+
98
+ if (!verified) {
99
+ return c.json({ error: "Invalid webhook signature" }, 401);
100
+ }
101
+ } else {
102
+ // "match": shared-secret equality. An unconfigured source stays OPEN
103
+ // (parity with the pre-engine route).
104
+ if (secret) {
105
+ const provided =
106
+ headers[source.auth.header.toLowerCase()] ??
107
+ headers.authorization?.replace("Bearer ", "");
108
+
109
+ if (provided !== secret) {
110
+ return c.json({ error: "Invalid webhook secret" }, 401);
111
+ }
67
112
  }
68
113
  }
69
114
 
70
- let payload: unknown = await c.req.json();
115
+ let payload: unknown;
116
+ try {
117
+ payload = JSON.parse(rawBody);
118
+ } catch {
119
+ return c.json(
120
+ { error: "Invalid payload", details: "Malformed JSON" },
121
+ 400,
122
+ );
123
+ }
124
+
71
125
  if (source.schema) {
72
126
  const parsed = source.schema.safeParse(payload);
73
127
  if (!parsed.success) {
@@ -79,7 +133,12 @@ export function registerWebhookSourceRoutes(
79
133
  payload = parsed.data;
80
134
  }
81
135
 
82
- const event = await source.transform(payload, { db, logger });
136
+ const event = await source.transform(payload, {
137
+ db,
138
+ logger,
139
+ rawBody,
140
+ headers,
141
+ });
83
142
  if (!event) {
84
143
  logger.info("Webhook event skipped", { source: sourceId });
85
144
  return c.json({ ok: true, skipped: true });
@@ -2,16 +2,62 @@ import type { Database } from "@hogsend/db";
2
2
  import type { z } from "zod";
3
3
  import type { IngestEvent } from "../lib/ingestion.js";
4
4
  import type { Logger } from "../lib/logger.js";
5
+ import type { SignatureScheme, VerifySignatureArgs } from "./verify.js";
5
6
 
6
- export interface WebhookSourceAuth {
7
- header: string;
8
- envKey: string;
9
- type: "match";
10
- }
7
+ /**
8
+ * How a webhook source authenticates inbound requests.
9
+ *
10
+ * A discriminated union on `type`:
11
+ *
12
+ * - `"match"` — plain shared-secret equality. The route compares a configured
13
+ * secret against the request header (or `Authorization: Bearer`). When the
14
+ * secret is UNSET the source stays OPEN (parity with the pre-engine route);
15
+ * this variant is unchanged so PostHog + all consumer sources keep compiling.
16
+ *
17
+ * - `"signature"` — provider HMAC signature verification (Svix / Stripe /
18
+ * generic hex HMAC). The route resolves the secret from `env[envKey]`, reads
19
+ * the EXACT raw request body, and calls `verifySignature` (or the optional
20
+ * per-source `verify` override) over those bytes. Signature sources FAIL
21
+ * CLOSED (401) when their secret is unset — they are security-sensitive.
22
+ */
23
+ export type WebhookSourceAuth =
24
+ | {
25
+ type: "match";
26
+ header: string;
27
+ envKey: string;
28
+ }
29
+ | {
30
+ type: "signature";
31
+ scheme: SignatureScheme;
32
+ envKey: string;
33
+ header: string;
34
+ /**
35
+ * For schemes (notably `"svix"`) whose providers may also send a plain
36
+ * shared-secret header: when the scheme's signature headers are absent but
37
+ * this header matches the secret verbatim, accept the request. Lets
38
+ * Supabase's `x-supabase-webhook-secret` plain-secret mode coexist with
39
+ * its Svix mode.
40
+ */
41
+ fallbackMatchHeader?: string;
42
+ /**
43
+ * Optional per-source override of the built-in scheme verification. When
44
+ * provided, the route calls this INSTEAD of `verifySignature(scheme, …)`.
45
+ * Receives the EXACT received bytes; must return (or resolve to) a boolean.
46
+ */
47
+ verify?(args: VerifySignatureArgs): boolean | Promise<boolean>;
48
+ };
11
49
 
12
50
  export interface WebhookSourceCtx {
13
51
  db: Database;
14
52
  logger: Logger;
53
+ /**
54
+ * The EXACT raw request body bytes (text), populated by the route. Required by
55
+ * signature schemes (the signature covers these bytes) and available to any
56
+ * `transform()` that needs provider-specific raw access.
57
+ */
58
+ rawBody?: string;
59
+ /** The inbound request headers (lowercased keys), populated by the route. */
60
+ headers?: Record<string, string>;
15
61
  }
16
62
 
17
63
  export interface WebhookSourceMeta {
@@ -32,3 +78,9 @@ export function defineWebhookSource<T>(
32
78
  ): DefinedWebhookSource<T> {
33
79
  return def;
34
80
  }
81
+
82
+ export {
83
+ type SignatureScheme,
84
+ type VerifySignatureArgs,
85
+ verifySignature,
86
+ } from "./verify.js";
@@ -0,0 +1,185 @@
1
+ import { z } from "zod";
2
+ import type { IngestEvent } from "../../lib/ingestion.js";
3
+ import { defineWebhookSource } from "../define-webhook-source.js";
4
+
5
+ /**
6
+ * Clerk webhook preset.
7
+ *
8
+ * Auth: Svix-signed (`svix-id`/`svix-timestamp`/`svix-signature`). Clerk's
9
+ * webhook signing secret is a `whsec_…` value — set `CLERK_WEBHOOK_SECRET` to
10
+ * auto-enable this source at `POST /v1/webhooks/clerk`. Signature sources FAIL
11
+ * CLOSED when the secret is unset.
12
+ *
13
+ * Event mapping (decision #16, normalized to the outbound vocabulary):
14
+ * - `user.created` → `contact.created`
15
+ * - `user.updated` → `contact.updated`
16
+ * - `user.deleted` → `contact.deleted` (EVENT only — decision #15)
17
+ * - `waitlistEntry.created` → `waitlist.joined`
18
+ *
19
+ * D2 split (decision, mirrors `webhook-sources/posthog.ts`): identity/profile
20
+ * fields → `contactProperties` ONLY; behavioral/source fields → `eventProperties`
21
+ * ONLY. The two bags are NEVER merged.
22
+ */
23
+
24
+ const clerkEmailAddressSchema = z
25
+ .object({
26
+ id: z.string().optional(),
27
+ email_address: z.string().optional(),
28
+ })
29
+ .catchall(z.unknown());
30
+
31
+ const clerkUserDataSchema = z
32
+ .object({
33
+ id: z.string().optional(),
34
+ primary_email_address_id: z.string().nullish(),
35
+ email_addresses: z.array(clerkEmailAddressSchema).nullish(),
36
+ first_name: z.string().nullish(),
37
+ last_name: z.string().nullish(),
38
+ image_url: z.string().nullish(),
39
+ profile_image_url: z.string().nullish(),
40
+ public_metadata: z.record(z.string(), z.unknown()).nullish(),
41
+ })
42
+ .catchall(z.unknown());
43
+
44
+ const clerkWaitlistDataSchema = z
45
+ .object({
46
+ id: z.string().optional(),
47
+ email_address: z.string().nullish(),
48
+ })
49
+ .catchall(z.unknown());
50
+
51
+ const clerkWebhookSchema = z
52
+ .object({
53
+ type: z.string(),
54
+ object: z.string().optional(),
55
+ data: z.object({}).catchall(z.unknown()).nullish(),
56
+ })
57
+ .catchall(z.unknown());
58
+
59
+ type ClerkPayload = z.infer<typeof clerkWebhookSchema>;
60
+
61
+ /** Resolve the primary email address from Clerk's `email_addresses` array. */
62
+ function resolveClerkEmail(
63
+ data: z.infer<typeof clerkUserDataSchema>,
64
+ ): string | undefined {
65
+ const addresses = data.email_addresses ?? [];
66
+ if (addresses.length === 0) {
67
+ return undefined;
68
+ }
69
+ const primaryId = data.primary_email_address_id;
70
+ if (primaryId) {
71
+ const primary = addresses.find((a) => a.id === primaryId);
72
+ if (primary?.email_address) {
73
+ return primary.email_address;
74
+ }
75
+ }
76
+ return addresses[0]?.email_address ?? undefined;
77
+ }
78
+
79
+ export const clerkSource = defineWebhookSource({
80
+ meta: {
81
+ id: "clerk",
82
+ name: "Clerk",
83
+ description:
84
+ "Receives Clerk user lifecycle + waitlist webhooks (Svix-signed).",
85
+ },
86
+ auth: {
87
+ type: "signature",
88
+ scheme: "svix",
89
+ envKey: "CLERK_WEBHOOK_SECRET",
90
+ header: "svix-signature",
91
+ },
92
+ schema: clerkWebhookSchema,
93
+ async transform(payload: ClerkPayload): Promise<IngestEvent | null> {
94
+ const type = payload.type;
95
+
96
+ if (type === "waitlistEntry.created") {
97
+ const data = clerkWaitlistDataSchema.parse(payload.data ?? {});
98
+ const userEmail =
99
+ typeof data.email_address === "string" ? data.email_address : "";
100
+ const userId = data.id ?? userEmail;
101
+ if (!userId) {
102
+ return null;
103
+ }
104
+ return {
105
+ event: "waitlist.joined",
106
+ userId,
107
+ userEmail,
108
+ eventProperties: {
109
+ source: "clerk",
110
+ _clerkEvent: type,
111
+ },
112
+ contactProperties: {},
113
+ };
114
+ }
115
+
116
+ let event: string;
117
+ switch (type) {
118
+ case "user.created":
119
+ event = "contact.created";
120
+ break;
121
+ case "user.updated":
122
+ event = "contact.updated";
123
+ break;
124
+ case "user.deleted":
125
+ event = "contact.deleted";
126
+ break;
127
+ default:
128
+ return null;
129
+ }
130
+
131
+ const data = clerkUserDataSchema.parse(payload.data ?? {});
132
+ const userId = data.id;
133
+ if (!userId) {
134
+ return null;
135
+ }
136
+ const userEmail = resolveClerkEmail(data) ?? "";
137
+
138
+ // Deletes carry no profile to merge — emit the event only (decision #15).
139
+ if (event === "contact.deleted") {
140
+ return {
141
+ event,
142
+ userId,
143
+ userEmail,
144
+ eventProperties: {
145
+ source: "clerk",
146
+ clerkUserId: userId,
147
+ _clerkEvent: type,
148
+ },
149
+ contactProperties: {},
150
+ };
151
+ }
152
+
153
+ const avatarUrl =
154
+ (typeof data.image_url === "string" ? data.image_url : undefined) ??
155
+ (typeof data.profile_image_url === "string"
156
+ ? data.profile_image_url
157
+ : undefined);
158
+
159
+ const contactProperties: Record<string, unknown> = {
160
+ ...(data.public_metadata ?? {}),
161
+ };
162
+ if (typeof data.first_name === "string") {
163
+ contactProperties.firstName = data.first_name;
164
+ }
165
+ if (typeof data.last_name === "string") {
166
+ contactProperties.lastName = data.last_name;
167
+ }
168
+ if (avatarUrl) {
169
+ contactProperties.avatarUrl = avatarUrl;
170
+ }
171
+ contactProperties.clerkUserId = userId;
172
+
173
+ return {
174
+ event,
175
+ userId,
176
+ userEmail,
177
+ eventProperties: {
178
+ source: "clerk",
179
+ clerkUserId: userId,
180
+ _clerkEvent: type,
181
+ },
182
+ contactProperties,
183
+ };
184
+ },
185
+ });