
Research
/Security News
OpenAPI React Query Codegen Compromised in Mini Shai-Hulud npm Supply Chain Attack
Ten malicious OpenAPI React Query Codegen versions were published to npm in the Mini Shai-Hulud attack, all with valid provenance.
@eusend_dev/sdk
Advanced tools
Official Node.js SDK for the Eusend API — the EU-native transactional email platform.
npm install @eusend_dev/sdk
# or
bun add @eusend_dev/sdk
import { Eusend } from '@eusend_dev/sdk'
const client = new Eusend('eu_live_...')
Your API key can also be set via the EUSEND_API_KEY environment variable, in which case the constructor argument can be omitted:
const client = new Eusend()
const { data, error } = await client.emails.send({
// `from` accepts a bare email or a display-name form: `Acme <you@yourdomain.com>`
from: 'Acme <you@yourdomain.com>',
to: 'user@example.com',
subject: 'Hello',
html: '<p>Hello world</p>',
text: 'Hello world',
})
console.log(data?.id) // 9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d (UUID)
| Field | Type | Description |
|---|---|---|
from | string | Sender address — a bare email or display-name form (Acme <you@yourdomain.com>). Must be from a verified domain. |
to | string | string[] | Recipient(s). Maximum 50. |
cc | string | string[] | CC recipient(s). Maximum 50. |
bcc | string | string[] | BCC recipient(s). Maximum 50. |
replyTo | string | string[] | Reply-to address(es). Maximum 50. |
subject | string | Email subject |
html | string | HTML body |
text | string | Plain text body |
templateId | string | ID of a saved template |
variables | Record<string, unknown> | Template variable substitutions |
headers | Record<string, string> | Custom email headers, written into the outbound message. Header names and values may not contain line breaks. |
trackOpens | boolean | Track open events. Omit to use your organization default |
trackClicks | boolean | Track click events. Omit to use your organization default |
At least one of html, react, text, or templateId is required.
Pass a React Email component via the react field. The SDK renders it to HTML locally before sending — the JSX source never travels over the wire.
import { Eusend } from '@eusend_dev/sdk'
import { WelcomeEmail } from './emails/welcome'
const client = new Eusend()
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
If you prefer to render yourself, pass the resulting HTML via html instead — useful when you want one rendered template to serve multiple sends.
Pass an idempotencyKey to safely retry without sending duplicates. If a request with the same key was already accepted, the original email ID is returned.
const { data } = await client.emails.send(
{
from: 'you@yourdomain.com',
to: 'user@example.com',
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
},
{ idempotencyKey: `receipt-${orderId}` },
)
Up to 100 emails in a single request.
const { data } = await client.batch.send([
{
from: 'you@yourdomain.com',
to: 'alice@example.com',
subject: 'Hello Alice',
html: '<p>Hi Alice</p>',
},
{
from: 'you@yourdomain.com',
to: 'bob@example.com',
subject: 'Hello Bob',
html: '<p>Hi Bob</p>',
},
])
console.log(data?.data) // [{ id: '...' }, { id: '...' }]
const { data } = await client.emails.get('9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d')
console.log(data?.status) // 'delivered'
console.log(data?.events) // [{ type: 'sent', ... }, { type: 'delivered', ... }]
const { data } = await client.emails.list({ limit: 20 })
console.log(data?.data) // array of emails
console.log(data?.nextCursor) // pass as cursor to fetch the next page
// By status
await client.emails.list({ status: 'delivered' })
// By sender
await client.emails.list({ from: 'you@yourdomain.com' })
// By recipient
await client.emails.list({ to: 'user@example.com' })
// Pagination
await client.emails.list({ limit: 50, cursor: data.nextCursor })
Available statuses: queued sending sent delivered bounced complained suppressed failed
const { data } = await client.domains.create('yourdomain.com')
// DNS records to add to your domain
console.log(data?.dkim) // { type: 'TXT', name: 'eusend._domainkey.yourdomain.com', value: '...' }
console.log(data?.spf) // { type: 'TXT', name: 'yourdomain.com', value: '...' }
console.log(data?.dmarc) // { type: 'TXT', name: '_dmarc.yourdomain.com', value: '...' }
After adding the DNS records, trigger verification:
await client.domains.verify(domainId)
const { data } = await client.domains.list()
// [{ id, name, status: 'verified', createdAt }]
const { data } = await client.domains.get(domainId)
// { id, name, dkimPublicKey, dkimSelector, status, createdAt, verifiedAt }
await client.domains.delete(domainId)
const { data } = await client.apiKeys.create({ name: 'Production' })
console.log(data?.key) // eu_live_... — only returned once, store it securely
Pass testMode: true to create a sandbox key. Emails sent with a test key are accepted and tracked but never delivered.
const { data } = await client.apiKeys.create({ name: 'Sandbox', testMode: true })
// data.key → 'eu_test_...'
permission defaults to 'full_access' — every resource. 'sending_access' limits the key to sending email (plus rescheduling and canceling a scheduled send); every other endpoint, including reading your email logs, returns 403 FORBIDDEN.
await client.apiKeys.create({ name: 'App server', permission: 'sending_access' })
A sending-access key can additionally be pinned to one sending domain. Sends from any other domain are rejected. domainId is only valid with permission: 'sending_access'.
await client.apiKeys.create({
name: 'Billing service',
permission: 'sending_access',
domainId,
})
Deleting a domain revokes every key restricted to it.
const { data } = await client.apiKeys.list()
// [{ id, name, prefix, testMode, permission, domainId, domainName, createdAt, lastUsedAt }]
The full key is never returned after creation — only the prefix (e.g. eu_live_Lx_e).
await client.apiKeys.delete(keyId)
const { data } = await client.audiences.create('Newsletter')
const audienceId = data!.id
const { data } = await client.audiences.list()
// [{ id, name, createdAt, contactCount }]
await client.audiences.delete(audienceId)
const { data } = await client.audiences.createContact(audienceId, {
email: 'user@example.com',
firstName: 'Jane',
lastName: 'Smith',
})
If a contact with that email already exists in the audience, it will be updated instead.
Up to 1,000 contacts per call. Existing contacts are upserted.
const { data } = await client.audiences.batchCreateContacts(audienceId, {
contacts: [
{ email: 'alice@example.com', firstName: 'Alice' },
{ email: 'bob@example.com', firstName: 'Bob' },
],
})
console.log(data?.count) // 2
const { data } = await client.audiences.listContacts(audienceId, { limit: 100 })
// Filter by subscription status
await client.audiences.listContacts(audienceId, { subscribed: true })
await client.audiences.listContacts(audienceId, { subscribed: false })
// Search by email
await client.audiences.listContacts(audienceId, { search: 'gmail.com' })
// Pagination
await client.audiences.listContacts(audienceId, { cursor: data.nextCursor })
const { data } = await client.audiences.getContact(audienceId, contactId)
// { id, audienceId, email, firstName, lastName, status, unsubscribedAt, createdAt, updatedAt }
// Update name
await client.audiences.updateContact(audienceId, contactId, {
firstName: 'Janet',
})
// Unsubscribe
await client.audiences.updateContact(audienceId, contactId, {
unsubscribed: true,
})
// Re-subscribe
await client.audiences.updateContact(audienceId, contactId, {
unsubscribed: false,
})
await client.audiences.deleteContact(audienceId, contactId)
Addresses the account will not send to. Hard bounces and spam complaints are added
automatically; these methods cover the ones you manage yourself. A send to a suppressed
address is skipped and recorded with status suppressed; if every recipient is
suppressed the send fails with ALL_SUPPRESSED.
Test-mode keys can read the list but not modify it.
const { data } = await client.suppressions.list({ reason: 'bounce', limit: 50 })
// { data: [{ id, email, reason, created_at }], next_cursor: null }
// Everything suppressed at one domain
await client.suppressions.list({ email: '@acme.com' })
await client.suppressions.create({ email: 'opted-out@example.com' })
If the address is already suppressed the existing entry is returned unchanged — a manual add never rewrites a real bounce or complaint.
Up to 1,000 addresses per call. Items may be bare strings or objects, so a column lifted straight out of a CSV works as-is. Import before your first send when migrating, so addresses that already bounced elsewhere don't get a fresh attempt from a new IP.
const { data } = await client.suppressions.import([
'one@example.com',
{ email: 'two@example.com', reason: 'complaint' },
])
console.log(data?.count) // written
console.log(data?.already_suppressed) // were already on the list
console.log(data?.duplicates) // repeated rows collapsed
await client.suppressions.remove('invalid@example.com') // or the entry id
const { data: csv } = await client.suppressions.export()
// "email,reason,created_at\n..."
Templates let you define reusable email layouts with {{variable}} placeholders that are substituted at send time.
Variable values are HTML-escaped. A value you pass in
variablesis inserted as text, not markup —{{name}}with"<b>Jane</b>"renders the literal characters, not bold text. Put any HTML structure (links, formatting) in the templatehtmlitself, not in the variable values.
const { data } = await client.templates.create({
name: 'Welcome email',
subject: 'Welcome, {{name}}!',
html: '<h1>Hi {{name}}</h1><p>Welcome to {{product}}.</p>',
})
Pass a React Email component via react. The SDK renders it to HTML locally before submitting — the JSX source never travels over the wire.
import { OrderConfirmation } from './emails/order-confirmation'
const { data } = await client.templates.create({
name: 'Order confirmation',
subject: 'Your order {{order_id}} is confirmed',
react: <OrderConfirmation />,
})
Use {{variable}} placeholders anywhere in your React component; they pass through to the rendered HTML and are substituted at send time when you provide variables. If you'd rather render yourself, pass html instead.
await client.emails.send({
from: 'you@yourdomain.com',
to: 'user@example.com',
templateId: data!.id,
variables: {
first_name: 'Jane',
order_id: 'ORD-1234',
},
})
await client.templates.list()
await client.templates.get(templateId)
await client.templates.update(templateId, { name: 'New name', subject: 'New subject' })
await client.templates.delete(templateId)
Receive real-time events when email statuses change.
const { data } = await client.webhooks.create({
url: 'https://yourapp.com/webhooks/eusend',
events: ['email.sent', 'email.delivered', 'email.bounced', 'email.complained'],
})
console.log(data?.secret) // signing secret — only returned once, store it securely
Pass '*' in the events array to subscribe to all events.
Available events: email.sent email.delivered email.bounced email.complained email.opened email.clicked
Endpoint requirements: the url must be a public http(s) endpoint — private, loopback, and internal addresses are rejected, both at creation and (after DNS resolution) before each delivery. Your endpoint must respond directly with a 2xx; redirects (3xx) are not followed and are treated as a failed delivery.
Every delivery is signed with HMAC-SHA256. Verify the signature before processing:
import { createHmac, timingSafeEqual } from 'crypto'
async function verifyWebhook(req: Request, secret: string): Promise<boolean> {
const webhookId = 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(`${webhookId}.${timestamp}.${body}`).digest('base64')
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
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)
Send a single email to every contact in an audience.
const { data } = await client.broadcasts.create({
name: 'May newsletter',
audienceId: '550e8400-e29b-41d4-a716-446655440000',
from: 'Sivert <hello@yourdomain.com>',
subject: 'May update',
html: '<p>Hi {{first_name}}, your monthly update is here...</p>',
})
You can also pass a React Email component via react, a saved template via templateId, or plain HTML. With react, the SDK renders to HTML locally before submitting:
import { MayNewsletter } from './emails/may-newsletter'
await client.broadcasts.create({
name: 'May newsletter',
audienceId: '550e8400-e29b-41d4-a716-446655440000',
from: 'Sivert <hello@yourdomain.com>',
subject: 'May update',
react: <MayNewsletter />,
})
{{first_name}}, {{last_name}}, {{full_name}}, and {{email}} are automatically available per recipient. Custom variables can be defined on the broadcast and are merged with per-recipient data.
Broadcasts — and any single-recipient send whose recipient is a known audience contact — automatically include RFC 8058 one-click unsubscribe headers (List-Unsubscribe + List-Unsubscribe-Post: List-Unsubscribe=One-Click), so you satisfy Gmail/Yahoo bulk-sender requirements without any extra work. (Sends to multiple recipients at once omit the header, since a single unsubscribe link can't be attributed to one recipient.) Broadcasts additionally render a visible unsubscribe footer in the email body. An unsubscribe is recorded against the contact (unsubscribedAt) and excludes them from future broadcasts; transactional sends to that address still go through. You don't need to set these headers yourself.
await client.broadcasts.send(broadcastId)
Calling send on a paused broadcast resumes it — sending continues from where it stopped, skipping recipients already sent. A broadcast pauses if it hits your monthly or daily send limit, the sender domain becomes unverified, or platform-wide sending is paused.
await client.broadcasts.send(broadcastId, {
scheduledAt: '2026-06-01T09:00:00.000Z',
})
await client.broadcasts.cancel(broadcastId)
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)
Every method returns { data, error, headers }. On success error is null; on failure data is null.
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
} else {
console.log(data.id)
}
| Code | Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Invalid or missing API key |
FORBIDDEN | 403 | Action not allowed on your plan |
NOT_FOUND | 404 | Resource not found |
VALIDATION_ERROR | 400 | Invalid request body |
BAD_REQUEST | 400 | Malformed request |
CONFLICT | 409 | Resource already exists |
RATE_LIMITED | 429 | Too many requests |
MONTHLY_LIMIT_EXCEEDED | 429 | Monthly send quota reached |
DAILY_LIMIT_EXCEEDED | 429 | Daily send ceiling reached (applies to all plans; ramps up as your account warms, resets midnight UTC) |
PLAN_LIMIT_EXCEEDED | 403 | Feature not available on your plan |
DOMAIN_NOT_VERIFIED | 403 | The sender domain is not verified for your organisation |
SENDING_SUSPENDED | 403 | Sending suspended for your account (high bounce or complaint rate) |
LIST_SEND_HELD | 403 | Past the unreviewed account's list-send allowance — the rest waits for review |
BROADCAST_HELD | 403 | Broadcast used its unreviewed allowance; the remainder waits for review |
ALL_SUPPRESSED | 422 | All recipients are on the suppression list |
SERVICE_PAUSED | 503 | Sending is temporarily paused platform-wide |
INTERNAL_ERROR | 500 | Server error |
application_error | null | Network failure — request never reached the server |
The SDK is written in TypeScript and 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'
fetch API)FAQs
Official Node.js SDK for the Eusend API
The npm package @eusend_dev/sdk receives a total of 709 weekly downloads. As such, @eusend_dev/sdk popularity was classified as not popular.
We found that @eusend_dev/sdk demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Research
/Security News
Ten malicious OpenAPI React Query Codegen versions were published to npm in the Mini Shai-Hulud attack, all with valid provenance.

Security News
Socket joins more than 100 technology, cybersecurity, and financial organizations calling for a global surge in cyber defense.

Product
Enterprise security teams can now detect malware, credential theft, suspicious network activity, and risky updates across Microsoft Edge extensions.