Skip to content

Webhook

The webhook transport delivers submissions to an HTTP receiver you control instead of a mail provider: a tiny bridge that posts into Slack, a ticketing system, an analytics pipeline. Everything upstream is identical — same ingress shapes, same spam stack, same rate limits, same retry policy — only the sink changes.

[[endpoints]]
path = "/api/contact"
to = ["ops@example.com"] # carried in the payload envelope
from = "Contact <noreply@example.com>"
subject = "Contact from {{.name}}"
body = "{{.message}}"
[endpoints.transport]
type = "webhook"
[endpoints.transport.settings]
url = "https://hooks.internal.example/contact-form"
secret = "${env.CONTACT_HOOK_SECRET}" # 16+ bytes
[endpoints.transport.settings.headers] # optional static headers
X-Team = "infra"
SettingRequiredDescription
urlyesAbsolute http(s) receiver URL.
secretyesHMAC-SHA256 signing key, minimum 16 bytes. Never logged.
headersnoStatic headers added to every request (receiver auth tokens, routing labels). Content-Type and X-Posthorn-Signature are reserved and rejected at config load.

Unlike mail transports, the receiver gets the structured submission, not just rendered prose:

{
"submission_id": "0d5aa9c8-...",
"from": "Contact <noreply@example.com>",
"to": ["ops@example.com"],
"reply_to": "jane@example.com",
"subject": "Contact from Jane",
"body_text": "rendered body text",
"body_html": "<p>only when body_format = \"html\"</p>",
"fields": {
"name": ["Jane"],
"message": ["the raw submitted values"]
}
}

fields carries the raw form/JSON values (structural token fields removed, honeypot redacted); submissions from the SMTP listener have no fields. Deduplicate on submission_id: with storage enabled, queued retries are at-least-once, and a replayed delivery carries the same ID.

Same scheme as lifecycle events: X-Posthorn-Signature: sha256=<hex> is the HMAC-SHA256 of the exact request body under your secret. One verifier covers both.

Receiver responseError classRetry?
Any 2xx(success)no
429ErrRateLimitedyes, after 5s
5xxErrTransientyes, after 1s
Network timeout / refusedErrTransientyes, after 1s
Other 4xxErrTerminalno

With storage enabled, transient exhaustion queues the submission for background retry exactly like mail — a flaky receiver gets the delivery when it recovers.