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.
inbound.yourdomain.com.POST it to the endpoint the matching route names.inbound.received. In the panel, open your domain and go to Inbound → Setup. The field is pre-filled with inbound.<your domain>; any subdomain works.
Changing the hostname later replaces the record, and the verification goes with it — the new name is checked from scratch.
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.
inbound.receivedInbound 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. 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".
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:
| Pattern | Kind | Matches |
|---|---|---|
support | exact | support@ and nothing else |
ticket-* | prefix | ticket-91@, ticket-abc@ — but not the bare ticket-@ |
* | catch_all | anything 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.
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.
| Address | Route support | Route 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.
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.
{
"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"
}| Field | Notes |
|---|---|
event_id | Your idempotency key. Identical across retries. |
inbound_id | The stored message's uuid — the key for every API call below. |
route | The pattern of the route that matched, e.g. ticket-*. |
recipient | The address the message was accepted for. |
mail_from | The SMTP envelope sender — often not the From: header, and empty for a bounce. |
from | Parsed From: header: email and display name. |
to / cc | Parsed header addresses. A message can list recipients you never accepted — do not trust these as delivery targets. |
text / html | The body parts, whichever the message carried. Truncated at 500,000 characters. |
headers | A fixed set of headers, each a list of values — the exact list is below. |
message_id | The 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. |
auth | What we measured — see below. |
size | Size of the raw message in bytes. |
raw_url | API path to the verbatim .eml. |
attachments | Filename, 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.
| Header | What it is for |
|---|---|
Date | When the sender's client says it was written. |
Reply-To | Where a reply should go, when it is not From:. |
In-Reply-To | The parent message's Message-ID. |
References | Included. The full thread chain — the fallback when In-Reply-To is missing or the client only sets one of the two. |
Return-Path | The bounce address as written by the last hop. |
Auto-Submitted | RFC 3834 automation marker (auto-replied, auto-generated). |
Precedence | The older bulk / list / junk convention. |
List-Id | Mailing-list identity. |
List-Unsubscribe | The list's unsubscribe target. |
Content-Type | The top-level type of the message you were handed. |
User-Agent / X-Mailer | Which client sent it — the two spellings in the wild. |
X-Priority | Sender-declared priority, where the client sets one. |
Three details that decide how you parse it:
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..get(name, []); the example payload shows only Auto-Submitted because that message carried only that one.References chain on an old thread can hit that — go to the .eml if you need it whole.auth — DKIM only, on purposeauth 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."spf": "none" would be a false claim — in RFC 8601 none asserts the domain publishes no SPF record at all.Treat a sender as verified when dkim == "pass" and dkim_aligned is true.
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 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:
| Credential | Where you get it | Header |
|---|---|---|
| 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 token | POST /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> |
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.
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.
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)# 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.
inbound.received..eml and each attachment. Deleting a message removes its stored blobs too.| Limit | Value |
|---|---|
| Maximum message size | 30 MB |
| Attachments per message | 25, up to 25 MB each |
| Stored body length | 500,000 characters per part |
| Messages per domain | 500 per hour |
| Routes per domain | Set by the plan |
| Retention | Your plan's log retention — messages, the .eml and the attachments are all deleted together |
| Reply | When |
|---|---|
250 Message accepted | Stored, and the webhook is queued. |
550 | No enabled route matches the address. A hard bounce — the sender is told the address does not exist. |
552 | Over the size limit. |
450 | Over the hourly rate for the domain; the sender's server will retry. |
451 | Something failed on our side; the sender's server will retry. |
DATA re-delivers, and our own webhook retries on failure. Deduplicate on event_id.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.Auto-Submitted alone is not evidence of a loop. The identifying headers are handed to you in headers so your code can filter.| Endpoint | What 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.
| Symptom | Look 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 service | The 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 arriving | Your 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-all | Expected: plus tags are not stripped. Add a support* prefix route. |
401 fetching raw_url or an attachment | Wrong 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 all | Re-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 route | Remember the ordering: exact, then longest prefix, then *. |
| Download links return 403 | A signed link was reused after it expired — request a fresh one. |