Email Operations
Track every email from queue to delivery — monitor opens, clicks, bounces, and troubleshoot issues
Every email Hogsend sends is tracked in the database — from initial queue through delivery, opens, clicks, bounces, and complaints. Hogsend sends email through a swappable provider — Resend by default — but tracking, preferences, and the email_sends record are engine-owned, so the numbers below look the same no matter which provider delivers the mail. This page covers how to monitor email delivery, investigate issues, and maintain healthy deliverability.
Email Send History
List email sends with optional filters:
# All emails, most recent first
curl -H "Authorization: Bearer your-api-key" \
http://localhost:3002/v1/admin/emails
# Emails to a specific recipient
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?toEmail=user@acme.com"
# Emails using a specific template
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?templateKey=activation/welcome"
# Failed emails in the last week
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?status=failed&from=2026-05-18T00:00:00Z"
# Bounced emails
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?status=bounced"
# Everything a single user has been sent, oldest first
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?userId=user_abc123&sort=createdAt&order=asc"
# Emails from one journey that were opened
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?journeyId=activation-welcome&engagement=opened"
# Most recently clicked emails across a template
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?templateKey=conversion-trial-expiring&engagement=clicked&sort=clickedAt&order=desc"Filters: toEmail, templateKey, category, status, journeyId, userId, engagement (opened / clicked / bounced / complained), and a from/to window on createdAt. engagement matches on the underlying timestamp column rather than the latest status, so a filter like engagement=opened still catches an email that has since clicked or bounced. Sort with sort (createdAt, sentAt, openedAt, clickedAt) and order (asc / desc); the default is newest-first by createdAt. journeyId and userId are resolved from the email's linked journey state.
{
"emails": [
{
"id": "email-uuid",
"journeyStateId": "state-uuid",
"templateKey": "activation/welcome",
"messageId": "provider-message-id",
"resendId": "provider-message-id",
"fromEmail": "noreply@hogsend.com",
"toEmail": "user@acme.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 — both fields are returned and carry the same value during the deprecation window, so always read messageId going forward.
Email Status Lifecycle
An email moves through these statuses as delivery progresses:
queued -> rendered -> sent -> delivered -> opened -> clicked
\-> bounced
\-> complained
\-> failed| Status | Meaning |
|---|---|
queued | Email task created, waiting for worker to pick it up |
rendered | Template rendered successfully, ready to send |
sent | Handed off to your email provider (Resend by default) for delivery |
delivered | The provider confirmed the email reached the recipient's mail server |
opened | Recipient opened the email (tracked via pixel) |
clicked | Recipient clicked a tracked link |
bounced | Email bounced (hard bounce -- invalid address, mailbox does not exist) |
complained | Recipient marked the email as spam |
failed | Template rendering or the provider send call failed |
First-party open/click tracking is the single source of truth — the engine rewrites links and injects the open pixel itself before handing the HTML to the provider, so opened/clicked are recorded the same way regardless of provider. delivered, bounced, and complained come from the provider's webhook, normalized to a provider-neutral EmailEvent. Open and click tracking still depends on the recipient's email client -- some clients block tracking pixels or pre-fetch links, so these numbers are lower bounds.
Email Detail View
Get the full detail for a single email, including tracked link clicks and the journey context:
curl -H "Authorization: Bearer your-api-key" \
http://localhost:3002/v1/admin/emails/email-uuid{
"email": {
"id": "email-uuid",
"journeyStateId": "state-uuid",
"templateKey": "activation/welcome",
"messageId": "provider-message-id",
"resendId": "provider-message-id",
"fromEmail": "noreply@hogsend.com",
"toEmail": "user@acme.com",
"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
},
"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 journeyContext field is null if the email was not sent from a journey. The trackedLinks array shows every link that was tracked in the email, with individual click records including timestamps, IP addresses, and user agents.
The events array is the same history pre-assembled into a single chronological (ascending) timeline — one entry per lifecycle moment (queued, sent, delivered, opened, bounced, complained, and failed when a send fails) plus one clicked entry per recorded link click (carrying its url, ipAddress, and userAgent). It's the easiest thing to render in a per-contact activity view.
Understanding the Delivery Timeline
The events array above gives you the ready-made timeline; the raw timestamps on the email object are the underlying source:
| Timestamp | What happened |
|---|---|
createdAt | Email send record created in the database |
sentAt | Email handed to your email provider's API |
deliveredAt | The provider confirmed delivery to the recipient's mail server |
openedAt | Recipient opened the email |
clickedAt | Recipient clicked a tracked link |
The gap between sentAt and deliveredAt is the delivery latency. This is typically under 10 seconds. If you see consistently large gaps, check your provider's sender reputation and DNS configuration.
Email Metrics by Template
See how each template is performing:
curl -H "Authorization: Bearer your-api-key" \
http://localhost:3002/v1/admin/metrics/emails{
"templates": [
{
"templateKey": "activation/welcome",
"sent": 480,
"delivered": 475,
"opened": 320,
"clicked": 150,
"bounced": 5,
"deliveryRate": 0.99,
"openRate": 0.6737,
"clickRate": 0.4688,
"clickToDeliveryRate": 0.3158
},
{
"templateKey": "activation/getting-started",
"sent": 300,
"delivered": 298,
"opened": 150,
"clicked": 45,
"bounced": 2,
"deliveryRate": 0.99,
"openRate": 0.5034,
"clickRate": 0.30,
"clickToDeliveryRate": 0.1510
}
]
}Scope it with a from/to window (on createdAt), and pass includeUntemplated=true to fold raw/sendRaw sends (no templateKey) into a "(none)" bucket:
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/metrics/emails?from=2026-05-01T00:00:00Z&to=2026-06-01T00:00:00Z"openRate is opens ÷ delivered, but falls back to opens ÷ sent when no deliveries are recorded yet (so opens aren't silently zeroed if your provider's delivered webhook isn't wired up). clickToDeliveryRate (clicks ÷ delivered) is the most robust headline number when open tracking is partially blocked by mail clients.
Benchmarks
Use these as rough guidelines for SaaS lifecycle emails:
| Metric | Good | Needs attention | Investigate |
|---|---|---|---|
| Delivery rate | >98% | 95-98% | <95% |
| Open rate | >50% | 30-50% | <30% |
| Click rate | >15% | 5-15% | <5% |
| Bounce rate | <1% | 1-3% | >3% |
Low open rates usually indicate subject line or timing issues. Low click rates suggest the email content or CTA is not compelling. High bounce rates point to stale contact data.
Deliverability Trends
Track deliverability over time to spot degradation early:
# Daily deliverability for the last 30 days
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/metrics/emails/deliverability?period=day&from=2026-04-25T00:00:00Z"
# Weekly trends
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/metrics/emails/deliverability?period=week&from=2026-01-01T00:00:00Z"
# Monthly overview
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/metrics/emails/deliverability?period=month&from=2025-01-01T00:00:00Z"{
"points": [
{
"date": "2026-05-24",
"total": 120,
"delivered": 118,
"bounced": 1,
"complained": 1,
"deliveryRate": 0.983
},
{
"date": "2026-05-25",
"total": 95,
"delivered": 94,
"bounced": 1,
"complained": 0,
"deliveryRate": 0.989
}
]
}Watch for:
- Delivery rate dropping below 95% -- may indicate DNS/SPF/DKIM issues, blacklisting, or sudden contact quality problems
- Bounce spikes -- could mean a bad batch import or stale data
- Complaint increases -- review email frequency and content. Complaints above 0.1% are a sender reputation risk.
Resending Failed Emails
If an email failed due to a transient issue (provider API timeout, temporary rendering error), you can retry it:
curl -X POST http://localhost:3002/v1/admin/emails/email-uuid/resend \
-H "Authorization: Bearer your-api-key"{
"emailId": "email-uuid",
"status": "queued"
}Only emails in failed or bounced status can be resent. The email must have a templateKey so it can be re-rendered with the original data. Attempting to resend an email in any other status returns 409 Conflict.
Before resending a bounced email, check whether the recipient's address is actually valid. Resending to an invalid address will bounce again and further damage your sender reputation.
Bounce Tracking and Suppression
Hogsend automatically tracks bounces via your provider's webhooks, normalized to a provider-neutral EmailEvent. Each bounce carries a BounceClass that decides what happens:
permanent(hard bounce — invalid address, mailbox does not exist): the email is markedbounced, the contact'sbounceCountis incremented, and oncebounceCountreaches the threshold (default: 3) the contact is suppressed (suppressed: true).complaint: immediate suppression.transient(soft bounce — mailbox full, temporary failure): recorded but never suppresses.unknown: recorded conservatively, never suppresses.
So for a permanent bounce:
- The email record's status is updated to
bouncedwith abouncedAttimestamp - The contact's
bounceCountis incremented in their email preferences - If
bounceCountreaches the threshold (default: 3), the contact is suppressed (suppressed: true)
Suppressed contacts:
- Will not receive any emails from any journey
- Will not pass the subscription check in entry guards
- Remain in the system (not deleted) with all historical data intact
Checking Suppressed Contacts
# View a contact's bounce status
curl -H "Authorization: Bearer your-api-key" \
http://localhost:3002/v1/admin/contacts/user_abc123/preferencesIf suppressed: true and bounceCount >= 3, the contact was auto-suppressed due to bounces.
Un-suppressing a Contact
If a user confirms their email is valid (e.g., they contact support), you can un-suppress them:
curl -X PUT http://localhost:3002/v1/admin/contacts/user_abc123/preferences \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{ "suppressed": false }'Note that this does not reset the bounce count. If the email bounces again, the contact will be re-suppressed immediately. To also reset the bounce count, you will need to update the database directly.
Troubleshooting
Every email goes to one address
If all sends land in a single inbox with a [TEST → …] subject prefix, the instance is in test mode — the engine redirects every send while the sending domain is unverified. Verify it with hogsend domain check; sends go live within a minute of DNS verifying.
High Bounce Rates
If your bounce rate exceeds 3%:
- Check recent imports -- a bad import with stale email addresses is the most common cause
# Find recently imported contacts that bounced
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/emails?status=bounced&from=2026-05-20T00:00:00Z"- Review the deliverability trend -- was it a sudden spike or gradual increase?
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/metrics/emails/deliverability?period=day&from=2026-05-01T00:00:00Z"-
Check DNS configuration -- verify SPF, DKIM, and DMARC records for your sending domain are correct in Resend
-
Review contact sources -- if bounces are concentrated in contacts from a specific source, that source may need validation
Emails Not Being Delivered
If emails show sent status but never move to delivered:
- Check your provider dashboard -- the email may be queued on the provider's side (the Resend dashboard if you're on the default provider)
- Check the recipient's spam folder -- the email may have been delivered but classified as spam
- Verify provider webhooks are working -- the
deliveredstatus comes from your provider's webhook events (delivered toPOST /v1/webhooks/email/:providerId, normalized to a provider-neutralEmailEvent). If the webhook is misconfigured, Hogsend will not know about delivery
# Check the health endpoint -- if Redis is down, webhook processing may be affected
curl http://localhost:3002/v1/healthEmail Shows "failed" Status
Check the DLQ for the failure details:
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3002/v1/admin/dlq?source=email&status=pending"Common failure causes:
| Error | Resolution |
|---|---|
| Resend API timeout | Transient -- retry via /resend endpoint |
| Template rendering error | Bug in the email template code. Fix and redeploy. |
| Invalid email address | Contact has a malformed email. Update the contact. |
| Resend API key invalid | Check the RESEND_API_KEY environment variable |
| Rate limited by Resend | Reduce sending volume or contact Resend for higher limits |
For the full endpoint specification, see the API Reference. For per-template metrics and trends, see Metrics & Analytics.
Bucket Operations
Run buckets in production — enable/disable, the reconcile cron, backfill and re-evaluation, observing membership in Studio, and the optional PostHog sync
Test mode
The provider-neutral safety net — while your sending domain is unverified, every email redirects to your own inbox instead of the real recipient. How it activates, what a redirected send looks like, and how to exit it.