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
- You add an MX record pointing your domain's mail to Quolle.
- Someone emails an address at that domain (e.g.
support@mail.yourdomain.com). - Quolle parses the raw message into JSON.
- Quolle POSTs it to the webhook URL on your matching inbound route, signed so you can verify it came from us.
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:
| Type | Name | Priority | Value |
|---|---|---|---|
MX | inbound.mail.yourdomain.com | 10 | inbound-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.
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"
}'| Field | Required | Description |
|---|---|---|
matchAddress | one of* | Route a single address |
matchDomain | one of* | Route every address at a domain |
webhookUrl | required | HTTPS 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 }
]
}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);
});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.
| Endpoint | Description |
|---|---|
GET /v1/inbound/emails | List received emails with delivery status (?status=pending|delivered|failed, limit, offset) |
GET /v1/inbound/emails/:id | Retrieve one email with its full parsed content |
GET /v1/inbound/emails/:id/attachments/:index | Download an attachment's content (streamed) |
POST /v1/inbound/emails/:id/replay | Re-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
}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.