Hogsend
API Reference

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": [] }
  }
}
FieldTypeDescription
status"healthy" | "degraded" | "migration_pending"Current service status. migration_pending if either migration track is behind; degraded if a component is down; otherwise healthy
uptimenumberProcess uptime in seconds
timestampstringISO 8601 timestamp
versionstringAPI version
componentsobjectPer-component health status
components.databaseobjectDatabase health (status: "up" | "down", latencyMs: number)
components.redisobjectRedis health (status: "up" | "down", latencyMs: number when measured)
schemaobjectTwo-track migration state
schema.engineobjectEngine track (bundled in @hogsend/db) — see below
schema.clientobjectClient track (the client repo's own migrations/) — see below

Each schema track block has the same shape:

FieldTypeDescription
requiredstring | nullLatest migration tag the running build expects (e.g. "0012"). null for the client track when the client ships no migrations
appliedstring | nullLatest migration tag recorded in that track's ledger. null when the track has no ledger yet
inSyncbooleanWhether 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
pendingstring[]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 with SKIP_SCHEMA_CHECK=true.
  • client — migrations the scaffolded app owns in its own migrations/ 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 to migration_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/health

Event 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

ParamTypeDescription
sourceIdstringRegistered webhook source identifier

Authentication -- Each source declares how it authenticates inbound requests via a discriminated union on auth.type:

auth.typeBehaviorSecret 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"
    }
  }
}
FieldTypeRequiredDescription
event.eventstringYesPostHog event name
event.distinct_idstringYesPostHog distinct ID (maps to userId)
event.uuidstringNoPostHog event UUID (stored as _posthogEventId property)
event.timestampstringNoEvent timestamp
event.propertiesRecord<string, unknown>NoEvent properties
person.properties.emailstringNoUser 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.

On this page