@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/dist/bin.js +441 -4
- 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-cli/SKILL.md +1 -0
- package/skills/hogsend-client-sdk/SKILL.md +58 -1
- package/skills/hogsend-client-sdk/references/api-surface.md +108 -0
- 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 +32 -5
- package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +57 -12
- package/src/commands/index.ts +2 -0
- package/src/commands/webhooks.ts +566 -0
- package/src/lib/http.ts +6 -0
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 —
|
|
@@ -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
|
|
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
|
|