DEV Community

Lunea Labs
Lunea Labs

Posted on Originally published at luneamail.com

How to send license key emails from your SaaS

A license key email is the message your app sends right after a purchase, with the key the buyer needs to activate your software. Send it from your server when your payment provider confirms the payment, not from the checkout page. Put the key in the body (never the subject line), in a format that is easy to copy. Add the product, the number of seats, the download links and a way to find the key again. Send it from your own authenticated domain, record that you sent it so it never goes out twice, and keep promotions out of it.

What a license key email must contain

Buyers' "where is my key?" threads show what goes wrong: keys land in spam, addresses are mistyped, keys get lost. A good email prevents those questions. Include:

  • The key itself, on its own line, in a monospace font, with nothing else on that line.
  • What it covers: product name, plan, number of seats, and the expiry or renewal date if there is one.
  • How to activate it: two or three short steps, or one link to your activation docs.
  • Download links, if the product is installed software.
  • Where to find the key later: a link to the account or billing page where the key is also shown.
  • Who to contact: a reply-to address that a person reads, not no-reply@.
  • An order reference, so support can find the purchase quickly.

Sketch's keys start with SK3, so buyers can search their inbox for it. A fixed prefix on your keys saves support tickets.

When to send it: after the payment is confirmed

Send the email from your backend when your payment provider confirms the payment. With Stripe Checkout, that is the checkout.session.completed webhook (a webhook is an HTTP request the provider sends to your server when something happens), plus checkout.session.async_payment_succeeded for slower payment methods such as bank transfers.

Two rules from Stripe's fulfilment guide apply to every provider:

  1. Don't rely on the thank-you page. A buyer can pay and lose their connection before it loads. The webhook goes to your server directly and is retried if delivery fails.
  2. Expect to be called more than once. Stripe says your fulfilment function "might be called multiple times, possibly concurrently, for the same Checkout Session." Send on every call and some buyers get two keys.

So: confirm the payment, assign the key, save it with the order, then send the email and record the send. If the email fails, retry it without creating a new key.

From the LuneaMail team: We built LuneaMail because we needed to send highly customised emails from our own product, and there was no simple way to do it. With Gmail we ended up rendering templates in Google Sheets and trying plugins, and the result was far from what we wanted. HubSpot took many steps to send one styled email, and its templates limited what we could build. So we built the tool we wanted as developers: paste your HTML, make it dynamic with Handlebars, send your data as JSON, and the email goes out looking exactly as designed. A license key email is that idea in its simplest form: one person, one moment, their own data.

A license key email template you can copy

Replace the parts in {{double braces}} with your data. The same names are used in the API example below.

Subject: Your {{product}} license key

Preheader (the grey preview text after the subject in most inboxes): Your key, {{seats}} seats, and how to activate it.

Body:

Hi {{firstName}},

Thanks for buying {{product}}. Here is your license key:

{{licenseKey}}

It includes {{seats}} seats.

