eusend
SDKs

Node.js SDK

Official TypeScript SDK for the eusend API. Works in Node.js 18+, Bun, and any runtime with native fetch.

Official TypeScript SDK for the eusend API. Works in Node.js 18+, Bun, and any runtime that supports the native fetch API.

Installation

npm install @eusend_dev/sdk
# or
bun add @eusend_dev/sdk

Getting started

Create a client with your API key. The key can also be read from the EUSEND_API_KEY environment variable.

import { Eusend } from '@eusend_dev/sdk'

const client = new Eusend('eu_live_...')
// or: const client = new Eusend() // reads EUSEND_API_KEY

Sending an email

Every method returns a { data, error, headers } union. On success error is null; on failure data is null.

const { data, error } = await client.emails.send({
  from: 'hello@yourdomain.com',
  to: 'customer@example.com',
  subject: 'Your order is confirmed',
  html: '<p>Thanks for your order!</p>',
  text: 'Thanks for your order!',
})

if (error) {
  console.error(error.name, error.message) // 'VALIDATION_ERROR' ...
} else {
  console.log(data.id) // 9a8b7c6d-...
}

Send options

FieldTypeDescription
fromstringSender address — bare email or "Name <email>"
tostring | string[]Recipient(s)
ccstring | string[]CC recipient(s)
bccstring | string[]BCC recipient(s)
replyTostring | string[]Reply-to address(es)
subjectstringEmail subject
htmlstringHTML body
reactReact.ReactElementA React Email component. The SDK renders to HTML locally before sending. Requires react and @react-email/render as peer dependencies.
textstringPlain text body
templateIdstringID of a saved template
variablesRecord<string, unknown>Template variable substitutions
headersRecord<string, string>Custom email headers
trackOpensbooleanTrack open events. Omit to use your organization default
trackClicksbooleanTrack click events. Omit to use your organization default
attachmentsAttachment[]File attachments (up to 20, 10 MB combined). Each: { filename, one of content (base64 string or Uint8Array) or path (a public URL fetched at send time), contentType?, contentId? }. Set contentId to embed an image inline via cid:.
scheduledAtstring | DateSchedule the send for a future time, at most 30 days out. Reschedule with emails.update() or cancel with emails.cancel() while the email is still scheduled.

At least one of html, react, text, or templateId is required.

Send with React Email

Pass a React Email component via react. The SDK renders it to HTML locally before POSTing — the JSX source never travels over the wire.

import { WelcomeEmail } from './emails/welcome'

await client.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Welcome',
  react: <WelcomeEmail name="Jane" />,
})

Requires react and @react-email/render as peer dependencies: npm install react @react-email/render.

Idempotent sends

Pass an idempotencyKey to safely retry without sending duplicates.

const { data } = await client.emails.send(
  { from: 'you@domain.com', to: 'user@example.com', subject: 'Receipt', html: '<p>Thanks</p>' },
  { idempotencyKey: `receipt-${orderId}` },
)

Batch send

Send up to 100 emails in a single request.

const { data } = await client.batch.send([
  { from: 'you@domain.com', to: 'alice@example.com', subject: 'Hi Alice', html: '<p>Hi Alice</p>' },
  { from: 'you@domain.com', to: 'bob@example.com', subject: 'Hi Bob', html: '<p>Hi Bob</p>' },
])

console.log(data?.data) // [{ id: '9a8b7c6d-...' }, { id: '1f2e3d4c-...' }]

Scheduled sends

Pass scheduledAt — a Date, an ISO 8601 string, or natural language like "in 1 hour" (at most 30 days out) — to schedule instead of sending immediately. While the email's status is scheduled you can move or cancel it; after that, both calls return a 409.

// Schedule for tomorrow morning
const { data } = await client.emails.send({
  from: 'you@domain.com',
  to: 'user@example.com',
  subject: 'Your trial ends tomorrow',
  html: '<p>Reminder: your trial ends in 24 hours.</p>',
  scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})

// Reschedule
await client.emails.update(data!.id, {
  scheduledAt: '2026-07-04T09:00:00Z',
})

// Or cancel — the send is refunded to your monthly quota
await client.emails.cancel(data!.id)

Retrieve & list emails

// Get a single email with its delivery events
const { data } = await client.emails.get('9a8b7c6d-...')
console.log(data?.status) // 'delivered'
console.log(data?.events) // [{ type: 'sent', ... }, ...]

