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.
[email protected]). - 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
Point your sending subdomain's mail to Quolle's inbound endpoint:
| Type | Name | Priority | Value |
|---|---|---|---|
MX | mail.yourdomain.com | 10 | inbound-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"
}'
| 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 |
slaHours | optional | First-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 }
]
}
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": "[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
}
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.