Ingestion & Webhooks
Event ingestion endpoint, provider email webhook handler, generic webhook sources, and health check.
Health
GET /v1/health
Returns service health status and the two-track migration state. Used by Railway health checks and monitoring.
Response 200
{
"status": "healthy",
"uptime": 12345.678,
"timestamp": "2025-01-15T10:30:00.000Z",
"version": "0.0.1",
"components": {
"database": { "status": "up", "latencyMs": 1 },
"redis": { "status": "up" }
},
"schema": {
"engine": { "required": "0012", "applied": "0012", "inSync": true, "pending": [] },
"client": { "required": "0003", "applied": "0003", "inSync": true, "pending": [] }
}
}| Field | Type | Description |
|---|---|---|
status | "healthy" | "degraded" | "migration_pending" | Current service status. migration_pending if either migration track is behind; degraded if a component is down; otherwise healthy |
uptime | number | Process uptime in seconds |
timestamp | string | ISO 8601 timestamp |
version | string | API version |
components | object | Per-component health status |
components.database | object | Database health (status: "up" | "down", latencyMs: number) |
components.redis | object | Redis health (status: "up" | "down", latencyMs: number when measured) |
schema | object | Two-track migration state |
schema.engine | object | Engine track (bundled in @hogsend/db) — see below |
schema.client | object | Client track (the client repo's own migrations/) — see below |
Each schema track block has the same shape:
| Field | Type | Description |
|---|---|---|
required | string | null | Latest migration tag the running build expects (e.g. "0012"). null for the client track when the client ships no migrations |
applied | string | null | Latest migration tag recorded in that track's ledger. null when the track has no ledger yet |
inSync | boolean | Whether the database has at least as many migrations applied as required. A DB ahead of the build (more applied than required) still reports true by design |
pending | string[] | Pending migration tags, if any |
Two tracks, one status. Hogsend uses two independent migration streams against the same database:
engine— migrations bundled in@hogsend/db, applied first. The startup boot guard checks this track and refuses to start (exit(1)) if it is behind, so a running API is always engine-in-sync unless the guard was bypassed withSKIP_SCHEMA_CHECK=true.client— migrations the scaffolded app owns in its ownmigrations/folder, applied second. This track never gates boot; it is surfaced here non-fatally. A client with no migrations reports an empty track (required: null,applied: null,pending: [],inSync: true) and never flips the status tomigration_pending.
status is "migration_pending" whenever either track has inSync: false. A healthy status requires both schema.engine.inSync and schema.client.inSync to be true and every component to be up. See Two-track migrations for the migration workflow.
curl http://localhost:3002/v1/healthEvent Ingestion
The old unauthenticated POST /v1/ingest endpoint has been removed. Direct event ingestion now lives on the public data plane at POST /v1/events — authenticated with an ingest-scoped key, using name (not event), the contactProperties / eventProperties split, and email or userId. See the Data API section for the full surface and the @hogsend/client SDK.
Events still flow into the same ingestEvent() pipeline (store → route to Hatchet → check exits → upsert contact); only the public entry point changed. Webhook sources (below) feed that same pipeline.
Webhooks
POST /v1/webhooks/email/{providerId}
Receives email delivery-status webhooks from your active email provider. Hogsend sends email through a swappable provider — Resend by default — so this route is provider-neutral: {providerId} is dispatched on the provider's meta.id (resend, postmark, …) via the container-held EmailProviderRegistry. The matched provider owns its own secrets and verifies the request itself (verifyWebhook), then the engine normalizes the result to a provider-neutral EmailEvent before processing.
POST /v1/webhooks/resend is kept as a thin deprecated alias for the Resend path. (email is a reserved/forbidden webhook-source id, so it can never collide with a defineWebhookSource.)
Request Body -- Raw provider webhook payload (Record<string, unknown>). For Resend this is the Resend event envelope; for Postmark, the Postmark delivery event; and so on.
Headers -- The provider's own signature/auth headers. Resend verifies an HMAC against RESEND_WEBHOOK_SECRET; Postmark uses HTTP Basic credentials (POSTMARK_WEBHOOK_USER / POSTMARK_WEBHOOK_PASS) and fails closed when they are unset.
Response 200
{ "ok": true }A provider may throw a WebhookHandshakeSignal for a non-status handshake (e.g. an SES SubscribeURL confirmation); the route treats that as success and returns 200.
Response 401
{ "error": "Webhook verification failed" }Any error thrown while verifying or handling the webhook (including a missing/invalid signature) results in a 401. This endpoint processes the provider-neutral EmailEvent lifecycle — email.delivered, email.bounced, email.complained, email.opened, email.clicked, etc. (the email. prefix is kept). Bounce handling reads event.bounce.class: a permanent bounce increments bounceCount and auto-suppresses once it crosses the bounce threshold (default 3), a complaint suppresses immediately, while transient/unknown are recorded but never suppress. Handler bodies read event.messageId and event.bounce; the raw legacy Resend shape is still reachable via event.raw as LegacyResendWebhookEvent (@deprecated, one minor).
messageId is the provider message id (Resend email_id, Postmark MessageID, …); it replaced resendId, which lingers as a @deprecated alias for one minor — always read messageId. See the email guide for the full swappable-provider model.
POST /v1/webhooks/{sourceId}
Generic webhook ingestion endpoint. Each registered webhook source has its own sourceId and transforms incoming payloads into Hogsend events.
Path Parameters
| Param | Type | Description |
|---|---|---|
sourceId | string | Registered webhook source identifier |
Authentication -- Each source declares how it authenticates inbound requests via a discriminated union on auth.type:
auth.type | Behavior | Secret unset |
|---|---|---|
"match" | Plain shared-secret equality. The configured secret is compared against the request header (or Authorization: Bearer). | Open — the source stays unauthenticated (legacy parity). |
"signature" | Provider HMAC verification (scheme: "svix" | "stripe" | "hmac-hex"). The route reads the exact raw body bytes and verifies the signature before the payload reaches transform. | 401 — fail-closed. A signature source whose secret is unset is rejected, never an open pass-through. |
A "signature" source resolves its secret from env[envKey] and reads its scheme's well-known headers (svix-*, stripe-signature, or — for hmac-hex — the configured header). An optional fallbackMatchHeader lets a Svix source also accept a plain shared-secret header (this is how Supabase's x-supabase-webhook-secret mode coexists with its Svix mode). The 5-minute timestamp tolerance applies to the Stripe and Svix schemes.
Integration presets
Hogsend ships built-in inbound sources in @hogsend/engine for Clerk, Supabase, Stripe, and Segment, each served at POST /v1/webhooks/{id} (clerk, supabase, stripe, segment). Each preset uses a "signature" source — so it fails closed when its secret is unset — and transforms the provider payload into a Hogsend event. A preset mounts only when both its secret env var is set and ENABLED_WEBHOOK_PRESETS allows it. A consumer's own defineWebhookSource with the same id overrides the preset. See Integrations for the per-provider event mappings and enablement.
Available Sources
PostHog (sourceId: posthog)
Receives events from PostHog webhook destinations and workflow batch triggers.
Auth header: X-PostHog-Webhook-Secret (matched against POSTHOG_WEBHOOK_SECRET env var)
Request Body
{
"event": {
"uuid": "event-uuid",
"event": "user:signed_up",
"distinct_id": "user_abc123",
"timestamp": "2025-01-15T10:30:00.000Z",
"properties": { "plan": "pro" }
},
"person": {
"id": "person-uuid",
"properties": {
"email": "user@example.com"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
event.event | string | Yes | PostHog event name |
event.distinct_id | string | Yes | PostHog distinct ID (maps to userId) |
event.uuid | string | No | PostHog event UUID (stored as _posthogEventId property) |
event.timestamp | string | No | Event timestamp |
event.properties | Record<string, unknown> | No | Event properties |
person.properties.email | string | No | User email (maps to userEmail) |
Response 200
{
"ok": true,
"event": "user:signed_up",
"userId": "user_abc123",
"exits": []
}If the source's transform function returns null, the event is skipped:
{ "ok": true, "skipped": true }Response 401 -- { "error": "Invalid webhook secret" } for a "match" source, or { "error": "Invalid webhook signature" } / { "error": "Webhook signature not configured" } for a "signature" source. A "match" source is only enforced when its secret env var is configured (unconfigured = open); a "signature" source fails closed and returns 401 whenever its secret is unset or the signature does not verify.
Response 404 -- { "error": "Unknown webhook source" }.
Response 400 -- { "error": "Invalid payload", "details": ... } when the payload fails the source's optional Zod schema.
curl -X POST http://localhost:3002/v1/webhooks/posthog \
-H "Content-Type: application/json" \
-H "X-PostHog-Webhook-Secret: your-posthog-secret" \
-d '{
"event": {
"event": "user:signed_up",
"distinct_id": "user_abc123",
"properties": { "plan": "pro" }
},
"person": {
"properties": { "email": "user@example.com" }
}
}'Adding a Webhook Source
Webhook sources are content you own in your scaffolded app, not part of the engine. Author one with defineWebhookSource (imported from @hogsend/engine) to transform external payloads into Hogsend events:
// src/webhook-sources/my-source.js
import { defineWebhookSource } from "@hogsend/engine";
import { z } from "zod";
const payloadSchema = z.object({
action: z.string(),
user_id: z.string(),
user_email: z.string().optional(),
});
export const mySource = defineWebhookSource({
meta: {
id: "my-source",
name: "My Source",
description: "Receives events from My Source",
},
auth: {
header: "x-my-source-secret",
envKey: "MY_SOURCE_WEBHOOK_SECRET",
type: "match",
},
schema: payloadSchema,
transform: async (payload, ctx) => {
return {
event: `my_source:${payload.action}`,
userId: payload.user_id,
userEmail: payload.user_email ?? "",
eventProperties: {}, // → user_events + journey trigger.where
// contactProperties: {}, // → contacts.properties (optional)
};
},
});The transform function receives the validated payload and a context object ({ db, logger, rawBody, headers }), and returns an IngestEvent (or null to skip the event). The IngestEvent carries two property bags — eventProperties (the event row + journey triggers) and the optional contactProperties (merged onto the contact). See Identity for the split.
The auth block is a discriminated union. The example above uses type: "match" (shared-secret equality, open when the secret is unset). For provider-signed webhooks, use type: "signature" with a scheme:
auth: {
type: "signature",
scheme: "hmac-hex", // "svix" | "stripe" | "hmac-hex"
envKey: "MY_SOURCE_WEBHOOK_SECRET",
header: "x-signature", // header carrying the hex digest (hmac-hex)
// fallbackMatchHeader: "x-plain-secret", // optional plain-secret fallback
},A "signature" source fails closed: if its secret env var is unset, the endpoint returns 401 — it never falls back to an open pass-through. Only "match" sources stay open when unconfigured.
Collect your sources into the array your app owns in src/webhook-sources/index.js:
// src/webhook-sources/index.js
import { mySource } from "./my-source.js";
import { posthogSource } from "./posthog.js";
export const webhookSources = [posthogSource, mySource];Then inject that array into the engine when you build the app — you never edit the engine itself:
// 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 });The source is now served at POST /v1/webhooks/my-source. See the Events & webhook sources guide for more.
Outbound webhooks
The sources above are inbound — they receive events from other systems. Hogsend also emits a signed event stream of its own: contact, email, journey, and bucket lifecycle events delivered to your HTTPS endpoints with Standard Webhooks (Svix-style) HMAC-SHA256 signatures, at-least-once delivery, and durable retries.
You manage outbound endpoints on the admin plane at POST /v1/admin/webhooks (create, rotate the signing secret, send a test) and verify deliveries on the subscriber side with verifyHogsendWebhook from @hogsend/client.
See Outbound webhooks for the full event catalog, signed envelope, signature verification, and delivery semantics.