Tech Stack21 June 2026 · 10 min read

Building a SaaS Customer Support Inbox With Resend Webhooks

How to build a SaaS support inbox with Resend's inbound webhooks: threading, multi-tenant routing, attachment handling, and when to stop building and buy Plain or Front.

Building a SaaS Customer Support Inbox With Resend Webhooks

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?

A single bone-white card face-down on a raw concrete platform inside a brutalist architectural space, with a glowing cyan neon strip above, implying the actual content described by the card is absent and must be retrieved separately

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?

A chain of overlapping concrete and bone-white rings laid diagonally on a raw concrete surface, with the central ring glowing electric cyan as the focal point

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

A grid of square concrete pigeonhole compartments filling the frame, all dark grey except one central slot glowing distinctly electric cyan, standing apart as the routed destination

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:

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

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

DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What does the Resend inbound webhook payload include?

The `email.received` webhook payload includes email metadata — sender address, recipient addresses, subject line, a `message_id` string, and an attachments array with file names and content types — but does not include the email body or full headers. To retrieve the body and header content, you must make a separate call to Resend's Received Emails API using the `email_id` from the webhook payload. This two-step design exists to avoid payload size limits in serverless environments, since emails with large attachments can exceed typical request body constraints. Resend stores every inbound email immediately on receipt, so the API call remains available even if your webhook handler failed on the first attempt.

How do you implement email threading in a SaaS support inbox?

Email threading works by capturing three headers from each inbound email — Message-ID, In-Reply-To, and References — and using them to link new messages to existing conversations in your database. Store the `message_id` of every inbound and outbound email in a `support_messages` table. When a new inbound email arrives, look up its `in_reply_to` value against existing `message_id` records to find the parent thread. If a match exists, add the message to that thread. If not, create a new thread. When sending outbound replies, set `In-Reply-To` to the last message's `message_id` and `References` to the full ordered chain — mail clients use these to render the conversation as a single grouped thread rather than disconnected emails.

Should I build my own SaaS support inbox or use Plain or Front?

Build your own inbox if you're handling under 30 tickets per week with one person managing support — the Resend inbound webhook approach covers the basics without a subscription cost. Switch to a dedicated tool like Plain or Front the moment two people start working the same support queue, because the coordination overhead of collision detection, assignment visibility, and SLA tracking outweighs the engineering cost of a subscription at that point. Plain is the strongest option for engineering-led B2B SaaS teams specifically: it's API-first, GraphQL-native, Slack-native, and lets you push customer context from your app directly into the conversation view. Front is better suited to larger teams handling email alongside sales and operations workflows.

How do I route inbound support emails to the right tenant in a multi-tenant SaaS?

Route inbound support emails by extracting the `to` field from the Resend webhook payload and mapping it to a tenant in your database. Resend processes all email for your receiving domain through a single webhook endpoint, so tenant routing is entirely your responsibility. Two patterns work: subdomain slugs — `{tenant-slug}@support.yourapp.com` — where you extract the local part and look up the tenant by slug, or opaque aliases — `t-{uuid}@support.yourapp.com` — provisioned at onboarding where you route by UUID lookup. The opaque alias pattern is more secure for multi-tenant systems because it eliminates cross-tenant address guessing. Apply rate-limiting per sender in your handler to control spam to your catch-all receiving domain.

How do I verify Resend inbound webhook signatures?

Verify Resend inbound webhook signatures using the `resend.webhooks.verify()` SDK method before processing any event payload. Resend delivers webhooks via Svix, which adds three authentication headers to every request: `svix-id`, `svix-timestamp`, and `svix-signature`. Pass the raw request body as text (before any JSON parsing), all request headers, and your webhook secret to `resend.webhooks.verify()` — it throws an error if the signature doesn't match. Never parse the JSON body before running verification, since any modification to the raw payload (including whitespace normalization) will invalidate the HMAC signature. Without verification, your inbound handler is an unauthenticated public POST endpoint that anyone can call with fabricated email events.