@hogsend/cli 0.2.3 → 0.7.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 +904 -168
- package/dist/bin.js.map +1 -1
- package/package.json +2 -2
- package/skills/hogsend-authoring-journeys/SKILL.md +1 -1
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +8 -3
- package/skills/hogsend-authoring-lists/SKILL.md +178 -0
- package/skills/hogsend-cli/SKILL.md +44 -18
- package/skills/hogsend-client-sdk/SKILL.md +185 -0
- package/skills/hogsend-client-sdk/references/api-surface.md +181 -0
- package/src/bin.ts +4 -1
- package/src/commands/campaigns.ts +309 -0
- package/src/commands/contacts.ts +176 -6
- package/src/commands/emails.ts +231 -0
- package/src/commands/events.ts +253 -15
- package/src/commands/index.ts +4 -0
- package/src/commands/types.ts +8 -2
- package/src/lib/config.ts +23 -1
- package/src/lib/http.ts +122 -49
- package/studio/assets/{index-r9qr4mus.js → index-D7Ax_oFF.js} +1 -1
- package/studio/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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.7.0",
|
|
36
36
|
"@repo/typescript-config": "0.0.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
@@ -32,9 +32,14 @@ interface JourneyMeta {
|
|
|
32
32
|
### `trigger.event`
|
|
33
33
|
|
|
34
34
|
The event name that enrolls a user. Each journey declares `onEvents:
|
|
35
|
-
[trigger.event]` on its Hatchet durable task, so when
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
[trigger.event]` on its Hatchet durable task, so when that event is pushed,
|
|
36
|
+
Hatchet routes it straight to this journey. The event can originate from any of
|
|
37
|
+
the ingestion spine's entry points: the data-plane `POST /v1/events` endpoint
|
|
38
|
+
(the public event API, also driven by `@hogsend/client`'s `hs.events.send()`), a
|
|
39
|
+
registered webhook source (`POST /v1/webhooks/:sourceId`), or `ctx.trigger()`
|
|
40
|
+
fired from inside another journey. All three funnel through the same
|
|
41
|
+
`ingestEvent()` pipeline, so any of them can enroll a user. Use a constant from
|
|
42
|
+
your `src/journeys/constants/`:
|
|
38
43
|
|
|
39
44
|
```ts
|
|
40
45
|
trigger: { event: Events.USER_CREATED },
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hogsend-authoring-lists
|
|
3
|
+
description: Use when adding or editing a code-defined email list in src/lists/ — defineList({ id, name, description?, defaultOptIn, enabled? }) from @hogsend/engine. A list is just a named email_preferences.categories key (NO new table); defaultOptIn:false = opt-in, defaultOptIn:true = opt-out. Covers the reserved ids, the id pattern, and the register ritual (src/lists/index.ts + thread the lists array into createHogsendClient in BOTH src/index.ts and src/worker.ts — lists are NOT passed to createWorker). Surfaced at GET /v1/lists + POST /v1/lists/:id/(un)subscribe.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: withSeismic
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Authoring Hogsend lists
|
|
11
|
+
|
|
12
|
+
A **list** is a code-defined email subscription category — a newsletter, a
|
|
13
|
+
product-updates digest, a beta-announcements channel. You declare it with
|
|
14
|
+
`defineList()` in `src/lists/`, mirroring `defineJourney()` / `defineBucket()`:
|
|
15
|
+
a synchronous, definition-time call that validates the id and returns a
|
|
16
|
+
`DefinedList`.
|
|
17
|
+
|
|
18
|
+
The headline fact: **a list is NOT a new table.** It is a named key inside the
|
|
19
|
+
existing `email_preferences.categories` JSONB. `defineList` just declares that
|
|
20
|
+
key, gives it a human name, and sets its **default polarity** (`defaultOptIn`).
|
|
21
|
+
The engine's mailer suppression check and the preference center read that key;
|
|
22
|
+
the data plane exposes it at `GET /v1/lists` + `POST /v1/lists/:id/(un)subscribe`.
|
|
23
|
+
|
|
24
|
+
You are editing a **scaffolded consumer app** (content only). You import
|
|
25
|
+
`defineList` from `@hogsend/engine`; you never touch engine internals (the
|
|
26
|
+
registry, the suppression check, the preference-center wiring are all engine-owned).
|
|
27
|
+
|
|
28
|
+
## The shape
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { defineList } from "@hogsend/engine";
|
|
32
|
+
|
|
33
|
+
export const productUpdates = defineList({
|
|
34
|
+
id: "product-updates", // category key — see id rules below
|
|
35
|
+
name: "Product updates", // human label (shown in GET /v1/lists)
|
|
36
|
+
description: "New features and product news.", // optional
|
|
37
|
+
defaultOptIn: false, // opt-in (blocked until subscribed)
|
|
38
|
+
// enabled: true, // optional, defaults to true
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`defineList({ id, name, description?, defaultOptIn, enabled? })`:
|
|
43
|
+
|
|
44
|
+
| field | required | notes |
|
|
45
|
+
|-------|----------|-------|
|
|
46
|
+
| `id` | yes | The `email_preferences.categories` key. Must match `/^[a-z0-9_-]+$/i` (letters, digits, `-`, `_`). `transactional` and `journey` are **RESERVED** and rejected (they are the engine's built-in non-list categories — colliding would corrupt suppression logic). |
|
|
47
|
+
| `name` | yes | Human label surfaced on the list API + preference center. |
|
|
48
|
+
| `description` | no | Optional one-liner; omitted from `meta` entirely when absent. |
|
|
49
|
+
| `defaultOptIn` | yes | The default polarity. See below — this is the one decision that matters. |
|
|
50
|
+
| `enabled` | no | Defaults to `true`. A disabled list is dropped from the registry. |
|
|
51
|
+
|
|
52
|
+
A malformed or reserved `id` makes `defineList` **throw at definition time** — so
|
|
53
|
+
a bad list id fails fast at boot, not silently at send time.
|
|
54
|
+
|
|
55
|
+
## `defaultOptIn` — opt-in vs opt-out (the only real decision)
|
|
56
|
+
|
|
57
|
+
A list's default polarity decides whether a contact is subscribed BEFORE they
|
|
58
|
+
ever touch the preference center. The mailer's suppression check reads
|
|
59
|
+
`email_preferences.categories[id]` against this default:
|
|
60
|
+
|
|
61
|
+
- **`defaultOptIn: false` (opt-in).** The contact is NOT subscribed until they
|
|
62
|
+
explicitly subscribe. A send gated on this list is **blocked unless
|
|
63
|
+
`categories[id] === true`** (an exact `true`). This is the right default for a
|
|
64
|
+
marketing newsletter, a beta list, anything that needs affirmative consent.
|
|
65
|
+
- **`defaultOptIn: true` (opt-out).** The contact IS subscribed by default. A
|
|
66
|
+
send is blocked **only on an explicit `false`** (`categories[id] === false`) —
|
|
67
|
+
absence or any other value means subscribed. This is the "default newsletter
|
|
68
|
+
everyone gets until they unsubscribe" pattern.
|
|
69
|
+
|
|
70
|
+
The asymmetry is deliberate: opt-in requires an exact `true`, opt-out requires an
|
|
71
|
+
exact `false`. Everything in between resolves to "subscribed" for opt-out and
|
|
72
|
+
"not subscribed" for opt-in.
|
|
73
|
+
|
|
74
|
+
To gate a send on a list, pass the list id as the `category` on the send (the
|
|
75
|
+
engine's `sendEmail()` / the data-plane `POST /v1/emails`). The suppression check
|
|
76
|
+
then applies the polarity above. A send with no `category` is not gated on any
|
|
77
|
+
list.
|
|
78
|
+
|
|
79
|
+
## Subscribe / unsubscribe paths
|
|
80
|
+
|
|
81
|
+
Membership is just a write to `categories[id]`. Three surfaces flip it:
|
|
82
|
+
|
|
83
|
+
- **Data plane:** `POST /v1/lists/:id/subscribe` (sets `true`) /
|
|
84
|
+
`POST /v1/lists/:id/unsubscribe` (sets `false`), by identity (`email` or
|
|
85
|
+
`userId`). `GET /v1/lists` returns every defined list's
|
|
86
|
+
`{ id, name, description?, defaultOptIn }`.
|
|
87
|
+
- **`@hogsend/client`:** `hs.lists.list()` / `hs.lists.subscribe(...)` /
|
|
88
|
+
`hs.lists.unsubscribe(...)` (see the hogsend-client-sdk skill).
|
|
89
|
+
- **As a side effect of a write:** `contacts.upsert` and `events.send` accept a
|
|
90
|
+
`lists: { [id]: boolean }` map that subscribes/unsubscribes inline.
|
|
91
|
+
|
|
92
|
+
You author the list; you do NOT write any of these endpoints — the engine mounts
|
|
93
|
+
them off the `ListRegistry` built from your `lists` array.
|
|
94
|
+
|
|
95
|
+
## Registering a list (the wiring ritual)
|
|
96
|
+
|
|
97
|
+
A defined list does nothing until it is (1) exported from the barrel and (2)
|
|
98
|
+
threaded into the client in BOTH entry points. **Lists are NOT passed to
|
|
99
|
+
`createWorker`** — unlike buckets, there is no worker-side list wiring. Lists
|
|
100
|
+
resolve entirely through the client's `ListRegistry`, so the worker process picks
|
|
101
|
+
them up via its OWN `createHogsendClient({ lists })` call, not via `createWorker`.
|
|
102
|
+
|
|
103
|
+
### 1. Export from `src/lists/index.ts`
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// src/lists/index.ts
|
|
107
|
+
import { defineList } from "@hogsend/engine";
|
|
108
|
+
|
|
109
|
+
export const productUpdates = defineList({
|
|
110
|
+
id: "product-updates",
|
|
111
|
+
name: "Product updates",
|
|
112
|
+
description: "Occasional emails about new features and product news.",
|
|
113
|
+
defaultOptIn: false,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// All defined lists for this app. Passed to createHogsendClient({ lists }) in
|
|
117
|
+
// BOTH src/index.ts and src/worker.ts. Edit freely — this is your content.
|
|
118
|
+
export const lists = [productUpdates];
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
(Let the array infer — no `DefinedList[]` annotation is required, mirroring the
|
|
122
|
+
buckets barrel; the base type re-widens each id literal back to `string`, but a
|
|
123
|
+
`DefinedList<Id>` is still assignable to the base `DefinedList[]` the client
|
|
124
|
+
accepts.)
|
|
125
|
+
|
|
126
|
+
### 2. Thread into `createHogsendClient` in `src/index.ts`
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// src/index.ts
|
|
130
|
+
import { createApp, createHogsendClient } from "@hogsend/engine";
|
|
131
|
+
import { lists } from "./lists/index.js";
|
|
132
|
+
// ...templates, journeys, webhookSources...
|
|
133
|
+
|
|
134
|
+
const client = createHogsendClient({
|
|
135
|
+
journeys,
|
|
136
|
+
lists, // ← builds the ListRegistry; powers GET /v1/lists + suppression
|
|
137
|
+
email: { templates },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const app = createApp(client, { webhookSources });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 3. Thread into `createHogsendClient` in `src/worker.ts`
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// src/worker.ts
|
|
147
|
+
import { createHogsendClient, createWorker } from "@hogsend/engine";
|
|
148
|
+
import { lists } from "./lists/index.js";
|
|
149
|
+
|
|
150
|
+
const client = createHogsendClient({
|
|
151
|
+
journeys,
|
|
152
|
+
lists, // ← same lists array; the worker's mailer needs the registry too
|
|
153
|
+
email: { templates },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const worker = createWorker({ container: client, journeys /* …, NO lists here */ });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Note the asymmetry vs buckets: `lists` goes into `createHogsendClient` in BOTH
|
|
160
|
+
files, but it is **never** passed to `createWorker`. Passing it to `createWorker`
|
|
161
|
+
is not an option the factory takes. The worker's send path gets lists through the
|
|
162
|
+
client's `ListRegistry`.
|
|
163
|
+
|
|
164
|
+
Reference implementation: `apps/api/src/lists/index.ts` in the engine monorepo.
|
|
165
|
+
|
|
166
|
+
## Golden rules
|
|
167
|
+
|
|
168
|
+
1. A list is a `categories` key, NOT a table. There is no migration, no
|
|
169
|
+
`db:generate` — `defineList` + the wiring is the whole change.
|
|
170
|
+
2. `defaultOptIn` is the one decision. `false` = opt-in (needs an exact `true` to
|
|
171
|
+
send); `true` = opt-out (blocked only on an exact `false`). Pick consciously.
|
|
172
|
+
3. `id` must match `/^[a-z0-9_-]+$/i`. `transactional` and `journey` are reserved
|
|
173
|
+
and throw — they are the engine's own non-list categories.
|
|
174
|
+
4. Wire `lists` into `createHogsendClient` in BOTH `src/index.ts` AND
|
|
175
|
+
`src/worker.ts`. Do NOT pass `lists` to `createWorker` — it is not an accepted
|
|
176
|
+
option; lists resolve via the client's `ListRegistry`.
|
|
177
|
+
5. Gate a send on a list by passing the list id as the send's `category`. No
|
|
178
|
+
`category` = not gated.
|
|
@@ -4,7 +4,7 @@ description: Use when an agent needs to inspect or operate a running Hogsend lif
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: withSeismic
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.1.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Hogsend CLI
|
|
@@ -17,16 +17,18 @@ importing the database.
|
|
|
17
17
|
|
|
18
18
|
## The --json contract (READ THIS FIRST)
|
|
19
19
|
|
|
20
|
-
Every data
|
|
21
|
-
command prints EXACTLY ONE valid JSON document to stdout and nothing
|
|
22
|
-
spinners, no color, no prose. **Always pass `--json` when you are
|
|
23
|
-
output programmatically.** Without it, you get a human-pretty
|
|
24
|
-
rendering meant for a terminal
|
|
20
|
+
Every data command — read AND write — takes a global `--json` flag. In `--json`
|
|
21
|
+
mode the command prints EXACTLY ONE valid JSON document to stdout and nothing
|
|
22
|
+
else — no spinners, no color, no prose. **Always pass `--json` when you are
|
|
23
|
+
parsing the output programmatically.** Without it, you get a human-pretty
|
|
24
|
+
table/keyvalue rendering meant for a terminal (a write still confirms what it
|
|
25
|
+
did — the server's result body, e.g. `{ id, created, linked }`).
|
|
25
26
|
|
|
26
27
|
```bash
|
|
27
28
|
hogsend stats --json
|
|
28
29
|
hogsend journeys list --json
|
|
29
30
|
hogsend contacts get user_123 --json
|
|
31
|
+
hogsend events send signup --user-id user_123 --json
|
|
30
32
|
```
|
|
31
33
|
|
|
32
34
|
On error in `--json` mode the CLI prints `{"error":"<message>"}` to stdout and
|
|
@@ -34,35 +36,56 @@ exits 1. On success it exits 0 (doctor is the exception — see below).
|
|
|
34
36
|
|
|
35
37
|
## Connecting to an instance
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
Global flags / env vars control which instance you talk to and with which key.
|
|
40
|
+
The CLI carries TWO key kinds because it spans two HTTP planes:
|
|
41
|
+
|
|
42
|
+
- **Base URL:** `--url <baseUrl>` > `HOGSEND_API_URL` env > `.env`
|
|
43
|
+
`HOGSEND_API_URL` > default `http://localhost:3002`.
|
|
44
|
+
- **Admin key** (the `/v1/admin/*` read commands): `--admin-key <key>` >
|
|
45
|
+
`HOGSEND_ADMIN_KEY` env > `ADMIN_API_KEY` env > the `.env` equivalents. Sent as
|
|
46
|
+
`Authorization: Bearer <key>`.
|
|
47
|
+
- **Data key** (the data-plane WRITE commands — `contacts upsert`,
|
|
48
|
+
`events send`, `emails send`): `--data-key <key>` > `HOGSEND_DATA_KEY` env >
|
|
49
|
+
`HOGSEND_API_KEY` env > the `.env` equivalents. This is an `ingest`-scoped key
|
|
50
|
+
(a fresh scaffold mints `HOGSEND_API_KEY` as one). Its precedence is
|
|
51
|
+
INDEPENDENT of the admin key — but since `full-admin` implies `ingest`, an
|
|
52
|
+
admin key also works as the data key if no dedicated data key is set.
|
|
43
53
|
|
|
44
54
|
```bash
|
|
45
55
|
hogsend stats --url https://api.example.com --admin-key "$ADMIN_API_KEY" --json
|
|
56
|
+
hogsend events send signup --user-id u_1 --data-key "$HOGSEND_API_KEY" --json
|
|
46
57
|
```
|
|
47
58
|
|
|
48
|
-
`doctor` hits the unauthenticated `/v1/health` and needs no
|
|
49
|
-
|
|
59
|
+
`doctor` hits the unauthenticated `/v1/health` and needs no key. Read commands
|
|
60
|
+
require the admin key; the data-plane write commands require the data key.
|
|
50
61
|
|
|
51
62
|
## Command map
|
|
52
63
|
|
|
64
|
+
Most commands READ (admin API). A handful WRITE through the data plane — marked
|
|
65
|
+
**(write)** and using the **data key**, not the admin key.
|
|
66
|
+
|
|
53
67
|
| Command | Purpose |
|
|
54
68
|
|---------|---------|
|
|
55
69
|
| `hogsend doctor` | Health + schema-drift verdict (reachability check). |
|
|
56
70
|
| `hogsend stats` | Overview metrics (contacts, emails, bounce/unsub rates). |
|
|
57
|
-
| `hogsend journeys list/get/enable/disable` | Inspect + toggle journeys. |
|
|
58
|
-
| `hogsend contacts list/get/timeline` | Inspect contacts + their activity. |
|
|
59
|
-
| `hogsend
|
|
71
|
+
| `hogsend journeys list/get/enable/disable` | Inspect + toggle journeys (enable/disable is a write to the admin API). |
|
|
72
|
+
| `hogsend contacts list/get/timeline` | Inspect contacts + their activity (read). |
|
|
73
|
+
| `hogsend contacts upsert` | **(write)** Create/update a contact → `PUT /v1/contacts`. `--email`/`--user-id` (≥1 required), `--prop key=value`/`--props <json>`, `--list <id>`/`--unlist <id>`. |
|
|
74
|
+
| `hogsend events <userId>` | Raw event stream for one user (READ — `<userId>` stays the read path). |
|
|
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
|
+
| `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). |
|
|
60
77
|
| `hogsend skills list/add` | Manage these bundled agent skills. |
|
|
61
78
|
| `hogsend upgrade` | Bump `@hogsend/*` deps to latest + refresh vendored skills. |
|
|
62
79
|
| `hogsend setup` | Interactive LOCAL onboarding (docker, secret, migrate). |
|
|
63
80
|
| `hogsend eject <pkg>` | Vendor a `@hogsend/*` package (unchanged). |
|
|
64
81
|
| `hogsend patch <pkg>` | Wrap `pnpm patch` (unchanged). |
|
|
65
82
|
|
|
83
|
+
`events <userId>` is the READ path; `events send` is its WRITE subcommand —
|
|
84
|
+
they share the `events` command but split on the first positional (`send`). The
|
|
85
|
+
write commands map 1:1 onto the `@hogsend/client` data-plane resources (see the
|
|
86
|
+
hogsend-client-sdk skill); the `--prop` vs `--contact-prop` split on
|
|
87
|
+
`events send` mirrors the SDK's `eventProperties` vs `contactProperties`.
|
|
88
|
+
|
|
66
89
|
Run `hogsend <command> --help` for per-command usage.
|
|
67
90
|
|
|
68
91
|
## Task playbooks (load the matching reference)
|
|
@@ -77,5 +100,8 @@ Run `hogsend <command> --help` for per-command usage.
|
|
|
77
100
|
1. Pass `--json` whenever you will parse output. Never screen-scrape the table.
|
|
78
101
|
2. Start a debugging session with `hogsend doctor --json` to confirm the
|
|
79
102
|
instance is reachable and the schema is in sync before trusting other reads.
|
|
80
|
-
3.
|
|
103
|
+
3. Most commands READ, but `contacts upsert`, `events send`, `emails send` (and
|
|
104
|
+
`journeys enable/disable`) WRITE — confirm intent before running them, and
|
|
105
|
+
make sure a data key resolves (`--data-key` > `HOGSEND_DATA_KEY` >
|
|
106
|
+
`HOGSEND_API_KEY`) for the data-plane writes.
|
|
81
107
|
4. Use `--limit`/`--offset` for pagination instead of dumping everything.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
---
|
|
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.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: withSeismic
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Hogsend client SDK (`@hogsend/client`)
|
|
11
|
+
|
|
12
|
+
`@hogsend/client` is the typed HTTP client for Hogsend's **public data plane** —
|
|
13
|
+
contacts, events, transactional emails, and lists. It is a thin wrapper over
|
|
14
|
+
native `fetch` (no heavy deps, ships ESM + CJS + `.d.ts`). This is how you talk
|
|
15
|
+
to Hogsend from your OWN product code: a signup handler, a Stripe webhook, a
|
|
16
|
+
nightly cron — anything OUTSIDE the engine that needs to upsert a contact, fire
|
|
17
|
+
an event, or send a one-off email.
|
|
18
|
+
|
|
19
|
+
## Where this belongs (read first)
|
|
20
|
+
|
|
21
|
+
This SDK is for **app code, not journey code.**
|
|
22
|
+
|
|
23
|
+
- **Outside the engine** (your API routes, webhooks, jobs) → use this client.
|
|
24
|
+
You're crossing the network into Hogsend's HTTP data plane.
|
|
25
|
+
- **Inside a journey's `run(user, ctx)`** → do NOT use this client. Use the
|
|
26
|
+
engine's in-process primitives instead: `sendEmail()` (from `@hogsend/engine`)
|
|
27
|
+
to send, and `ctx.trigger({ event, userId, properties })` to fire an event
|
|
28
|
+
back through the ingestion spine. Those run in-process with full attribution
|
|
29
|
+
and durability; reaching back out over HTTP from inside a journey is wrong.
|
|
30
|
+
|
|
31
|
+
The scaffold already ships a **preconfigured client** at `src/lib/hogsend.ts`
|
|
32
|
+
exporting `hs`. Import that — do not re-instantiate `new Hogsend(...)` per call:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { hs } from "./lib/hogsend.js";
|
|
36
|
+
|
|
37
|
+
await hs.events.send({ userId: "u_1", name: "signup" });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`src/lib/hogsend.ts` wires `baseUrl` from `API_PUBLIC_URL` and `apiKey` from
|
|
41
|
+
`HOGSEND_API_KEY` for you. `pnpm bootstrap` mints a local ingest key and writes
|
|
42
|
+
it; in production you create a key with the `ingest` scope and set
|
|
43
|
+
`HOGSEND_API_KEY`.
|
|
44
|
+
|
|
45
|
+
## Construction + auth
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Hogsend } from "@hogsend/client";
|
|
49
|
+
|
|
50
|
+
const hs = new Hogsend({
|
|
51
|
+
baseUrl: "https://api.example.com", // your deployed API
|
|
52
|
+
apiKey: process.env.HOGSEND_API_KEY!, // hsk_… key with the `ingest` scope
|
|
53
|
+
// fetch?: typeof fetch — override for tests / custom agents (default: global)
|
|
54
|
+
// timeoutMs?: number — per-request timeout, aborts the request (default 30000)
|
|
55
|
+
// headers?: {…} — extra headers on every request (e.g. tracing)
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Auth: the data plane requires an `ingest`-scoped key.** `ingest` is an
|
|
60
|
+
*orthogonal* scope (not part of the `read < journey-admin < full-admin`
|
|
61
|
+
hierarchy): a key must either be granted `ingest` explicitly OR hold `full-admin`
|
|
62
|
+
(which implies every data-plane scope). A bare `read` admin key will NOT work
|
|
63
|
+
against the data plane. This is distinct from the admin key the `hogsend` CLI's
|
|
64
|
+
read commands use.
|
|
65
|
+
|
|
66
|
+
**Identity on every write.** Every write takes at least one of `email` or
|
|
67
|
+
`userId` (your external/distinct id). Both may be supplied. A union type + a
|
|
68
|
+
runtime `assertIdentity` guard enforce that at least one is present — calling
|
|
69
|
+
with neither throws before the request goes out.
|
|
70
|
+
|
|
71
|
+
## The resources at a glance
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// Contacts ----------------------------------------------------------------
|
|
75
|
+
await hs.contacts.upsert({ // PUT /v1/contacts
|
|
76
|
+
email: "ada@example.com",
|
|
77
|
+
userId: "u_1",
|
|
78
|
+
properties: { plan: "pro" }, // merged onto the contact
|
|
79
|
+
lists: { newsletter: true }, // inline list membership
|
|
80
|
+
}); // -> { id, created, linked }
|
|
81
|
+
|
|
82
|
+
await hs.contacts.find({ email: "ada@example.com" }); // GET /v1/contacts/find -> Contact[]
|
|
83
|
+
await hs.contacts.delete({ userId: "u_1" }); // DELETE /v1/contacts -> { deleted }
|
|
84
|
+
|
|
85
|
+
// Events ------------------------------------------------------------------
|
|
86
|
+
await hs.events.send({ // POST /v1/events
|
|
87
|
+
userId: "u_1",
|
|
88
|
+
name: "signup",
|
|
89
|
+
eventProperties: { source: "landing" }, // stored ON the event
|
|
90
|
+
contactProperties: { country: "GB" }, // merged onto the CONTACT
|
|
91
|
+
idempotencyKey: "evt_abc",
|
|
92
|
+
}); // -> { stored, exits, listsError? }
|
|
93
|
+
hs.events.track(/* … */); // alias of events.send
|
|
94
|
+
|
|
95
|
+
// Emails ------------------------------------------------------------------
|
|
96
|
+
await hs.emails.send({ // POST /v1/emails
|
|
97
|
+
to: "ada@example.com",
|
|
98
|
+
template: "welcome",
|
|
99
|
+
props: { name: "Ada" },
|
|
100
|
+
}); // -> { emailSendId, status, reason? }
|
|
101
|
+
|
|
102
|
+
// Lists -------------------------------------------------------------------
|
|
103
|
+
await hs.lists.list(); // GET /v1/lists -> ListSummary[]
|
|
104
|
+
await hs.lists.subscribe({ list: "newsletter", email: "ada@example.com" });
|
|
105
|
+
await hs.lists.unsubscribe({ list: "newsletter", userId: "u_1" });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## `eventProperties` vs `contactProperties` (the split that trips people up)
|
|
109
|
+
|
|
110
|
+
`POST /v1/events` (`hs.events.send`) takes **two distinct property bags** — they
|
|
111
|
+
land in different places and serve different jobs:
|
|
112
|
+
|
|
113
|
+
- **`eventProperties`** — stored ON the event row. These are what a journey's
|
|
114
|
+
`trigger.where` and `exitOn` rules evaluate against. Use them for the
|
|
115
|
+
per-occurrence facts of THIS event: `{ source: "landing", amount: 49,
|
|
116
|
+
plan: "pro" }`. They do not change the contact.
|
|
117
|
+
- **`contactProperties`** — merged onto the CONTACT record (the same upsert
|
|
118
|
+
`contacts.upsert` does). Use them for durable facts about the person:
|
|
119
|
+
`{ country: "GB", lifecycleStage: "trial" }`. They persist across events and
|
|
120
|
+
are what later condition checks on the contact read.
|
|
121
|
+
|
|
122
|
+
Mnemonic: **eventProperties describe the event; contactProperties describe the
|
|
123
|
+
person.** A `purchase` event might carry `eventProperties: { amount: 49 }` (this
|
|
124
|
+
purchase) AND `contactProperties: { hasPurchased: true }` (a lasting fact). The
|
|
125
|
+
same split is exposed by the CLI as `--prop` (event) vs `--contact-prop`
|
|
126
|
+
(contact) on `hogsend events send` — see the hogsend-cli skill.
|
|
127
|
+
|
|
128
|
+
## The 202 + `listsError` warning
|
|
129
|
+
|
|
130
|
+
`POST /v1/events` returns **202 Accepted** (the event is durably stored and
|
|
131
|
+
queued for routing), NOT 200. The result is `{ stored, exits }` plus an optional
|
|
132
|
+
`listsError`:
|
|
133
|
+
|
|
134
|
+
- `stored` — `true` once the event row is written (`false` only on a dedup via
|
|
135
|
+
`idempotencyKey`).
|
|
136
|
+
- `exits` — the `{ journeyId, stateId, exited }[]` from evaluating active
|
|
137
|
+
journeys' `exitOn` rules for this user.
|
|
138
|
+
- **`listsError?`** — present ONLY when the event was ingested fine but the
|
|
139
|
+
(non-atomic, post-ingest) `lists` membership write failed. **The event itself
|
|
140
|
+
is durably stored** — this is a soft warning surfaced on the 202, not a 400.
|
|
141
|
+
If you passed `lists` and care that membership applied, check for `listsError`
|
|
142
|
+
in the result rather than assuming success.
|
|
143
|
+
|
|
144
|
+
## Errors
|
|
145
|
+
|
|
146
|
+
All non-2xx responses (and transport failures) throw typed errors:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { HogsendAPIError, RateLimitError } from "@hogsend/client";
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await hs.emails.send({ to: "x@y.com", template: "welcome", props: {} });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof RateLimitError) {
|
|
155
|
+
// 429 — back off for err.retryAfter seconds (from the Retry-After header)
|
|
156
|
+
} else if (err instanceof HogsendAPIError) {
|
|
157
|
+
// err.status (0 = transport failure: DNS/connect/timeout), err.body (parsed JSON or raw text)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- `HogsendAPIError` — `{ status, body }`. `status === 0` means the request never
|
|
163
|
+
reached the server.
|
|
164
|
+
- `RateLimitError extends HogsendAPIError` — `status === 429`, with `retryAfter`
|
|
165
|
+
(seconds) when the `Retry-After` header is present.
|
|
166
|
+
|
|
167
|
+
## Task playbooks — load the matching reference
|
|
168
|
+
|
|
169
|
+
- **Full per-resource API: every method's input/result shape, identity rules,
|
|
170
|
+
the typed `emails.send` registry, idempotency** → `references/api-surface.md`
|
|
171
|
+
|
|
172
|
+
## Golden rules
|
|
173
|
+
|
|
174
|
+
1. Use this client from APP code only. Inside a journey, use `sendEmail()` /
|
|
175
|
+
`ctx.trigger()` — never reach back out over HTTP.
|
|
176
|
+
2. Import the preconfigured `hs` from `src/lib/hogsend.ts`; don't re-instantiate
|
|
177
|
+
`new Hogsend(...)` per call.
|
|
178
|
+
3. The key needs the `ingest` scope (or `full-admin`). A read admin key is
|
|
179
|
+
rejected by the data plane.
|
|
180
|
+
4. On `events.send`: `eventProperties` describe the event (drive
|
|
181
|
+
`trigger.where`/`exitOn`); `contactProperties` merge onto the contact. Don't
|
|
182
|
+
conflate them.
|
|
183
|
+
5. `events.send` returns 202; check `listsError` if you passed `lists` and care
|
|
184
|
+
that membership applied.
|
|
185
|
+
6. Every write needs an identity (`email` and/or `userId`).
|