Hogsend

API Reference

REST API endpoints for event ingestion, contact management, email preferences, and admin visibility.

Hogsend exposes a REST API built on Hono with Zod OpenAPI for request/response validation. All endpoints are versioned under /v1.

Base URL

http://localhost:3002   # local development
https://api.hogsend.com # production

Interactive Docs

In non-production environments, the API serves an auto-generated OpenAPI spec and an interactive Scalar UI:

PathDescription
GET /openapi.jsonOpenAPI 3.1 spec (JSON)
GET /docsScalar API reference UI

These endpoints are disabled when NODE_ENV=production.

Authentication

Hogsend uses two authentication mechanisms depending on the endpoint:

Better Auth (session-based)

Dashboard and user-facing endpoints use Better Auth with email/password authentication. Auth routes are mounted at /api/auth/* and support:

  • Email and password sign-up/sign-in (min 8, max 128 characters)
  • Session-based auth with 7-day expiry (refreshed daily)
  • Organization plugin (up to 5 orgs, 100 members each)

Admin API Key (bearer token)

All /v1/admin/* endpoints require a bearer token matching the ADMIN_API_KEY environment variable:

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/contacts

If ADMIN_API_KEY is not configured, admin endpoints return 503 Service Unavailable.

Middleware Stack

Every request passes through the following middleware in order:

  1. Container injection — Sets the DI container on the Hono context
  2. Secure headers — Adds security-related HTTP headers
  3. CORS — Cross-origin resource sharing (permissive by default)
  4. Compression — gzip/brotli response compression
  5. Request ID — Generates a unique X-Request-Id header
  6. Request logger — Logs method, path, status, and duration
  7. Error handler — Catches thrown errors, returns JSON { error } with appropriate status codes

Error Responses

All errors follow this shape:

{
  "error": "Human-readable error message"
}
  • 400 — Validation error (Zod schema mismatch)
  • 401 — Missing or invalid authentication
  • 404 — Resource not found
  • 409 — Conflict (e.g., duplicate externalId)
  • 500 — Internal server error (message is generic in production)
  • 503 — Service not configured

Health

GET /v1/health

Returns service health status. 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"
}
FieldTypeDescription
status"healthy" | "degraded"Current service status
uptimenumberProcess uptime in seconds
timestampstringISO 8601 timestamp
versionstringAPI version
curl http://localhost:3002/v1/health

Ingestion

POST /v1/ingest

Receives events from direct API calls. Stores the event in userEvents, pushes it to Hatchet for journey routing, processes exit conditions on active journeys, and upserts the contact record.

Request Body

{
  "event": "user:signed_up",
  "userId": "user_abc123",
  "userEmail": "[email protected]",
  "properties": {
    "plan": "pro",
    "source": "website"
  },
  "timestamp": "2025-01-15T10:30:00.000Z"
}
FieldTypeRequiredDescription
eventstringYesEvent name (min 1 char)
userIdstringYesExternal user identifier (min 1 char)
userEmailstringNoUser email address (validated format)
propertiesRecord<string, unknown>NoArbitrary event properties
timestampstringNoISO 8601 datetime (defaults to now)

Response 202

{
  "stored": true,
  "exits": [
    {
      "journeyId": "onboarding-welcome",
      "stateId": "550e8400-e29b-41d4-a716-446655440000",
      "exited": false
    }
  ]
}
FieldTypeDescription
storedbooleanWhether the event was persisted
exitsExitResult[]Exit condition evaluation results for active journeys
exits[].journeyIdstringJourney that was checked
exits[].stateIdstringJourney state ID
exits[].exitedbooleanWhether the user exited this journey
curl -X POST http://localhost:3002/v1/ingest \
  -H "Content-Type: application/json" \
  -d '{
    "event": "user:signed_up",
    "userId": "user_abc123",
    "userEmail": "[email protected]",
    "properties": { "plan": "pro" }
  }'

Admin — Contacts

All admin endpoints require the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/contacts

List contacts with optional search and pagination. Results are ordered by lastSeenAt descending.

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
searchstringSearch by email or externalId (case-insensitive)

Response 200

{
  "contacts": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "externalId": "user_abc123",
      "email": "[email protected]",
      "properties": { "plan": "pro" },
      "firstSeenAt": "2025-01-10T08:00:00.000Z",
      "lastSeenAt": "2025-01-15T10:30:00.000Z",
      "createdAt": "2025-01-10T08:00:00.000Z",
      "updatedAt": "2025-01-15T10:30:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/contacts?limit=10&search=example.com"

GET /v1/admin/contacts/{id}

Get a single contact by internal UUID or externalId, along with their email preferences.

Path Parameters

ParamTypeDescription
idstringContact UUID or externalId

Response 200

{
  "contact": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "externalId": "user_abc123",
    "email": "[email protected]",
    "properties": { "plan": "pro" },
    "firstSeenAt": "2025-01-10T08:00:00.000Z",
    "lastSeenAt": "2025-01-15T10:30:00.000Z",
    "createdAt": "2025-01-10T08:00:00.000Z",
    "updatedAt": "2025-01-15T10:30:00.000Z"
  },
  "preferences": {
    "id": "pref-uuid",
    "userId": "user_abc123",
    "email": "[email protected]",
    "unsubscribedAll": false,
    "suppressed": false,
    "bounceCount": 0,
    "categories": { "journey": true }
  }
}

The preferences field is null if no preference record exists for the contact.

Response 404

{ "error": "Contact not found" }
curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/contacts/user_abc123

POST /v1/admin/contacts

Create a new contact.

Request Body

{
  "externalId": "user_abc123",
  "email": "[email protected]",
  "properties": { "plan": "pro", "company": "Acme" }
}
FieldTypeRequiredDescription
externalIdstringYesYour system's user identifier (min 1 char)
emailstringNoEmail address (validated format)
propertiesRecord<string, unknown>NoArbitrary contact properties

Response 201

{
  "contact": { ... }
}

Response 409

{ "error": "Contact with this externalId already exists" }
curl -X POST http://localhost:3002/v1/admin/contacts \
  -H "Authorization: Bearer your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "user_abc123",
    "email": "[email protected]",
    "properties": { "plan": "pro" }
  }'

PATCH /v1/admin/contacts/{id}

Update an existing contact. Properties are merged (deep merge via jsonb ||) rather than replaced.

Path Parameters

ParamTypeDescription
idstringContact UUID or externalId

Request Body

{
  "email": "[email protected]",
  "properties": { "plan": "enterprise" }
}
FieldTypeRequiredDescription
emailstringNoUpdated email address
propertiesRecord<string, unknown>NoProperties to merge into existing

Response 200 — Updated contact object.

Response 404 — Contact not found.

curl -X PATCH http://localhost:3002/v1/admin/contacts/user_abc123 \
  -H "Authorization: Bearer your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "properties": { "plan": "enterprise" } }'

DELETE /v1/admin/contacts/{id}

Delete a contact and their associated email preferences.

Path Parameters

ParamTypeDescription
idstringContact UUID or externalId

Response 200

{ "deleted": true }

Response 404 — Contact not found.

curl -X DELETE http://localhost:3002/v1/admin/contacts/user_abc123 \
  -H "Authorization: Bearer your-admin-api-key"

Admin — Email Preferences

These endpoints manage email preferences for a specific contact. They are mounted under the admin contacts router and require the same Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/contacts/{contactId}/preferences

Get email preferences for a contact.

Path Parameters

ParamTypeDescription
contactIdstringContact UUID or externalId

Response 200

{
  "preferences": {
    "id": "pref-uuid",
    "userId": "user_abc123",
    "email": "[email protected]",
    "unsubscribedAll": false,
    "suppressed": false,
    "bounceCount": 0,
    "categories": { "journey": true },
    "suppressedAt": null,
    "lastBounceAt": null
  }
}
FieldTypeDescription
idstringPreference record UUID
userIdstringThe contact's externalId
emailstringEmail address
unsubscribedAllbooleanGlobal unsubscribe flag
suppressedbooleanWhether delivery is suppressed (e.g., due to bounces)
bounceCountnumberTotal hard bounces recorded
categoriesRecord<string, boolean>Per-category subscription status
suppressedAtstring | nullISO timestamp when suppression started
lastBounceAtstring | nullISO timestamp of most recent bounce

Response 404 — Contact not found, or no preferences exist.

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/contacts/user_abc123/preferences

PUT /v1/admin/contacts/{contactId}/preferences

Create or update email preferences for a contact. Uses upsert semantics — if no preference record exists, one is created. The contact must have an email address on file.

Path Parameters

ParamTypeDescription
contactIdstringContact UUID or externalId

Request Body

{
  "unsubscribedAll": false,
  "suppressed": false,
  "categories": { "journey": true, "marketing": false }
}
FieldTypeRequiredDescription
unsubscribedAllbooleanNoSet global unsubscribe
suppressedbooleanNoSet delivery suppression
categoriesRecord<string, boolean>NoSet per-category preferences

Response 200 — Updated preferences object.

Response 400

{ "error": "Contact has no email address" }

Response 404 — Contact not found.

curl -X PUT http://localhost:3002/v1/admin/contacts/user_abc123/preferences \
  -H "Authorization: Bearer your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "unsubscribedAll": false,
    "categories": { "journey": true }
  }'

Admin — Journeys

Journey admin endpoints let you see which journeys are registered, inspect their state, enable/disable them at runtime, cancel stuck instances, and manually enroll users. All require the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/journeys

List all registered journeys with their enabled status and live state counts.

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
enabled"true" | "false"Filter by enabled status

Response 200

{
  "journeys": [
    {
      "id": "activation-welcome",
      "name": "Activation — Welcome Series",
      "description": "Sends a welcome email series on signup",
      "enabled": true,
      "trigger": { "event": "user:created" },
      "entryLimit": "once",
      "counts": {
        "active": 12,
        "waiting": 5,
        "completed": 340,
        "failed": 2,
        "exited": 15
      }
    }
  ],
  "total": 10,
  "limit": 50,
  "offset": 0
}

The enabled field reflects the effective state — if an admin has toggled a journey via PATCH, that override takes precedence over the code-level default.

curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/journeys?enabled=true"

GET /v1/admin/journeys/{id}

Get full journey detail including trigger conditions, exit conditions, suppress duration, state counts, and the 10 most recent journey instances.

Path Parameters

ParamTypeDescription
idstringJourney ID (e.g., activation-welcome)

Response 200

{
  "journey": {
    "id": "activation-welcome",
    "name": "Activation — Welcome Series",
    "enabled": true,
    "trigger": {
      "event": "user:created",
      "where": [{ "type": "property", "property": "plan", "operator": "eq", "value": "pro" }]
    },
    "entryLimit": "once",
    "exitOn": [{ "event": "user:deleted" }],
    "suppress": { "hours": 12 },
    "counts": { "active": 12, "waiting": 5, "completed": 340, "failed": 2, "exited": 15 },
    "recentStates": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "userId": "user_abc123",
        "userEmail": "[email protected]",
        "journeyId": "activation-welcome",
        "currentNodeId": "post-welcome",
        "status": "waiting",
        "hatchetRunId": "run-uuid",
        "context": { "plan": "pro" },
        "errorMessage": null,
        "entryCount": 1,
        "completedAt": null,
        "exitedAt": null,
        "createdAt": "2025-01-15T10:30:00.000Z",
        "updatedAt": "2025-01-15T10:30:00.000Z"
      }
    ]
  }
}

Response 404 — Journey not found.

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/journeys/activation-welcome

PATCH /v1/admin/journeys/{id}

Enable or disable a journey at runtime. This persists to the database and takes effect on the next trigger — no redeploy needed. In-flight journey instances are not affected.

Path Parameters

ParamTypeDescription
idstringJourney ID

Request Body

{ "enabled": false }
FieldTypeRequiredDescription
enabledbooleanYesWhether the journey should accept new enrollments

Response 200

{
  "journey": {
    "id": "activation-welcome",
    "name": "Activation — Welcome Series",
    "enabled": false,
    "updatedAt": "2025-01-15T10:30:00.000Z"
  }
}

Response 404 — Journey not found.

curl -X PATCH http://localhost:3002/v1/admin/journeys/activation-welcome \
  -H "Authorization: Bearer your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'

GET /v1/admin/journeys/{id}/states

List journey instances (enrollments) with optional filters. Results are ordered by createdAt descending.

Path Parameters

ParamTypeDescription
idstringJourney ID

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
statusstringFilter by status: active, waiting, completed, failed, exited
userIdstringFilter by user ID

Response 200

{
  "states": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "userId": "user_abc123",
      "userEmail": "[email protected]",
      "journeyId": "activation-welcome",
      "currentNodeId": "post-welcome",
      "status": "waiting",
      "hatchetRunId": "run-uuid",
      "context": { "plan": "pro" },
      "errorMessage": null,
      "entryCount": 1,
      "completedAt": null,
      "exitedAt": null,
      "createdAt": "2025-01-15T10:30:00.000Z",
      "updatedAt": "2025-01-15T10:30:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}

Response 404 — Journey not found.

curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/journeys/activation-welcome/states?status=active"

GET /v1/admin/journeys/{id}/states/{stateId}

Get a single journey instance with its full log trail.

Path Parameters

ParamTypeDescription
idstringJourney ID
stateIdstringJourney state UUID

Response 200

{
  "state": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "userId": "user_abc123",
    "userEmail": "[email protected]",
    "journeyId": "activation-welcome",
    "currentNodeId": "post-welcome",
    "status": "waiting",
    "hatchetRunId": "run-uuid",
    "context": { "plan": "pro" },
    "errorMessage": null,
    "entryCount": 1,
    "completedAt": null,
    "exitedAt": null,
    "createdAt": "2025-01-15T10:30:00.000Z",
    "updatedAt": "2025-01-15T10:30:00.000Z"
  },
  "logs": [
    {
      "id": "log-uuid-1",
      "fromNodeId": null,
      "toNodeId": "start",
      "action": "entered",
      "detail": null,
      "createdAt": "2025-01-15T10:30:00.000Z"
    },
    {
      "id": "log-uuid-2",
      "fromNodeId": "start",
      "toNodeId": "post-welcome",
      "action": "email_sent",
      "detail": { "template": "activation/welcome" },
      "createdAt": "2025-01-15T10:30:01.000Z"
    }
  ]
}

Response 404 — State not found or journey ID mismatch.

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/journeys/activation-welcome/states/550e8400-e29b-41d4-a716-446655440000

DELETE /v1/admin/journeys/{id}/states/{stateId}

Cancel a running journey instance. Sets the status to exited and attempts to cancel the corresponding Hatchet task run.

Path Parameters

ParamTypeDescription
idstringJourney ID
stateIdstringJourney state UUID

Response 200

{
  "state": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "exited",
    "exitedAt": "2025-01-15T10:30:00.000Z"
  },
  "hatchetCancelled": true
}
FieldTypeDescription
hatchetCancelledbooleanWhether the Hatchet run was successfully cancelled. false if the run had already finished or Hatchet was unreachable.

Response 404 — State not found.

Response 409 — State is already in a terminal status (completed, failed, or exited).

{ "error": "Cannot cancel journey in 'completed' status" }
curl -X DELETE http://localhost:3002/v1/admin/journeys/activation-welcome/states/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer your-admin-api-key"

POST /v1/admin/journeys/{id}/enroll

Manually enroll a user in a journey by dispatching the journey's trigger event through the standard ingestion pipeline. The event is processed the same way as any other event — it goes through Hatchet routing, entry guards (limits, preferences), and exit condition checks.

Path Parameters

ParamTypeDescription
idstringJourney ID

Request Body

{
  "userId": "user_abc123",
  "userEmail": "[email protected]",
  "properties": { "source": "admin_enroll" }
}
FieldTypeRequiredDescription
userIdstringYesUser's external identifier
userEmailstringYesUser's email address
propertiesRecord<string, unknown>NoAdditional event properties

Response 202

{
  "enrolled": true,
  "event": "user:created",
  "userId": "user_abc123"
}

The 202 Accepted status indicates the trigger event has been dispatched. Enrollment is asynchronous — the user may still be rejected by entry guards (e.g., already enrolled, unsubscribed, entry limit reached).

Response 404 — Journey not found.

curl -X POST http://localhost:3002/v1/admin/journeys/activation-welcome/enroll \
  -H "Authorization: Bearer your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "user_abc123",
    "userEmail": "[email protected]",
    "properties": { "source": "admin_enroll" }
  }'

Admin — Events

Browse and inspect ingested events. All require the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/events

List events with optional filters. Results are ordered by occurredAt descending.

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
userIdstringFilter by user ID
eventstringFilter by event name
fromstringISO 8601 datetime lower bound
tostringISO 8601 datetime upper bound

Response 200

{
  "events": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "userId": "user_abc123",
      "event": "user:signed_up",
      "properties": { "plan": "pro", "source": "website" },
      "occurredAt": "2025-01-15T10:30:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/events?event=user:signed_up&from=2025-01-01T00:00:00Z"

GET /v1/admin/events/{id}

Get a single event with its full properties.

Path Parameters

ParamTypeDescription
idstringEvent UUID

Response 200

{
  "event": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "userId": "user_abc123",
    "event": "user:signed_up",
    "properties": { "plan": "pro", "source": "website" },
    "occurredAt": "2025-01-15T10:30:00.000Z"
  }
}

Response 404 — Event not found.

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/events/550e8400-e29b-41d4-a716-446655440000

Admin — Emails

Browse email send history and inspect delivery details. All require the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/emails

List email sends with optional filters. Results are ordered by createdAt descending.

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
toEmailstringFilter by recipient email
templateKeystringFilter by template key
statusstringFilter by status: queued, rendered, sent, delivered, opened, clicked, bounced, complained, failed
fromstringISO 8601 datetime lower bound
tostringISO 8601 datetime upper bound

Response 200

{
  "emails": [
    {
      "id": "email-uuid",
      "journeyStateId": "state-uuid",
      "templateKey": "activation/welcome",
      "resendId": "resend-id",
      "fromEmail": "[email protected]",
      "toEmail": "[email protected]",
      "subject": "Welcome to Hogsend",
      "category": "journey",
      "status": "delivered",
      "sentAt": "2025-01-15T10:30:00.000Z",
      "deliveredAt": "2025-01-15T10:30:05.000Z",
      "openedAt": "2025-01-15T11:00:00.000Z",
      "clickedAt": null,
      "bouncedAt": null,
      "complainedAt": null,
      "createdAt": "2025-01-15T10:30:00.000Z",
      "updatedAt": "2025-01-15T11:00:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/emails?status=delivered&[email protected]"

GET /v1/admin/emails/{id}

Get a single email with its delivery timeline, tracked link clicks, and journey context.

Path Parameters

ParamTypeDescription
idstringEmail send UUID

Response 200

{
  "email": {
    "id": "email-uuid",
    "journeyStateId": "state-uuid",
    "templateKey": "activation/welcome",
    "resendId": "resend-id",
    "fromEmail": "[email protected]",
    "toEmail": "[email protected]",
    "subject": "Welcome to Hogsend",
    "category": "journey",
    "status": "delivered",
    "sentAt": "2025-01-15T10:30:00.000Z",
    "deliveredAt": "2025-01-15T10:30:05.000Z",
    "openedAt": "2025-01-15T11:00:00.000Z",
    "clickedAt": null,
    "bouncedAt": null,
    "complainedAt": null,
    "createdAt": "2025-01-15T10:30:00.000Z",
    "updatedAt": "2025-01-15T11:00:00.000Z"
  },
  "trackedLinks": [
    {
      "id": "link-uuid",
      "originalUrl": "https://example.com/docs",
      "clickCount": 3,
      "clicks": [
        {
          "id": "click-uuid",
          "clickedAt": "2025-01-15T11:05:00.000Z",
          "ipAddress": "192.168.1.1",
          "userAgent": "Mozilla/5.0..."
        }
      ]
    }
  ],
  "journeyContext": {
    "journeyId": "activation-welcome",
    "userId": "user_abc123",
    "status": "completed",
    "currentNodeId": "done"
  }
}

The journeyContext field is null if the email was not sent from a journey. The trackedLinks array is empty if no links were tracked.

Response 404 — Email not found.

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/emails/email-uuid

Admin — Journey Logs

Retrieve the full execution log for a journey instance. Requires the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/journey-logs/{stateId}

Get the journey state and its complete log sequence, ordered chronologically.

Path Parameters

ParamTypeDescription
stateIdstringJourney state UUID

Response 200

{
  "state": {
    "id": "state-uuid",
    "userId": "user_abc123",
    "userEmail": "[email protected]",
    "journeyId": "activation-welcome",
    "currentNodeId": "post-welcome",
    "status": "completed",
    "hatchetRunId": "run-uuid",
    "context": { "plan": "pro" },
    "errorMessage": null,
    "entryCount": 1,
    "completedAt": "2025-01-16T10:30:00.000Z",
    "exitedAt": null,
    "createdAt": "2025-01-15T10:30:00.000Z",
    "updatedAt": "2025-01-16T10:30:00.000Z"
  },
  "logs": [
    {
      "id": "log-uuid-1",
      "fromNodeId": null,
      "toNodeId": "start",
      "action": "entered",
      "detail": null,
      "createdAt": "2025-01-15T10:30:00.000Z"
    },
    {
      "id": "log-uuid-2",
      "fromNodeId": "start",
      "toNodeId": "post-welcome",
      "action": "email_sent",
      "detail": { "template": "activation/welcome" },
      "createdAt": "2025-01-15T10:30:01.000Z"
    }
  ]
}

Response 404 — Journey state not found.

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/journey-logs/state-uuid

Admin — Contact Timeline

View a contact's full activity history across events, journeys, and emails. Requires the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/contacts/{id}/timeline

Get a chronological feed of all activity for a contact, interleaving events received, journey state changes, and emails sent. Results are ordered by timestamp descending.

Path Parameters

ParamTypeDescription
idstringContact UUID or externalId

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
typestringFilter by entry type: event, journey, email

Response 200

{
  "timeline": [
    {
      "type": "event",
      "timestamp": "2025-01-15T10:30:00.000Z",
      "data": {
        "id": "event-uuid",
        "event": "user:signed_up",
        "properties": { "plan": "pro" }
      }
    },
    {
      "type": "journey",
      "timestamp": "2025-01-15T10:30:01.000Z",
      "data": {
        "id": "state-uuid",
        "journeyId": "activation-welcome",
        "status": "completed",
        "currentNodeId": "done",
        "completedAt": "2025-01-16T10:30:00.000Z",
        "exitedAt": null
      }
    },
    {
      "type": "email",
      "timestamp": "2025-01-15T10:30:02.000Z",
      "data": {
        "id": "email-uuid",
        "templateKey": "activation/welcome",
        "subject": "Welcome to Hogsend",
        "status": "delivered",
        "toEmail": "[email protected]",
        "sentAt": "2025-01-15T10:30:02.000Z",
        "deliveredAt": "2025-01-15T10:30:07.000Z",
        "openedAt": null
      }
    }
  ],
  "total": 3,
  "limit": 50,
  "offset": 0
}

Each entry has a type discriminator and a data object whose shape varies by type:

TypeData fields
eventid, event, properties
journeyid, journeyId, status, currentNodeId, completedAt, exitedAt
emailid, templateKey, subject, status, toEmail, sentAt, deliveredAt, openedAt

Response 404 — Contact not found.

curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/contacts/user_abc123/timeline?type=email&limit=10"

Email — Unsubscribe and Preferences

These public endpoints handle email unsubscription and the preference center. They are token-authenticated (no API key needed) — each link contains a signed JWT that identifies the user and action.

GET /v1/email/unsubscribe

Process an unsubscribe or resubscribe action. Returns an HTML confirmation page. Typically accessed by clicking a link in an email footer.

Query Parameters

ParamTypeRequiredDescription
tokenstringYesSigned JWT containing user identity, action, and optional category

The token payload includes:

FieldDescription
externalIdThe user's external identifier
emailThe email address
action"unsubscribe" or "resubscribe"
categoryOptional category ID (e.g., "journey")

Response 200 — HTML confirmation page.

Response 400 — HTML error page (invalid or expired token).

Behavior:

  • Unsubscribe with category — Sets categories.{category}: false in preferences
  • Unsubscribe without category — Sets unsubscribedAll: true
  • Resubscribe with category — Sets categories.{category}: true and unsubscribedAll: false
  • Resubscribe without category — Sets unsubscribedAll: false

The unsubscribe confirmation page includes a link to the preference center.

GET /v1/email/preferences

Renders an HTML preference center where users can manage their email subscriptions per category and globally. Also accessed via a signed token.

Query Parameters

ParamTypeRequiredDescription
tokenstringYesSigned JWT identifying the user

Response 200 — HTML preference center with toggle links for each email category and a global unsubscribe/resubscribe option.

Response 400 — HTML error page (invalid or expired token).

The preference center currently includes one category:

Category IDLabel
journeyJourney & lifecycle emails

Each category shows its current status (Subscribed/Unsubscribed) with a link to toggle it.


Webhooks

POST /v1/webhooks/resend

Receives webhook events from Resend (email delivery status updates). Handles signature verification internally via the EmailService.

Request Body — Raw Resend webhook payload (Record<string, unknown>).

Headers — Resend includes signature headers for verification against RESEND_WEBHOOK_SECRET.

Response 200

{ "ok": true }

Response 401

{ "error": "Email service not configured" }

or

{ "error": "Webhook verification failed" }

This endpoint processes email events like email.delivered, email.bounced, email.complained, etc. The EmailService tracks bounces and updates suppression status when the bounce threshold (default: 3) is reached.

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 defines its own auth header. The value is matched against the corresponding environment variable.

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": "[email protected]"
    }
  }
}
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 — Invalid webhook secret.

Response 404 — Unknown source ID.

Response 400 — Payload failed schema validation.

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": "[email protected]" }
    }
  }'

DI Container Pattern

The API uses a dependency-injection container that is created once at startup and set on every request context. Handlers access it via c.get("container").

// Container interface
interface Container {
  env: typeof env;          // Validated environment variables
  logger: Logger;           // Structured logger (winston)
  db: Database;             // Drizzle ORM instance
  dbClient: DatabaseClient; // Raw pg client
  auth: Auth;               // Better Auth instance
  email: Resend;            // Resend client
  emailService: EmailService | null; // Email service (requires RESEND_WEBHOOK_SECRET)
  registry: JourneyRegistry;        // Journey registry
  hatchet: HatchetClient;           // Hatchet task orchestration client
}

In route handlers, destructure what you need:

app.openapi(myRoute, async (c) => {
  const { db, logger, hatchet } = c.get("container");
  // ...
});

The AppEnv type defines what is available on the Hono context:

type AppEnv = {
  Variables: {
    container: Container;
    requestId: string;
    user: User | null;      // Set by auth middleware
    session: Session | null; // Set by auth middleware
  };
};

Adding a Webhook Source

To add a new webhook source that transforms external payloads into Hogsend events:

// src/webhook-sources/my-source.ts
import { defineWebhookSource } from "./define-webhook-source.js";
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,
  async transform(payload) {
    return {
      event: `my_source:${payload.action}`,
      userId: payload.user_id,
      userEmail: payload.user_email ?? "",
      properties: {},
    };
  },
});

Then register it in src/webhook-sources/index.ts:

import { mySource } from "./my-source.js";

const allSources: DefinedWebhookSource[] = [posthogSource, mySource];

The source is now available at POST /v1/webhooks/my-source.


Environment Variables

The API validates all environment variables at startup using @t3-oss/env-core with Zod schemas. The server will not start if required variables are missing.

VariableRequiredDefaultDescription
NODE_ENVNodevelopmentdevelopment, production, or test
PORTNo3002HTTP server port
LOG_LEVELNoinfoerror, warn, info, http, debug
DATABASE_URLYesTimescaleDB/Postgres connection string
REDIS_URLNoredis://localhost:6379Redis connection for PostHog property cache
BETTER_AUTH_SECRETYesSession signing secret
BETTER_AUTH_URLNohttp://localhost:3002Auth base URL
RESEND_API_KEYYesResend API key
RESEND_FROM_EMAILNo[email protected]Default sender email
RESEND_WEBHOOK_SECRETNoEnables Resend webhook verification and EmailService
HATCHET_CLIENT_TOKENNoHatchet authentication token
POSTHOG_API_KEYNoPostHog project API key
POSTHOG_HOSTNoPostHog instance URL
POSTHOG_WEBHOOK_SECRETNoPostHog webhook source authentication
ADMIN_API_KEYNoBearer token for admin endpoints
API_PUBLIC_URLNohttp://localhost:3002Public URL for generating links (unsubscribe, preferences)
ENABLED_JOURNEYSNo*Comma-separated journey IDs or * for all

On this page