The first support email for Callidus came in at 9pm on a Monday, and I had no idea where it went.
A user had replied to a system email — an invoice notification — and the reply had routed into the void. We had Resend set up for outbound transactional email but no inbound handling at all. By Tuesday morning the user had sent a follow-up, that one also vanished, and by the time I found both in Gmail's "All Mail" under a system account that nobody checked, they'd already opened a Stripe dispute. Not great.
That story ends fine. But it's the reason I spent a weekend building a real inbound support inbox into the app. Resend added email receiving in November 2025 — meaning you can now handle both outbound and inbound through the same provider, without switching to Mailgun or standing up Postmark just for inbound parsing. This post covers the actual implementation: what the webhook gives you (and what it doesn't), how threading works, how to route by tenant, and when to stop building and just buy a tool.
What Does the Resend email.received Webhook Actually Send?

The webhook delivers metadata about the email — sender, recipient, subject, and attachment names — but excludes the body and full headers entirely.
This surprises most people who expect a ready-to-read payload. When an email arrives at your Resend-managed receiving domain, Resend posts an email.received event to your webhook endpoint. You get email_id, from, to, subject, a message_id string (the RFC 5322 Message-ID header value), and an attachments array containing file metadata only. The email body and full headers are absent from the webhook payload; you must call the Received Emails API separately to get them.
This two-step design is deliberate. Serverless environments have request body size limits — typically 4–6MB — and an email with three large PDF attachments would exceed them. Resend separates notification from content retrieval so your handler can acknowledge the webhook immediately and fetch content asynchronously.
In practice:
export async function POST(req: Request) {
// Verify signature first — always
const payload = await req.text();
const headers = Object.fromEntries(req.headers);
const event = resend.webhooks.verify(payload, headers, process.env.RESEND_WEBHOOK_SECRET!);
if (event.type !== 'email.received') return new Response('ok');
const { email_id, from, to, subject, message_id } = event.data;
// Fetch body in the same handler, or enqueue for async processing
const fullEmail = await resend.emails.get(email_id);
const bodyText = fullEmail.text ?? '';
await processInboundEmail({ from, to, subject, message_id, bodyText });
return new Response('ok');
}
Verification first. Non-negotiable. Every webhook hits an unauthenticated public URL. Resend embeds Svix headers (svix-id, svix-timestamp, svix-signature) in every request — call resend.webhooks.verify() with your webhook secret or you're running an open POST endpoint.
One thing that actually works well: Resend stores every inbound email immediately upon receipt, whether your webhook fires successfully or not. If your handler 500s, the email isn't lost — replay the event from the dashboard or query the Received Emails API directly. On non-Enterprise plans, that storage window is 30 days before emails are purged.
How Do You Thread Support Emails Into Conversations?

Threading works through three email headers — Message-ID, In-Reply-To, and References — plus a database lookup that links each new email to its parent thread.
Email clients use these headers to group messages into visible threads: Message-ID is unique per email (set by the sending mail server), In-Reply-To holds the Message-ID of the email being replied to, and References carries the full ordered chain of Message-IDs from the original. Miss any one of them and replies appear as disconnected conversations.
Here's the schema I use:
CREATE TABLE support_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE support_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES support_threads(id),
message_id TEXT NOT NULL UNIQUE,
in_reply_to TEXT,
references_ids TEXT[],
direction TEXT NOT NULL, -- 'inbound' | 'outbound'
from_address TEXT NOT NULL,
body_text TEXT,
ai_triage JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
When your inbound handler fires: look up event.data.message_id against in_reply_to across existing support_messages. A match means this is a reply — add it to the existing thread. No match, subject doesn't correspond to an open thread? New thread.
When you send a reply outbound, set In-Reply-To to the message_id of the last message in the thread, and References to the full ordered chain. Actually — let me back up. You also need to persist your own outbound message IDs. Resend returns a message_id on send; write it into support_messages with direction = 'outbound' so it participates in thread lookups when the user replies again.
The failure mode nobody warns you about: Resend's webhook fires asynchronously, and if a user sends two emails in quick succession, both webhooks can race your database write. The second arrives, finds nothing to link to, creates a new thread. Thread broken. Fix: either add a uniqueness constraint on message_id with a short idempotency window, or route webhook events through a queue with ordered delivery per sender address. Inngest's concurrencyKey per sender handles this cleanly.
Routing Inbound Emails to the Right Tenant