// List with filters
const { data: list } = await client.emails.list({ status: 'delivered', limit: 50 })
console.log(list?.data) // array of emails
console.log(list?.nextCursor) // pass as cursor for next page

Domains

// Add a domain and get DNS records
const { data } = await client.domains.create('yourdomain.com')
console.log(data?.records) // every record to publish, in order
console.log(data?.dkim) // { type: 'TXT', name: 'eusend._domainkey...', value: '...' }
console.log(data?.dmarc) // { type: 'TXT', name: '_dmarc...', value: '...' }

// Creating the domain already starts checking DNS; this forces a fresh check
await client.domains.verify(domainId)

// List / get / delete
await client.domains.list()
await client.domains.get(domainId)
await client.domains.delete(domainId)

API Keys

// Create a live key
const { data } = await client.apiKeys.create({ name: 'Production' })
console.log(data?.key) // eu_live_... — only returned once

// Create a test/sandbox key
const { data: test } = await client.apiKeys.create({ name: 'Sandbox', testMode: true })
// test.key → 'eu_test_...'

// Send-only key, restricted to one domain. Omit domainId for any verified domain.
await client.apiKeys.create({
  name: 'Billing service',
  permission: 'sending_access',
  domainId,
})

await client.apiKeys.list()
await client.apiKeys.delete(keyId)

Templates

// Create an HTML template with {{variable}} placeholders
const { data } = await client.templates.create({
  name: 'Welcome email',
  subject: 'Welcome, {{name}}!',
  html: '<h1>Hi {{name}}</h1><p>Welcome to {{product}}.</p>',
})

// Using React Email — SDK renders locally before sending
import { OrderConfirmation } from './emails/order-confirmation'

await client.templates.create({
  name: 'Order confirmation',
  subject: 'Order {{order_id}} confirmed',
  react: <OrderConfirmation />,
})

// Send using a template
await client.emails.send({
  from: 'you@domain.com',
  to: 'user@example.com',
  templateId: data!.id,
  variables: { name: 'Jane', product: 'Acme' },
})

await client.templates.list()
await client.templates.get(templateId)
await client.templates.update(templateId, { name: 'New name' })
await client.templates.delete(templateId)

Audiences & Contacts

// Create an audience
const { data: aud } = await client.audiences.create('Newsletter')

// Add / upsert a contact
await client.audiences.createContact(aud!.id, {
  email: 'user@example.com',
  firstName: 'Jane',
  lastName: 'Smith',
})

// Bulk import (up to 1,000 per call). `unsubscribed` and `createdAt` are for
// migrating a list in — an import can add an opt-out but never clear one.
await client.audiences.batchCreateContacts(aud!.id, {
  contacts: [
    { email: 'alice@example.com', firstName: 'Alice' },
    { email: 'bob@example.com', unsubscribed: true, createdAt: '2024-02-01T10:00:00.000Z' },
  ],
})

// List with filters
await client.audiences.listContacts(aud!.id, { subscribed: true, search: 'gmail.com' })

// Bulk delete by id (up to 1,000). Removes them from the audience — not an unsubscribe.
await client.audiences.batchDeleteContacts(aud!.id, [contactId])

// Update / unsubscribe / delete
await client.audiences.updateContact(aud!.id, contactId, { unsubscribed: true })
await client.audiences.deleteContact(aud!.id, contactId)

await client.audiences.list()
await client.audiences.delete(aud!.id)

Suppressions

Addresses the account will not send to. Hard bounces and spam complaints are added automatically — these methods cover the ones you manage yourself. See Suppressions for the full API.

// What's blocked, and why
await client.suppressions.list({ reason: 'bounce', limit: 50 })
await client.suppressions.list({ email: '@acme.com' })

// Stop sending to an address. Already suppressed? The existing entry comes back
// unchanged — a manual add never rewrites a real bounce or complaint.
await client.suppressions.create({ email: 'opted-out@example.com' })

// Import up to 1,000 per call — do this before your first send when migrating
await client.suppressions.import([
  'one@example.com',
  { email: 'two@example.com', reason: 'complaint' },
])

// Un-suppress by entry id or by address
await client.suppressions.remove('invalid@example.com')

// The whole list as CSV
const { data: csv } = await client.suppressions.export()

Broadcasts

// Create a broadcast — HTML, a React Email component, or a saved template
import { MayNewsletter } from './emails/may-newsletter'

