Client SDK
@hogsend/client — the typed HTTP client for the data plane. Install, configure, every method, identity rules, and typed errors.
@hogsend/client is the typed HTTP client for the Data API — contacts, events, transactional emails, and lists. It's a thin wrapper over native fetch with no heavy dependencies, and ships compiled ESM + CJS + .d.ts. It is distinct from @hogsend/cli (the read-only operator tool): the client is for your application code writing the data plane.
Install
pnpm add @hogsend/clientThe optional type-only @hogsend/email peer
For fully type-checked emails.send — autocomplete on template names and prop-checking on props — also install @hogsend/email and augment its template registry in your app. It is a type-only optional peer: the runtime JS of @hogsend/client has no dependency on it. Without the peer, the emails.send shape degrades gracefully to { template: string; props? }.
The shipped .d.ts references @hogsend/email by module name. If you do not install the optional peer, keep skipLibCheck: true in your tsconfig (the default for most app scaffolds) — otherwise tsc emits TS2307: Cannot find module '@hogsend/email' from this package's declarations. Installing the peer (even for types only) removes the caveat entirely.
Configure
import { Hogsend } from "@hogsend/client";
const hs = new Hogsend({
baseUrl: "https://api.example.com",
apiKey: process.env.HOGSEND_DATA_KEY!, // hsk_… key with the `ingest` scope
});Options
| Option | Type | Default | Notes |
|---|---|---|---|
baseUrl | string | — | API base, e.g. https://api.example.com |
apiKey | string | — | Data-plane hsk_… key with the ingest scope |
fetch | typeof fetch | global | Override for tests / custom agents |
timeoutMs | number | 30000 | Per-request timeout (aborts the request) |
headers | Record<string, string> | {} | Extra headers on every request |
Methods
Contacts
await hs.contacts.upsert({
email: "ada@example.com",
userId: "u_1",
properties: { plan: "pro" },
lists: { newsletter: true },
}); // → { id, created, linked }
const found = await hs.contacts.find({ email: "ada@example.com" }); // → Contact[]
await hs.contacts.delete({ userId: "u_1" }); // → { deleted }Events
await hs.events.send({
userId: "u_1",
name: "signup",
eventProperties: { source: "landing" }, // → trigger.where / exitOn
contactProperties: { country: "GB" }, // → contact record
idempotencyKey: "evt_abc",
}); // → { stored, exits: [{ journeyId, stateId, exited }] }
hs.events.track(/* … */); // alias of events.sendEmails
await hs.emails.send({
to: "ada@example.com",
template: "welcome",
props: { name: "Ada" },
}); // → { emailSendId, status }template and props are typed against your augmented TemplateRegistryMap (re-exported via the @hogsend/email peer), so an unknown template key is a compile-time error.
Lists
await hs.lists.list(); // → ListSummary[]
await hs.lists.subscribe({ list: "newsletter", email: "ada@example.com" });
await hs.lists.unsubscribe({ list: "newsletter", userId: "u_1" });Webhooks
hs.webhooks.* manages outbound webhook endpoints — the signed event stream Hogsend POSTs to your subscriber URLs.
Unlike every other resource on this page, hs.webhooks.* targets the admin plane (/v1/admin/webhooks) and requires the client be constructed with a full-admin apiKey — not the ingest data key used by contacts / events / emails / lists. Signing-secret management is the same trust class as API-key management: a leaked ingest key must never be able to register an exfiltration endpoint. Construct a separate admin-scoped client for these calls.
const admin = new Hogsend({
baseUrl: "https://api.hogsend.com",
apiKey: process.env.HOGSEND_ADMIN_KEY!, // a full-admin hsk_… key
});
// Create — returns the FULL signing secret. This is the only time (besides
// rotateSecret) the secret is returned. Store it now.
const endpoint = await admin.webhooks.create({
url: "https://yourapp.com/webhooks/hogsend",
eventTypes: ["contact.created", "email.delivered", "journey.completed"],
description: "prod subscriber",
disabled: false,
}); // → WebhookEndpoint & { secret: "whsec_…" }
await admin.webhooks.list({ limit: 50, offset: 0, includeDisabled: true }); // → WebhookEndpoint[]
await admin.webhooks.get(endpoint.id); // → WebhookEndpoint (no secret)
await admin.webhooks.update(endpoint.id, {
eventTypes: ["email.delivered", "email.bounced"],
description: null, // null clears the description
disabled: true,
}); // → WebhookEndpoint
await admin.webhooks.delete(endpoint.id); // → { deleted: true }
// Rotate — hard cutover. The OLD secret is invalidated immediately; update
// every subscriber with the returned new secret (returned ONCE).
await admin.webhooks.rotateSecret(endpoint.id); // → { id, secret: "whsec_…", secretPrefix }
// Enqueue an out-of-band webhook.test delivery, sent regardless of eventTypes.
await admin.webhooks.sendTest(endpoint.id); // → { enqueued: true, eventType: "webhook.test" }
// A KEYED DESTINATION — fan the event stream out to PostHog/Segment/Slack instead
// of a signed POST. Credentials live in `config`; no signing secret is returned.
await admin.webhooks.create({
kind: "posthog",
eventTypes: ["email.delivered", "email.opened", "email.clicked", "email.bounced", "email.complained"],
config: { apiKey: "phc_…", eventNames: { "email.clicked": "email.link_clicked" } },
}); // → WebhookEndpoint (kind="posthog", secretPrefix: null, no `secret`)| Method | Returns | Notes |
|---|---|---|
create(input) | WebhookEndpoint & { secret } | Returns the full whsec_… secret — once. eventTypes requires ≥ 1 type |
list(opts?) | WebhookEndpoint[] | Newest first. includeDisabled defaults to true. No secrets |
get(id) | WebhookEndpoint | No secret (only secretPrefix) |
update(id, input) | WebhookEndpoint | PATCH — only provided fields change; description: null clears it |
delete(id) | { deleted } | Hard delete; cascades the endpoint's deliveries |
rotateSecret(id) | { id, secret, secretPrefix } | New secret returned once; old secret invalid immediately |
sendTest(id) | { enqueued, eventType } | Out-of-band webhook.test, delivered regardless of subscription |
list / get only ever expose the display secretPrefix (e.g. whsec_AbCd); the full secret is recoverable only from create and rotateSecret. create / update also take kind (defaults to "webhook") and config for keyed destinations — a keyed destination carries no signing secret (secretPrefix: null, no secret on create) and its config credentials are redacted on list / get. See Outbound webhooks for the event catalog, envelope, and delivery semantics, and Outbound destinations for the keyed kinds.
Verifying inbound webhooks
When your app receives Hogsend's signed POSTs, verifyHogsendWebhook is the subscriber-side counterpart of the engine's signing. Call it before trusting the body — it confirms the request really came from Hogsend.
import { verifyHogsendWebhook } from "@hogsend/client";
const event = verifyHogsendWebhook({
payload: rawBody, // RAW request body bytes (string)
headers: req.headers, // the request headers
secret: process.env.HOGSEND_WEBHOOK_SECRET!, // the endpoint's whsec_… secret
}); // → { id, type, timestamp, data }Pass the raw request body bytes — the exact string Hogsend signed. A re-stringified JSON object (e.g. the parsed req.body run back through JSON.stringify) will not match the signature. Read the raw body before any JSON body parser consumes it.
It returns the parsed event envelope ({ id, type, timestamp, data }) on success, and throws on:
- a bad signature,
- a missing signature header (
Webhook-Id/Webhook-Timestamp/Webhook-Signature, or theirsvix-*aliases), or - a timestamp outside the 5-minute tolerance window.
Wrap the call in a try/catch and return 401 on failure:
import express from "express";
import { verifyHogsendWebhook } from "@hogsend/client";
const app = express();
app.post(
"/webhooks/hogsend",
express.raw({ type: "application/json" }), // keep the raw bytes
(req, res) => {
let event: { id: string; type: string; timestamp: string; data: unknown };
try {
event = verifyHogsendWebhook({
payload: req.body.toString("utf8"),
headers: req.headers as Record<string, string>,
secret: process.env.HOGSEND_WEBHOOK_SECRET!,
}) as typeof event;
} catch {
return res.sendStatus(401);
}
switch (event.type) {
case "contact.created":
// handle event.data …
break;
// …
}
res.sendStatus(200);
},
);Delivery is at-least-once, so dedupe on the Webhook-Id header (the same value as event.id) — a retried delivery of one logical event reuses its id. Verification uses svix when available and falls back to a pure node:crypto HMAC-SHA256 check, so it works with or without svix installed.
The identity rule
Every write takes an identity — at least one of email or userId (your external id). Both may be supplied. The type union encodes "≥ 1 key", and a runtime guard (assertIdentity) enforces it, so a call with neither throws before any request is made.
type Identity =
| { email: string; userId?: string }
| { email?: string; userId: string };Error types
All non-2xx responses (and transport failures) throw typed errors:
import { HogsendAPIError, RateLimitError } from "@hogsend/client";
try {
await hs.emails.send({ to: "x@y.com", template: "welcome", props: {} });
} catch (err) {
if (err instanceof RateLimitError) {
// 429 — back off for err.retryAfter seconds
} else if (err instanceof HogsendAPIError) {
// err.status (0 = transport failure), err.body (parsed JSON or raw text)
}
}| Error | Shape | When |
|---|---|---|
HogsendAPIError | { status, body } | Any non-2xx. status === 0 means the request never reached the server (DNS / connect / timeout); body is parsed JSON or raw text |
RateLimitError extends HogsendAPIError | adds retryAfter? | status === 429; retryAfter (seconds) is read from the Retry-After header when present |
Where the key comes from
The apiKey is a data-plane key with the ingest scope — see Authentication for how to mint one. A scaffolded app generates a first ingest key during bootstrap and wires a configured Hogsend instance into src/lib/hogsend.ts for you.
Outbound destinations
Fan Hogsend's event stream out to PostHog, Segment, Slack, a CRM, or a warehouse — keyed destinations that ride the same durable, retried webhook delivery spine as a signed POST.
Integrations
Built-in webhook presets that turn Clerk, Supabase, Stripe, and Segment webhooks into Hogsend events — signature-verified, env-driven, and served at /v1/webhooks/{id}.