@hogsend/cli 0.8.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/dist/bin.js +4 -3
- package/dist/bin.js.map +1 -1
- package/package.json +2 -2
- package/skills/hogsend-authoring-destinations/SKILL.md +217 -0
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +7 -2
- package/skills/hogsend-authoring-journeys/SKILL.md +11 -4
- package/skills/hogsend-authoring-journeys/references/journey-context.md +22 -8
- package/skills/hogsend-client-sdk/SKILL.md +18 -0
- package/skills/hogsend-client-sdk/references/api-surface.md +13 -4
- package/skills/hogsend-extending/SKILL.md +42 -11
- package/skills/hogsend-extending/references/swap-a-provider.md +15 -2
- package/skills/hogsend-webhooks-and-workflows/SKILL.md +16 -4
- package/src/commands/webhooks.ts +10 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "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.
|
|
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`, `
|
|
63
|
-
`history.hasEvent/journey/email
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
##
|
|
75
|
+
## Fanning data out — use DESTINATIONS, not `ctx`
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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 (
|
|
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 —
|
|
@@ -126,6 +126,24 @@ if your data-plane `hs` only holds an ingest key.
|
|
|
126
126
|
signing secret (`whsec_…`) is returned **only once** — on `create` and
|
|
127
127
|
`rotateSecret`; `list`/`get` only expose `secretPrefix`. Store it on create.
|
|
128
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
|
+
|
|
129
147
|
**Subscriber side:** in the handler that RECEIVES Hogsend's signed POSTs, call
|
|
130
148
|
`verifyHogsendWebhook({ payload, headers, secret })` (also exported from
|
|
131
149
|
`@hogsend/client`). Pass the **raw request body bytes** (never a re-stringified
|
|
@@ -167,8 +167,10 @@ Signing-secret management is the same trust class as API-key management.
|
|
|
167
167
|
### `webhooks.create(input) → CreatedWebhookEndpoint`
|
|
168
168
|
|
|
169
169
|
`POST /v1/admin/webhooks`. Register an endpoint subscribed to one or more of the
|
|
170
|
-
|
|
171
|
-
`secret` (`whsec_…`) — shown ONLY here and on
|
|
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.
|
|
172
174
|
|
|
173
175
|
```ts
|
|
174
176
|
type CreateWebhookInput = {
|
|
@@ -176,6 +178,8 @@ type CreateWebhookInput = {
|
|
|
176
178
|
eventTypes: OutboundEventType[]; // contact.* | email.* | journey.completed | bucket.*
|
|
177
179
|
description?: string;
|
|
178
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"
|
|
179
183
|
};
|
|
180
184
|
|
|
181
185
|
interface WebhookEndpoint {
|
|
@@ -183,7 +187,9 @@ interface WebhookEndpoint {
|
|
|
183
187
|
url: string;
|
|
184
188
|
description: string | null;
|
|
185
189
|
eventTypes: OutboundEventType[];
|
|
186
|
-
secretPrefix: string;
|
|
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"
|
|
187
193
|
status: "enabled" | "disabled";
|
|
188
194
|
organizationId: string | null;
|
|
189
195
|
lastDeliveryAt: string | null; // ISO
|
|
@@ -191,7 +197,8 @@ interface WebhookEndpoint {
|
|
|
191
197
|
updatedAt: string; // ISO
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
|
|
200
|
+
// `secret` is present ONLY for kind="webhook" (keyed destinations carry none), hence optional.
|
|
201
|
+
type CreatedWebhookEndpoint = WebhookEndpoint & { secret?: string }; // full secret ONCE
|
|
195
202
|
```
|
|
196
203
|
|
|
197
204
|
### `webhooks.list(opts?) → WebhookEndpoint[]`
|
|
@@ -215,6 +222,8 @@ type UpdateWebhookInput = {
|
|
|
215
222
|
eventTypes?: OutboundEventType[];
|
|
216
223
|
description?: string | null;
|
|
217
224
|
disabled?: boolean;
|
|
225
|
+
kind?: WebhookKind; // switch the delivery transform
|
|
226
|
+
config?: Record<string, unknown> | null; // replace per-destination config; null clears it
|
|
218
227
|
};
|
|
219
228
|
```
|
|
220
229
|
|
|
@@ -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
|
|
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 **
|
|
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** (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
**
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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`, `
|
|
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
|
|
119
|
-
|
|
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
|
|
|
@@ -77,7 +77,19 @@ Relative imports use the ESM `.js` extension.
|
|
|
77
77
|
- To verify a webhook or task against a running instance (events landing,
|
|
78
78
|
contacts upserted, journeys firing), see the **hogsend-cli** skill.
|
|
79
79
|
- **Inbound vs outbound:** this skill is about *inbound* sources (HTTP → engine).
|
|
80
|
-
The engine also emits an *outbound*
|
|
81
|
-
`
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
The engine also emits an *outbound* event stream (`contact.*`, `email.*`,
|
|
81
|
+
`journey.completed`, `bucket.*`). Two halves:
|
|
82
|
+
- **Subscriber endpoints** — manage the `webhook_endpoints` rows with
|
|
83
|
+
`hogsend webhooks …` (hogsend-cli skill) or `hs.webhooks.*` (hogsend-client-sdk
|
|
84
|
+
skill); verify signed deliveries with `verifyHogsendWebhook`. `create`/`update`
|
|
85
|
+
now take a `kind` + `config`: the default `kind="webhook"` is the byte-identical
|
|
86
|
+
signed `whsec_` POST (returns a one-time `secret`); a keyed destination
|
|
87
|
+
(`kind="posthog"|"segment"|"slack"|…`) carries its credentials in `config`
|
|
88
|
+
(e.g. `{ apiKey }`) and returns NO secret. Same durable retry/backoff/DLQ spine
|
|
89
|
+
either way — `kind` just selects the delivery-time transform.
|
|
90
|
+
- **Code-defined destinations** — author a delivery-time transform for a new
|
|
91
|
+
fan-out TARGET (a CRM, a warehouse, a custom shape) with `defineDestination()`
|
|
92
|
+
in `src/destinations/`. This is the symmetric twin of `defineWebhookSource`
|
|
93
|
+
on the OUTBOUND side. → the **hogsend-authoring-destinations** skill. NOTE:
|
|
94
|
+
outbound is no longer "just code in a journey" — for event fan-out, reach for
|
|
95
|
+
a destination, not a per-step integration call.
|
package/src/commands/webhooks.ts
CHANGED
|
@@ -4,10 +4,10 @@ import { color } from "../lib/output.js";
|
|
|
4
4
|
import type { Command, CommandContext } from "./types.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The
|
|
7
|
+
* The 13-event outbound catalog, VENDORED from the engine's
|
|
8
8
|
* `WEBHOOK_EVENT_TYPES` (lib/webhook-signing.ts). The CLI cannot import the
|
|
9
|
-
* engine, so the tuple is re-declared here
|
|
10
|
-
* `webhook.test` sentinel is NOT a member
|
|
9
|
+
* engine, so the tuple is re-declared here and MUST be kept in sync BY HAND when
|
|
10
|
+
* the engine catalog changes. The `webhook.test` sentinel is NOT a member.
|
|
11
11
|
*/
|
|
12
12
|
const WEBHOOK_EVENT_TYPES = [
|
|
13
13
|
"contact.created",
|
|
@@ -19,6 +19,7 @@ const WEBHOOK_EVENT_TYPES = [
|
|
|
19
19
|
"email.opened",
|
|
20
20
|
"email.clicked",
|
|
21
21
|
"email.bounced",
|
|
22
|
+
"email.complained",
|
|
22
23
|
"journey.completed",
|
|
23
24
|
"bucket.entered",
|
|
24
25
|
"bucket.left",
|
|
@@ -49,14 +50,14 @@ list options:
|
|
|
49
50
|
create options (--url required, plus at least one event):
|
|
50
51
|
--url <url> Destination URL (required).
|
|
51
52
|
--event <type> Subscribe to an event; repeatable.
|
|
52
|
-
--all-events Subscribe to all
|
|
53
|
+
--all-events Subscribe to all 13 event types.
|
|
53
54
|
--description <text> Human label.
|
|
54
55
|
--disabled Create the endpoint disabled.
|
|
55
56
|
|
|
56
57
|
update options (only the provided fields change):
|
|
57
58
|
--url <url> New destination URL.
|
|
58
59
|
--event <type> Replace the subscribed events (repeatable).
|
|
59
|
-
--all-events Subscribe to all
|
|
60
|
+
--all-events Subscribe to all 13 event types.
|
|
60
61
|
--description <text> New description.
|
|
61
62
|
--disabled / --enabled Disable or enable the endpoint.
|
|
62
63
|
|
|
@@ -80,7 +81,9 @@ interface WebhookEndpoint {
|
|
|
80
81
|
url: string;
|
|
81
82
|
description: string | null;
|
|
82
83
|
eventTypes: OutboundEventType[];
|
|
83
|
-
|
|
84
|
+
// null for keyed destinations (kind !== "webhook"), which carry no signing
|
|
85
|
+
// secret — their credentials live in the endpoint config, not a whsec_.
|
|
86
|
+
secretPrefix: string | null;
|
|
84
87
|
status: "enabled" | "disabled";
|
|
85
88
|
organizationId: string | null;
|
|
86
89
|
lastDeliveryAt: string | null;
|
|
@@ -228,7 +231,7 @@ function renderEndpoint(
|
|
|
228
231
|
? color.green(ep.status)
|
|
229
232
|
: color.yellow(ep.status),
|
|
230
233
|
eventTypes: ep.eventTypes,
|
|
231
|
-
secretPrefix: ep.secretPrefix,
|
|
234
|
+
secretPrefix: ep.secretPrefix ?? color.dim("(none — keyed destination)"),
|
|
232
235
|
lastDeliveryAt: ep.lastDeliveryAt ?? color.dim("(never)"),
|
|
233
236
|
createdAt: ep.createdAt,
|
|
234
237
|
updatedAt: ep.updatedAt,
|