Events & Ingestion
Your PostHog events flow into Hogsend and trigger journeys automatically. Stripe, custom webhooks, and the REST API work too.
Overview
Your PostHog events are Hogsend events. When you connect PostHog's webhook to Hogsend, every event your users generate — user_signed_up, feature_used, subscription_created — flows straight into the ingestion pipeline and can trigger journeys, update contacts, and evaluate exit conditions.
There's no separate "Hogsend event" concept to learn. Events come from several sources:
- PostHog — via a webhook source. PostHog is the standard event source (the scaffold ships one ready to register), but it is not required to boot — a Hogsend instance runs fine without it.
- Your own app — call the public data plane directly:
POST /v1/events(with an ingest-scopedHOGSEND_API_KEY) or the@hogsend/clientSDK. This is the first-party front door for events from your signup handler, billing webhook, or a cron. - Other systems (Stripe, Clerk, Supabase, Segment, …) — via built-in integration presets or your own custom webhook sources.
- Hogsend itself — lifecycle events like
journey:completedandjourney:failed(and bucket transitions) are emitted automatically and can trigger other journeys.
Webhook sources are your content — you author them under src/webhook-sources/ in your scaffolded app, import defineWebhookSource from @hogsend/engine, and register them in an array you own that gets passed to createApp(container, { webhookSources }). See Engine vs content for the content-vs-framework boundary.
All events — regardless of source — flow through the same pipeline. When an event is ingested, four things happen:
- Store — the event is persisted to the
user_eventstable - Route — the event is pushed to Hatchet, which routes it to any journey whose trigger matches the event name
- Exit check — all active/waiting journey states for the user are evaluated against
exitOnrules - Contact upsert — the user's contact record is created or updated with the latest properties
This all happens in a single request — the API returns the result synchronously.
Events API
POST /v1/events
Send events directly from your application code. This is the public data-plane endpoint (the replacement for the removed /v1/ingest) — it requires a bearer key with the ingest scope.
Request body
{
name: string; // required -- event name (e.g. "user.signed_up")
email?: string; // email OR userId required
userId?: string; // email OR userId required -- your external user id
eventProperties?: Record<string, unknown>; // → the event row + journey trigger.where
contactProperties?: Record<string, unknown>; // → the contact record only
lists?: Record<string, boolean>; // optional -- list membership
idempotencyKey?: string; // optional (or the Idempotency-Key header, which wins)
timestamp?: string; // optional -- ISO 8601 datetime
}Event properties and contact properties are separate bags: eventProperties are what a journey trigger.where / exitOn evaluates, while contactProperties merge onto the durable contact record. See the Data API events reference and Identity for the full model.
Response 202 Accepted
{
stored: boolean;
exits: Array<{
journeyId: string;
stateId: string;
exited: boolean;
}>;
listsError?: string; // present only if a post-ingest list write failed
}The exits array reports every active journey state that was evaluated. Entries with exited: true indicate the user was removed from that journey because this event matched an exit condition.
Example
curl -X POST https://api.hogsend.com/v1/events \
-H "Authorization: Bearer $HOGSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "user.signed_up",
"email": "alice@example.com",
"userId": "user_abc123",
"eventProperties": { "source": "landing-page" },
"contactProperties": { "plan": "pro" }
}'{
"stored": true,
"exits": []
}Event data model
Events are stored in the user_events table with the following schema:
| Column | Type | Description |
|---|---|---|
id | uuid | Auto-generated primary key |
user_id | text | The external user identifier |
event | text | Event name |
properties | jsonb | Arbitrary event properties |
occurred_at | timestamptz | Defaults to now() |
The table is indexed on user_id, event, occurred_at, and the composite (user_id, event, occurred_at) for efficient lookups during journey condition checks.
How routing works
When Hogsend pushes an event to Hatchet, the event payload is serialized as:
{
userId: string;
userEmail: string;
properties: Record<string, string | number | boolean | null>;
}Properties are filtered to JSON-primitive values only (strings, numbers, booleans, and null). Complex nested objects are stripped before dispatch to Hatchet.
Each journey declares its trigger event in meta.trigger.event. Hatchet routes the event to all journeys listening on onEvents: [trigger.event]. If the journey has trigger.where conditions, they are evaluated inside the journey task before the run() function executes.
Exit conditions
Every ingested event also triggers an exit condition scan. Hogsend queries all active or waiting journey states for the user, then checks each journey's exitOn rules:
exitOn?: Array<{
event: string; // event name to match
where?: PropertyCondition[]; // optional property conditions
}>;If the ingested event name matches and all where conditions pass (or none are defined), the journey state is set to "exited" and the exitedAt timestamp is recorded. This happens regardless of where the user is in the journey flow.
Contact upsert
Every ingested event resolves a contact in the contacts table by userId and/or email (creating one the first time a user is seen). The contact's lastSeenAt is updated on each event, and only the event's contactProperties are merged onto contacts.properties — eventProperties stay on the event row and never touch the contact. If the resolve fails, it logs a warning but does not block the event from being processed. See Identity for the resolution and merge rules.
Connecting PostHog (recommended)
The fastest way to get events into Hogsend is to connect your PostHog instance. The scaffold ships a PostHog webhook source at src/webhook-sources/posthog.ts, already registered in your webhookSources array — set it up once and every PostHog event is available to your journeys.
Setup (2 minutes)
- Set your webhook secret — add
POSTHOG_WEBHOOK_SECRETto your Hogsend environment - Create a PostHog Action or Webhook destination — in PostHog, go to Data pipelines → Destinations → Webhook. Set the URL to:
https://your-hogsend-api.com/v1/webhooks/posthog- Add the auth header — configure PostHog to send
x-posthog-webhook-secret: your-secret-valuewith each request - That's it — events start flowing. Every PostHog event becomes a Hogsend event with the same name.
How PostHog events map to Hogsend
| PostHog field | Hogsend field | Notes |
|---|---|---|
event.event | Event name | Used as-is (e.g., user_signed_up) |
event.distinct_id | userId | Your user identifier |
person.properties.email | userEmail | Used for sending emails |
event.properties | eventProperties | The event's own data — what journey trigger.where sees |
person.properties | contactProperties | Person attributes — merged onto the contact record |
event.uuid | eventProperties._posthogEventId | Preserved for deduplication |
The scaffold's PostHog source keeps these two bags separate (person → contactProperties, event → eventProperties), matching the property split the rest of the engine uses.
PostHog payload format
For reference, here's the full payload structure PostHog sends:
{
event: {
uuid?: string;
event: string;
distinct_id: string;
timestamp?: string;
properties?: Record<string, unknown>;
url?: string;
};
person?: {
id?: string;
name?: string;
url?: string;
properties?: {
email?: string;
[key: string]: unknown;
};
};
groups?: Record<string, unknown>;
project?: Record<string, unknown>;
}The transform routes event.properties to eventProperties and person.properties to contactProperties — keeping them in separate bags rather than merging them.
Other webhook sources
Webhook sources let any external system push events into Hogsend through dedicated endpoints at POST /v1/webhooks/:sourceId. Each source defines its own authentication, payload validation, and transform logic.
How webhook sources work
- A request arrives at
/v1/webhooks/:sourceId - The source is looked up by ID — returns
404if unknown - Authentication is verified by comparing the configured header against the expected secret from environment variables
- If a Zod schema is defined, the payload is validated — returns
400with details on failure - The
transform()function converts the external payload into anIngestEvent - If
transform()returnsnull, the event is skipped (returns200withskipped: true) - Otherwise, the event is passed to
ingestEvent()and processed through the full pipeline
Example: adding Stripe as a webhook source
Stripe — along with Clerk, Supabase, and Segment — ships as a built-in preset. If one of those covers your need, you don't write any of the code below: set the provider's signing secret (STRIPE_WEBHOOK_SECRET, CLERK_WEBHOOK_SECRET, SUPABASE_WEBHOOK_SECRET, or SEGMENT_WEBHOOK_SECRET) and the source mounts itself with signature verification already wired. See Integrations. You only hand-roll a defineWebhookSource for systems without a preset — the Stripe example below is the teaching template for building your own.
Stripe is the most common second event source after PostHog. Here's how to build a custom source for it — the same pattern works for any system that can send webhooks.
1. Define the source
import { defineWebhookSource } from "@hogsend/engine";
import { z } from "zod";
const stripeEventSchema = z.object({
id: z.string(),
type: z.string(),
data: z.object({
object: z.record(z.string(), z.unknown()),
}),
});
export const stripeSource = defineWebhookSource({
meta: {
id: "stripe",
name: "Stripe",
description: "Receives Stripe webhook events.",
},
auth: {
header: "x-stripe-secret",
envKey: "STRIPE_WEBHOOK_SECRET",
type: "match",
},
schema: stripeEventSchema,
async transform(payload) {
const userId = payload.data.object.customer as string;
if (!userId) return null; // skip events without a customer
return {
event: `stripe:${payload.type}`,
userId,
userEmail: (payload.data.object.email as string) ?? "",
eventProperties: {
stripeEventId: payload.id,
...payload.data.object,
},
};
},
});2. Register it
Add the source to the exported webhookSources array in your src/webhook-sources/index.ts — the array you own and pass to createApp:
import type { DefinedWebhookSource } from "@hogsend/engine";
import { posthogSource } from "./posthog.js";
import { stripeSource } from "./stripe.js";
export const webhookSources: DefinedWebhookSource[] = [
posthogSource,
stripeSource,
];That array is wired into the engine once, in your thin src/index.ts:
import { createApp, createHogsendClient } from "@hogsend/engine";
import { journeys } from "./journeys/index.js";
import { webhookSources } from "./webhook-sources/index.js";
const container = createHogsendClient({ journeys });
const app = createApp(container, { webhookSources });No engine code is touched — you only edit your own files.
3. Set the environment variable
Add STRIPE_WEBHOOK_SECRET to your .env and deployment configuration.
The new source is immediately available at POST /v1/webhooks/stripe. Now any Stripe event with a customer field triggers journeys — stripe:invoice.payment_failed can kick off a churn recovery flow, stripe:customer.subscription.created can trigger a welcome sequence.
Creating your own webhook source
The Stripe example above follows the same defineWebhookSource() pattern you'd use for any system — Intercom, HubSpot, your own app. Here's the full API:
The defineWebhookSource API
defineWebhookSource is imported from @hogsend/engine, alongside the DefinedWebhookSource, WebhookSourceMeta, and WebhookSourceCtx types:
import {
defineWebhookSource,
type DefinedWebhookSource,
type WebhookSourceCtx,
type VerifySignatureArgs,
} from "@hogsend/engine";interface WebhookSourceMeta {
id: string; // URL-safe identifier, used in the endpoint path
name: string; // Human-readable name
description?: string;
}
// Auth is a discriminated union on `type`.
type WebhookSourceAuth =
| {
type: "match"; // plain shared-secret equality
header: string; // HTTP header carrying the secret (or Authorization: Bearer)
envKey: string; // env var holding the expected secret
}
| {
type: "signature"; // provider HMAC signature verification
scheme: "svix" | "stripe" | "hmac-hex";
envKey: string; // env var holding the signing secret
header: string; // signature header (e.g. svix-signature, stripe-signature)
fallbackMatchHeader?: string; // optional plain-secret header that also passes
verify?(args: VerifySignatureArgs): boolean | Promise<boolean>; // optional per-source override
};
interface WebhookSourceCtx {
db: Database; // Drizzle database instance
logger: Logger; // Structured logger
rawBody?: string; // exact raw request body bytes — signature schemes verify over these
headers?: Record<string, string>; // inbound request headers (lowercased keys)
}
function defineWebhookSource<T>(def: {
meta: WebhookSourceMeta;
auth: WebhookSourceAuth;
schema?: z.ZodSchema<T>; // Optional Zod schema for payload validation
transform(
payload: T,
ctx: WebhookSourceCtx
): Promise<IngestEvent | null>; // Return null to skip the event
}): DefinedWebhookSource<T>;The transform function receives the validated payload and a context object with database, logger, and — when present — the raw body and headers. Return an IngestEvent to process the event, or null to silently skip it.
auth picks how the route authenticates the request:
| Variant | When to use | Behavior when the secret is unset |
|---|---|---|
type: "match" | A simple shared-secret header. The route compares the configured secret against header (or Authorization: Bearer). | Open — the source still accepts requests. This is the legacy variant (PostHog uses it). |
type: "signature" | Provider HMAC verification. The route reads the exact raw body once and verifies it against the signing secret using scheme. | Fail-closed — returns 401 and never reaches transform. |
For type: "signature", scheme is one of:
"svix"— Standard Webhooks / Svix signatures (thesvix-signatureheader). SetfallbackMatchHeaderif the provider can also send a plain shared-secret header."stripe"— Stripe'st=...,v1=...signature format with a 5-minute timestamp tolerance."hmac-hex"— a plain HMAC-SHA256 hex digest of the raw body.
The route reads the raw request body once and hands the exact bytes to both the signature verifier and your transform, so the signature always covers what you parse. Supply verify only when you need to override the built-in scheme verification for a source.
The IngestEvent type
Every event -- whether from the API or a webhook source -- must conform to this shape before entering the pipeline. The type is exported from @hogsend/engine (import type { IngestEvent } from "@hogsend/engine"):
interface IngestEvent {
event: string; // event name
userId?: string; // external user id (optional — email-only / anon)
userEmail?: string; // user email
anonymousId?: string; // stable anonymous id (future anon path)
eventProperties: Record<string, unknown>; // → user_events + journey trigger.where / exitOn
contactProperties?: Record<string, unknown>; // → contacts.properties merge only
idempotencyKey?: string;
}userId is now optional (a contact can be keyed by email alone), and the single properties bag is gone — replaced by eventProperties (required) and the optional contactProperties. See Identity for the split.
Best practices
- Use your PostHog event names as-is — Hogsend uses the exact event name from PostHog (e.g.,
user_signed_up,feature_used). No need to rename or namespace them separately. If you also have Stripe events, thestripe:prefix from the webhook source keeps them distinct. - Keep properties flat — nested objects are stripped when routing to Hatchet. PostHog person properties are already flat, which works perfectly. For custom events, use flat key-value pairs for properties that journey conditions need to evaluate.
- Make sure PostHog has email on the person — the
person.properties.emailfield from PostHog becomes theuserEmailin Hogsend. Without it, Hogsend can track behavior but cannot send emails. Set email as a person property in PostHog early (e.g., on signup). - Return
nullfrom transform for irrelevant events — webhook sources often receive events you don't care about. PostHog may send$pageview,$autocapture, etc. that you don't need for journeys. The scaffold's PostHog source forwards everything — since the source is your content, filter in your journey trigger conditions or edittransform()to skip noisy events. - Validate with Zod schemas — defining a schema on custom webhook sources catches malformed payloads at the boundary, before they reach your transform logic.
Buckets
Real-time, code-defined membership groups — power users, trials expiring soon, users who went dormant. Joining or leaving a bucket fires an event that can trigger a journey.
Webhook Sources & Custom Workflows
Author inbound webhook sources that turn external HTTP payloads into Hogsend events, reach for a built-in preset, and write custom Hatchet tasks for background work.