# Transactional email for a side project

Send welcome emails, receipts and notifications from a Node script or serverless function with two API calls, on a domain you verify once, with no SMTP server to run.

Every side project eventually needs to send one email to one person: a welcome note, a receipt,
a "your export is ready". The traditional answer is SMTP, and the traditional experience is
discovering that your VPS provider blocks port 25, that Gmail wants DKIM, SPF and a
one-click unsubscribe header, and that nobody told you about bounces. This guide uses SendBeam's
REST API instead. It is two HTTP calls: make sure the recipient exists as a contact, then send.
Delivery, authentication for your domain, bounce handling and the unsubscribe headers are done
by SendBeam's managed delivery, and every send shows up in the workspace's Activity page.

## What you get and what it costs

Be clear about the plan gate before you write code. API keys exist on every plan, but on
every plan they work. What the plan sets is how many writes you get an hour —
**120 on Free**, **600 on Starter**, unlimited on Pro and Business —
and going over answers `429` with a `Retry-After` rather than failing
permanently. Reads are never counted. Plans cover an account, not a site, so one plan serves the
API for every workspace you run. The
send counts against the account's monthly email quota (60,000 on Pro) and an hourly sending
ceiling, which is shown in the app under Billing. Details are on [Billing and plans](https://sendbeam.io/docs/admin/billing).

In return there is nothing to host. The from-address is your workspace's default sender: the
shared `ws-@mail.sendbeam.io` address until you verify a domain, and
`you@yourdomain.com` after. Merge tags such as `{{first_name}}`
are filled in from the contact.

## Prerequisites

- A SendBeam workspace on any plan. API sending is metered per hour, not restricted by plan.
- An API key from **Settings → API keys** with the `contacts:write` and `transactional:send` permissions. Only workspace admins can create keys, the full key is shown once, and it is stored hashed. Keep it in a server-side environment variable.
- Node 18 or newer, or any runtime with `fetch` (Cloudflare Workers, Vercel and Netlify functions, Deno, Bun).
- Recommended: a [verified sending domain](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns), so the email comes from you rather than a shared address.

## Steps

1. **Create the key and store it.** In **Settings → API keys**, create a
  key with `contacts:write` and `transactional:send`. Put it in your server
  environment. It must never reach a browser bundle; anyone holding it can send email as your
  workspace up to your quota.
  `# .env — server side only. Never ship this to the browser.
  SENDBEAM_API_KEY=sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY`
2. **Add a small client.** Sending targets a *contact*, not a bare address, so
  the helper first creates the contact (`POST /api/v1/contacts`) and, if it already
  exists, looks it up. A 409 also covers suppressed addresses: one that unsubscribed needs
  explicit new consent (`resubscribe: true`), and one that bounced or complained
  cannot be re-added at all. The helper treats all of those as "cannot mail" and lets your app
  decide what to do, which for a login link means showing it on screen instead.
  `// sendbeam.ts — a 40-line client for the two calls a side project needs.
  // Node 18+ (global fetch). Works unchanged in Cloudflare Workers, Vercel and Netlify functions.
  
  const BASE = 'https://sendbeam.io';
  const KEY = process.env.SENDBEAM_API_KEY;
  if (!KEY) console.warn('SENDBEAM_API_KEY is not set; sends will fail with 401');
  
  type Contact = { id: string; email: string; status: string };
  
  async function api(method: string, path: string, body?: unknown): Promise {
  const res = await fetch(BASE + path, {
  method,
  headers: { 'x-api-key': KEY ?? '', 'Content-Type': 'application/json' },
  body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = (await res.json().catch(() => ({}))) as T;
  return { status: res.status, data };
  }
  
  /** Find the contact for an address, creating it if needed. Returns null if it cannot be mailed. */
  export async function ensureContact(email: string, firstName?: string): Promise {
  const created = await api('POST', '/api/v1/contacts', {
  email,
  first_name: firstName,
  source: 'app',
  });
  if (created.status === 201 && created.data.contact) return created.data.contact;
  
  if (created.status === 409) {
  // Already exists (or suppressed). Look it up; q is a substring match, so compare exactly.
  const list = await api('GET', '/api/v1/contacts?q=' + encodeURIComponent(email) + '&limit=50');
  const match = list.data.contacts?.find((c) => c.email.toLowerCase() === email.toLowerCase());
  return match && match.status === 'subscribed' ? match : null;
  }
  
  throw new Error('SendBeam contact error ' + created.status + ': ' + (created.data.error ?? 'unknown'));
  }
  
  /** Send one email. Resolves to the message id (or null), or throws with SendBeam's error text. */
  export async function sendEmail(contactId: string, subject: string, html: string, text?: string): Promise {
  const res = await api('POST', '/api/v1/send', {
  contact_id: contactId,
  subject,
  html_content: html,
  text_content: text,
  });
  if (res.status === 200 && res.data.ok) return res.data.message_id ?? null;
  throw new Error('SendBeam send error ' + res.status + ': ' + (res.data.error ?? 'unknown'));
  }`
3. **Send from your application code.** Subject and HTML are yours; a plain-text
  alternative is optional but worth providing. The merge tags are resolved by SendBeam from the
  contact record, so a first name captured at signup appears without you interpolating it.
  `// welcome.ts — call this after a user signs up to your app
  import { ensureContact, sendEmail } from './sendbeam';
  
  export async function sendWelcome(email: string, firstName: string, loginUrl: string) {
  const contact = await ensureContact(email, firstName);
  if (!contact) {
  // Unsubscribed, bounced or complained: SendBeam will not mail this address. Show the link in-app instead.
  return { sent: false, reason: 'address cannot be mailed' };
  }
  
  const html = `
  Hi {{first_name}},
  Your account is ready. Sign in here:
  [${loginUrl}](${loginUrl})
  If you did not create an account, you can ignore this email.
  `;
  const text = `Hi {{first_name}},\n\nYour account is ready. Sign in here: ${loginUrl}\n\nIf you did not create an account, you can ignore this email.`;
  
  const messageId = await sendEmail(contact.id, 'Welcome — your account is ready', html, text);
  return { sent: true, messageId };
  }`
4. **Decide what happens when a send fails.** A 429 means the hourly ceiling; the
  response carries a `Retry-After` header with the number of seconds to wait, so
  queue and retry after it. A 403 for quota or a pause
  will not clear by retrying; log it and alert yourself. A 503 means delivery was refused for
  this message; the `error` text says why. None of these should block the user's
  action in your app: the email is a courtesy, the account was still created.

> **Time-critical mail.** A magic link or a one-time code is fine at side-project
> volume, but the hourly ceiling is per account, and a newsletter campaign running in the same
> hour shares it. If login depends on the email arriving, keep a fallback path or keep marketing
> sends on a different hour.
> 

## Test it

Use curl with your own address first. The first call returns the contact with its
`id`; the second sends to it.

```
curl -s -X POST https://sendbeam.io/api/v1/contacts \
  -H "x-api-key: $SENDBEAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","first_name":"Test","source":"app"}'
# 201 → {"contact":{"id":"0f8c6d2e-…","email":"you@example.com","status":"subscribed", …}}
```

```
curl -s -X POST https://sendbeam.io/api/v1/send \
  -H "x-api-key: $SENDBEAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contact_id":"0f8c6d2e-…","subject":"Test from curl","html_content":"<p>Hi {{first_name}}, it works.</p>","text_content":"Hi, it works."}'
# 200 → {"ok":true,"message_id":"…"}
```

The email should arrive within seconds. Open **Activity** in SendBeam and filter by
*API*: the send is listed with its delivery, open and click events, which is where you
look when a user says they did not get something. Run the curl a second time to see the 409 on
contact creation and confirm your helper's lookup path handles it.

## Limits, unsubscribes and errors

Two behaviours surprise people who come from raw SMTP. First, only *subscribed* contacts
can be mailed: a 422 on an unsubscribed contact is SendBeam refusing to send to someone who
asked it not to, and it applies to transactional mail as much as campaigns. Second, every email
SendBeam sends carries the RFC 8058 `List-Unsubscribe` and
`List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers with a signed URL, and a
signed unsubscribe link is appended to the HTML if your template has none. That is what Gmail
and Yahoo require of bulk senders and it protects your domain's reputation, but it also means a
recipient can unsubscribe from a receipt. Keep account-critical email short and infrequent, and
do not rely on email as the only channel for anything the user cannot live without.

The responses you will meet, in the order you are likely to meet them:

```
401  {"error":"Unauthorized"}                                   key missing, revoked or mistyped
429  {"error":"This workspace has used its 120 API writes for the hour…"}       Retry-After: 3600
403  {"error":"Forbidden: transactional:send permission required"}   key lacks the permission
403  {"error":"Monthly email limit reached (60,000 on the pro plan)."}
404  {"error":"Contact not found"}                                wrong contact_id, or another workspace's
422  {"error":"Cannot send to contact with status: unsubscribed"}
429  {"error":"Hourly send limit reached. Try again later."}                          Retry-After: <seconds>
503  {"error":"…"}                                                delivery failed; the reason is in error
```

## Why not SMTP from the server?

Running your own outbound mail is the cheapest option on paper and the most expensive in
evenings. Many budget VPS providers block ports 25 and 587 outright, so the first step is often a
support ticket. After that you own the DKIM key, the SPF record, a DMARC policy, the return path,
bounce processing, complaint feedback loops, the one-click unsubscribe requirement, and an IP
address whose reputation starts at zero. A managed relay behind SMTP fixes the ports and the IP
but leaves the rest with you.

SendBeam's trade-offs are real too. It is HTTP only, with no SMTP relay, so a legacy application
that only speaks SMTP cannot use it without a small shim. API sending works on every plan, metered per hour. There
are quotas. And the contact model means a first call before the first send. For a side project
with a few hundred emails a month and no appetite for mail operations, that is usually the
right side of the bargain; for a product sending millions, a dedicated transactional provider
is the better fit.

## Next steps

- Verify your domain so the email is from you: [Verify a sending domain with Cloudflare DNS](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns).
- For a welcome sequence rather than a single email, let a signup form add the contact and use an [automation](https://sendbeam.io/docs/automations) instead of the send endpoint. No API key is needed for that.
- Trigger sends from no-code tools: the [Zapier](https://sendbeam.io/integrations/zapier) and [Make](https://sendbeam.io/integrations/make) integration pages show the same two calls from a webhook step.
- Everything the API accepts and returns, with schemas, is in the [API reference](https://sendbeam.io/docs/api); the OpenAPI document is at [/openapi.json](https://sendbeam.io/openapi.json).

## Starter kits

The starters are front-end projects that use the public form endpoint rather than the API, so
they are the quickest way to get the signup half working; the Next.js one already has a
server-side place to put the send call from this guide:

- Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel)
- Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages)
- HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html)
- Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify)
- Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo)

Every starter covers the same five things — newsletter signup, contact form, double opt-in,
unsubscribe and spam protection — reads its form IDs from environment variables or site config,
and is MIT licensed. Browse them all in
[sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters).

---
Source: https://sendbeam.io/docs/guides/transactional-email-side-project
