Welcome

One next step, not a tour of the product.

Get an API key

Onboarding · 7 placeholders

The job

The welcome email is read once, in the thirty seconds after signup, by somebody who has already decided to try the thing. It does not need to sell. It needs to point at the one action that makes the product work for them.

How this one works

This one names that action, links the docs, and gives a real address to reply to. Everything else that products put in a welcome email, the feature grid, the founder photo, the discount, either belongs later in a sequence or nowhere.

What you need

A key, a domain, and the two or three things only your app knows. Nothing here takes longer than the email did to read.

When it sends

Once, after the address is verified and the account exists. Sending it before verification means welcoming an address that may never confirm.

01

An emails.sh API key

Created in the dashboard, starting with esh_. It stays on your server.

02

A sending domain

Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.

03

One next step worth naming

The action that correlates with people staying. Pick it from your own data, not from a list.

04

A reply-to somebody reads

A welcome email gets replies. Point them at a mailbox with a person behind it.

The email

Table layout, inline styles, no stylesheet, no web font, no JavaScript. It renders the same in Outlook, Gmail, and Apple Mail, and the plain-text version goes with it every time.

Subject
Welcome to {{PRODUCT_NAME}}
HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<!-- Stops iOS Mail resizing the text on its own. -->
<meta name="x-apple-disable-message-reformatting">
<title>Welcome</title>
</head>
<body style="margin:0;padding:0;width:100%;background-color:#f4f4f5;">
<div style="display:none;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#f4f4f5;">Start here: {{NEXT_STEP}}.&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f4f4f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;background-color:#ffffff;border:1px solid #e4e4e7;">
<tr><td style="padding:32px;">
<h1 style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:21px;line-height:28px;font-weight:600;color:#18181b;">Welcome to {{PRODUCT_NAME}}, {{FIRST_NAME}}</h1>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">Your account is ready. The one thing worth doing first is to {{NEXT_STEP}}.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#18181b" style="background-color:#18181b;"><a href="{{DASHBOARD_URL}}" style="display:inline-block;padding:13px 24px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;">Open {{PRODUCT_NAME}}</a></td></tr></table>
<p style="margin:0 0 12px;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#71717a;">Button not working? Paste this into your browser:<br><a href="{{DASHBOARD_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{DASHBOARD_URL}}</a></p>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">The quickstart walks the same path in about five minutes: <a href="{{DOCS_URL}}" style="color:#18181b;text-decoration:underline;">{{DOCS_URL}}</a>.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td height="1" style="height:1px;background-color:#e4e4e7;font-size:0;line-height:0;">&nbsp;</td></tr></table>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">If you get stuck, reply to this email. It goes to <a href="mailto:{{SUPPORT_EMAIL}}" style="color:#18181b;text-decoration:underline;">{{SUPPORT_EMAIL}}</a> and a person reads it.</p>
</td></tr>
</table>
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;">
<tr><td style="padding:20px 32px 0;">
<p style="margin:0 0 8px;font-family:Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a;">You are getting this because you created a {{PRODUCT_NAME}} account.</p>
<p style="margin:0 0 8px;font-family:Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a;">Choose which emails you get: <a href="{{PREFERENCES_URL}}" style="color:#18181b;text-decoration:underline;">{{PREFERENCES_URL}}</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

How it works

One send, start to finish. Every message is in your logs with its delivery events.

01

The address is verified

Only then is there somebody to welcome.

02

You pick the next step

One action, chosen from what your retained accounts did first.

03

You send this

With reply_to pointed at a mailbox somebody reads.

04

They reply, sometimes

Answer those. The replies to a welcome email are the cheapest research you will get.

Code

The same send three ways. Each one fills the placeholders, reads the two bodies, and posts them to POST /v1/emails.

npm install @emails.sh/sdk
import { readFileSync } from 'node:fs';
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

// What changes per send. In your app these come from the row you just
// wrote, not from literals.
const vars: Record<string, string> = {
  PRODUCT_NAME: 'Acme',
  FIRST_NAME: 'Ada',
  NEXT_STEP: 'connect your first repository',
  DASHBOARD_URL: 'https://acme.com/app',
  DOCS_URL: 'https://acme.com/docs/quickstart',
  SUPPORT_EMAIL: 'support@acme.com',
  PREFERENCES_URL: 'https://acme.com/settings/email',
};

const fill = (body: string) =>
  Object.entries(vars).reduce(
    (out, [name, value]) => out.replaceAll('{{' + name + '}}', value),
    body
  );

const { id } = await mail.send({
  from: 'Acme <onboarding@emails.sh>',
  to: ['ada@example.com'],
  subject: fill('Welcome to {{PRODUCT_NAME}}'),
  html: fill(readFileSync('email.html', 'utf8')),
  text: fill(readFileSync('email.txt', 'utf8')),
  replyTo: 'support@acme.com',
  tags: { type: 'welcome' }
});

// Queued, not delivered. Ask for the id later, or take the webhook.
console.log(id);

Questions

Should the welcome email come from a person?

A person as the display name and a monitored mailbox as the address works well. What does not work is a name with noreply@ behind it, which invites a reply into a black hole.

Why only one link to the product?

Because a welcome email with six equal links gets none of them clicked. The docs link is there in body text at a lower weight, which is a deliberate second place rather than a second button.

Does a welcome email need an unsubscribe?

It needs a way out of the sequence it starts. This one links preferences instead of a bare unsubscribe, so somebody can stop onboarding mail without also opting out of receipts and password resets.

Send this one in the next five minutes.

Before your domain is verified you can send from onboarding@emails.sh, so the first send needs a key and nothing else. Verification is three DNS records and one screen.

Every message has delivery events you can read: accepted, delivered, bounced, complained. No support ticket to find out whether it arrived.

More onboarding templates