Email verification

Prove the address is real before the account is worth anything.

Get an API key

Authentication · 5 placeholders

The job

This is the first email your product ever sends, and it is the one most likely to decide whether somebody finishes signing up. It has to arrive in seconds, survive a corporate link rewriter, and read as the thing the person was just doing.

How this one works

It carries both a link and a code. The link is what most people click; the code is what saves the person whose webmail opens links in a browser that is not signed in, which is the single most common way a verification flow dead-ends.

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

Immediately after the signup form is submitted, before the account can do anything that matters. One send per attempt, with a fresh token, and a resend that invalidates the previous one.

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

A token you can expire

Single use, short lived, and stored hashed. The URL in this email is a credential.

04

A page that consumes it

One route that accepts the token, marks the address verified, and signs the person in.

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
Verify your email for {{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>Verify your email</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;">Your code is {{VERIFY_CODE}}, good for {{EXPIRY_MINUTES}} minutes.&#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;">Verify your email address</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;">Confirm this address to finish setting up your {{PRODUCT_NAME}} account. The link is good for {{EXPIRY_MINUTES}} minutes.</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="{{VERIFY_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;">Verify email address</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="{{VERIFY_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{VERIFY_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;">Or type this code into the signup page instead:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#f4f4f5" style="background-color:#f4f4f5;border:1px solid #e4e4e7;padding:16px;text-align:center;font-family:Consolas,Menlo,Courier,monospace;font-size:26px;line-height:32px;letter-spacing:6px;color:#18181b;">{{VERIFY_CODE}}</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;">If you did not create a {{PRODUCT_NAME}} account, ignore this email and nothing further happens. Questions go to <a href="mailto:{{SUPPORT_EMAIL}}" style="color:#18181b;text-decoration:underline;">{{SUPPORT_EMAIL}}</a>.</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;">{{PRODUCT_NAME}} sent this because someone signed up with this address.</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

Somebody signs up

Your form writes the account as unverified and mints a single-use token.

02

You send this

One call to POST /v1/emails with the html and the text together.

03

They click or type

The link and the code both resolve to the same token on your server.

04

You mark it verified

Then delete the token, so a forwarded email cannot be replayed.

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',
  VERIFY_URL: 'https://acme.com/verify?token=8f3c1a',
  VERIFY_CODE: '481902',
  EXPIRY_MINUTES: '30',
  SUPPORT_EMAIL: 'support@acme.com',
};

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('Verify your email for {{PRODUCT_NAME}}'),
  html: fill(readFileSync('email.html', 'utf8')),
  text: fill(readFileSync('email.txt', 'utf8')),
  tags: { type: 'email-verification' }
});

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

Questions

Why send a code as well as a link?

Because links break in ways you do not control. A link opened in a webmail preview browser lands in a session that is not signed in, and corporate scanners follow links before the person does, which can consume a single-use token before they ever click. A code is typed into the tab they already have open.

How long should the token last?

Thirty minutes covers somebody who gets distracted, and it is short enough that a leaked mailbox archive is not a standing key to the account. Make the resend cheap instead of the expiry long.

Does this need an unsubscribe link?

No. It answers a form the person just submitted and it is not marketing. Adding one would let a reader opt out of the mail that lets them sign in. The digest template is where an unsubscribe belongs.

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 authentication templates