Inbound Email

Receive email at your verified domain, parsed into clean JSON and delivered to your webhook. Build reply handling, support inboxes, and email-to-action workflows without running your own mail server.

How it works

  1. You add an MX record pointing your domain's mail to Quolle.
  2. Someone emails an address at that domain (e.g. support@mail.yourdomain.com).
  3. Quolle parses the raw message into JSON.
  4. Quolle POSTs it to the webhook URL on your matching inbound route, signed so you can verify it came from us.
You can only route inbound mail for a domain you've verified — this prevents anyone from intercepting mail for a domain they don't own.

1. Add the MX record

Pick a dedicated subdomain to receive mail on — e.g. inbound.mail.yourdomain.com — and point its MX at Quolle's inbound endpoint:

TypeNamePriorityValue
MXinbound.mail.yourdomain.com10inbound-smtp.us-east-1.amazonaws.com

Because a verified domain also covers its subdomains, this works with no extra verification — verifying mail.yourdomain.com to send already lets you receive at any subdomain of it. The region in the value matches where your account sends from.

Don't put this MX on your sending or MAIL FROM subdomain (e.g. mail.yourdomain.com itself). That host must keep exactly one MX — its Return-Path record for bounces — and a second MX there breaks bounce handling. Always use a separate subdomain like inbound.mail.yourdomain.com. Avoid a root-domain MX too: it diverts all of that domain's mail.

2. Create an inbound route

A route maps an address (or a whole domain) to a destination webhook. An exact-address match takes precedence over a domain-wide match.

curl -X POST https://api.quolle.com/v1/inbound/routes \
  -H "Authorization: Bearer <jwt>" \
  -H "Content-Type: application/json" \
  -d '{
    "matchAddress": "support@inbound.mail.yourdomain.com",
    "webhookUrl": "https://yourapp.com/inbound/quolle"
  }'
FieldRequiredDescription
matchAddressone of*Route a single address
matchDomainone of*Route every address at a domain
webhookUrlrequiredHTTPS URL to receive parsed mail

* Provide either matchAddress or matchDomain.

// Response 201 — save the secret, it's shown only once
{
  "route": { "id": "uuid", "matchAddress": "support@inbound.mail.yourdomain.com", "webhookUrl": "...", "enabled": true },
  "secret": "whsec_...",
  "message": "Save this secret — it verifies inbound webhook payloads and won't be shown again."
}

// 403 — domain not verified
{ "error": "You must verify mail.yourdomain.com before routing its inbound mail" }

List routes with GET /v1/inbound/routes and remove one with DELETE /v1/inbound/routes/:id. You can also manage routes from the Settings → Inbound tab in your dashboard.

3. Receive the webhook

Each inbound email arrives as a signed POST to your webhookUrl:

{
  "event": "email.inbound",
  "from": "customer@gmail.com",
  "to": ["support@mail.yourdomain.com"],
  "subject": "Where is my order?",
  "text": "Hi, order ORD-8821 hasn't arrived…",
  "html": "<p>Hi, order ORD-8821…</p>",
  "messageId": "<abc@mail.gmail.com>",
  "date": "2026-07-14T10:03:00.000Z",
  "attachments": [
    { "filename": "receipt.pdf", "contentType": "application/pdf", "size": 48213 }
  ]
}
The webhook carries attachment metadata (filename, type, size) to keep payloads small — attachment content is stored and downloadable via the Received emails API below. Large emails and attachments are handled (no small size cap).

Verify the signature

Every request carries a Quolle-Signature header (t=<timestamp>,v1=<hmac>). Compute HMAC-SHA256 of <timestamp>.<raw-body> with your route secret and compare:

import crypto from "crypto";

app.post("/inbound/quolle", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.headers["quolle-signature"];       // "t=...,v1=..."
  const [tPart, v1Part] = header.split(",");
  const timestamp = tPart.slice(2);
  const signature = v1Part.slice(3);

  const expected = crypto
    .createHmac("sha256", process.env.QUOLLE_INBOUND_SECRET)  // the whsec_ from step 2
    .update(`${timestamp}.${req.body}`)                        // req.body is the raw Buffer
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const email = JSON.parse(req.body);
  // …handle email.from, email.subject, email.text…
  res.sendStatus(200);
});
Respond 2xx quickly, then do slow work (AI replies, ticket creation) asynchronously. If your endpoint is down or returns a non-2xx, delivery is retried automatically with backoff (5 attempts), and the email is stored either way — so nothing is lost, and you can replay it later.

Received emails API

Every received email is stored (for 30 days), so you can list, retrieve, download attachments, and re-deliver — even for mail whose webhook delivery failed. These endpoints use your dashboard session (JWT), the same as route management.

EndpointDescription
GET /v1/inbound/emailsList received emails with delivery status (?status=pending|delivered|failed, limit, offset)
GET /v1/inbound/emails/:idRetrieve one email with its full parsed content
GET /v1/inbound/emails/:id/attachments/:indexDownload an attachment's content (streamed)
POST /v1/inbound/emails/:id/replayRe-deliver the email to its route webhook
// GET /v1/inbound/emails
{
  "emails": [
    {
      "id": "uuid",
      "fromAddr": "customer@gmail.com",
      "toAddrs": ["support@mail.yourdomain.com"],
      "subject": "Where is my order?",
      "status": "delivered",        // pending | delivered | failed
      "attempts": 1,
      "lastError": null,
      "deliveredAt": "2026-07-14T10:03:05.000Z",
      "receivedAt": "2026-07-14T10:03:00.000Z"
    }
  ],
  "total": 1
}
A failed delivery (endpoint was down, kept returning non-2xx) shows as status: "failed". Fix your endpoint, then POST /v1/inbound/emails/:id/replay to send it again. You can also do all of this from the Settings → Inbound tab in your dashboard, including downloading attachments.