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. [email protected]).
  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

Point your sending subdomain's mail to Quolle's inbound endpoint:

TypeNamePriorityValue
MXmail.yourdomain.com10inbound-smtp.us-east-1.amazonaws.com

The region in the value matches where your account sends from. If you also use this subdomain's MX for a white-label Return-Path, both records can coexist.

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": "[email protected]",
    "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
slaHoursoptionalFirst-response SLA for replies on this route (1–168, default 24) — surfaced in Conversations

* Provide either matchAddress or matchDomain.

// Response 201 — save the secret, it's shown only once
{
  "route": { "id": "uuid", "matchAddress": "[email protected]", "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": "[email protected]",
  "to": ["[email protected]"],
  "subject": "Where is my order?",
  "text": "Hi, order ORD-8821 hasn't arrived…",
  "html": "<p>Hi, order ORD-8821…</p>",
  "messageId": "<[email protected]>",
  "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": "[email protected]",
      "toAddrs": ["[email protected]"],
      "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.