Docs / Receiving email

Inbound email

Inbound is the mirror of the send API: instead of handing us a message to deliver, you point a hostname you own at Postwing and we hand you every message that arrives for it — parsed, signed and POSTed to your service as an inbound.received webhook, with links to the verbatim .eml and each attachment.

This is not a mailbox. Nothing is kept for a human to read in a mail client, there is no IMAP or POP3, and you cannot reply from an inbound address (send through the normal API or SMTP instead). It exists so mail can reach your code — a ticket system, a CRM, a parser, a reply-tracking pipeline.

How it works

  1. You set a receiving hostname — a subdomain of a domain you have already verified, e.g. inbound.yourdomain.com.
  2. You publish one MX record for that hostname pointing at us, and we confirm it resolves.
  3. You add routes: which local parts you accept, and which webhook endpoint each one delivers to.
  4. A sender's mail server connects to ours, we accept the message, store it, and POST it to the endpoint the matching route names.
  5. The message stays browsable in the panel until your plan's log retention sweeps it.

Before you start

  • A plan with inbound. See the pricing table — the plan also caps how many routes a domain may hold. Reading stays open after a downgrade; only creating and editing is refused.
  • A verified domain. The domain's DKIM must be verified. This is the load-bearing check: an MX record is public zone data that anyone could claim to expect, so verification of the domain — not of the MX — is what proves the mail is yours.
  • A webhook endpoint for the domain, subscribed to inbound.received.

1. Set the receiving hostname

In the panel, open your domain and go to Inbound → Setup. The field is pre-filled with inbound.<your domain>; any subdomain works.

  • It must be a strict subdomain of the domain — never the apex. Pointing the apex's MX at us would take over the mail your company already receives there.
  • It cannot be a hostname inside our own namespace.
  • Saving an empty hostname turns inbound off for the domain and removes the MX record we track.

Changing the hostname later replaces the record, and the verification goes with it — the new name is checked from scratch.

2. Publish the MX record

The setup screen shows the exact record, including the target to use. Publish it in your DNS zone:

inbound.yourdomain.com.   IN   MX   10   mx.postwing.app.

Preference 10, and it should be the only MX for that hostname. Saving the hostname schedules an immediate check; after that a daily sweep re-checks it, and the Re-check button on the setup screen forces one. The badge turns green once the record resolves to us — until then, senders are rejected.

Both conditions are required before a single message is accepted: the domain is verified and the MX resolves to us. A route created before either is done is stored happily and receives nothing.

3. Subscribe an endpoint to inbound.received

Inbound uses the same webhook machinery as delivery events — the same signature scheme, the same headers, the same retry schedule. See the Webhooks page for verification code. Two differences matter:

  • inbound.received is opt-in. It is not in a new endpoint's default events — tick it explicitly, or the endpoint looks correct and delivers nothing. The route editor warns you when the endpoint you picked is not subscribed, and offers to fix it in place.
  • It does not fan out. Ordinary events go to every subscribed endpoint; an inbound message goes to the one endpoint its route names. Subscribing is necessary but not sufficient.

What happens when your endpoint fails

The same as for any other event, and worth knowing before you need it: anything other than a 2xx — or a network error, or no answer within the 10-second timeout — counts as a failure, and the delivery is retried on the standard backoff (60s → 5m → 30m → 2h → 6h → 24h). After the last retry it is marked permanently failed and dropped. Full detail on Webhooks.

What is specific to inbound: the message itself is not lost with the delivery. It was stored before your endpoint was ever called, so a message whose webhook exhausted its retries is still in Inbound → Messages, with its body, its .eml and its attachments, until retention removes it. If your service was down for an afternoon, replay from there or from GET /api/inbound/messages/ rather than asking senders to resend. Webhooks → Deliveries shows every attempt, its response code and the last error, so you can tell "we never sent it" from "you never took it".

4. Add routes

Inbound → Routes. A route is a pattern for the local part only — the host is always the domain's current receiving hostname, so a route cannot outlive a hostname change pointing at nothing. The kind is derived from the pattern:

PatternKindMatches
supportexactsupport@ and nothing else
ticket-*prefixticket-91@, ticket-abc@ — but not the bare ticket-@
*catch_allanything no other route claimed

The most specific route wins: an exact match first, then the longest matching prefix, then catch-all. An address that no enabled route matches is refused at the SMTP level — we never accept mail for a hostname just because we recognise it.

Plus tags are not stripped