All email to your receiving domain arrives at one webhook. The routing is yours to build.
Resend processes inbound at domain level, not address level. You can't register separate receiving inboxes per tenant — there's no per-address provisioning API. Everything sent to @support.yourapp.com routes to your single endpoint, and you extract the to field to determine who it's for.
Two patterns that work:
-
Subdomain slug: Each tenant gets
{slug}@support.yourapp.com. Your handler extracts the local part, looks up the tenant by slug. Requires wildcard MX records and catch-all receiving on the domain. Readable for end users. Has a security edge: anyone who knows another tenant's slug can email their queue. -
Opaque alias: Generate a unique address per tenant at onboarding —
t-{uuid}@support.yourapp.com. Route by UUID lookup. Zero cross-tenant address collision risk. Users see an opaque address but never interact with it directly.
Pattern 2 is more robust for multi-tenant isolation. Pattern 1 looks cleaner in user-facing onboarding copy but requires additional validation to prevent cross-tenant misdirection.
Production reality: your receiving domain becomes a spam magnet. Apply rate-limiting per sender address in your handler. Discard events where from has generated zero real replies from your team in the past 30 days and appears more than 10 times in an hour.
When Should You Upgrade to Plain or Front?
Stop building your own and buy a tool the moment two agents work the same ticket without knowing each other is in it.
Roll-your-own with Resend handles inbound parsing, threading, and tenant routing well. It doesn't handle — without significant additional work — collision detection (two agents drafting simultaneous replies), SLA timers and escalation rules, assignment and mention workflows, or a unified view across channels.
Have you ever sent a support reply only to realize a colleague already answered differently three minutes earlier? That's the collision detection gap. It's not a rare edge case — it happens the first week you add a second support person.
| Capability | Roll-your-own (Resend) | Plain | Front | |---|---|---|---| | Inbound parsing | ✓ build it | ✓ native | ✓ native | | Email threading | ✓ build it | ✓ native | ✓ native | | Collision detection | ✗ | ✓ | ✓ | | SLA timers | ✗ | ✓ | ✓ | | GraphQL API | ✗ | ✓ (API-first) | partial | | Slack-native | ✗ | ✓ | partial | | Pricing entry | ~$20/mo (Resend Pro) | custom / ~$50+/agent | ~$19/agent/mo |
Plain is the right graduation path for engineering-led B2B SaaS teams. It's API-first and GraphQL-native, every action is programmable, and it's Slack-native as a first-class integration rather than a bolt-on. A 2026 Plain analysis of 627 B2B support team conversations found 54% of B2B buyers prefer Slack-native support for issue resolution — which tracks with the kind of engineering team that's also your customer.
You can push customer context from your app — plan tier, recent error logs, billing status — directly into the Plain conversation view. That context-in-conversation capability is what Zendesk and Front handle poorly. They're designed for support teams; Plain is designed for engineering-led teams where the app state matters as much as the message thread.
The threshold I use: under 30 tickets/week, one person handling it, build it with Resend and save the cost. Over that, or with two humans coordinating, buy the tool. The Callidus support workflow hit this exact threshold — two people working the same account sent conflicting replies within 20 minutes of each other, and patching that with custom locking logic would have cost more in engineering time than a month of Plain.
Attachment Handling: Fetch Once, Store It Yourself
Thirty-day storage is a staging area, not an archive. Treat it that way.
When your handler processes an email with attachments: loop over event.data.attachments, call the Resend Attachments API for each id, get the download_url, fetch the bytes, and upload to your own S3 or Cloudflare R2 bucket. Store your bucket URL in support_messages. After 30 days, Resend purges the original. Your copy persists as long as you need it.
This is the step most tutorials omit. You discover the gap when a ticket from six weeks ago references a screenshot and the agent clicks the thumbnail and gets a 404.
Is Resend the Right Choice vs Postmark for Inbound?
For a support inbox built from scratch, Postmark has one practical advantage: its inbound webhook includes full email headers by default, without a separate API call.
If your threading logic needs the References header chain immediately, Postmark saves one round-trip per email. That matters at scale; less so at 50 emails a day.
Resend wins on outbound developer experience — the Next.js + Resend stack is the most cohesive in the ecosystem right now — and on cost at volume ($20/month Pro for 50,000 emails vs Postmark's $15/month for just 10,000). A February 2026 comparison by Pingram notes Resend lacks automatic reply threading out of the box, while Mailgun offers flexible native routing rules for inbound processing. If you're already sending through Resend, the two-step inbound pattern is a manageable tradeoff for not splitting providers. If you're starting fresh and inbound volume is the primary concern, compare both directly before committing.
The practical next step: add support_threads and support_messages to your database schema, wire a /api/support/inbound route handler to your Resend receiving webhook, and send a test email to your domain. The scaffolding is an afternoon. The threading race condition and attachment storage gap take longer to harden — but now you know which edges to test before they surface in production. The SaaS MVP stack reference covers where this inbound infrastructure fits in the broader picture.
