Skip to content

Log format

Posthorn writes structured JSON logs to stdout. Every line is a single JSON object with a consistent set of fields. This page documents the schema so you can build queries, alerts, and dashboards on top of it.

These appear on every log line:

FieldTypeDescription
timestring (RFC 3339, UTC, ms precision)When the log was written
levelstringINFO, WARN, ERROR (Posthorn does not currently emit DEBUG)
msgstringEvent name (see catalog below)

These appear on every log line emitted while processing a specific HTTP request:

FieldTypeDescription
submission_idstring (UUIDv4)Generated at request receipt; threads through every log line for the request
endpointstringEndpoint path from config ("/api/contact")
transportstringTransport type from config ("postmark")

These appear on terminal events (the final line for a request, success or failure):

FieldTypeDescription
latency_msintTotal request duration

These appear on every log line emitted by a single SMTP session (when the SMTP ingress is configured):

FieldTypeDescription
session_idstring (UUIDv4)Generated when the TCP connection is accepted; threads through every session log line
{
"time": "2026-05-16T20:01:23.000Z",
"level": "INFO",
"msg": "submission_received",
"submission_id": "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f",
"endpoint": "/api/contact",
"transport": "postmark"
}

Emitted after all defenses pass (mode checks, idempotency lookup, origin, rate limit, body parse, honeypot, CSRF, validation) and the submission is about to be rendered + sent. The terminal event (submission_sent, submission_failed, or submission_dry_run) carries latency_ms.

API-mode only. The Authorization: Bearer <key> was missing or didn’t match any configured key.

{
"level": "INFO",
"msg": "auth_failed",
"submission_id": "...",
"endpoint": "/api/transactional",
"transport": "postmark",
"latency_ms": 0
}

Key material is never logged.

API-mode only. Per-IP brute-force defense: 10 failed-auth attempts from this client IP within ~1 minute, so subsequent failures return 429 instead of 401. The bucket refills over the same window, so a slow legitimate caller who occasionally typos a key isn’t affected; a brute-force scanner hits it immediately.

{
"level": "INFO",
"msg": "auth_rate_limited",
"submission_id": "...",
"endpoint": "/api/transactional",
"transport": "postmark",
"client_ip": "203.0.113.42",
"latency_ms": 0
}

client_ip is included for forensic follow-up. Omitted when strip_client_ip = true is set on the endpoint.

API-mode only. An Idempotency-Key matched a cached prior request; the original response was replayed.

{
"level": "INFO",
"msg": "idempotent_replay",
"submission_id": "...",
"endpoint": "/api/transactional",
"transport": "postmark",
"idempotency_key": "reset:user-123:2026-05-16T20",
"replayed_status": 200,
"latency_ms": 1
}

API-mode only. A request arrived with an Idempotency-Key that’s still in flight (an earlier request with the same key hasn’t returned from the upstream yet). Returned as 409.

{
"level": "INFO",
"msg": "idempotent_conflict",
"submission_id": "...",
"endpoint": "/api/transactional",
"transport": "postmark",
"idempotency_key": "...",
"latency_ms": 0
}
{
"level": "INFO",
"msg": "spam_blocked",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"kind": "honeypot",
"latency_ms": 1
}

kind is one of:

kindTriggerExtra fields
honeypotHoneypot field non-empty
originOrigin/Referer failed the allowed_origins checkreason (specific check that fired)

The body_too_large case is its own event (below), not a spam_blocked.kind.

The request body exceeded max_body_size (or the api-mode body-cap default). Returned as 413.

{
"level": "INFO",
"msg": "body_too_large",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"limit_bytes": 1048576,
"latency_ms": 2
}

Rate-limit token bucket empty for this client (form mode = client IP, API mode = matched API key). Returned as 429.

{
"level": "INFO",
"msg": "rate_limited",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"client_ip": "203.0.113.42",
"latency_ms": 1
}

client_ip is present on form-mode rate-limit events; omitted when strip_client_ip = true is set on the endpoint, or on api-mode events (api keys are never logged).

Form-mode only, when csrf_secret is configured. The _csrf_token form field was missing, expired, or had an invalid signature. Returned as 403.

{
"level": "INFO",
"msg": "csrf_rejected",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"reason": "csrf: token expired",
"latency_ms": 1
}

Required field missing or email malformed. Returned as 422.

{
"level": "INFO",
"msg": "validation_failed",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"fields": ["name", "email"],
"latency_ms": 2
}

Subject or body template failed to render against the submitted form. Returned as 500.

{
"level": "ERROR",
"msg": "template_render_failed",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"error": "template: subject:1:8: executing \"subject\" at <.missing_field>: ..."
}

Dry-run endpoint (dry_run = true) short-circuited before transport. Returned as 200 with the prepared message in the response body.

{
"level": "INFO",
"msg": "submission_dry_run",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"latency_ms": 3
}

Transport returned a retryable error (transient/5xx or 429). Posthorn is about to wait delay and try again.

