Receiving inbound emails via webhooks
Point an MX record at our inbound mail server, enable inbound on a verified domain, and we'll forward every email sent to <anything>@inbound.<your-domain> to your HTTPS endpoint as JSON — with attachments uploaded to S3 and a ready-to-fetch download URL.
Overview
We host an inbound mail server. When someone emails any address on a domain you've authenticated and turned inbound on for, we receive the message, parse it, store it in your organization, and POST it to every active webhook you've registered against that domain.
The address recipients use is <anything>@inbound.<your-domain>. Anything before the @ routes to the same place — useful for unique reply addresses, ticket IDs, or per-user mailboxes.
Flow
- Sender's mail provider
- Our MX server (inbound.sendtrim.com)
- We parse the message, dedupe by Message-Id, and resolve the destination domain to your organization
- The email row + any attachment rows are persisted; attachment bytes stream to S3
- One POST per active webhook is sent with attachment metadata and a download URL
- Each attempt is recorded — status code, latency, response body — for inspection in the dashboard or via API
Prerequisites
- A domain already authenticated on SendTrim (SPF + DKIM + DMARC validated). See Domain Setup.
- An MX record on inbound.<your-domain> pointing to inbound.sendtrim.com.
- Inbound enabled on the domain (PUT /api/v1/domains/{domain}/inbound or via the UI toggle). Webhooks only fire for domains where inbound is enabled.
- At least one webhook URL registered against the domain.
- A publicly reachable HTTPS endpoint that accepts JSON POST requests and returns 2xx within ~10 seconds.
1) Set the MX record
Add a single MX record at your DNS provider:
| Type | Host | Value | Priority |
|---|---|---|---|
| MX | inbound.yourdomain.com | inbound.sendtrim.com. | 10 |
DNS may take up to an hour to propagate. Verify with:
dig MX inbound.yourdomain.com +short
# expected: 10 inbound.sendtrim.com.2) Enable inbound for the domain
Flip the inbound toggle from the dashboard, or call the API:
PUT /api/v1/domains/yourdomain.com/inbound
Authorization: Bearer <your-jwt>
{ "enabled": true }We re-check the MX record before allowing the toggle. If the record is missing or wrong, the call fails with:
400 Bad Request
Inbound is not enabled for domain: yourdomain.com3) Manage webhooks (API)
All endpoints are under /api/v1 and require the user's JWT in the Authorization: Bearer <token> header. Organization scoping is automatic.
Create a webhook
POST /api/v1/domains/yourdomain.com/webhooks
Authorization: Bearer <your-jwt>
Content-Type: application/json
{
"url": "https://yourapp.example.com/inbound",
"description": "Customer support intake"
}- url — required. Must start with https:// (or http:// for local testing). Max 1024 characters.
- description — optional free text.
Returns 201 Created with the webhook DTO. Errors: 400 if the URL is invalid or inbound isn't enabled; 404 if the domain doesn't belong to your org.
List webhooks for a domain
GET /api/v1/domains/yourdomain.com/webhooks
Authorization: Bearer <your-jwt>Returns an array of webhook DTOs scoped to your organization.
Update a webhook
PUT /api/v1/webhooks/42
Authorization: Bearer <your-jwt>
Content-Type: application/json
{
"url": "https://yourapp.example.com/inbound-v2",
"description": "Renamed",
"isActive": true
}All fields are optional — only the ones you send are updated. Re-activating (isActive: true) is rejected with 400 if the domain's inbound toggle has been turned off in the meantime.
Delete a webhook
DELETE /api/v1/webhooks/42
Authorization: Bearer <your-jwt>Returns 204 No Content. Soft-deleted — the row is preserved with a deleted_at timestamp.
Webhook DTO shape
{
"id": 42,
"domain": "yourdomain.com",
"url": "https://yourapp.example.com/inbound",
"description": "Customer support intake",
"isActive": true,
"createdAt": "2026-05-20T14:11:02",
"updatedAt": "2026-05-21T09:03:48"
}4) Implement the webhook receiver
Request format
We send a single POST to exactly the URL you registered — we do not append paths or query strings:
POST <your-url>
Content-Type: application/json
X-Sendtrim-Event: inbound.email.received
<JSON body — see "JSON payload reference">Required response
Respond with any 2xx status within ~10 seconds. Anything else (or a timeout) is recorded as a failed delivery. The first 4,096 characters of your response body are captured for inspection.
How to handle it correctly
- Respond fast, process asynchronously. Acknowledge 200 immediately and push the event onto an internal queue or background job. Heavy work — especially attachment downloads — must not block the response.
- Dedupe on referenceId. We dedupe inbound at ingest by Message-Id, but the same event may legitimately be redelivered if we re-process. Store processed referenceIds and skip duplicates.
- Don't trust from for authorization. It is the email's From header, which is forgeable. For routing decisions, use deliveredTo — the SMTP envelope Delivered-To header is the most reliable signal of “which mailbox actually received this”.
- Treat text and html as nullable. Both can be null; if both are present, both are sent.
- Always handle attachments[].failed. When an attachment exceeded the size limit or the S3 upload failed, failed is true and there are no bytes to download.
JSON payload reference
Example payload:
{
"event": "inbound.email.received",
"referenceId": "em_8x2Lq7…",
"messageId": "<CAJh+abc@mail.gmail.com>",
"inReplyTo": "<msg-being-replied-to@…>",
"from": "alice@example.com",
"to": ["support@inbound.yourdomain.com"],
"cc": ["copied@example.com"],
"deliveredTo": "support@inbound.yourdomain.com",
"subject": "Help with my order",
"text": "Plain-text body…",
"html": "<p>HTML body…</p>",
"sentAt": "2026-05-21T10:14:33",
"organizationId": 17,
"attachments": [
{
"referenceId": "att_K9xz…",
"filename": "Screenshot.png",
"contentType": "image/png",
"sizeBytes": 184321,
"s3Key": "inbound/mCc7dgU6w3ZB/…/Screenshot.png",
"isInline": false,
"contentId": null,
"failed": false,
"url": "https://api.sendtrim.com/api/v1/auth/s3/download?folderName=…&fileName=…"
}
]
}| Field | Type | Description |
|---|---|---|
| event | string | Always "inbound.email.received". Also sent in the X-Sendtrim-Event header. |
| referenceId | string | Our stable internal ID for this email. Use it as your dedupe key and to look the message up via API. |
| messageId | string | null | RFC 822 Message-Id including angle brackets. null if the sender omitted it. |
| inReplyTo | string | null | RFC 822 In-Reply-To. Useful for threading. null for new threads. |
| from | string | Sender address (envelope-stripped, no display name). Don't use for authorization. |
| to | string[] | All To recipients. Possibly empty, never null. |
| cc | string[] | All Cc recipients. Possibly empty, never null. |
| deliveredTo | string | SMTP Delivered-To header — the mailbox that actually received the email. Use this for routing. |
| subject | string | Email subject. May be an empty string. |
| text | string | null | Plain-text body, or null if the email had no text part. |
| html | string | null | HTML body, or null if the email had no HTML part. Not sanitized — clean before rendering. |
| sentAt | string | ISO-8601 LocalDateTime (no timezone) from the email's Date header. |
| organizationId | number | Your organization's ID on SendTrim. |
| attachments | object[] | Array of attachment metadata. Empty array when the email had no attachments. See Attachments. |
Attachments
Attachment bytes are not base64-encoded into the webhook. We upload them to S3 during ingest and the webhook carries metadata plus an authenticated download URL. This keeps the payload small and lets you fetch attachments asynchronously after you ack the webhook.
Attachment shape
{
"referenceId": "att_K9xz…",
"filename": "Screenshot 2026-05-19 at 10.31.26.png",
"contentType": "image/png",
"sizeBytes": 184321,
"s3Key": "inbound/mCc7dgU6w3ZB/sD-OSIAXzkCd/XVkFtFbPebPX/Screenshot 2026-05-19 at 10.31.26.png",
"isInline": false,
"contentId": null,
"failed": false,
"url": "https://api.sendtrim.com/api/v1/auth/s3/download?folderName=inbound%2FmCc7dgU6w3ZB%2FsD-OSIAXzkCd%2FXVkFtFbPebPX&fileName=Screenshot%202026-05-19%20at%2010.31.26.png"
}| Field | Description |
|---|---|
| referenceId | Stable per-attachment ID. |
| filename | Original filename. May contain spaces and non-ASCII characters. |
| contentType | MIME type as declared by the sender. |
| sizeBytes | Attachment size in bytes. |
| s3Key | The S3 object key. null when failed: true. |
| isInline | true if the attachment was embedded in the HTML body via cid:<contentId>. |
| contentId | RFC Content-Id. Use to map an inline attachment to its cid: reference in the HTML. |
| failed | true if the S3 upload failed or the attachment exceeded the size limit. When true, s3Key and url may be null — there are no bytes to download. |
| url | Authenticated download URL. Always included unless the attachment failed. |
Size limit
Max per-attachment size is 25 MB (26,214,400 bytes). Over-limit attachments appear in the payload with failed: true and failedReason: "size_exceeded", so you still know they existed.
Downloading the bytes
The url points at GET /api/v1/auth/s3/download and requires authentication — send your SendTrim JWT in the Authorization header. The endpoint streams the bytes with the original Content-Type and Content-Disposition: inline; filename="…".
# cURL
curl -L -o screenshot.png \
-H "Authorization: Bearer $SENDTRIM_TOKEN" \
"$ATTACHMENT_URL"// JavaScript / TypeScript
const res = await fetch(att.url, {
headers: { Authorization: `Bearer ${SENDTRIM_TOKEN}` },
});
const bytes = await res.arrayBuffer();Delivery contract
| Behavior | Detail |
|---|---|
| Timeout | 10 seconds per HTTP call. |
| Retries | None today. Each event is delivered once per active webhook. Failures are logged but not retried. The email itself is still saved and can be replayed via API. |
| Success criteria | HTTP 2xx. Anything else (including timeouts and connection errors) is recorded as success: false. |
| Response capture | We store the first 4,096 characters of your response body for inspection. |
| Order | One POST per (message × active webhook). Multiple webhooks on the same domain all receive the same event independently — if one fails, the others still fire. |
| Dedup contract | We dedupe inbound at ingest by Message-Id. Customers should still dedupe on referenceId because the same event may legitimately be redelivered if we re-process. |
| Signature | We don't currently sign payloads. Treat your webhook URL as a secret. Signed payloads are on our roadmap and will be added in a backwards-compatible way. |
Delivery history API
Every attempt — status code, latency, response body, error — is recorded so you can inspect deliveries from the dashboard or the API. All endpoints are paginated (defaults: page=0, size=50; max size 200).
| Endpoint | Purpose |
|---|---|
| GET /api/v1/webhooks/{webhookId}/deliveries | All deliveries for one webhook. |
| GET /api/v1/inbound-messages/{referenceId}/deliveries | All deliveries for one inbound message — useful for fan-out troubleshooting. |
| GET /api/v1/inbound-deliveries | All deliveries in your organization. |
Delivery row shape
{
"id": 1024,
"referenceId": "em_8x2Lq7…",
"url": "https://yourapp.example.com/inbound",
"statusCode": 200,
"success": true,
"errorMessage": null,
"responseBody": "ok",
"attemptCount": 1,
"durationMs": 187,
"dispatchedAt": "2026-05-21T10:14:34",
"completedAt": "2026-05-21T10:14:34",
"createdAt": "2026-05-21T10:14:34"
}responseBody is truncated to 2,000 characters in the DTO with a trailing … when longer.
Code samples
Node.js (Express)
app.post("/webhook/inbound", express.json({ limit: "10mb" }), async (req, res) => {
res.sendStatus(200); // ack first
const evt = req.body;
if (evt.event !== "inbound.email.received") return;
// Push the event to your queue and process async.
// Download attachments outside the request handler.
await queue.publish("inbound-email", evt);
});Python (FastAPI)
@app.post("/webhook/inbound")
async def inbound(req: Request, bg: BackgroundTasks):
payload = await req.json()
if payload.get("event") == "inbound.email.received":
bg.add_task(process_email, payload)
return {"ok": True}Downloading an attachment after acking
async function downloadAttachments(evt) {
for (const att of evt.attachments ?? []) {
if (att.failed) continue; // no bytes available
const res = await fetch(att.url, {
headers: { Authorization: `Bearer ${SENDTRIM_TOKEN}` },
});
const bytes = await res.arrayBuffer();
await store(att.referenceId, att.filename, bytes);
}
}Edge cases & gotchas
- Inbound disabled mid-flow. If a domain has inbound turned off after webhooks are registered, ingestion drops messages and existing webhooks get no events. Re-enabling resumes delivery — but messages that arrived while inbound was off are lost.
- Inactive webhooks. Webhooks with isActive: false are not fetched at dispatch time and receive no events. The configuration is preserved; flip isActive back to true to resume.
- No matching domain. If an email lands at an address whose host doesn't match any registered domain after stripping the inbound. prefix, it is dropped silently.
- Duplicate Message-Id. Skipped silently at ingest — we treat the second arrival as a duplicate of the first.
- No organization owner on the domain. Dropped — the message can't be attributed.
- Receiver crashes the dispatcher. Any exception inside our dispatch path is caught — failure to record the delivery row never masks the outcome of the HTTP call itself.
- Fan-out is not transactional. If you have three webhooks and one fails, the other two still get the payload. Each is dispatched independently.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Email never arrives | MX record wrong | dig MX inbound.yourdomain.com +short should return inbound.sendtrim.com. |
| Webhook never fires | Inbound disabled on domain | Toggle Inbound email back on, or PUT /api/v1/domains/{domain}/inbound. |
| Webhook never fires | No active webhooks for the domain | Create one and confirm isActive: true. |
| Webhook returns 4xx in your delivery log | Your endpoint is rejecting the payload | Check Content-Type is application/json, JSON parser limits, and route auth. |
| Webhook returns timeout | Handler is doing heavy work inline | Ack 2xx first; process asynchronously. Move attachment downloads off the handler. |
| Attachment download 401/403 | Missing or expired JWT | Send Authorization: Bearer <sendtrim-jwt> on the download request. |
| Attachment url missing | failed: true — upload skipped or oversize | Inspect failedReason. No bytes to download. |
| Duplicate calls | Re-processed event | Dedupe on referenceId. |
| text and html both null | Email had no body parts (rare) | Treat as empty body. |
FAQ
- Can I have a catch-all address?
- Yes — anything before @ works. Route by deliveredTo.
- Are emails stored permanently?
- Yes — every inbound email is persisted to your organization (with is_inbound = true) and can be queried via API even if no webhook was registered at the time it arrived.
- Does HTML get sanitized?
- No — we pass it through as the sender wrote it. Sanitize before rendering (e.g. with DOMPurify).
- Are attachments embedded in the payload?
- No. The payload carries metadata only; bytes live in S3 and are fetched via the authenticated url. This keeps payloads small and lets you defer attachment work.
- What's the max attachment size?
- 25 MB per attachment. Over-limit attachments arrive with failed: true and failedReason: "size_exceeded".
- Can I disable a webhook temporarily?
- Yes — set isActive: false. The configuration is preserved; flip it back on anytime (provided the domain's inbound is still enabled).
- Will failed deliveries be retried?
- Not today. Each event is delivered once. The email is saved regardless, so you can re-fetch via API. Retries are on our roadmap.