@hogsend/cli 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "tsup": "^8.5.1",
33
33
  "tsx": "^4.22.4",
34
34
  "vitest": "^4.1.7",
35
- "@hogsend/studio": "^0.7.0",
35
+ "@hogsend/studio": "^0.9.0",
36
36
  "@repo/typescript-config": "0.0.0"
37
37
  },
38
38
  "engines": {
@@ -0,0 +1,217 @@
1
+ ---
2
+ name: hogsend-authoring-destinations
3
+ description: Use when adding or editing a code-defined OUTBOUND destination in src/destinations/ — defineDestination({ meta:{id}, events, transform(envelope, ctx) -> { url, method?, headers, body, isSuccess? } | null }) from @hogsend/engine. A destination is a delivery-time transform keyed by webhook_endpoints.kind that fans the outbound event catalog (contact.*, email.*, journey.completed, bucket.*) out to a product/data tool (PostHog, Segment, Slack, a CRM, a warehouse), reusing the engine's durable retry/backoff/DLQ delivery for free. Covers the shipped presets (webhook/posthog/segment/slack), ENABLED_DESTINATION_PRESETS, per-endpoint config credentials, the null-skip and throw-is-config-error contract, and the register ritual (src/destinations/index.ts + thread destinations into createHogsendClient in BOTH src/index.ts and src/worker.ts). NOT for ad-platform CAPI (deferred to PostHog CDP).
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Authoring Hogsend destinations
11
+
12
+ A **destination** is a code-defined target for Hogsend's OUTBOUND event stream —
13
+ PostHog, Segment, Slack, a CRM, a data warehouse. You declare it with
14
+ `defineDestination()` in `src/destinations/`, the symmetric twin of
15
+ `defineWebhookSource()` on the inbound side. It is the AUTHORING layer for event
16
+ **fan-out**.
17
+
18
+ The headline fact: a destination is a **delivery-time transform**, not a new
19
+ delivery pipeline. The engine already has a durable outbound spine — every
20
+ catalog event (`contact.*`, `email.*`, `journey.completed`, `bucket.*`) is
21
+ written as a `webhook_deliveries` row and POSTed with retry / backoff / DLQ /
22
+ reaper. A destination just **rewrites the HTTP request** for an endpoint whose
23
+ `kind` matches your destination's `id`. You inherit ALL the durable delivery
24
+ machinery for free — you only write the per-vendor projection.
25
+
26
+ You are editing a **scaffolded consumer app** (content only). You import
27
+ `defineDestination` from `@hogsend/engine`; you never touch engine internals (the
28
+ registry, the delivery task, the retry machinery are all engine-owned). Relative
29
+ imports use the ESM `.js` extension.
30
+
31
+ > ⚠️ Destinations are for event **fan-out**. They are NOT the home for
32
+ > ad-platform conversion forwarding (CAPI) — that stays deferred to PostHog CDP;
33
+ > Hogsend just fires the events.
34
+
35
+ ## Do you even need to write one?
36
+
37
+ Probably not. The engine ships four presets, each `defineDestination()` already:
38
+
39
+ | preset id | target | credentials (per-endpoint `config`) |
40
+ |-----------|--------|-------------------------------------|
41
+ | `webhook` | the DEFAULT signed Standard-Webhooks POST to a subscriber URL | `secret` column (a `whsec_…`) |
42
+ | `posthog` | PostHog capture endpoint | `{ apiKey, host?, eventNames? }` |
43
+ | `segment` | Segment HTTP Tracking API (`/v1/track`, Basic auth) | `{ writeKey, host?, eventNames? }` |
44
+ | `slack` | Slack incoming webhook (formatted text block) | `{ url?, username?, iconEmoji? }` — `url` falls back to the endpoint `url` column |
45
+
46
+ `webhook` and `posthog` are **always** registered. `segment`/`slack` register when
47
+ `ENABLED_DESTINATION_PRESETS` allows them (see below). To USE a preset you create a
48
+ `webhook_endpoints` row with that `kind` and its `config` (via the admin API /
49
+ `hs.webhooks` SDK) — **no code**. Write a `defineDestination()` only for a NEW
50
+ target shape, or to OVERRIDE a preset of the same id.
51
+
52
+ ## The shape
53
+
54
+ ```ts
55
+ import { defineDestination } from "@hogsend/engine";
56
+
57
+ export const crm = defineDestination({
58
+ meta: {
59
+ id: "crm", // == webhook_endpoints.kind it delivers
60
+ name: "Acme CRM",
61
+ description: "Forward lifecycle events to Acme.",
62
+ },
63
+ events: ["contact.created", "email.bounced"], // catalog events it accepts
64
+ transform(envelope, ctx) {
65
+ // envelope = the FROZEN { id, type, timestamp, data } emitOutbound wrote.
66
+ // ctx.endpoint = the LIVE webhook_endpoints row (url, config, secret).
67
+ const cfg = (ctx.endpoint.config ?? {}) as { token?: string };
68
+ if (!cfg.token) {
69
+ // A THROW = non-retryable CONFIG error → straight to the DLQ.
70
+ throw new Error("crm destination missing config.token");
71
+ }
72
+ return {
73
+ url: "https://api.acme.example/ingest",
74
+ method: "POST", // optional, defaults to POST
75
+ headers: {
76
+ "Content-Type": "application/json",
77
+ Authorization: `Bearer ${cfg.token}`,
78
+ },
79
+ body: JSON.stringify({ type: envelope.type, data: envelope.data }),
80
+ // isSuccess?: (status, bodySnippet) => boolean — optional; default is 2xx.
81
+ };
82
+ },
83
+ });
84
+ ```
85
+
86
+ `defineDestination({ meta, events, transform })`:
87
+
88
+ | field | required | notes |
89
+ |-------|----------|-------|
90
+ | `meta.id` | yes | The `webhook_endpoints.kind` this destination delivers. Pick a stable lowercase id; an endpoint with `kind === meta.id` routes here. Reusing a preset id (`posthog`/`segment`/`slack`) **overrides** that preset (you win on the merge). |
91
+ | `meta.name` | yes | Human label. |
92
+ | `meta.description` | no | One-liner. |
93
+ | `events` | yes | The outbound catalog events this destination accepts (`OutboundEventName[]`). Per-endpoint subscription is STILL scoped by `webhook_endpoints.event_types`, so an endpoint only ever receives what it subscribed to — `events` documents intent and is the authoring-time contract. |
94
+ | `transform` | yes | `(envelope, ctx) => { url, method?, headers, body, isSuccess? } \| null`. **Synchronous.** |
95
+
96
+ `defineDestination` is an identity/validating function (like `defineWebhookSource`)
97
+ — it returns its argument so a typo in the shape is a compile error.
98
+
99
+ ## The transform contract — three outcomes
100
+
101
+ The `transform` runs once per delivery ATTEMPT (including retries), so it must be a
102
+ **pure projection** of the envelope + endpoint — never mutate external state in it.
103
+
104
+ 1. **Return an `AdapterRequest`** (`{ url, headers, body, method?, isSuccess? }`)
105
+ → the delivery task POSTs exactly those bytes. `body` is the EXACT bytes sent
106
+ (for the `webhook` preset they are the SIGNED bytes — never re-stringify them).
107
+ Success is the default 2xx rule unless you supply `isSuccess`.
108
+ 2. **Return `null`** → SKIP delivery for that envelope. The row is marked
109
+ `delivered` as a successful **no-op** (no POST, no retry, no DLQ). Use this to
110
+ filter: e.g. only forward `email.bounced` for a certain template, drop the
111
+ rest.
112
+ 3. **Throw** → a non-retryable CONFIG error (missing credential, bad shape). The
113
+ row fast-fails straight to the dead-letter queue — it does NOT burn the retry
114
+ budget. A bad config should fail loudly, not silently retry 8 times.
115
+
116
+ A network error / timeout / retryable HTTP status (`5xx`, `408`, `429`) is the
117
+ delivery task's job — it retries with backoff off `nextRetryAt`. You never handle
118
+ retries in a transform.
119
+
120
+ ## Where credentials live
121
+
122
+ Destination credentials are **per-endpoint**, in `webhook_endpoints.config`
123
+ (a JSONB bag) — NOT env vars, NOT a fake `whsec_`. The transform reads
124
+ `ctx.endpoint.config`. This is the deliberate split from inbound presets (whose
125
+ secrets are env-gated): a destination can have many endpoints, each with its own
126
+ key, region, channel. `ENABLED_DESTINATION_PRESETS` only decides which preset
127
+ TRANSFORMS are resolvable, never supplies a credential.
128
+
129
+ ## `ENABLED_DESTINATION_PRESETS` — which presets register
130
+
131
+ A process-wide env knob (same `*`/csv/`none`/absent grammar as
132
+ `ENABLED_WEBHOOK_PRESETS`), resolving which PRESET transforms are in the registry:
133
+
134
+ - absent → `webhook` + `posthog` only (the always-on set).
135
+ - `"none"` → STILL `webhook` + `posthog` (you can never disable the
136
+ no-regression signed-POST path or the auto-seeded PostHog destination).
137
+ - a csv (e.g. `"segment,slack"`) → those, **unioned** with the always-on set.
138
+ - `"*"` → every shipped preset.
139
+
140
+ Your own `defineDestination()` destinations are NOT gated by this env — they are
141
+ always registered (they came from your `destinations` array). The env governs
142
+ PRESETS only.
143
+
144
+ ## Registering a destination (the wiring ritual)
145
+
146
+ A defined destination does nothing until it is (1) exported from the barrel and
147
+ (2) threaded into `createHogsendClient` in BOTH entry points. Like buckets — and
148
+ UNLIKE lists — the wiring touches both `src/index.ts` and `src/worker.ts`, because
149
+ the durable delivery task runs in the WORKER process and resolves transforms from
150
+ the process registry `createHogsendClient` installs. **`destinations` is NOT passed
151
+ to `createWorker`** — the worker's `createHogsendClient` call installs the registry.
152
+
153
+ ### 1. Export from `src/destinations/index.ts`
154
+
155
+ ```ts
156
+ // src/destinations/index.ts
157
+ import type { DefinedDestination } from "@hogsend/engine";
158
+ import { crm } from "./crm.js"; // your defineDestination(), or inline it here
159
+
160
+ // All defined destinations for this app. Passed to
161
+ // createHogsendClient({ destinations }) in BOTH src/index.ts and src/worker.ts.
162
+ export const destinations: DefinedDestination[] = [crm];
163
+ ```
164
+
165
+ ### 2. Thread into `createHogsendClient` in `src/index.ts`
166
+
167
+ ```ts
168
+ import { createApp, createHogsendClient } from "@hogsend/engine";
169
+ import { destinations } from "./destinations/index.js";
170
+ // ...templates, journeys, webhookSources...
171
+
172
+ const client = createHogsendClient({
173
+ journeys,
174
+ destinations, // ← merged with the env presets; consumer wins on id collision
175
+ email: { templates },
176
+ });
177
+ const app = createApp(client, { webhookSources });
178
+ ```
179
+
180
+ ### 3. Thread into `createHogsendClient` in `src/worker.ts`
181
+
182
+ ```ts
183
+ import { createHogsendClient, createWorker } from "@hogsend/engine";
184
+ import { destinations } from "./destinations/index.js";
185
+
186
+ const client = createHogsendClient({
187
+ journeys,
188
+ destinations, // ← same array; the WORKER's delivery task needs the registry
189
+ email: { templates },
190
+ });
191
+ const worker = createWorker({ container: client, journeys /* …, NO destinations */ });
192
+ ```
193
+
194
+ Wire `destinations` into `createHogsendClient` in BOTH files. Passing it to
195
+ `createWorker` is not an accepted option — the worker resolves the registry through
196
+ its OWN `createHogsendClient` call.
197
+
198
+ ## Creating the endpoint that uses your destination
199
+
200
+ The destination is the TRANSFORM; an endpoint row is what makes it fire. Create
201
+ one with the admin API / `hs.webhooks.create` with `kind` = your destination id,
202
+ its `config` credentials, and the `eventTypes` it subscribes to. See the
203
+ **hogsend-client-sdk** / **hogsend-cli** skills for managing outbound endpoints.
204
+
205
+ ## Golden rules
206
+
207
+ 1. A destination is a delivery-time transform keyed by `webhook_endpoints.kind`,
208
+ reusing the engine's durable delivery. You write the projection, not a pipeline.
209
+ 2. `transform` is SYNCHRONOUS and a PURE projection (runs per attempt). Return a
210
+ request, return `null` to skip (delivered no-op), or throw on bad config (→ DLQ).
211
+ 3. Credentials live per-endpoint in `webhook_endpoints.config`, never in env.
212
+ `ENABLED_DESTINATION_PRESETS` only governs which PRESETS register.
213
+ 4. `webhook` + `posthog` presets are always on; you cannot disable them.
214
+ 5. Wire `destinations` into `createHogsendClient` in BOTH `src/index.ts` AND
215
+ `src/worker.ts`. Do NOT pass it to `createWorker`.
216
+ 6. Reusing a preset id overrides that preset (consumer wins on the merge).
217
+ 7. Destinations are event fan-out — NOT ad-platform CAPI (deferred to PostHog CDP).
@@ -41,7 +41,10 @@ per unique URL, then single-pass replaces each href with
41
41
  `WHERE clicked_at IS NULL`), then **302-redirects to the original URL**.