{
"level": "INFO",
"msg": "send_retry_scheduled",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"class": "transient",
"status": 503,
"delay": 1000000000
}

class is one of:

ValueMeaningDelay
transient5xx or network error1s
rate_limitedTransport returned 4295s

delay is nanoseconds (slog’s Duration default encoding).

The retry returned success.

{
"level": "INFO",
"msg": "send_retry_succeeded",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark"
}

The subsequent submission_sent carries latency_ms and transport_message_id.

The retry also failed. Terminal — the subsequent line is submission_failed.

{
"level": "INFO",
"msg": "send_retry_failed",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"error": "postmark: 422 The 'From' address is not a valid Sender Signature."
}

The transport accepted the message. Returned as 200.

{
"level": "INFO",
"msg": "submission_sent",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"latency_ms": 312,
"transport_message_id": "abc123-postmark-id"
}

transport_message_id is the upstream’s identifier (Postmark’s MessageID, Resend’s id, SES’s SES Message ID, etc.). Useful for pivoting from Posthorn logs to the provider’s UI. Always omitted on outbound-SMTP sends — stdlib net/smtp doesn’t surface the relay’s queued as <id> response, so there’s no ID to log. Correlate by timestamp + recipient against the relay’s own logs in that case.

The transport rejected the message terminally (4xx other than 429, or 5xx after retry exhausted, or 10s timeout). Returned as 502.

{
"level": "ERROR",
"msg": "submission_failed",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"error": "postmark: 422 The 'From' address is not a valid Sender Signature.",
"form": {
"name": "Alice Smith",
"email": "alice@example.com",
"message": "..."
},
"latency_ms": 287
}

When log_failed_submissions = false, form is replaced by form_fields (key names only, no values):

{
"...": "...",
"form_fields": ["name", "email", "message"]
}

The honeypot field, if present in the submitted form, is redacted to "<redacted>" in the form map so spam payloads don’t immortalize in operator logs.

Emitted when [smtp_listener] is configured. Every event carries session_id and standard fields.

TCP connection accepted.

{
"level": "INFO",
"msg": "smtp_session_open",
"session_id": "...",
"tls": "no"
}

smtp_tls_established / smtp_tls_handshake_failed

Section titled “smtp_tls_established / smtp_tls_handshake_failed”

After STARTTLS. The failed variant carries error.

AUTH PLAIN result. Carries user (the username, never the password).

MAIL FROM: not in allowed_senders. Carries from.

RCPT TO: exceeded session cap or hit the allowed_recipients block. Carries to.

Outbound transport accepted the message. Carries transport_message_id if the upstream returned one.

Outbound transport rejected the message. ERROR level.

The session ended. Carries reason ("quit" for clean QUIT; other values reflect connection-level termination).

Emitted once at process start.

{
"level": "INFO",
"msg": "posthorn starting",
"version": "v1.0.0",
"listen": ":8080",
"config": "/etc/posthorn/config.toml",
"endpoints": 2
}

Emitted per endpoint at startup.

{
"level": "INFO",
"msg": "endpoint registered",
"path": "/api/contact",
"transport": "postmark",
"recipients": 1
}

Emitted at startup if [smtp_listener] is in config.

{
"level": "INFO",
"msg": "smtp_listener registered",
"listen": ":2525",
"transport": "postmark",
"smtp_users": 1
}

http ingress listening / smtp ingress listening

Section titled “http ingress listening / smtp ingress listening”

Each ingress prints a one-time line when its accept loop starts.

SIGTERM or SIGINT received. Posthorn begins draining in-flight requests with a 15-second deadline.

{
"level": "INFO",
"msg": "shutdown signal received",
"signal": "terminated"
}

WARN level. A second SIGTERM/SIGINT arrived during drain; the process exits immediately.

Emitted once after all ingresses have stopped (or the shutdown deadline expired).

# All errors in the last hour
{service="posthorn"} | json | level="ERROR"
# Failed submissions on a specific endpoint
{service="posthorn"} | json | msg="submission_failed" | endpoint="/api/contact"
# Rate of submissions by endpoint over time
sum by (endpoint) (
rate({service="posthorn"} | json | msg="submission_sent" [5m])
)
# Top spam sources
topk(10,
sum by (client_ip) (
count_over_time({service="posthorn"} | json | msg="spam_blocked" [1h])
)
)
Terminal window
# Tail and pretty-print
docker logs -f posthorn | jq .
# All failures from the last hour
docker logs --since 1h posthorn | jq 'select(.level=="ERROR")'
# Latency P99 over the last 1000 sent submissions
docker logs --tail 10000 posthorn \
| jq -s 'map(select(.msg=="submission_sent")) | sort_by(.latency_ms) | .[-10:]'
# Find a specific submission across all log lines
docker logs posthorn | jq 'select(.submission_id=="7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f")'
# Find a specific SMTP session across all log lines
docker logs posthorn | jq 'select(.session_id=="...")'