To activate: open {{product}}, go to Settings › License, and paste the key. (Use your product's real steps.)

Download: macOS · Windows

You can see this key at any time in your account: {{accountUrl}}

Order reference: {{orderId}}

Questions? Reply to this email and a person will answer.

Keep the plain-text version the same. For several keys in one order, repeat a block with {{#each}}, as the download links do below.

Send it with one API call

With LuneaMail's transactional API, the template lives in your workspace, designed in the visual builder or written in HTML, and your server sends only the data. Variables use Handlebars: {{licenseKey}} inserts a value, {{#if seats}}…{{/if}} shows a section only when the data has it, and {{#each downloads}}…{{/each}} repeats a block for each item in a list. In the visual builder, a section's "Show this section" condition becomes the same {{#if}} block.

The template body:

<p>Hi {{firstName}}, thanks for buying {{product}}.</p>
<p>Your license key: <strong>{{licenseKey}}</strong></p>

{{#if seats}}
  <p>It includes {{seats}} team seats.</p>
{{/if}}

{{#each downloads}}
  <a href="{{url}}">Download for {{name}}</a>
{{/each}}

<p>Find this key any time in <a href="{{accountUrl}}">your account</a>.</p>
<p>Order reference: {{orderId}}</p>
Enter fullscreen mode Exit fullscreen mode

The call from your fulfilment code (Node.js):

async function sendLicenseEmail(order) {
  const res = await fetch("https://app.luneamail.com/api/v1/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LUNEAMAIL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      templateId: process.env.LICENSE_TEMPLATE_ID,
      to: order.email,
      subject: "Your {{product}} license key",
      data: {
        firstName: order.firstName,
        product: "Pro",
        licenseKey: order.licenseKey,
        seats: order.seats,
        orderId: order.id,
        accountUrl: "https://example.com/account/licenses",
        downloads: [
          { name: "macOS", url: "https://example.com/download/mac" },
          { name: "Windows", url: "https://example.com/download/windows" },
        ],
      },
    }),
  });

  if (res.status === 202) {
    const { messageId } = await res.json(); // { status: "queued", messageId }
    await markLicenseEmailSent(order.id, messageId);
    return;
  }

  const { error, message } = await res.json();
  throw new Error(`License email not sent: ${error}: ${message}`);
}
Enter fullscreen mode Exit fullscreen mode

The subject takes the same variables as the body. to is one address per call.

What the API can answer

Status Error What it means What to do
202 – Accepted and queued Save the messageId with the order
400 MISSING_VARIABLE The template uses a variable you didn't send Fix the data; nothing was sent
400 INVALID_TEMPLATE Wrong template ID, or the template is empty Check the ID
401 UNAUTHORIZED Missing or wrong API key Check the key
422 SUPPRESSED The address bounced or reported spam before Don't retry; show the key in the app and ask for another address
429 RATE_LIMITED More than 600 sends a minute on this key Wait for the Retry-After header
429 QUOTA_EXCEEDED Your monthly email allowance is used up Top up or upgrade
502 SEND_FAILED The send failed downstream Retry later

MISSING_VARIABLE is the useful one. Variables outside {{#if}} and {{#each}} are required, so if your code forgets licenseKey, the API refuses instead of sending "Your license key:" with nothing after it. More on the developers page.

Don't send it twice

With two webhook deliveries at the same moment, "check if sent, then send" can pass twice. Claim the order first with one atomic update, for example UPDATE orders SET license_email = 'sending' WHERE id = $1 AND license_email IS NULL, and only send if a row changed.

Format the key so people can copy it

  • Monospace, on its own line, with no full stop after it to copy by mistake.
  • Groups with dashes, like ABCD-1234-EFGH, so it can be read aloud to support.
  • No look-alike characters in your key alphabet: 0 and O, 1, I and l. Someone will type it from a screenshot.
  • Accept the key with or without dashes and spaces when activating. Pasted text often has a trailing space.

Security: what not to put in the email

A license key is a credential: whoever has it can use what the buyer paid for. Email is the expected way to deliver it, so limit the damage.

  • Not in the subject line. Subjects appear in phone notifications, on lock screens and in inbox lists. "Your Pro license key" is fine; the key itself is not.
  • Show the key in the account too, and let the owner replace it. A leaked email then has a fix.
  • For high-value keys, send a link instead. The email says "Your key is ready" and links to the account page behind a login.
  • Don't log the request body. If your app logs every outgoing API call, your logs now hold every key you've sold.
  • Resend only to the address on the order. A "send my key again" form that accepts any address lets anyone collect keys.

Make sure it arrives

A key in the spam folder becomes a support ticket, or a refund request.

  • Send from your own domain, authenticated. Gmail requires SPF or DKIM from every sender (SPF and DKIM are DNS records that prove a mail server may send for your domain). In LuneaMail you add your domain and its DKIM records once. Until the domain is verified, mail goes out from LuneaMail's address with replies going to yours.
  • Use a reply-to someone reads. Buyers reply to license emails with real problems.
  • Handle bounces. If an address bounced before, the API returns SUPPRESSED and sends nothing. Show the key in the account too, so a bad address never blocks a buyer.

Transactional, not marketing

A license key email is transactional: it delivers what someone just paid for. The FTC's CAN-SPAM guide counts messages that "deliver goods or services as part of a transaction that the recipient already has agreed to" as transactional, so you need no marketing consent and no unsubscribe link. In LuneaMail, people who unsubscribed from your newsletter still get their license emails.

The catch: the FTC judges mixed emails by their primary purpose. A subject that reads like an ad, or a promotion above the key, can make the whole email commercial. So no discount banner, no newsletter sign-up, no "you might also like". Upsell in a separate marketing email, to people who agreed to receive one.

Resends, wrong addresses and refunds

  • Resend: add an "Email my key again" button in the account. It calls the same function with the same template and the same key.
  • Wrong address: buyers mistype emails. Let support change the address on an order, then resend. The key should never be emailed to the new address without that check.
  • Refund or chargeback: revoke the key on your license server. A short, factual notice is transactional too.

Checklist

  • [ ] Email sent from the payment webhook, not the thank-you page
  • [ ] One email per order, even if the webhook arrives twice
  • [ ] Key in the body, monospace, on its own line; not in the subject
  • [ ] Product, plan, seats, activation steps and order reference
  • [ ] Link to the account page where the key is also shown
  • [ ] A reply-to address a person reads
  • [ ] Sent from your own domain with SPF and DKIM
  • [ ] No promotions anywhere in the email
  • [ ] Resend button that only sends to the order's address
  • [ ] Key revoked on refund

Common mistakes

  • Sending from the checkout success page. Some buyers never reach it.
  • No idempotency. Duplicate webhooks mean duplicate keys.
  • The key in the subject line. Visible on every lock screen.
  • A blank key. A missing variable rendered as an empty string. Fail loudly instead.
  • An upsell above the key. It can turn a transactional email into a marketing one.
  • The key only in email. Lose the email and the buyer has to contact support.

Sending license keys with LuneaMail

LuneaMail's transactional API is one POST /send call with a template ID, an address and your data. Your team designs the template in the same editor as your newsletters, so license emails match your brand without a second tool, and they use the same monthly allowance as campaigns on every plan, including Free (pricing). The same pattern works for other app email; see our guide to team invite emails.


Originally published on the LuneaMail blog.

Top comments (0)