The local part is compared verbatim, lowercased and otherwise untouched. There is no +tag handling, no dot folding, and no other normalisation — support+ticket42 is a different local part from support, and that is all the matcher sees.

AddressRoute supportRoute support*Route *
support@✅ matches❌ (needs something after the prefix)
support+ticket42@no match✅ matches

So if you generate plus-tagged reply addresses and want them handled by one route, add the prefix route support* — a catch-all is not the only option. Note that support* is a plain string prefix, so it also claims supportive@ and support-eu@; keep both the exact support route and the prefix one if you want them recorded separately, since exact wins over prefix for the bare address.

Each route names one webhook endpoint, carries an optional description, and can be disabled without deleting it. The number of routes a domain may hold comes from its plan; the routes screen shows the count against the limit.

The payload

Sent as a signed POST exactly like every other event — X-Webhook-Event is inbound.received. The body is a third shape, larger than the delivery events and with no sending status in it: what you get is the message.

json
{
    "event_id": "6f1d2c30-0004-4c7a-9b21-0c8e5a3d7f44",
    "event": "inbound.received",
    "inbound_id": "b41f9a70-2c55-4d1e-9a7f-1d3e5c9b2a08",
    "domain": "yourdomain.com",
    "domain_id": "0f2b8c14-9a3d-4e57-8b6f-2c1d0e9a7b53",
    "route": "support",
    "recipient": "support@inbound.yourdomain.com",
    "mail_from": "alex@partner.example",
    "from": { "email": "alex@partner.example", "name": "Alex" },
    "to": ["support@inbound.yourdomain.com"],
    "cc": [],
    "subject": "Invoice 4417",
    "text": "Attached, as promised.",
    "html": "<p>Attached, as promised.</p>",
    "headers": { "Auto-Submitted": ["no"] },
    "message_id": "<CAF9x1@mail.partner.example>",
    "auth": { "dkim": "pass", "dkim_aligned": true },
    "size": 24188,
    "raw_url": "https://api.postwing.app/api/inbound/messages/b41f9a70-.../raw/",
    "attachments": [
        {
            "id": "9c7e1b52-40a8-4f9d-8c31-6b2a4e0d1f77",
            "filename": "invoice.pdf",
            "content_type": "application/pdf",
            "size": 18422,
            "inline": false,
            "content_id": "",
            "url": "https://api.postwing.app/api/inbound/messages/b41f9a70-.../attachments/9c7e1b52-.../"
        }
    ],
    "timestamp": "2026-06-24T09:41:13.482921+00:00"
}
FieldNotes
event_idYour idempotency key. Identical across retries.
inbound_idThe stored message's uuid — the key for every API call below.
routeThe pattern of the route that matched, e.g. ticket-*.
recipientThe address the message was accepted for.
mail_fromThe SMTP envelope sender — often not the From: header, and empty for a bounce.
fromParsed From: header: email and display name.
to / ccParsed header addresses. A message can list recipients you never accepted — do not trust these as delivery targets.
text / htmlThe body parts, whichever the message carried. Truncated at 500,000 characters.
headersA fixed set of headers, each a list of values — the exact list is below.
message_idThe sender's own Message-ID, verbatim and including the angle brackets, or "" if the message carried none. Nothing is generated here — see Webhooks for the contrast with delivery events, where message_id is the id we assigned to mail you sent.
authWhat we measured — see below.
sizeSize of the raw message in bytes.
raw_urlAPI path to the verbatim .eml.
attachmentsFilename, content type, size, whether it was inline, its content_id, and an API path to fetch it.

headers — the exact list

Not a sample: this is the whole set, and nothing outside it is ever present. Everything else — the Received: chain, the ARC-* block, X-Spam-* from an upstream filter, any header your correspondent's client invented — is in the .eml at raw_url and nowhere else. Copying the whole block would mean tens of KB of forwarding metadata in every payload.

HeaderWhat it is for
DateWhen the sender's client says it was written.
Reply-ToWhere a reply should go, when it is not From:.
In-Reply-ToThe parent message's Message-ID.
ReferencesIncluded. The full thread chain — the fallback when In-Reply-To is missing or the client only sets one of the two.
Return-PathThe bounce address as written by the last hop.
Auto-SubmittedRFC 3834 automation marker (auto-replied, auto-generated).
PrecedenceThe older bulk / list / junk convention.
List-IdMailing-list identity.
List-UnsubscribeThe list's unsubscribe target.
Content-TypeThe top-level type of the message you were handed.
User-Agent / X-MailerWhich client sent it — the two spellings in the wild.
X-PrioritySender-declared priority, where the client sets one.

