Hogsend
API Reference

Emails API

Email send history, delivery details, tracked links, unsubscribe handling, and the preference center.

These are the admin/ops email endpoints (browse send history, inspect delivery — admin-scoped). To send a transactional email from your application code, use the public data plane: POST /v1/emails, authed with an ingest-scoped key. Tracking and unsubscribe are inherited automatically.

Admin -- Email Sends

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 sorted by createdAt descending by default.

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
toEmailstring--Filter by recipient email
templateKeystring--Filter by template key
statusstring--Filter by status: queued, rendered, sent, delivered, opened, clicked, bounced, complained, failed
categorystring--Filter by email category (e.g. journey, transactional)
journeyIdstring--Filter to emails sent from this journey (resolved via the linked journey state)
userIdstring--Filter to emails sent to this user (resolved via the linked journey state)
engagementstring--Filter by engagement signal: opened, clicked, bounced, or complained (matches rows where the corresponding timestamp is set)
sortstringcreatedAtSort column: createdAt, sentAt, openedAt, or clickedAt
orderstringdescSort direction: asc or desc
fromstring--ISO 8601 datetime lower bound (on createdAt)
tostring--ISO 8601 datetime upper bound (on createdAt)

The journeyId and userId fields in each result are resolved from the email's linked journey state (soft-deleted states are ignored); both are null for emails not sent from a journey.

Response 200

