Home /Docs /Inbound Emails & Webhooks

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

  1. Sender's mail provider
  2. Our MX server (inbound.sendtrim.com)
  3. We parse the message, dedupe by Message-Id, and resolve the destination domain to your organization
  4. The email row + any attachment rows are persisted; attachment bytes stream to S3
  5. One POST per active webhook is sent with attachment metadata and a download URL
  6. 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:

TypeHostValuePriority
MXinbound.yourdomain.cominbound.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.
Info: The subdomain prefix is configurable on our side but defaults to inbound.. If a message arrives at a host that doesn't match any registered domain after stripping the prefix, it's dropped silently.

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.com
Info: Once inbound is enabled, every email sent to <anything>@inbound.<your-domain> is received and saved — even before you add a webhook. Webhooks are how you get notified; emails are stored regardless and can be queried later.

3) 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"
}
Info: You can attach multiple webhooks to one domain. Each active webhook receives a copy of every inbound email — useful for fan-out (CRM + Slack + analytics, for example).

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=…"
    }
  ]
}
FieldTypeDescription
eventstringAlways "inbound.email.received". Also sent in the X-Sendtrim-Event header.
referenceIdstringOur stable internal ID for this email. Use it as your dedupe key and to look the message up via API.
messageIdstring | nullRFC 822 Message-Id including angle brackets. null if the sender omitted it.
inReplyTostring | nullRFC 822 In-Reply-To. Useful for threading. null for new threads.
fromstringSender address (envelope-stripped, no display name). Don't use for authorization.
tostring[]All To recipients. Possibly empty, never null.
ccstring[]All Cc recipients. Possibly empty, never null.
deliveredTostringSMTP Delivered-To header — the mailbox that actually received the email. Use this for routing.
subjectstringEmail subject. May be an empty string.
textstring | nullPlain-text body, or null if the email had no text part.
htmlstring | nullHTML body, or null if the email had no HTML part. Not sanitized — clean before rendering.
sentAtstringISO-8601 LocalDateTime (no timezone) from the email's Date header.
organizationIdnumberYour organization's ID on SendTrim.
attachmentsobject[]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"
}
FieldDescription
referenceIdStable per-attachment ID.
filenameOriginal filename. May contain spaces and non-ASCII characters.
contentTypeMIME type as declared by the sender.
sizeBytesAttachment size in bytes.
s3KeyThe S3 object key. null when failed: true.
isInlinetrue if the attachment was embedded in the HTML body via cid:<contentId>.
contentIdRFC Content-Id. Use to map an inline attachment to its cid: reference in the HTML.
failedtrue 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.
urlAuthenticated 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();
Warning: Download attachments after you respond 2xx to the webhook. Streaming megabytes inside the webhook handler will blow past the 10-second timeout and the delivery will be marked as failed.

Delivery contract

BehaviorDetail
Timeout10 seconds per HTTP call.
RetriesNone 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 criteriaHTTP 2xx. Anything else (including timeouts and connection errors) is recorded as success: false.
Response captureWe store the first 4,096 characters of your response body for inspection.
OrderOne 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 contractWe 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.
SignatureWe 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.
Warning: Customers MUST respond 2xx within 10 seconds. Heavy work — especially attachment downloads — belongs in a background job after acking.

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).

EndpointPurpose
GET /api/v1/webhooks/{webhookId}/deliveriesAll deliveries for one webhook.
GET /api/v1/inbound-messages/{referenceId}/deliveriesAll deliveries for one inbound message — useful for fan-out troubleshooting.
GET /api/v1/inbound-deliveriesAll 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

SymptomLikely causeFix
Email never arrivesMX record wrongdig MX inbound.yourdomain.com +short should return inbound.sendtrim.com.
Webhook never firesInbound disabled on domainToggle Inbound email back on, or PUT /api/v1/domains/{domain}/inbound.
Webhook never firesNo active webhooks for the domainCreate one and confirm isActive: true.
Webhook returns 4xx in your delivery logYour endpoint is rejecting the payloadCheck Content-Type is application/json, JSON parser limits, and route auth.
Webhook returns timeoutHandler is doing heavy work inlineAck 2xx first; process asynchronously. Move attachment downloads off the handler.
Attachment download 401/403Missing or expired JWTSend Authorization: Bearer <sendtrim-jwt> on the download request.
Attachment url missingfailed: true — upload skipped or oversizeInspect failedReason. No bytes to download.
Duplicate callsRe-processed eventDedupe on referenceId.
text and html both nullEmail 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.