Three details that decide how you parse it:

  • Every value is a list, even when the header appeared once — headers["References"][0], never headers["References"]. A header can legitimately repeat, and a consumer that has to branch on the type of each value is a consumer that will get it wrong once.
  • A key is absent, not empty, when the header was not present. Use .get(name, []); the example payload shows only Auto-Submitted because that message carried only that one.
  • Values are normalised: folded lines are joined and runs of whitespace collapsed to a single space, then the value is truncated at 2,000 characters. A very long References chain on an old thread can hit that — go to the .eml if you need it whole.

auth — DKIM only, on purpose

auth carries dkim and dkim_aligned, and deliberately no spf and no dmarc.

  • dkim is the RFC 8601 five-valued result: pass, fail, none, temperror, permerror. temperror means our resolver blinked — it is not evidence of forgery, and treating it as one will drop real mail.
  • dkim_aligned says the signing domain matches the From: domain. A valid signature by some unrelated domain is not the sender being who they claim.
  • There is no SPF evaluator here, and reporting "spf": "none" would be a false claim — in RFC 8601 none asserts the domain publishes no SPF record at all.
  • A DMARC verdict is a disjunction (SPF-aligned or DKIM-aligned). Computing it from DKIM alone produces false failures, which is the wrong direction of error for a field people drop mail on.

Treat a sender as verified when dkim == "pass" and dkim_aligned is true.

Handling it

python
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhooks/inbound")
def inbound():
    # Verify the signature exactly as for any other event — see the Webhooks
    # page. The scheme and headers are identical.
    evt = request.get_json()
    if evt["event"] != "inbound.received":
        return "", 200

    # Dedupe on event_id: retries re-send the same payload unchanged.
    if already_handled(evt["event_id"]):
        return "", 200

    # Trust the sender only when DKIM passed AND aligns with the From: domain.
    trusted = evt["auth"]["dkim"] == "pass" and evt["auth"]["dkim_aligned"]

    open_ticket(
        route=evt["route"],                  # "support"
        sender=evt["from"]["email"],
        subject=evt["subject"],
        body=evt["text"] or evt["html"],
        trusted=trusted,
        attachments=[a["url"] for a in evt["attachments"]],
    )
    return "", 200

Authenticating these API calls

Everything under /api/ — the routes, the messages, the raw_url and every attachment link — is the account API, and it authenticates as you, the account holder. It accepts two credentials, and they are sent differently:

CredentialWhere you get itHeader
Account token
24 characters, no prefix
Dashboard → Profile, the Using Terraform card. It is shown in full whenever you open the page; Generate New Token issues a new one and kills the old one immediately. This is the one to put in an environment variable.Authorization: <token>
sent bare — no Bearer
JWT access tokenPOST /api/users/auth/ with your dashboard username and password, which answers { "access": …, "refresh": … }. The access value expires 60 minutes after issue. This is what the dashboard itself uses; for a server-side integration prefer the account token.Authorization: Bearer <access>
The domain token from Sending email over the API — the auth: { username, password } pair you also use for the SMTP relay — is a different credential and does not work here. It authenticates one domain for sending, on the /external/ endpoints only, and is passed in the request body, never in a header. Sending it to /api/ returns 401.

Despite the card's name, the account token is not Terraform-specific: it is a general-purpose token for the whole account API, and the Terraform provider is just one thing that consumes it. Treat it as a password — it carries everything your login can do, so keep it server-side and rotate it if it leaks.

The raw message and attachments

raw_url and each attachment's url are our own API paths, never presigned storage URLs. The payload is stored on the delivery row and re-sent unchanged by every retry — the last of which is 24 hours later — so a signature baked into it would be long dead by then. These paths are stable and authenticated, and redirect to a freshly-signed download link when you fetch them.

python
import os
import requests

# The links in the payload are OUR API paths, not storage URLs: they are
# stable, they need an account token, and they 302 to a freshly-signed
# download link when you actually fetch them.
res = requests.get(
    attachment_url,
    # The account token, sent BARE — no "Bearer", no "Token" prefix.
    headers={"Authorization": os.environ["POSTWING_API_TOKEN"]},
    allow_redirects=True,      # follow the 302 to the signed URL
)
open("invoice.pdf", "wb").write(res.content)
python
# The other accepted credential: a JWT access token from POST /api/users/auth/.
# This one DOES take the "Bearer " prefix, and expires 60 minutes after issue.
res = requests.get(
    attachment_url,
    headers={"Authorization": f"Bearer {access_token}"},
    allow_redirects=True,
)