{
  "emails": [
    {
      "id": "email-uuid",
      "journeyStateId": "state-uuid",
      "templateKey": "activation/welcome",
      "messageId": "provider-message-id",
      "resendId": "provider-message-id",
      "fromEmail": "noreply@hogsend.com",
      "toEmail": "user@example.com",
      "subject": "Welcome to Hogsend",
      "category": "journey",
      "status": "delivered",
      "userId": "user_abc123",
      "journeyId": "activation-welcome",
      "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
}

messageId is the provider message id (Resend email_id, Postmark MessageID, …); it replaced resendId, which lingers as a @deprecated alias for one minor. Each response carries both during the deprecation window — always read messageId. The underlying DB column is message_id (renamed from resend_id).

# Opened emails for a given journey, oldest first
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/emails?journeyId=activation-welcome&engagement=opened&sort=openedAt&order=asc"

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",
    "messageId": "provider-message-id",
    "resendId": "provider-message-id",
    "fromEmail": "noreply@hogsend.com",
    "toEmail": "user@example.com",
    "subject": "Welcome to Hogsend",
    "category": "journey",
    "status": "delivered",
    "userId": "user_abc123",
    "journeyId": "activation-welcome",
    "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"
  },
  "events": [
    { "type": "queued", "timestamp": "2025-01-15T10:30:00.000Z" },
    { "type": "sent", "timestamp": "2025-01-15T10:30:01.000Z" },
    { "type": "delivered", "timestamp": "2025-01-15T10:30:05.000Z" },
    { "type": "opened", "timestamp": "2025-01-15T11:00:00.000Z" },
    {
      "type": "clicked",
      "timestamp": "2025-01-15T11:05:00.000Z",
      "url": "https://example.com/docs",
      "ipAddress": "192.168.1.1",
      "userAgent": "Mozilla/5.0..."
    }
  ],
  "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 events array is a chronological (ascending) delivery timeline assembled from the email's lifecycle timestamps — one entry per non-null event (queued, sent, delivered, opened, bounced, complained, and failed when the send failed) plus one clicked entry per recorded link click (with url, ipAddress, and userAgent).

The userId and journeyId fields on email are resolved from the linked journey state and are null when the email was not sent from a journey. 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

POST /v1/admin/emails/{id}/resend

Retry a failed or bounced email send.

Path Parameters

ParamTypeDescription
idstringEmail send UUID

Response 202

{
  "emailId": "email-uuid",
  "status": "queued"
}

Response 409 -- The email is not in a failed or bounced status, or its templateKey is missing.

{ "error": "Email is not in a retriable status" }
curl -X POST http://localhost:3002/v1/admin/emails/email-uuid/resend \
  -H "Authorization: Bearer your-api-key"

Admin -- Templates

Browse the registered template catalog and render server-side previews. Previews use each template's optional examples props merged over engine-injected defaults (name, unsubscribeUrl, journeyName, eventName, body), then any caller-supplied props on top. Previews never use a real tracking domain, so rendering a preview never writes tracked links or touches the send pipeline. All require the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/templates

List every template registered with the engine.

Response 200

{
  "templates": [
    {
      "key": "welcome",
      "defaultSubject": "Welcome to Hogsend",
      "category": "transactional",
      "hasPreview": true
    }
  ]
}
FieldTypeDescription
keystringTemplate registry key
defaultSubjectstringDefault subject line
categorystring | nullTemplate category (e.g. transactional, journey)
hasPreviewbooleanWhether the template defines a preview-text function
curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/templates

GET /v1/admin/templates/{key}/preview

Render one template to HTML and plain text.

Path Parameters

ParamTypeDescription
keystringTemplate registry key

Query Parameters

ParamTypeDefaultDescription
propsstring--Base64-encoded JSON object of props, merged over examples + engine defaults
formatstring--html or text to return the raw rendered body; omit for the JSON envelope

Response 200 (default, JSON envelope)

{
  "key": "welcome",
  "subject": "Welcome to Hogsend",
  "category": "transactional",
  "preview": "Welcome, Ada — lifecycle email as code starts here.",
  "html": "<!DOCTYPE html> ...",
  "text": "Lifecycle email, as code. ..."
}

With ?format=html the response is text/html with the raw rendered body; with ?format=text it is text/plain.

Response 400 -- props was supplied but is not base64-encoded JSON (or decodes to a non-object).

Response 404 -- no template with that key is registered.

Response 500 -- the template threw while rendering.

# JSON envelope with custom props
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/templates/welcome/preview?props=$(echo -n '{"name":"Grace"}' | base64)"

# raw HTML for an iframe
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/templates/welcome/preview?format=html"

POST /v1/admin/templates/{key}/send-test

Render and send a single real test email of a template. Requires the full-admin scope. The recipient is exempt from preference/suppression checks so a test always lands.

Path Parameters

ParamTypeDescription
keystringTemplate registry key

Request Body

{
  "to": "you@example.com",
  "props": { "name": "Ada" }
}
FieldTypeRequiredDescription
tostringYesRecipient email address
propsobjectNoProps merged over the template's examples + engine defaults

Response 200

{ "status": "sent", "emailSendId": "email-uuid" }

Response 400 -- invalid recipient address.

Response 403 -- the API key lacks the full-admin scope.

Response 404 -- no template with that key is registered.

curl -X POST http://localhost:3002/v1/admin/templates/welcome/send-test \
  -H "Authorization: Bearer your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "to": "you@example.com", "props": { "name": "Ada" } }'

Admin -- Suppressions

List recipients who have been suppressed, have bounced, or have unsubscribed, drawn from the email_preferences table. Requires the Authorization: Bearer <ADMIN_API_KEY> header.

GET /v1/admin/suppressions

Query Parameters

ParamTypeDefaultDescription
typestring--bounced (any bounces), unsubscribed (global unsubscribe), or complained (suppressed via spam complaint). Omit for all preference rows
limitnumber50Results per page (1-200)
offsetnumber0Pagination offset

Response 200

{
  "suppressions": [
    {
      "id": "pref-uuid",
      "userId": "user_abc123",
      "email": "user@acme.com",
      "unsubscribedAll": false,
      "suppressed": false,
      "bounceCount": 2,
      "categories": {},
      "suppressedAt": null,
      "lastBounceAt": "2026-05-20T10:00:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}

The complained filter matches rows suppressed without any recorded bounces (a spam complaint sets suppressed without incrementing bounceCount).

curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/suppressions?type=bounced&limit=100"

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.

On this page