Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

Set up your customers' email sending domains

What a customer's email sending domain needs, which DNS records to write, and how to review, apply, and verify them from your app.

A sending domain proves to mailbox providers that your product is allowed to send as your customer. That proof is DNS records at the customer’s provider. This guide shows how to declare those records, have the customer approve them, and check that they resolve.

It covers DNS. Signing messages, choosing a sending service, and warming a domain are your product’s job.

The records a sending domain needs

Record Purpose Type
SPF Says which servers may send for the domain TXT
DKIM Lets receivers check a signature on each message TXT or CNAME, depending on the sending service
Return-path or mail-from Routes bounces, and aligns the envelope sender MX and TXT, or a CNAME, depending on the service
Tracking domain Serves branded links and open tracking CNAME
DMARC Tells receivers what to do when checks fail TXT

Sending services differ on the details. Postmark asks for a DKIM TXT record and a Return-Path CNAME (Postmark). Resend asks for MX and SPF TXT records on a send subdomain, plus DKIM (Resend). Declare whatever your service returns.

Put SPF where it can’t collide

A domain should have one SPF record. Two SPF records at the same name is a common failure.

DomainKit treats SPF as an ordinary TXT record. It doesn’t parse or merge SPF values. What happens at a name that already holds an SPF value depends on the requirement’s policy:

  • The same value is already there, so the operation is Noop.
  • A different value with the default append policy is a Create. Applying it leaves two SPF records at that name.
  • A different value with the exclusive policy is a Conflict. The plan can’t be approved as it stands, and nothing is written.
/**
 * SPF is an ordinary TXT record to DomainKit, so the default `append` policy plans a second TXT
 * beside any SPF value already at the name. `exclusive` turns that case into a Conflict, and it
 * does so for any other TXT record at the name, not only SPF.
 */
export const spfOrConflict = (name: string, value: string) =>
  DnsRecord.txt({
    name,
    value,
    policy: "exclusive",
    purpose: "Authorize Acme to send",
  });

Declare SPF with the exclusive policy. exclusive conflicts with any other TXT record at the name, not only SPF, so use it at a name your service owns, such as a send subdomain. At a root domain that carries other TXT records, such as verification tokens, it reports a conflict for those too.

The simplest way to avoid a collision is to publish your SPF on a subdomain your service owns, as Resend does with its send subdomain, instead of the customer’s root domain. Then your record never competes with the one they already have. Check what your own service asks for before you copy that shape.

Declare the records

Build one requirement for each record your service returns. Use an exclusive policy for SPF, and for MX when a second record at that name would break the first. Use a CNAME for DKIM and tracking. The values here are placeholders.

/** What a sending service returns for one customer domain, as DomainKit requirements. */
export const requirements = (domain: string) => [
  DnsRecord.txt({
    name: `send.${domain}`,
    value: "v=spf1 include:mail.acme.dev ~all",
    policy: "exclusive",
    purpose: "Authorize Acme to send for this domain",
  }),
  DnsRecord.mx({
    name: `send.${domain}`,
    exchange: "feedback.acme.dev",
    priority: 10,
    policy: "exclusive",
    purpose: "Receive bounces",
  }),
  DnsRecord.cname({
    name: `k1._domainkey.${domain}`,
    target: "k1.dkim.acme.dev",
    purpose: "Sign your mail",
  }),
  DnsRecord.cname({
    name: `track.${domain}`,
    target: "links.acme.dev",
    purpose: "Serve branded links",
  }),
];

Connect, review, and apply

From here the flow is the one in the custom domain guide. The customer connects Cloudflare or Vercel once, sees each record marked Will add, Already set, or Conflict, and approves. DomainKit writes what they approved and keeps a receipt. Writes go one record at a time and are not atomic, so a failure part way gives a partial receipt, and planning again turns the landed records into no-ops.

Verify that the records resolve

Observation reads the provider and public DNS for each requirement and stores the evidence. Your readiness screen can show which records are still pending and when the next check runs.

/**
 * One call reads the provider through the attachment's session and the public pool through
 * `Resolver`, stores readiness per requirement, and says when to look again.
 */
export const check = Effect.map(Verify.observe({ domain }), (readiness) => ({
  ready: readiness.overall === "ready",
  nextCheckAt: readiness.nextCheckAt,
  pending: readiness.requirements
    .filter((requirement) => requirement.status !== "satisfied")
    .map((requirement) => `${requirement.record._tag} ${requirement.record.name}`),
}));
/**
 * Every requirement keeps the evidence behind its status, one entry per source that answered.
 * `values` is what that source returned for the record's name and type, empty when it returned
 * nothing; `detail` is null when satisfied and otherwise says what went wrong.
 */
export const sources = Effect.map(Verify.observe({ domain }), (readiness) =>
  readiness.requirements.flatMap((requirement) =>
    requirement.evidence.map((evidence) => ({
      record: requirement.record.name,
      status: requirement.status,
      source:
        evidence._tag === "Provider"
          ? evidence.provider
          : evidence._tag === "PublicDns"
            ? evidence.resolver
            : evidence.source,
      found: evidence._tag === "Host" ? [] : evidence.values,
      detail: evidence.detail,
    })),
  ),
);

Verifying the DNS records is one step. Your sending service also has to confirm the domain on its side. Add that status to the same screen with host evidence.

/** Merge what only your app can see — an email identity, a certificate — without re-reading DNS. */
export const recordCertificate = Effect.gen(function* () {
  const observedAt = yield* DateTime.now;
  return yield* Verify.attachEvidence({
    domain,
    evidence: [
      new Verify.HostEvidence({
        source: "edge-certificate",
        status: "pending",
        label: "TLS certificate",
        detail: "Issuance starts once the CNAME resolves",
        observedAt,
      }),
    ],
  });
});

DMARC

A DMARC record sets policy for the whole domain, and the customer may already have one. Check for it and recommend a record, and let the customer decide. Samva does this. It flags a missing DMARC record and lets verification pass without one.

Case study

Samva sets up its customers’ sending domains this way, with TXT, MX, and CNAME records on Cloudflare and Vercel. Read how Samva sets up customer domains.

FAQ

Does DomainKit sign my messages?

No. It writes the DNS records that let receivers check your signatures.

What if the customer already has an SPF record?

See the section on putting SPF where it can’t collide. DomainKit doesn’t merge SPF records.

What if the customer's DNS isn't on Cloudflare or Vercel?

Show them the records to add by hand. DomainKit can’t write there today.

Can I set up many domains at once?

Yes. Batches plan several domains together, with one digest for one consent.

Last updated on September 29, 2026

Was this page helpful?