Inbound e-mail overview

Receive e-mail on your own domain: point its MX at wemail, route each message with a rule, then poll the mailbox or get it forwarded to your webhook.

wemail can be the mail exchanger for a domain you own. Every message delivered to that domain is parsed, checked for SPF / DKIM / DMARC, matched against your inbound routes, and then stored for the poll API (List inbound messages) and/or forwarded as a signed POST to a URL of yours. Replies to the mail you send land in the same place — the in-reply-to and references headers are preserved so you can thread them.

Set-up in three steps

  1. Enable inbound on a verified domain — PUT /v3/domains/{name}/inbound {"enabled":true} (reference). The response appends the MX requirement to dns_records: {"type":"MX","host":"<domain>","value":"10 inbound.wemail.io"}.
  2. Publish the MX record <domain>. MX 10 inbound.wemail.io at your DNS provider — use a dedicated subdomain (e.g. in.example.com): on a domain that already receives mail (inbound.existing_mx not empty) the MX change moves all of its mail to wemail — then call Verify a domain (or wait for the six-hourly re-check) until inbound.ok is true. Mail is accepted as soon as the domain is verified and inbound is enabled — the MX flag only tells you whether the world can reach us.
  3. Add routes (optional) — without a route every message is stored. Create an inbound route to forward some or all messages to your HTTPS endpoint, or to drop automated replies.

API keys and scopes

Reads — GET /v3/inbound/messages, GET /v3/inbound/messages/{id}, GET /v3/routes, GET /v3/routes/{id}, GET /v3/routes/{id}/deliveries — are allowed for `full_access` and `read_only` keys. A `send_only` key is denied on all of them (403 insufficient_scope): a sending credential must never read your inbound mail. Every write — PUT /v3/domains/{name}/inbound, route create / update / delete, DELETE /v3/inbound/messages/{id} — needs `full_access`. Keys are workspace-scoped: an id that belongs to another workspace answers 404, never a hint that it exists.

Route expressions

A route is one expression plus an ordered list of actions. Routes are evaluated in priority order (lower first, ties by creation time); the first matching route wins and its actions run in order. Exactly three forms are accepted — nothing else, no and / or, no single quotes, no trailing text:

  • match_recipient("<pattern>") — tested against the envelope recipient address (lower-cased).
  • match_header("<name>", "<pattern>") — tested against the first value of that header (header names are case-insensitive, values are compared on their first 1 KB).
  • catch_all() — matches every message.

A pattern without regex metacharacters (* + ? ^ $ ( ) [ ] { } | \) is a case-insensitive literal, matched in full — a bare . does not make it a regex, so match_recipient("kostas@leads.example.com") is a literal. Once any metacharacter is present the pattern is a JavaScript regular expression (flags iu, at most 256 characters, nested quantifiers such as (a+)+ are rejected) and . has its regex meaning — write \\. when you mean a literal dot (the expression string escapes a backslash as \\, so \\. reaches the regex as \.). Strings are double-quoted with \" and \\ escapes.

textAccepted
match_recipient("kostas@leads.example.com")      literal
match_recipient(".*@leads\\.example\\.com")       regex — any address on the domain
match_header("subject", "^Re:")                   regex on the subject
match_header("auto-submitted", "auto-replied")    literal
catch_all()
textRejected — 422 invalid_expression
match_recipient('x')                    single quotes
match_recipient("a") and catch_all()   no operators
catch_all                               missing parentheses
match_header("subject")                 two arguments required
match_recipient("")                     empty pattern
match_recipient("(a+)+
quot;) nested quantifier

Route actions

  • {"type":"store"} — keep the message for the poll API (GET /v3/inbound/messages).
  • {"type":"forward","url":"https://…","secret":"…"} — POST the message to a public HTTPS URL. secret (optional, 8–128 characters) turns on the Apifon-Signature HMAC header; it is stored encrypted and never returned (has_secret: true).
  • {"type":"stop"} — ignore the remaining actions of this route.

Between one and five actions per route. A matching route without store leaves the message out of the poll API (routing-only; a forward-only or stop-only route therefore acts as forward-only or drop). No matching route is the same as [{"type":"store"}]. Malformed actions answer 422 invalid_actions; a forward URL that is not a public HTTPS address (private networks, localhost, link-local) answers 400 validation_error.

The forward contract

Each forward action sends POST <url> with Content-Type: application/json and the headers X-Wemail-Event: inbound.received, X-Wemail-Delivery: whd_… (unique per delivery — use it to de-duplicate), X-Webhook-Attempt: <n>, Wemail-Signature-Ed25519 (the same key-based signature as every webhook) and, when the action has a secret, Apifon-Signature plus X-Apifon-Signature — the hex HMAC-SHA256 of the raw request body keyed with your secret (openssl dgst -sha256 -hmac "$SECRET" over the body reproduces it). The body is the `InboundMessage` object plus "event": "inbound.received". Answer 2xx within 30 seconds; timeouts, network errors and 5xx are retried up to 7 times with increasing back-off (30 s … 24 h), 4xx is final. Every attempt is visible under List forward deliveries.

Limits, retention and what the SMTP sender sees

  • Messages are kept for 30 days, then purged (attachment metadata and delivery attempts go with them). Delete earlier with `DELETE /v3/inbound/messages/{id}`.
  • Quota — a plan allows a number of stored messages and bytes per rolling 30 days (10 000 messages / 1 GiB on Professional). When either is reached the receiver answers `452 4.2.2 inbound quota exceeded` at RCPT TO: a well-behaved sender retries later; nothing is lost silently.
  • Message size up to 25 MB (552 5.3.4 above), at most 50 recipients per transaction (452 4.5.3), bodies truncated at 1 MB each (text_truncated / html_truncated), up to 100 attachments as metadata only (url is always null in this version).
  • A recipient domain that is not verified and inbound-enabled answers 550 5.1.1 … recipient domain is not configured for inbound mail; a temporary platform problem answers 451 4.3.0 (the sender retries).
  • Duplicates: a message re-delivered with the same Message-ID to the same domain is accepted (250) but stored only once and triggers no second forward.
  • SPF, DKIM and DMARC verdicts (pass, fail, softfail, neutral, none, unknown) are informational — they never block acceptance.