HTTP client
Reference for the Flaresend class. Every namespace and method, with retries and idempotency explained.
import { Flaresend } from '@flaresend/client';
const flaresend = new Flaresend({
apiKey: process.env.FLARESEND_API_KEY!,
baseUrl: 'https://mailer.example.com',
});Options
Prop
Type
Every request sends Authorization: Bearer <apiKey> and Accept: application/json, and JSON bodies with Content-Type: application/json.
Retries
| Request | Retried on 429, 5xx and network errors? |
|---|---|
Every GET | Yes |
emails.send, emails.sendBatch | Yes. Each call carries an idempotency key, so a retry never sends twice. |
Every other POST, PATCH, DELETE | No. Tried once. |
- The wait between tries is exponential backoff with jitter: about 0.5 s, 1 s, 2 s and so on, capped at 8 s.
- If the response has a
Retry-Afterheader, the client waits that long instead. IfRetry-Afteris more than 60 seconds, it doesn't wait and throws the error. 429 daily_limit_exceededis never retried: waiting a few seconds won't help.- Any other
4xxis thrown at once.
Idempotency
Every emails.send call gets an idempotency key, sent as the Idempotency-Key header. The key is, in order:
opts.idempotencyKey, the second argument.input.idempotencyKey, in the email itself.- A random
auto_<uuid>made for this call.
The same key is reused for that call's retries, so a timeout followed by a retry sends one email, not two. The random key does not protect you from your own code calling send twice. For that, pass a key that is stable for the thing you're sending:
await flaresend.emails.send(input, { idempotencyKey: `welcome-${user.id}` });A second call with the same key and the same content returns the first email with idempotent: true. The same key with different content throws 409 idempotency_payload_mismatch. Keys don't expire. See Idempotency.
emails.sendBatch works the same way with one key for the whole batch; the mailer stores it per item as <key>:<index>.
Methods
Paginated methods return { data, nextCursor }. Pass nextCursor back as cursor for the next page; it is null on the last page. Other list methods return a plain array.
me()
me(): Promise<MeRecord>GET /v1/me. Returns { project: { id, slug, name }, key: { id, name, mode } }. A cheap way to check the key and URL work.
emails
| Method | Endpoint | Returns |
|---|---|---|
send(input, opts?) | POST /v1/emails | { id, status, idempotent? } |
sendBatch(inputs, opts?) | POST /v1/emails/batch | { data }, or { dryRun: true, data } with opts.dryRun |
get(id) | GET /v1/emails/:id | EmailRecord with recipients and events |
list(query?) | GET /v1/emails | ListResponse<EmailRecord> |
content(id) | GET /v1/emails/:id/content | EmailContent |
cancel(id) | DELETE /v1/emails/:id | { id, status: 'canceled' } |
reschedule(id, scheduledAt) | PATCH /v1/emails/:id | EmailRecord |
opts for send is { idempotencyKey?: string }. For sendBatch it is { idempotencyKey?: string; dryRun?: boolean }.
list takes { limit, cursor, status, to, from, q, tag, since, until }. to matches any of to, cc and bcc. q searches the subject. tag is key:value.
const page = await flaresend.emails.list({ status: 'bounced', since: '2026-09-01T00:00:00Z', limit: 50 });events
| Method | Endpoint | Returns |
|---|---|---|
list(query?) | GET /v1/events | ListResponse<EventRecord> |
query is { limit, cursor, type, emailId, since }.
domains
| Method | Endpoint | Returns |
|---|---|---|
list() | GET /v1/domains | DomainRecord[] |
apiKeys
| Method | Endpoint | Returns |
|---|---|---|
list() | GET /v1/api-keys | ApiKeyRecord[] |
webhooks
| Method | Endpoint | Returns |
|---|---|---|
list() | GET /v1/webhooks | WebhookRecord[] |
create(input) | POST /v1/webhooks | WebhookRecord including secret |
get(id) | GET /v1/webhooks/:id | WebhookRecord |
update(id, input) | PATCH /v1/webhooks/:id | WebhookRecord |
remove(id) | DELETE /v1/webhooks/:id | { id, deleted: true } |
test(id) | POST /v1/webhooks/:id/test | { deliveryId } |
rotateSecret(id) | POST /v1/webhooks/:id/rotate-secret | WebhookRecord including the new secret |
deliveries(id, query?) | GET /v1/webhooks/:id/deliveries | ListResponse<WebhookDeliveryRecord> |
templates
| Method | Endpoint | Returns |
|---|---|---|
list() | GET /v1/templates | TemplateRecord[], stored and built-in |
get(name) | GET /v1/templates/:name | TemplateRecord |
create(input) | POST /v1/templates | TemplateRecord |
update(name, input) | PATCH /v1/templates/:name | TemplateRecord |
remove(name) | DELETE /v1/templates/:name | { name, deleted: true } |
versions(name) | GET /v1/templates/:name/versions | TemplateVersionRecord[] |
restore(name, version) | POST /v1/templates/:name/restore | TemplateRecord |
render(name, data?) | POST /v1/templates/:name/render | { subject, html, text } |
analytics
| Method | Endpoint | Returns |
|---|---|---|
get(query?) | GET /v1/analytics | AnalyticsResult |
query is { range: '7d' | '30d' | '90d', interval: 'day' | 'hour' }, defaults 7d and day.
contacts
| Method | Endpoint | Returns |
|---|---|---|
list(query?) | GET /v1/contacts | ListResponse<ContactRecord>. query is { limit, cursor, q } |
create(input) | POST /v1/contacts | ContactRecord. Upserts by email |
get(id) | GET /v1/contacts/:id | ContactRecord |
update(id, input) | PATCH /v1/contacts/:id | ContactRecord |
remove(id) | DELETE /v1/contacts/:id | { id, deleted: true } |
import(contacts) | POST /v1/contacts/import | { created, updated }. Up to 5,000 |
audiences
| Method | Endpoint | Returns |
|---|---|---|
list() | GET /v1/audiences | AudienceRecord[] |
create({ name }) | POST /v1/audiences | AudienceRecord |
get(id) | GET /v1/audiences/:id | AudienceRecord |
update(id, { name }) | PATCH /v1/audiences/:id | AudienceRecord |
remove(id) | DELETE /v1/audiences/:id | { id, deleted: true } |
contacts(id, query?) | GET /v1/audiences/:id/contacts | ListResponse<ContactRecord> |
addContacts(id, contactIds) | POST /v1/audiences/:id/contacts | { added } |
removeContacts(id, contactIds) | DELETE /v1/audiences/:id/contacts | { removed } |
broadcasts
| Method | Endpoint | Returns |
|---|---|---|
list() | GET /v1/broadcasts | BroadcastRecord[] |
create(input) | POST /v1/broadcasts | BroadcastRecord, a draft |
get(id) | GET /v1/broadcasts/:id | BroadcastRecord with counts |
update(id, input) | PATCH /v1/broadcasts/:id | BroadcastRecord |
remove(id) | DELETE /v1/broadcasts/:id | { id, deleted: true } |
send(id, { scheduledAt? }) | POST /v1/broadcasts/:id/send | BroadcastRecord |
cancel(id) | POST /v1/broadcasts/:id/cancel | BroadcastRecord |
Helpers
attachmentFromBytes(filename, bytes, type?)
Builds an attachment from an ArrayBuffer or Uint8Array. The API needs attachment content as base64; this does the encoding without Buffer, so it works in Workers and browsers too.
import { attachmentFromBytes } from '@flaresend/client';
const pdf = new Uint8Array(await file.arrayBuffer());
await flaresend.emails.send({
from: 'Acme <billing@acme.com>',
to: 'ada@example.com',
subject: 'Your invoice',
text: 'Your invoice is attached.',
attachments: [attachmentFromBytes('invoice.pdf', pdf, 'application/pdf')],
});bytesToBase64(bytes) is exported too, if you only need the encoding.
Typed templates
Pass a map of template name to data type as the type argument of send, and data is checked against the template you name:
type Templates = {
welcome: { name: string; appName: string; loginUrl: string };
'magic-link': { loginUrl: string; expiresInMinutes?: number };
};
await flaresend.emails.send<Templates>({
from: 'Acme <hello@acme.com>',
to: 'ada@example.com',
template: 'welcome',
data: { name: 'Ada', appName: 'Acme', loginUrl: 'https://acme.com/login' },
});TypedSend<Templates> is the input type on its own. See Typed templates.
Exported types
The package re-exports the request and response types from @flaresend/types, including SendEmailInput, SendEmailResult, EmailRecord, EmailContent, EventRecord, ListResponse, BatchResult, BatchDryRunResult, WebhookRecord, WebhookPayload, TemplateRecord, ContactRecord, AudienceRecord, BroadcastRecord and AnalyticsResult.