Both also have a …/url sibling (/raw_url/, /attachments/<id>/url) that answers the signed link in a JSON body instead of a redirect, for callers that cannot follow a cross-origin 302 with an Authorization header — that is what the panel's download buttons use. Signed links expire after 5 minutes; ask for a new one.

In the panel

  • Setup — the hostname, the MX record to publish, and whether it resolves.
  • Routes — the routes and their targets, with a warning on any whose endpoint is not subscribed to inbound.received.
  • Messages — everything received, filterable by route and DKIM result. Open one for the parsed body, the headers, the DKIM verdict, and download buttons for the .eml and each attachment. Deleting a message removes its stored blobs too.

Limits

LimitValue
Maximum message size30 MB
Attachments per message25, up to 25 MB each
Stored body length500,000 characters per part
Messages per domain500 per hour
Routes per domainSet by the plan
RetentionYour plan's log retention — messages, the .eml and the attachments are all deleted together

What a sender sees

ReplyWhen
250 Message acceptedStored, and the webhook is queued.
550No enabled route matches the address. A hard bounce — the sender is told the address does not exist.
552Over the size limit.
450Over the hourly rate for the domain; the sender's server will retry.
451Something failed on our side; the sender's server will retry.

Delivery guarantees

  • At-least-once, on both legs. A sender whose connection times out after DATA re-delivers, and our own webhook retries on failure. Deduplicate on event_id.
  • Identical bytes re-delivered for the same recipient are recognised and do not fire your webhook twice. A message addressed to two of your routes is two messages, not a duplicate.
  • Loops are guarded, not prevented. A message whose Received: chain shows too many hops is refused, and the hourly rate limit backs that up. Do not build an autoresponder that replies to the route address.
  • Autoreplies, bounces and list mail are not discarded — that is your mail, and Auto-Submitted alone is not evidence of a loop. The identifying headers are handed to you in headers so your code can filter.

API

EndpointWhat it does
GET /api/inbound/hostname/<domain uuid>/The hostname, the MX target to publish, and whether it resolves.
PUT /api/inbound/hostname/<domain uuid>/{"hostname": "inbound.yourdomain.com"} — creates the MX record and schedules a check. An empty string turns inbound off.
GET/POST /api/inbound/routes/List (?domain=<pk>) and create routes. kind is derived from the pattern and read-only.
GET/PUT/PATCH/DELETE /api/inbound/routes/<uuid>/One route.
GET /api/inbound/messages/Received messages. Filter with ?domain=<pk>, ?route=<uuid>, ?dkim_result=.
GET /api/inbound/messages/<uuid>/One message with its attachment list.
DELETE /api/inbound/messages/<uuid>/Delete it and its stored blobs.
GET /api/inbound/messages/<uuid>/raw/302 to the verbatim .eml.
GET /api/inbound/messages/<uuid>/raw_url/The same link in a JSON body.
GET /api/inbound/messages/<uuid>/attachments/<id>/302 to one attachment.
GET /api/inbound/messages/<uuid>/attachments/<id>/url/The same link in a JSON body.

Resellers manage their customers' routes through /api/wl/v1/inbound-routes/ with the inbound:read / inbound:write scopes; the MX record to publish comes from that domain's records endpoint. See the White-label page.

Troubleshooting

SymptomLook at
Senders get "user unknown"No enabled route matches that local part, or the domain/MX is not verified yet.
Messages appear in the panel, nothing reaches your serviceThe route's endpoint is not subscribed to inbound.received, or it is disabled. Check Webhooks → Deliveries.
A message reached your service once and then stopped arrivingYour endpoint answered non-2xx (or timed out at 10s) and the retries ran out. The message is still in Inbound → Messages — see above.
Mail to support+tag@ lands in the catch-allExpected: plus tags are not stripped. Add a support* prefix route.
401 fetching raw_url or an attachmentWrong credential or wrong header shape. The domain token does not work here; the account token is sent without Bearer. See Authenticating these API calls.
Nothing arrives at allRe-check the MX on the setup screen; confirm it is the only MX on that hostname and points at the target shown there.
Mail to the wrong routeRemember the ordering: exact, then longest prefix, then *.
Download links return 403A signed link was reused after it expired — request a fresh one.