const { data } = await client.broadcasts.create({
  name: 'May newsletter',
  audienceId: '550e8400-e29b-41d4-a716-446655440000',
  from: 'hello@yourdomain.com',
  subject: 'May update',
  react: <MayNewsletter />, // SDK renders to HTML locally
  // or: html: '<p>Hi {{first_name}}...</p>'
  // or: templateId: '7c9e6679-7425-40de-944b-e07fc1f90ae7'
})

// Check it first — delivers only to your own verified domains, leaves the
// broadcast a draft, and works on the free plan (send() needs a paid one).
await client.broadcasts.test(data!.id, { to: ['you@yourdomain.com'] })

// Send immediately
await client.broadcasts.send(data!.id)

// Or schedule for later
await client.broadcasts.send(data!.id, { scheduledAt: '2026-06-01T09:00:00.000Z' })

// Cancel a scheduled broadcast
await client.broadcasts.cancel(data!.id)

await client.broadcasts.list()
await client.broadcasts.get(broadcastId) // includes delivery stats
await client.broadcasts.update(broadcastId, { subject: 'Updated subject' })
await client.broadcasts.delete(broadcastId)

Webhooks

// Subscribe to events
const { data } = await client.webhooks.create({
  url: 'https://yourapp.com/webhooks/eusend',
  events: ['email.sent', 'email.delivered', 'email.bounced', 'email.complained'],
  // or: events: ['*'] to receive everything
})
console.log(data?.secret) // signing secret — store it securely, only returned once

await client.webhooks.list()
await client.webhooks.get(webhookId) // includes recent deliveries
await client.webhooks.update(webhookId, { events: ['email.bounced'] })
await client.webhooks.delete(webhookId)

Verifying webhook signatures

Every delivery is signed with HMAC-SHA256. Verify before processing:

import { createHmac, timingSafeEqual } from 'crypto'

async function verifyWebhook(req: Request, secret: string): Promise<boolean> {
  const id = req.headers.get('webhook-id') ?? ''
  const timestamp = req.headers.get('webhook-timestamp') ?? ''
  const signature = req.headers.get('webhook-signature') ?? ''

  const body = await req.text()
  const expected =
    'v1,' + createHmac('sha256', secret).update(`${id}.${timestamp}.${body}`).digest('base64')

  const received = Buffer.from(signature, 'utf8')
  const digest = Buffer.from(expected, 'utf8')

  // timingSafeEqual throws on a length mismatch — check lengths before comparing,
  // or a missing/truncated header crashes the handler instead of returning false.
  return received.length === digest.length && timingSafeEqual(received, digest)
}

Error handling

const { data, error } = await client.emails.send({ ... })

if (error) {
  console.error(error.name)       // 'MONTHLY_LIMIT_EXCEEDED'
  console.error(error.message)    // 'Monthly send limit exceeded'
  console.error(error.statusCode) // 429
}
CodeStatusDescription
UNAUTHORIZED401Invalid or missing API key
FORBIDDEN403Action not allowed on your plan
NOT_FOUND404Resource not found
VALIDATION_ERROR400Invalid request body
CONFLICT409Resource already exists
RATE_LIMITED429Too many requests
MONTHLY_LIMIT_EXCEEDED429Monthly send quota reached
DAILY_LIMIT_EXCEEDED429Daily send ceiling reached (resets midnight UTC)
PLAN_LIMIT_EXCEEDED403Feature not available on your plan
DOMAIN_NOT_VERIFIED403Sender domain is not verified
ALL_SUPPRESSED422All recipients are on the suppression list
SENDING_SUSPENDED403Account suspended — high bounce or complaint rate
ACCOUNT_RESTRICTED403Account held pending review before its first send
SENDER_NOT_PERMITTED403from is not permitted: it impersonates a brand the sending domain does not own, or is a sender identity this account has not used before
LIST_SEND_HELD403Past the unreviewed account's list-send allowance — the rest waits for review
BROADCAST_HELD403Broadcast used its unreviewed allowance; the remainder waits for review
SERVICE_PAUSED503Sending temporarily paused platform-wide
ATTACHMENT_STORAGE_ERROR503An attachment could not be stored — retry
BAD_REQUEST400Malformed request
PAYLOAD_TOO_LARGE413Request body exceeds the 16 MB limit
INTERNAL_ERROR500Server error
application_errornullNetwork failure — request never reached the server

TypeScript

The SDK ships with full type definitions. All request options, response shapes, and error codes are typed.

import type {
  SendEmailOptions,
  Email,
  EmailStatus,
  EusendError,
  EusendResponse,
} from '@eusend_dev/sdk'

View the full source and changelog on npm.