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
| Param | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (1-100) |
offset | number | 0 | Pagination offset |
toEmail | string | -- | Filter by recipient email |
templateKey | string | -- | Filter by template key |
status | string | -- | Filter by status: queued, rendered, sent, delivered, opened, clicked, bounced, complained, failed |
category | string | -- | Filter by email category (e.g. journey, transactional) |
journeyId | string | -- | Filter to emails sent from this journey (resolved via the linked journey state) |
userId | string | -- | Filter to emails sent to this user (resolved via the linked journey state) |
engagement | string | -- | Filter by engagement signal: opened, clicked, bounced, or complained (matches rows where the corresponding timestamp is set) |
sort | string | createdAt | Sort column: createdAt, sentAt, openedAt, or clickedAt |
order | string | desc | Sort direction: asc or desc |
from | string | -- | ISO 8601 datetime lower bound (on createdAt) |
to | string | -- | 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
| Param | Type | Description |
|---|---|---|
id | string | Email 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-uuidPOST /v1/admin/emails/{id}/resend
Retry a failed or bounced email send.
Path Parameters
| Param | Type | Description |
|---|---|---|
id | string | Email 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
}
]
}| Field | Type | Description |
|---|---|---|
key | string | Template registry key |
defaultSubject | string | Default subject line |
category | string | null | Template category (e.g. transactional, journey) |
hasPreview | boolean | Whether the template defines a preview-text function |
curl -H "Authorization: Bearer your-admin-api-key" \
http://localhost:3002/v1/admin/templatesGET /v1/admin/templates/{key}/preview
Render one template to HTML and plain text.
Path Parameters
| Param | Type | Description |
|---|---|---|
key | string | Template registry key |
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
props | string | -- | Base64-encoded JSON object of props, merged over examples + engine defaults |
format | string | -- | 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
| Param | Type | Description |
|---|---|---|
key | string | Template registry key |
Request Body
{
"to": "you@example.com",
"props": { "name": "Ada" }
}| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Recipient email address |
props | object | No | Props 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
| Param | Type | Default | Description |
|---|---|---|---|
type | string | -- | bounced (any bounces), unsubscribed (global unsubscribe), or complained (suppressed via spam complaint). Omit for all preference rows |
limit | number | 50 | Results per page (1-200) |
offset | number | 0 | Pagination 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
| Param | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Signed JWT containing user identity, action, and optional category |
The token payload includes:
| Field | Description |
|---|---|
externalId | The user's external identifier |
email | The email address |
action | "unsubscribe" or "resubscribe" |
category | Optional 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}: falsein preferences - Unsubscribe without category -- Sets
unsubscribedAll: true - Resubscribe with category -- Sets
categories.{category}: trueandunsubscribedAll: 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
| Param | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Signed 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 ID | Label |
|---|---|
journey | Journey & lifecycle emails |
Each category shows its current status (Subscribed/Unsubscribed) with a link to toggle it.
Buckets API
The defineBucket() object surface (typed refs, on() reactions, member access) plus the admin HTTP endpoints -- listing with live counts, detail with criteria and fed journeys, member listing, and enable/disable.
Tracking API
First-party link click tracking, email open tracking, and the event loop that connects tracking to PostHog and journeys.