Skip to content

Introducing Inbound

Receive mail on your own domain, forward messages to a shared inbox, and link replies back to the original send. Same dashboard, same API, same SDKs.

6 min read•May 19, 2026

Email at sevk has been a one-way pipe since day one. You compose, you broadcast, you fire transactional sends. Everything points outward. Inbound flips that. You can now receive mail on any verified domain, route messages by recipient, and link replies back to the campaign that started the conversation.

Why receive at all?

Outbound is half of what a product email surface actually does. The other half is what users send back: support replies, opt-out questions, parsed receipts forwarded to an inbox, password reset confirmations, mail-to actions from a transactional. Until today, you had to glue that to a separate receiving service and hope the threading made it across the two systems.

Inbound lives in the same project as your sends. Contacts, domains, broadcasts, suppression - all of it lines up against the messages you receive. One bill, one set of API keys, one place to read events.

Turn on receive

The fastest path is the dashboard. Open a verified domain, flip the Inbound toggle, and sevk shows the MX records to publish at your DNS provider. The moment they propagate, the domain accepts mail. There is no separate receiving identity and no second verification flow. Sending and receiving live on the same domain.

Prefer the API? Every dashboard toggle has a matching endpoint. Flip inboundEnabledon a domain and read back the DNS records to publish in one round-trip:

enable-inbound.http
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl -X PUT https://api.sevk.io/domains/$DOMAIN_ID \
  -H "Authorization: Bearer $SEVK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "inboundEnabled": true }'

# Response is the domain with dnsRecords for the records to publish
{
  "id": "dom_01H9...",
  "domain": "yourdomain.com",
  "inboundEnabled": true,
  "inboundVerified": false,
  "dnsRecords": [
    { "type": "MX", "name": "inbound", "value": "10 mx.inbound.sevk.io" }
  ]
}
UTF-8LFSpaces: 2
15 lines

The Node SDK wraps the same call and gives you typed results, so you can render the records in your own onboarding flow if you do not want to send people to the sevk dashboard:

enable-inbound.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Sevk } from 'sevk'

const sevk = new Sevk(process.env.SEVK_API_KEY!)

const domain = await sevk.domains.update(domainId, { inboundEnabled: true })

for (const record of domain.dnsRecords ?? []) {
  console.log(`${record.name}  ${record.type}  ${record.value}`)
}

// Once the MX record propagates, ask sevk to verify it:
const result = await sevk.domains.verifyInbound(domainId)
console.log(result.inboundVerified)
UTF-8LFSpaces: 2
13 lines

Routes

A route matches incoming recipients and decides what to do with them. The action is forward: pick an address, sevk relays accepted mail to it. Routes are ordered, the first match wins, and exact addresses beat wildcards.

routes.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Sevk } from 'sevk'

const sevk = new Sevk(process.env.SEVK_API_KEY!)

// Forward support mail to a shared inbox
await sevk.inbound.createRoute({
  name: 'support',
  pattern: 'support@yourdomain.com',
  target: 'team@yourcompany.com',
  priority: 10
})

// Wildcard for everything else on the domain
await sevk.inbound.createRoute({
  name: 'catch-all',
  pattern: '*@yourdomain.com',
  target: 'fallback@yourcompany.com',
  priority: 100
})
UTF-8LFSpaces: 2
19 lines

The same resource is available in every SDK. Python keeps the resource shape identical, only the call style changes:

routes.py
1
2
3
4
5
6
7
8
9
10
11
import os
from sevk import Sevk

sevk = Sevk(os.environ["SEVK_API_KEY"])

sevk.inbound.create_route({
    "name": "support",
    "pattern": "support@yourdomain.com",
    "target": "team@yourcompany.com",
    "priority": 10,
})
UTF-8LFSpaces: 2
11 lines

Unknown addresses without a matching route are rejected before sevk accepts the message. That detail matters: receiving providers bill per accepted message, so anything you can reject up front costs you nothing.

Reply linking

When a recipient replies to a broadcast or transactional, sevk ties the inbound message back to the original send. The reply event carries reply_to_email_id or reply_to_broadcast_id, so you can join straight to the send or campaign that started the conversation. You do not need to parse headers, decode envelopes, or maintain a Message-ID lookup yourself.

email.received.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "type": "email.received",
  "data": {
    "inbound_email_id": "inb_01H9...",
    "message_id": "<CAGz3...@mail.gmail.com>",
    "from": "alice@example.com",
    "to": "support@yourdomain.com",
    "cc": [],
    "subject": "Re: Your invoice",
    "in_reply_to": "<msg_01H8...@mail.sevk.io>",
    "references": ["<msg_01H8...@mail.sevk.io>"],
    "reply_to_email_id": "eml_01H8...",
    "reply_to_broadcast_id": null,
    "is_spam": false,
    "is_virus": false,
    "attachments": []
  }
}
UTF-8LFSpaces: 2
18 lines

The inbox view in the dashboard shows the linked original send right next to the reply, so you can jump from a reply to the email or broadcast that triggered it in one click. The same data is available through the API, webhooks, and the SSE event stream.

Webhooks for incoming mail

Subscribe to the inbound events the same way you subscribe to delivery, bounce, or open. The payload carries parsed metadata: headers, subject, text and HTML parts, attachment names, sizes, and content types. Bodies and attachments are fetched on demand so the webhook stays small.

webhook.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { WebhookVerifier, WebhookVerificationError } from 'sevk'

const verifier = new WebhookVerifier(process.env.SEVK_WEBHOOK_SECRET!)

export async function POST(request: Request) {
  const body = await request.text()

  let event: { type: string, data: Record<string, unknown> }
  try {
    event = verifier.verify(body, {
      'svix-id': request.headers.get('svix-id'),
      'svix-timestamp': request.headers.get('svix-timestamp'),
      'svix-signature': request.headers.get('svix-signature')
    })
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return new Response('Invalid signature', { status: 401 })
    }
    throw err
  }

  if (event.type === 'email.received') {
    const { from, subject, reply_to_email_id, reply_to_broadcast_id } = event.data
    await createSupportTicket({
      from,
      subject,
      replyToEmailId: reply_to_email_id as string | null,
      replyToBroadcastId: reply_to_broadcast_id as string | null
    })
  }

  return new Response(null, { status: 200 })
}
UTF-8LFSpaces: 2
33 lines

Defense by default

Inbound is a paid surface for an attacker. Without limits, anyone who learns your domain can ship unlimited mail at your bill. sevk pushes back in three places:

  • Recipient filter: addresses without a route are rejected before the receiving provider accepts the message, so they never reach your bill.
  • Spam scoring: every accepted message is scored. You decide what threshold quarantines.
  • Quota pause: per-project monthly limits halt receive when you hit them, so a burst never escalates into a bill spike.

Why the recipient filter matters

Receiving providers charge per accepted message, not per message you process. The only way to spend nothing on garbage mail is to reject it before the SMTP handshake completes. Routes drive that filter automatically.

Pricing

Each project includes 1,000 free accepted inbound messages per month. Additional accepted messages cost $0.001 each at the standard base rate. This allowance is separate from the 1,000 free outbound emails. Rejected mail (unknown recipients, blocked senders) is not billed because rejection happens before the receiving provider accepts the message.

What's next

Inbound is live in every region today. The next round expands route actions beyond pure forwarding: auto-reply templates, ticket creation against an audience, and contact updates driven by inbound fields. Bug reports and feature requests go in the repo or our Discord.

Turn on inbound

Open a verified domain, flip the toggle, and publish the MX records. The first receive lands a minute later.

Questions or feedback? Reach out at kubilay@sevk.io

kubilay
kubilay
just a tech