Inbound contract
Inbound routing and the delivery pipeline
What Mailhaven expects from your provider, what it does with a delivery, and how to read the record when something goes wrong.
Mailhaven exposes one signed inbound endpoint per account and one shared endpoint for platform-domain tenants. Both reject anything they cannot verify. This page documents the exact contract: the headers we check, the deduplication keys, the retry schedule for content fetches and the fields you can inspect from your own activity log.
Endpoints
Every account gets its own inbound endpoint keyed by account slug, verified against the signing secret you supplied. Tenants on the done-for-you path share a single operator endpoint and are routed by the received_for field on the delivery.
- Bring-your-own
- POST /webhook/inbound/<account-slug>
- Platform domain
- POST /webhook/inbound/platform
- Content type
- application/json
- Expected event
- email.received
Signature verification
The signed content is the concatenation of the delivery id, the timestamp and the raw request body, separated by full stops. The HMAC is computed with the base64-decoded portion of the whsec_ secret and compared in constant time. Deliveries outside the replay window are refused even if the signature is otherwise correct.
- Headers read
- svix-id · svix-timestamp · svix-signature
- Fallback headers
- webhook-id · webhook-timestamp · webhook-signature
- Signed content
- ${id}.${timestamp}.${rawBody}
- Algorithm
- HMAC-SHA256, base64, constant-time compare
- Replay window
- 300 seconds
- Rejection
- 401 unverified — recorded, never processed
Deduplication
A delivery is identified by the provider message id and by the Message-ID header, each scoped to the receiving account. A repeat of either creates no second message; the delivery is still recorded so the duplicate is visible in your activity log.
- Primary key
- unique(accountId, resendMessageId)
- Secondary key
- unique(accountId, messageIdHeader)
- Repeat outcome
- DUPLICATE — existing message untouched
- Race outcome
- Unique violation caught and reported as DUPLICATE
Content fetch and retries
Message metadata is committed before the fetch job is enqueued, so a failure during collection can never lose the message. The job reads the archived payload first and falls back to the provider API when the archive holds no body.
- Job type
- FETCH_MESSAGE_CONTENT
- Attempts
- 6 by default
- Backoff
- 5s · 10s · 20s · 40s · 80s · capped at 30m, with jitter
- Claiming
- SELECT … FOR UPDATE SKIP LOCKED
- Exhausted
- DEAD — retryable by the operator, message retained
Threading
Threads are resolved from In-Reply-To first, then the References chain, both matched against Message-IDs already stored for your account. When a client sends neither, a normalised subject plus a shared participant inside a thirty-day window keeps the conversation together.
- Priority
- In-Reply-To → References → subject window → new thread
- Outbound headers
- Message-ID, In-Reply-To, References
- Reference chain
- Parent chain plus parent Message-ID, trimmed to 20
Spam routing
Authentication results carry the most weight, followed by content heuristics. Scored mail is filed in Spam with the reasons attached and is never deleted. The threshold is an operator setting.
- Inputs
- SPF · DKIM · DMARC · link density · display-name spoofing
- Above threshold
- Filed in Spam with visible reasons
- Never
- Automatic deletion
When a delivery still will not behave
Your own activity log lists every delivery with its verification result, processing outcome and error text, plus the fetch jobs behind it. If the record does not explain it, send the delivery id to the postmaster and it will be traced from the archived payload.