42
42
  - After responding it fire-and-forgets an `email.link_clicked` event (props:
43
43
  `emailSendId`, `templateKey`, `linkUrl`, `linkId`) into PostHog + the ingest
44
- pipeline, so journeys can branch on it and `exitOn` can fire.
44
+ pipeline, so journeys can branch on it and `exitOn` can fire. It ALSO emits the
45
+ outbound-catalog `email.clicked` event, which fans out durably to every
46
+ subscribed DESTINATION — **per-hit, not first-touch** (every click is delivered,
47
+ unlike the first-only `clicked_at` column).
45
48
 
46
49
  Authoring implication: use real `<a href>` / react-email `Button`/`Link` with
47
50
  absolute `https://` URLs and they're tracked automatically. Non-HTTP links
@@ -56,7 +59,9 @@ just before `</body>` (so always compose inside `Layout`, which emits a proper
56
59
  - `GET /v1/t/o/:id` sets `email_sends.opened_at` (first open only), returns a
57
60
  42-byte transparent GIF with `Cache-Control: no-store`.
58
61
  - Then fire-and-forgets an `email.opened` event (props: `emailSendId`,
59
- `templateKey`).
62
+ `templateKey`) into PostHog + the ingest pipeline, and emits the outbound-catalog
63
+ `email.opened` event that fans out durably to every subscribed DESTINATION —
64
+ **per-hit, not first-touch**.
60
65
 
