# Transactional email for SaaS founders

Verification, password resets, receipts, and seat invites, from a domain you own, with a delivery log that answers "I never got it" in one search.

A SaaS sends five kinds of email before it sends a newsletter: verification, password reset, receipt, seat invite, and the billing warning. All five are transactional, all five have to arrive within seconds, and every one of them generates a support ticket when it does not. This page covers the setup that makes those five reliable and the log that settles the arguments.

## What happens next

1. **Verify the domain your customers already recognise.** Add acme.com in the dashboard, paste the two DNS records, wait for propagation. DKIM signs your mail and SPF authorises the sending host, which is what receiving servers check before deciding on the inbox.
1. **Split transactional from marketing at the domain level.** Receipts from acme.com, campaigns from news.acme.com. Reputation is tracked per sending domain, so a campaign with a bad complaint rate cannot take password resets with it.
1. **Give every send a tag and an idempotency key.** tags: { type: "receipt", plan: "pro" } makes the log filterable. idempotencyKey means a retried Stripe webhook does not bill-mail the same customer twice.
1. **Subscribe to delivery webhooks.** POST to your endpoint on delivered, bounced, and complained, signed with x-emailssh-signature. A hard bounce should mark the address invalid in your database rather than being retried forever.

## Seat invite with an id you can look up later

src/email/invite.ts, called when an admin adds a teammate.

```ts
import { Emailssh } from '@emails.sh/sdk';

const emails = new Emailssh(process.env.EMAILSSH_API_KEY!);

interface Invite {
	to: string;
	inviterName: string;
	workspace: string;
	token: string;
}

export async function sendSeatInvite({ to, inviterName, workspace, token }: Invite) {
	const link = `https://acme.com/invite/${token}`;

	const { id } = await emails.send({
		from: 'Acme <hello@acme.com>',
		to: [to],
		// The invite is a conversation starter, so replies go to a human inbox.
		replyTo: 'support@acme.com',
		subject: `${inviterName} invited you to ${workspace}`,
		html: `<p>${inviterName} invited you to the ${workspace} workspace on Acme.</p>
<p><a href="${link}">Accept the invite</a></p>
<p>The link expires in seven days.</p>`,
		text: `${inviterName} invited you to the ${workspace} workspace on Acme.
Accept the invite: ${link}
The link expires in seven days.`,
		tags: { type: 'seat-invite', workspace },
		// An admin who clicks Invite three times sends one email.
		idempotencyKey: `invite:${token}`
	});

	// Store the id against the invite row. When the customer says the invite
	// never arrived, this is the difference between a search and a shrug.
	return id;
}
```

## Worth knowing

### A password reset that arrives in nine minutes is a support ticket

Transactional mail has to be fast, which means it must not queue behind a campaign. Separate domains and separate API keys keep the two apart.

### DMARC is what stops someone spoofing your billing mail

Publish p=none first and read the reports, then move to quarantine once SPF and DKIM align. Going straight to p=reject on a domain with unknown senders will drop mail you needed.

### Store the message id next to the row it belongs to

Order, invite, reset: each should carry the id of the email it triggered. GET /v1/emails/:id then answers "did it arrive" with delivery events instead of a support thread.

### Handle hard bounces or your reputation pays for it

A bounced address that you keep sending to is counted against you. The bounce webhook should mark the address invalid, not schedule a retry.


## Questions

### How long does domain verification take?

Two DNS records. Verification completes once they propagate, usually inside an hour, and the dashboard shows the state of each record.

### Can I use a subdomain instead of the root domain?

Yes, and it is the better default. mail.acme.com keeps your sending reputation separate from anything else the root domain does.

### A customer says they never got the receipt. What do I do?

Search the id or the tag in the dashboard. You get the delivery events for that exact message, including a bounce reason if the receiving server rejected it.

### Do you support scheduled sends?

Yes. send_at with an ISO timestamp queues the message for that time, which covers trial-ending warnings and dunning notices.

### How do we migrate from SendGrid or Postmark?

Change the client and the key, keep your templates. Verify the domain here first and move one email type at a time so you can compare delivery before switching the rest.


## One send

```
To: teammate@example.com
Subject: Dana invited you to Acme

Dana invited you to the Acme workspace. Accept the invite: https://acme.com/invite/9f3ab2. The link expires in seven days.
```

Docs: https://emails.sh/docs.md