61
66
  The engine's own constants for these are `EMAIL_OPENED = "email.opened"` and
62
67
  `EMAIL_LINK_CLICKED = "email.link_clicked"`. In journey code, reference them via
@@ -59,10 +59,17 @@ export const welcome = defineJourney({
59
59
  ## Key concepts
60
60
 
61
61
  - **`ctx` is orchestration primitives ONLY** — `sleep`, `sleepUntil`, `when`,
62
- `waitForEvent`, `checkpoint`, `trigger`, `identify`, `guard.isSubscribed`,
63
- `history.hasEvent/journey/email`, `posthog.capture`. Features are standalone
64
- imports: `sendEmail()` and `getPostHog()` come from `@hogsend/engine`, NOT off
65
- `ctx`.
62
+ `waitForEvent`, `checkpoint`, `trigger`, `guard.isSubscribed`,
63
+ `history.hasEvent/journey/email`. Features are standalone imports: `sendEmail()`
64
+ comes from `@hogsend/engine`, NOT off `ctx`.
65
+ - **Fan-out is DESTINATIONS, not `ctx`.** There is no `ctx.identify` /
66
+ `ctx.posthog.capture` — those single-vendor PostHog shims were removed. To get
67
+ user/event data into product + data tools (PostHog, Segment, Slack, a CRM, a
68
+ warehouse), set up an outbound DESTINATION: the email/contact/journey/bucket
69
+ lifecycle is delivered there durably (retry/backoff/DLQ), keyed by
70
+ `webhook_endpoints.kind`. EVERY destination receives EVERY open and click
71
+ (per-hit, not first-touch), and `email.delivered` is the canonical "email was
72
+ received" signal. See the **hogsend-authoring-destinations** skill.
66
73
  - **Duration helpers** `days()` / `hours()` / `minutes()` from `@hogsend/core`
67
74
  (also re-exported by `@hogsend/engine`) — never magic strings.
68
75
  - **`user`** carries `id`, `email`, `properties`, `stateId`, `journeyId`,
@@ -72,15 +72,27 @@ await ctx.trigger({
72
72
  });
73
73
  ```
74
74
 
75
- ## PostHog (no-op without POSTHOG_API_KEY)
75
+ ## Fanning data out — use DESTINATIONS, not `ctx`
76
76
 
77
- ```ts
78
- // Set person properties on PostHog for the current user.
79
- ctx.identify({ plan: "pro", onboarded: true }); // synchronous, void
77
+ There is **no `ctx.identify` and no `ctx.posthog.capture`** — those single-vendor
78
+ PostHog shims were removed. `ctx` does not fan data out to product/data tools.
80
79
 
81
- // Fire a custom PostHog event for the current user.
82
- ctx.posthog.capture({ event: "journey_step_reached", properties: { step: 2 } });
83
- ```
80
+ To mirror user/event data into PostHog, Segment, Slack, a CRM or a warehouse, set
81
+ up an outbound **DESTINATION**: the email/contact/journey/bucket lifecycle is
82
+ delivered there DURABLY (retry / backoff / DLQ), keyed by `webhook_endpoints.kind`.
83
+ You don't fire it from `run` — it receives the lifecycle automatically:
84
+
85
+ - `email.sent` / `email.delivered` / `email.opened` / `email.clicked` /
86
+ `email.bounced` / `email.complained`, `contact.*`, `journey.completed`,
87
+ `bucket.entered` / `bucket.left`.
88
+ - `email.delivered` is the canonical **"email was received"** signal.
89
+ - EVERY destination receives EVERY open and click — **per-hit, not first-touch** —
90
+ so downstream tools see the full engagement stream.
91
+
92
+ See the **hogsend-authoring-destinations** skill. (PostHog is now JUST a
93
+ destination, `kind="posthog"`.) If you need a fire-and-forget raw write inside a
94
+ journey, `getPostHog()` is still importable from `@hogsend/engine` — but for
95
+ fan-out, reach for a destination, not an in-journey vendor call.
84
96
 
85
97
  ## Guards
86
98
 
@@ -122,7 +134,9 @@ orchestration:
122
134
  - **`sendEmail()`** — `import { sendEmail } from "@hogsend/engine"`. See
123
135
  `references/sending-email-from-a-journey.md`.
124
136
  - **`getPostHog()`** — `import { getPostHog } from "@hogsend/engine"` for the raw
125
- PostHog service (`ctx.identify` / `ctx.posthog.capture` cover the common cases).
137
+ PostHog service (a fire-and-forget escape hatch). For fanning lifecycle data out
138
+ to product/data tools, prefer an outbound DESTINATION (see above) — it delivers
139
+ durably and is vendor-neutral.
126
140
  - **SMS / push / Slack** — plain functions you import, never on `ctx`.
127
141
  - There is **no `ctx.db`, no `ctx.sendEmail`, no `ctx.hatchet`** surfaced to
128
142
  consumer journeys. If you reach for one of those, you are modelling it wrong —
@@ -74,6 +74,7 @@ Most commands READ (admin API). A handful WRITE through the data plane — marke
74
74
  | `hogsend events <userId>` | Raw event stream for one user (READ — `<userId>` stays the read path). |
75
75
  | `hogsend events send <name>` | **(write)** Push an event → `POST /v1/events`. `--email`/`--user-id` (≥1 required), `--prop`/`--props` (event props), `--contact-prop`/`--contact-props` (contact props), `--list`/`--unlist`, `--idempotency-key`, `--timestamp`. |
76
76
  | `hogsend emails send <template>` | **(write)** Send a transactional email → `POST /v1/emails`. `--to`/`--user-id` (≥1 required), `--prop`/`--props`, `--subject`, `--from`, `--reply-to`, `--category`, `--idempotency-key`, `--skip-preference-check` (needs full-admin). |
77
+ | `hogsend webhooks list/get/create/update/delete/rotate-secret/test` | Manage **outbound** signed webhook endpoints (the event stream Hogsend emits to your URLs) → `/v1/admin/webhooks`. Needs the **admin key**, not the data key. `create --url <url>` + repeatable `--event <type>` or `--all-events`; the signing secret prints ONCE on `create` + `rotate-secret`. |
77
78
  | `hogsend skills list/add` | Manage these bundled agent skills. |
78
79
  | `hogsend upgrade` | Bump `@hogsend/*` deps to latest + refresh vendored skills. |
79
80
  | `hogsend setup` | Interactive LOCAL onboarding (docker, secret, migrate). |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hogsend-client-sdk
3
- description: Use when calling Hogsend from your own product/app code (a signup handler, a billing webhook, a cron) via the @hogsend/client SDK + public data-plane API — new Hogsend({ baseUrl, apiKey }), then contacts.upsert/find/delete, events.send (alias .track), emails.send, lists.list/subscribe/unsubscribe. Teaches the contactProperties-vs-eventProperties split on POST /v1/events, the ingest-scoped HOGSEND_API_KEY, the 202 + listsError warning, and HogsendAPIError/RateLimitError. NOT for use inside a journey (there, use sendEmail()/ctx.trigger()). The scaffold ships a preconfigured `hs` at src/lib/hogsend.ts.
3
+ description: Use when calling Hogsend from your own product/app code (a signup handler, a billing webhook, a cron) via the @hogsend/client SDK + public data-plane API — new Hogsend({ baseUrl, apiKey }), then contacts.upsert/find/delete, events.send (alias .track), emails.send, lists.list/subscribe/unsubscribe, webhooks.create/list/get/update/delete/rotateSecret/sendTest (ADMIN plane — needs a full-admin key), and verifyHogsendWebhook for the subscriber side. Teaches the contactProperties-vs-eventProperties split on POST /v1/events, the ingest-scoped HOGSEND_API_KEY, the 202 + listsError warning, and HogsendAPIError/RateLimitError. NOT for use inside a journey (there, use sendEmail()/ctx.trigger()). The scaffold ships a preconfigured `hs` at src/lib/hogsend.ts.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: withSeismic
@@ -103,6 +103,63 @@ await hs.emails.send({ // POST /v1/emails
103
103
  await hs.lists.list(); // GET /v1/lists -> ListSummary[]
104
104
  await hs.lists.subscribe({ list: "newsletter", email: "ada@example.com" });
105
105
  await hs.lists.unsubscribe({ list: "newsletter", userId: "u_1" });
106
+
107
+ // Webhooks (ADMIN plane — needs a full-admin apiKey, NOT the ingest key) ---
108
+ await hs.webhooks.create({ // POST /v1/admin/webhooks
109
+ url: "https://your.app/hooks",
110
+ eventTypes: ["contact.created", "email.sent"],
111
+ }); // -> endpoint incl. full `secret` (shown ONCE)
112
+ await hs.webhooks.list(); // -> WebhookEndpoint[]
113
+ await hs.webhooks.rotateSecret("we_123"); // -> { id, secret, secretPrefix } (secret ONCE)
114
+ ```
115
+
116
+ ## `hs.webhooks` — outbound endpoints (DIFFERENT plane + key)
117
+
118
+ `hs.webhooks.*` manages the **outbound** signed event stream Hogsend emits to
119
+ your URLs (`contact.*`, `email.*`, `journey.completed`, `bucket.*`). Unlike every
120
+ other resource above, it targets the **ADMIN plane** (`/v1/admin/webhooks`) and
121
+ **requires a full-admin `apiKey`** — a leaked ingest key must never register an
122
+ exfiltration endpoint. Construct a separate `Hogsend` instance with an admin key
123
+ if your data-plane `hs` only holds an ingest key.
124
+
125
+ `create`/`list`/`get`/`update`/`delete`/`rotateSecret`/`sendTest`. The full
126
+ signing secret (`whsec_…`) is returned **only once** — on `create` and
127
+ `rotateSecret`; `list`/`get` only expose `secretPrefix`. Store it on create.
128
+
129
+ `create`/`update` also take a **`kind`** (+ **`config`**) — this is how you manage
130
+ an outbound DESTINATION from the SDK. `kind="webhook"` (the default) is the
131
+ byte-identical signed POST. A keyed destination (`kind="posthog"|"segment"|"slack"|…`)
132
+ fans the same event stream out to that tool via a server-side transform; its
133
+ credentials live in `config` (e.g. `{ apiKey }`) — never an env var — and it
134
+ returns NO `secret` (it authenticates via `config`). Same durable
135
+ retry/backoff/DLQ spine either way.
136
+
137
+ ```ts
138
+ // Fan the email lifecycle out to PostHog as a managed destination — no code:
139
+ await hs.webhooks.create({
140
+ url: "https://us.i.posthog.com", // host (the posthog transform appends /capture/)
141
+ eventTypes: ["email.delivered", "email.opened", "email.clicked", "email.bounced"],
142
+ kind: "posthog",
143
+ config: { apiKey: process.env.POSTHOG_API_KEY! },
144
+ });
145
+ ```
146
+
147
+ **Subscriber side:** in the handler that RECEIVES Hogsend's signed POSTs, call
148
+ `verifyHogsendWebhook({ payload, headers, secret })` (also exported from
149
+ `@hogsend/client`). Pass the **raw request body bytes** (never a re-stringified
150
+ object); it returns the parsed `{ id, type, timestamp, data }` envelope and
151
+ THROWS on a bad/missing signature or a timestamp outside the 5-minute tolerance.
152
+ Deliveries are at-least-once — dedupe on the `Webhook-Id` header.
153
+
154
+ ```ts
155
+ import { verifyHogsendWebhook } from "@hogsend/client";
156
+
157
+ const event = verifyHogsendWebhook({
158
+ payload: rawBody, // the EXACT bytes Hogsend signed
159
+ headers: req.headers,
160
+ secret: process.env.HOGSEND_WEBHOOK_SECRET!, // whsec_… from create / rotate
161
+ });
162
+ // switch on event.type …
106
163
  ```
107
164
 
108
165
  ## `eventProperties` vs `contactProperties` (the split that trips people up)
@@ -157,6 +157,114 @@ For how `subscribe`/`unsubscribe` interact with a list's `defaultOptIn` polarity
157
157
  (opt-in needs an exact `true`, opt-out is blocked only on an exact `false`), see
158
158
  the hogsend-authoring-lists skill.
159
159
 
160
+ ## `hs.webhooks` (ADMIN plane — full-admin key required)
161
+
162
+ Manage **outbound** webhook endpoints (the Svix-style signed event stream Hogsend
163
+ emits to subscriber URLs). Every method targets `/v1/admin/webhooks` and requires
164
+ a **full-admin** `apiKey` — NOT the ingest data key the resources above use.
165
+ Signing-secret management is the same trust class as API-key management.
166
+
167
+ ### `webhooks.create(input) → CreatedWebhookEndpoint`
168
+
169
+ `POST /v1/admin/webhooks`. Register an endpoint subscribed to one or more of the
170
+ 13 outbound event types. For the default `kind="webhook"` it returns the endpoint
171
+ INCLUDING the full signing `secret` (`whsec_…`) — shown ONLY here and on
172
+ `rotateSecret`. Store it now. A keyed DESTINATION (`kind="posthog"|"segment"|
173
+ "slack"|…`) carries its credentials in `config` and returns NO secret.
174
+
175
+ ```ts
176
+ type CreateWebhookInput = {
177
+ url: string;
178
+ eventTypes: OutboundEventType[]; // contact.* | email.* | journey.completed | bucket.*
179
+ description?: string;
180
+ disabled?: boolean;
181
+ kind?: WebhookKind; // "webhook" (default signed POST) | "posthog" | "segment" | "slack" | (string & {})
182
+ config?: Record<string, unknown>; // per-destination credentials, e.g. { apiKey } — ignored for kind="webhook"
183
+ };
184
+
185
+ interface WebhookEndpoint {
186
+ id: string; // we_…
187
+ url: string;
188
+ description: string | null;
189
+ eventTypes: OutboundEventType[];
190
+ secretPrefix: string | null; // first chars, e.g. whsec_AbCd — null for keyed destinations
191
+ kind: WebhookKind; // delivery transform selector
192
+ config: Record<string, unknown> | null; // credentials REDACTED in responses (apiKey → "***"); null for kind="webhook"
193
+ status: "enabled" | "disabled";
194
+ organizationId: string | null;
195
+ lastDeliveryAt: string | null; // ISO
196
+ createdAt: string; // ISO
197
+ updatedAt: string; // ISO
198
+ }
199
+
200
+ // `secret` is present ONLY for kind="webhook" (keyed destinations carry none), hence optional.
201
+ type CreatedWebhookEndpoint = WebhookEndpoint & { secret?: string }; // full secret ONCE
202
+ ```
203
+
204
+ ### `webhooks.list(opts?) → WebhookEndpoint[]`
205
+
206
+ `GET /v1/admin/webhooks`. Newest first; `opts` = `{ limit?, offset?,
207
+ includeDisabled? }`. Returns the endpoints array (unwrapped from the
208
+ `{ endpoints, total, limit, offset }` envelope). No `secret` — `secretPrefix` only.
209
+
210
+ ### `webhooks.get(id) → WebhookEndpoint`
211
+
212
+ `GET /v1/admin/webhooks/{id}`. One endpoint (404 → `HogsendAPIError`). No secret.
213
+
214
+ ### `webhooks.update(id, input) → WebhookEndpoint`
215
+
216
+ `PATCH /v1/admin/webhooks/{id}`. Only the provided fields change;
217
+ `description: null` clears it. Does NOT return or rotate the secret.
218
+
219
+ ```ts
220
+ type UpdateWebhookInput = {
221
+ url?: string;
222
+ eventTypes?: OutboundEventType[];
223
+ description?: string | null;
224
+ disabled?: boolean;
225
+ kind?: WebhookKind; // switch the delivery transform
226
+ config?: Record<string, unknown> | null; // replace per-destination config; null clears it
227
+ };
228
+ ```
229
+
230
+ ### `webhooks.delete(id) → { deleted }`
231
+
232
+ `DELETE /v1/admin/webhooks/{id}`. Hard-delete; cascade drops its deliveries.
233
+
234
+ ### `webhooks.rotateSecret(id) → { id, secret, secretPrefix }`
235
+
236
+ `POST /v1/admin/webhooks/{id}/rotate-secret`. Hard cutover — the OLD secret is
237
+ invalidated immediately; in-flight retries re-sign with the new one. The new
238
+ `secret` is returned ONCE. Update every subscriber.
239
+
240
+ ### `webhooks.sendTest(id) → { enqueued, eventType: "webhook.test" }`
241
+
242
+ `POST /v1/admin/webhooks/{id}/test`. Enqueues an out-of-band `webhook.test`
243
+ delivery, sent regardless of the endpoint's subscribed `eventTypes`.
244
+
245
+ ## `verifyHogsendWebhook(opts)` — the subscriber side
246
+
247
+ A standalone helper exported from `@hogsend/client` (not on the `Hogsend`
248
+ instance) for the handler that RECEIVES Hogsend's signed POSTs.
249
+
250
+ ```ts
251
+ import { verifyHogsendWebhook } from "@hogsend/client";
252
+
253
+ const event = verifyHogsendWebhook({
254
+ payload: rawBody, // the EXACT raw request body bytes — never a re-stringified object
255
+ headers: req.headers,
256
+ secret: "whsec_…", // the endpoint secret from create / rotateSecret
257
+ });
258
+ // event === { id, type, timestamp, data }
259
+ ```
260
+
261
+ - Returns the parsed envelope (`{ id, type, timestamp, data }`) on success.
262
+ - **THROWS** on a bad signature, a missing signature header, or a timestamp
263
+ outside the 5-minute tolerance — wrap in `try/catch` and reply `401` on throw.
264
+ - Accepts both the `Webhook-*` and `svix-*` header aliases (case-insensitive).
265
+ Uses svix when available, with a pure `node:crypto` HMAC-SHA256 fallback.
266
+ - Deliveries are **at-least-once** — dedupe on the `Webhook-Id` (`event.id`).
267
+
160
268
  ## Construction options (recap)
161
269
 
162
270
  ```ts
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hogsend-extending
3
- description: Use when extending a Hogsend app beyond journeys/emails/buckets — swapping the email or analytics provider behind its engine-owned contract (EmailProvider / PostHogService), wiring an outbound integration (Slack, a CRM, Stripe) as plain code called from a journey, or deciding when to publish a reusable @hogsend/plugin-* package. Covers the two categories of extension and where each is wired.
3
+ description: Use when extending a Hogsend app beyond journeys/emails/buckets — swapping the email or analytics provider behind its engine-owned contract (EmailProvider / PostHogService), wiring an outbound integration (Slack, a CRM, Stripe) as plain code called from a journey, fanning the outbound EVENT stream out to a tool via a code-defined destination (defineDestination, see hogsend-authoring-destinations), or deciding when to publish a reusable @hogsend/plugin-* package. Covers the categories of extension and where each is wired.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: withSeismic
@@ -9,7 +9,7 @@ metadata:
9
9
 
10
10
  # Extending Hogsend
11
11
 
12
- There are **two** ways to extend a Hogsend app, and they are different
12
+ There are **three** ways to extend a Hogsend app, and they are different
13
13
  mechanisms — don't reach for the wrong one.
14
14
 
15
15
  1. **Capability providers** (email, analytics). The engine itself drives these,
@@ -21,15 +21,30 @@ mechanisms — don't reach for the wrong one.
21
21
  `@hogsend/plugin-resend` and `@hogsend/plugin-posthog` are the **bundled
22
22
  defaults and reference implementations**; you only swap when you want a
23
23
  different vendor. → `references/swap-a-provider.md`.
24
+ Note: the `analytics` (`PostHogService`) role is now NARROW — it is NOT the
25
+ outbound-event firing path (that's destinations, below). It's load-bearing for
26
+ exactly two things: the identity **PULL** (`getPersonProperties` for per-user
27
+ timezone resolution at enrollment) and the opt-in `bucket.syncToPostHog`
28
+ person-property mirror. PostHog is otherwise just a destination (`kind="posthog"`).
24
29
 
25
- 2. **Integrations** (everything you call *out* to — Slack, a CRM, Stripe, an
26
- internal HTTP API). **No contract, no framework.** Install the SDK, write a
27
- thin wrapper in your own `src/lib/`, import it into a journey, and call it like
28
- a function. The engine never sees it. `references/build-an-integration.md`.
30
+ 2. **Integrations** (a one-directional call *out* to a service post to Slack,
31
+ create a CRM record, charge Stripe from inside a JOURNEY at a specific
32
+ step). **No contract, no framework.** Install the SDK, write a thin wrapper in
33
+ your own `src/lib/`, import it into a journey, and call it like a function. The
34
+ engine never sees it. → `references/build-an-integration.md`.
29
35
 
30
- **The deciding question: does the engine call it, or do you?** If the engine
31
- drives the capability (sending mail, capturing analytics) it's a provider behind
32
- a contract. If your journey reaches outward, it's a plain integration.
36
+ 3. **Destinations** (fan the engine's OUTBOUND EVENT stream `contact.*`,
37
+ `email.*`, `journey.completed`, `bucket.*` out to a tool DURABLY, on every
38
+ matching event, not at a single journey step). A code-defined
39
+ `defineDestination()` is a delivery-time transform keyed by an endpoint
40
+ `kind`; it reuses the engine's durable retry / backoff / DLQ delivery. The
41
+ engine ships `webhook`/`posthog`/`segment`/`slack` presets. → the
42
+ **hogsend-authoring-destinations** skill.
43
+
44
+ **The deciding questions.** Does the engine DRIVE the capability (sending mail,
45
+ capturing analytics)? → a provider behind a contract (1). Are you reaching out at
46
+ ONE journey step? → a plain integration (2). Do you want EVERY lifecycle event
47
+ mirrored to a tool, durably? → a destination (3).
33
48
 
34
49
  ## Swapping a capability provider — the short version
35
50
 
@@ -38,7 +53,8 @@ a contract. If your journey reaches outward, it's a plain integration.
38
53
  reference implementation to copy is `packages/plugin-resend/src/provider.ts`
39
54
  (`createResendProvider`).
40
55
  - **Wire it.** `createHogsendClient({ email: { templates, provider: createMyProvider(...) } })`.
41
- Analytics is a **top-level** option (the engine itself fires captures):
56
+ Analytics is a **top-level** option (the engine uses it for the identity pull +
57
+ bucket sync, not for outbound fan-out):
42
58
  `createHogsendClient({ analytics: createMyAnalytics(...) })`.
43
59
  - **You get everything else for free.** Template rendering, link-click + open
44
60
  tracking, preference/suppression checks, the frequency cap, and the
@@ -62,6 +78,19 @@ a contract. If your journey reaches outward, it's a plain integration.
62
78
  Hatchet task in `src/workflows/` and register it via
63
79
  `createWorker({ extraWorkflows })`.
64
80
 
81
+ ## Fanning the event stream to a tool — the short version
82
+
83
+ When you want a tool to receive EVERY matching lifecycle event (an analytics
84
+ warehouse mirroring `email.*`, a Slack channel pinged on `email.bounced`), don't
85
+ hand-roll an integration call in every journey — author an outbound
86
+ **destination** instead. `defineDestination({ meta:{id}, events, transform })` in
87
+ `src/destinations/` is a delivery-time projection keyed by an endpoint `kind`,
88
+ and it inherits the engine's durable retry / backoff / DLQ delivery for free. The
89
+ engine ships `webhook` (default signed POST), `posthog`, `segment`, and `slack`
90
+ presets, so most fan-out is config (a `webhook_endpoints` row), not code. Wire
91
+ your `destinations` array into `createHogsendClient` in BOTH `src/index.ts` and
92
+ `src/worker.ts`. → the **hogsend-authoring-destinations** skill.
93
+
65
94
  ## When to publish a `@hogsend/plugin-*` package
66
95
 
67
96
  Almost never from a scaffolded app. A standalone module in your `src/` is the
@@ -73,7 +102,9 @@ Hogsend monorepo, not in your client app.
73
102
  ## What NOT to do
74
103
 
75
104
  - **Don't put a service on `ctx`.** `ctx` is durable-orchestration primitives only
76
- (`sleep`, `checkpoint`, `trigger`, `guard`, `history`, `posthog`, `identify`).
105
+ (`sleep`, `sleepUntil`, `when`, `waitForEvent`, `checkpoint`, `trigger`, `guard`,
106
+ `history`). There is no `ctx.posthog` / `ctx.identify` — those were removed; fan
107
+ data out with a destination instead.
77
108
  - **Don't reach for a provider contract for a one-directional call** — that's an
78
109
  integration (just code).
79
110
  - **Don't import the `EmailProvider`/`PostHogService` contract from a
@@ -115,8 +115,21 @@ Same shape: the `PostHogService` contract lives in `@hogsend/core`
115
115
  (canonical `@hogsend/engine`); `createPostHogService` (`@hogsend/plugin-posthog`)
116
116
  is the default + reference impl. Supply your own via the **top-level**
117
117
  `createHogsendClient({ analytics })` option. PostHog is optional — with no
118
- `POSTHOG_API_KEY` the engine resolves analytics to `undefined` and every capture
119
- is a no-op.
118
+ `POSTHOG_API_KEY` the engine resolves analytics to `undefined` and the reads
119
+ below become no-ops.
120
+
121
+ Its role is now **NARROW**. The engine no longer fires the outbound event catalog
122
+ (`email.*` / `contact.*` / `journey.completed` / `bucket.*`) through analytics —
123
+ that fan-out moved to **destinations** on the durable webhook spine, and PostHog
124
+ is now just one destination (`kind="posthog"`, see hogsend-authoring-destinations).
125
+ `PostHogService` is load-bearing for exactly two things, both of which a swapped
126
+ provider must still satisfy:
127
+
128
+ 1. The identity **PULL** — `getPersonProperties(distinctId)`, used for per-user
129
+ timezone resolution at journey enrollment.
130
+ 2. The opt-in `bucket.syncToPostHog` person-property mirror (`$set`/`$unset` of a
131
+ cohort boolean on bucket transitions) — a PostHog-direct write because `$set`
132
+ identity semantics have no vendor-neutral envelope.
120
133
 
121
134
  ## Don't over-reach
122
135