# Introduction URL: https://eusend.dev/docs EU-native transactional and marketing email API. Your email data never leaves the European Union — built with GDPR in mind. eusend is a transactional and marketing email API built on EU infrastructure, from European providers. Your email data never leaves the European Union — built with GDPR in mind. The supporting services we use around the product are listed on our [sub-processors page](https://eusend.dev/legal/subprocessors). - **REST API** — Simple HTTP API. One POST to send an email. - **EU Infrastructure** — All email data in EU data centres. Germany & Finland. - **React Templates** — Design emails in React, send server-rendered HTML. Coming from another provider? [Migrating from Resend takes minutes](/migrate) — the SDK mirrors Resend's field-for-field. # Audiences URL: https://eusend.dev/docs/audiences Named contact lists used as targets for broadcasts. Create multiple audiences to segment your contacts. Audiences are named contact lists used as targets for broadcasts. Create multiple audiences to segment your contacts. `POST /audiences` | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `string` | Display name for the audience (e.g. "Newsletter subscribers"). | ```bash title="create audience" curl -X POST https://api.eusend.dev/audiences \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Newsletter subscribers" }' ``` ```json title="response — 201 Created" { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Newsletter subscribers", "createdAt": "2026-05-20T10:00:00.000Z" } ``` The rest of the audience surface: `GET /audiences` List audiences. `DELETE /audiences/:id` Delete an audience. # Contacts URL: https://eusend.dev/docs/audiences/contacts Add and manage individual contacts within an audience. Contacts can also be imported via CSV in the dashboard. Add and manage individual contacts within an audience. Contacts can also be imported via CSV in the dashboard. `POST /audiences/:id/contacts` Add a single contact to an audience. If the email already exists in the audience the record is updated (upsert). | Parameter | Type | Description | | --- | --- | --- | | `email` (required) | `string` | Contact email address. | | `first_name` | `string` | Contact first name. | | `last_name` | `string` | Contact last name. | `POST /audiences/:id/contacts/batch` Upsert up to 1,000 contacts at once. Existing contacts (matched by email) are updated; new ones are inserted. | Parameter | Type | Description | | --- | --- | --- | | `contacts` (required) | `array` | Array of contact objects. Each has email (required), first_name, last_name. | > [!NOTE] > Stored contacts are capped per plan — 1,000 on Free, 10,000 on Lite, 25,000 on Starter, 100,000 on > Pro, 500,000 on Scale — counted across every audience in the organization. A write that would > exceed the cap returns `403` with code `PLAN_LIMIT_EXCEEDED`. See [Plans & > Limits](/docs/reference/plans). ```bash title="batch upsert contacts" curl -X POST https://api.eusend.dev/audiences/550e8400-e29b-41d4-a716-446655440000/contacts/batch \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "contacts": [ { "email": "alice@example.com", "first_name": "Alice" }, { "email": "bob@example.com", "first_name": "Bob" } ] }' ``` The rest of the contacts surface: `GET /audiences/:id/contacts` List contacts. `GET /audiences/:id/contacts/:contactId` Get a contact. `PATCH /audiences/:id/contacts/:contactId` Update contact (name, unsubscribed). `DELETE /audiences/:id/contacts/:contactId` Remove a contact. # Unsubscribe URL: https://eusend.dev/docs/audiences/unsubscribe Broadcast emails include a one-click unsubscribe link pointing to a hosted unsubscribe page. Broadcast emails include a footer unsubscribe link pointing to a hosted unsubscribe page, so recipients can opt out with a single click. ## How it works [#how-it-works] When a recipient clicks "Unsubscribe", they're directed to a hosted page at `https://api.eusend.dev/unsubscribe/:token`. Confirming removes them from the audience and prevents future broadcasts. The contact record is retained — only the subscription status changes. > [!WARNING] > Unsubscribed contacts are excluded from all future broadcast sends to that audience. They are > **not** automatically suppressed from transactional emails sent via `POST /emails`. ## Branding the page [#branding-the-page] Settings → **Unsubscribe page** styles the hosted page for your whole organization: background, text and accent colors, a logo, and the wording of both the confirmation and the success screen. A live preview renders the same page the recipient will see. Colors must be hex values, and the logo is uploaded there — the page loads images from our asset host only, so a recipient's unsubscribe never fetches a third party. Copy is plain text; the confirm button itself can be relabelled but not removed, because a link scanner following the URL must not be able to opt someone out on their behalf. The "Powered by eusend" footer can be switched off on any paid plan. ## Suppression list [#suppression-list] Hard bounces and spam complaints automatically add addresses to your suppression list. Suppressed addresses are silently skipped on all future sends (including transactional emails). See [Suppressions](/docs/emails/suppressions) for reading, importing, and removing entries. # Broadcasts URL: https://eusend.dev/docs/broadcasts Campaigns sent to an entire audience. Create a broadcast as a draft, then send or schedule it. > [!WARNING] > **Coming soon.** The Broadcasts API is temporarily unavailable while we scale sending > infrastructure. The endpoints are documented below for reference. Broadcasts are campaigns sent to an entire audience (contact list). Create a broadcast as a draft, then send it when ready. In-flight broadcasts can be cancelled. ## Lifecycle [#lifecycle] `draft` → `scheduled` → `sending` → `sent` → `cancelled` A broadcast can also move to `paused` if sending is halted partway — for example it reaches your monthly or daily send limit, the sender domain is no longer verified, or platform-wide sending is paused. Resume it by sending again (`POST /broadcasts/:id/send`) once the underlying issue is resolved — it continues from where it stopped. ### Review on early list sends [#review-on-early-list-sends] New accounts can reach **500 recipients** by broadcast before we've reviewed the account. Most first campaigns are smaller than that and go out in full, with nothing to do. If your audience is larger, the first 500 are delivered immediately and the broadcast moves to `held` with the rest queued behind a quick manual review. You'll get an email when it's released, and the broadcast continues from where it stopped — nothing is lost and nobody is emailed twice. Unlike `paused`, a held broadcast can't be resumed by sending again; `POST /broadcasts/:id/send` returns `403` with code `BROADCAST_HELD` until the review clears. The same allowance applies to very large `POST /emails/batch` sends, which return `LIST_SEND_HELD` on the items beyond it. This is a one-time step per account, not a recurring limit. It exists so one bad actor can't damage the sending reputation that every customer on the platform shares. If you're on a deadline, email `support@eusend.dev` and we'll clear it. > [!NOTE] > Sending from a domain registered in the last 30 days? The same 500-recipient allowance applies to > broadcasts until the account is reviewed. Transactional sending through `POST /emails` is > unaffected — send from a brand-new domain from day one. ## API endpoints [#api-endpoints] `POST /broadcasts` | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `string` | Internal name for the campaign. | | `audience_id` (required) | `string (UUID)` | The audience (contact list) this broadcast is sent to. | | `subject` (required) | `string` | Email subject line. | | `from` (required) | `string` | Sender address. Accepts a bare email or a display name, e.g. "Acme ". Must be from a verified domain. | | `reply_to` | `string` | Reply-To address for the campaign. Same format as from. Omit to have replies go to the sender address. | | `html` | `string` | HTML body. Required unless template_id is provided. A plain-text part is generated automatically. | | `template_id` | `string (UUID)` | Saved template to use instead of html. | | `template_variables` | `object` | Default {{variable}} values applied to every recipient (per-contact fields like first_name override these). | | `track_opens` | `boolean` | Embed the open-tracking pixel. Omit to use your organization default (Settings → General → Email tracking). Resolved once at creation and kept for the life of the broadcast. | | `track_clicks` | `boolean` | Rewrite links to record clicks. Omit to use your organization default. Unsubscribe links are unaffected either way. | ```bash title="create broadcast" curl -X POST https://api.eusend.dev/broadcasts \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "May Newsletter", "audience_id": "550e8400-e29b-41d4-a716-446655440000", "subject": "What'"'"'s new in May", "from": "newsletter@acme.com", "html": "

Hello!

Here'"'"'s what'"'"'s new...

" }' ``` `POST /broadcasts/:id/send` Send a draft broadcast immediately, or schedule it for a future time by passing `scheduled_at`. This endpoint also **resumes a paused broadcast** — it continues from where it stopped, skipping recipients already sent. The audience is set when creating the broadcast. Broadcasts can also be pre-scheduled via `PATCH /broadcasts/:id` without triggering a send. | Parameter | Type | Description | | --- | --- | --- | | `scheduled_at` | `string (ISO 8601)` | Future UTC datetime to deliver the broadcast. Omit to send immediately. Sets status to scheduled until the delivery time. | ```bash title="schedule a broadcast" curl -X POST https://api.eusend.dev/broadcasts/3fa85f64-5717-4562-b3fc-2c963f66afa6/send \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-06-01T09:00:00Z" }' ``` `POST /broadcasts/:id/cancel` Cancel a scheduled or in-flight broadcast. Emails already sent are not recalled. The rest of the broadcast surface: `GET /broadcasts` List broadcasts. `GET /broadcasts/:id` Get broadcast details. `PATCH /broadcasts/:id` Update draft / set `scheduled_at`. # Contacts CSV URL: https://eusend.dev/docs/dashboard/contacts-csv Bulk contact management via CSV import and export, accessible from any audience detail page. Audiences support bulk contact management via CSV import and export, accessible from any audience detail page. ## Import format [#import-format] The first row must be a header row. Column names are case-insensitive. Only `email` is required — the other columns are optional. | Column | Required | Description | | ------------ | -------- | -------------------------------------------------------------- | | `email` | yes | Contact email address. Rows without a valid email are skipped. | | `first_name` | no | Contact first name. Used in template variables. | | `last_name` | no | Contact last name. | ```csv title="example import CSV" email,first_name,last_name alice@example.com,Alice,Smith bob@example.com,Bob, carol@example.com,,Jones ``` ## Export format [#export-format] Exported CSVs include two additional columns: `subscribed` (true/false) and `created_at` (ISO 8601 timestamp). Exported files can be re-imported — the extra columns are ignored on import. ```csv title="export column order" email,first_name,last_name,subscribed,created_at ``` # Domain Setup (UI) URL: https://eusend.dev/docs/dashboard/domain-setup Adding a domain from the dashboard walks you through a 3-step wizard — no API calls needed. Adding a domain from the dashboard walks you through a 3-step wizard. No API calls needed — DNS records are generated and shown inline with copy buttons. ### Enter your domain [#enter-your-domain] Type your domain name (e.g. acme.com) and click Add Domain. eusend generates a 2048-bit RSA DKIM keypair immediately. ### Add DNS records [#add-dns-records] The records are shown with one-click copy. Only the DKIM TXT record is required to verify the domain and start sending; DMARC is recommended, and the two Return-Path records are optional and enable SPF alignment. ### Verify [#verify] Click "I've added these records" to trigger verification. eusend polls your DNS until the records propagate and marks the domain verified — or reports which records are missing. > [!NOTE] > DNS propagation typically takes under an hour but can take up to 48 hours. The dashboard polls > automatically after you click verify — you don't need to stay on the page. The domain status > updates to **verified** once all records are found. ## DNS records added [#dns-records-added] | Record | Type | Purpose | | ---------------------------- | ----- | ------------------------------------------------------------------------- | | `eusend._domainkey.acme.com` | `TXT` | DKIM — enables cryptographic email signing. Required | | `_dmarc.acme.com` | `TXT` | DMARC — policy for failed SPF/DKIM checks. Recommended | | `send.acme.com` | `TXT` | SPF for the Return-Path subdomain. Optional — enables SPF alignment | | `send.acme.com` | `MX` | Routes delayed bounces back to us. Optional — pairs with the record above | > [!WARNING] > There is no SPF record on your root domain, deliberately. A domain may publish only one, and > adding a second breaks SPF for every service that sends from it — your own mailbox provider > included. See [DNS records](/docs/domains/dns-records) for why it isn't needed. # Overview URL: https://eusend.dev/docs/dashboard/overview The eusend dashboard lets you do everything the API does — no code required. The eusend dashboard at [eusend.dev](/login) lets you do everything the API does — no code required. Here's what each section is for. | Path | Section | Purpose | | ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | `/emails` | Emails | Full log of every send. Filter by status, sender, or recipient. Click any row for the event timeline (sent → delivered → opened → clicked). | | `/analytics` | Analytics | Charts for send volume, open rate, and click rate over time. Filterable by time period and domain. | | `/templates` | Templates | Create and edit email templates in a visual editor with live preview. Reference templates by ID in any send. | | `/audiences` | Audiences | Manage contact lists. Import and export contacts via CSV. Used as the target for broadcasts. | | `/broadcasts` | Broadcasts | Compose and schedule marketing campaigns. Send immediately or set a future delivery time. | | `/domains` | Domains | Add sending domains and copy the generated DNS records. A 3-step wizard walks you through setup and verification. | | `/api-keys` | API Keys | Create and revoke live (`eu_live_`) and test (`eu_test_`) keys. Free plan allows 1 key; paid plans are unlimited. | | `/webhooks` | Webhooks | Register HTTPS endpoints to receive real-time delivery events. The signing secret is shown once at creation. | | `/settings/billing` | Settings → Billing | Upgrade, downgrade, or cancel your plan. Current usage and monthly quota shown inline. | | `/settings/team` | Settings → Team | Invite team members by email. Invited users share the same organization and API keys. | # Template Editor URL: https://eusend.dev/docs/dashboard/templates Write and preview email templates in real time. The rendered HTML is stored and used at send time. The template editor at `/templates` lets you write and preview email templates in real time. The rendered HTML is stored and used at send time. ## Variables [#variables] Use `{{variable_name}}` anywhere in the subject or HTML body to create a variable. The editor detects them automatically and shows a panel where you can enter sample values for the preview. At send time, pass the actual values via the `variables` field. > [!NOTE] > Variable values are HTML-escaped when substituted, so they always render as text — a value like ` hi` shows literally rather than as bold. Any HTML structure (links, formatting) must live > in the template body, not in the variable values you pass. ```html title="template HTML"

Hi {{first_name}}!

Your order #{{order_id}} has shipped.

Track your package

``` ```bash title="send with variables" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "orders@acme.com", "to": "alice@example.com", "template_id": "550e8400-e29b-41d4-a716-446655440000", "variables": { "first_name": "Alice", "order_id": "1234", "tracking_url": "https://track.example.com/1234" } }' ``` > [!NOTE] > Templates are rendered once when saved (not at send time), so the preview you see in the editor is > exactly what will be sent. Variable substitution is the only step that happens per send. ## Editor layout [#editor-layout] The canvas is a visual editor — you write the email as it will look, with a style inspector alongside it for the block you have selected. Two mode tabs sit above it: | Mode | What it shows | | --------- | ------------------------------------------------------------------------------------ | | **Write** | The visual editor. Type `{{` to insert a variable. | | **HTML** | The underlying HTML, editable directly for anything the visual editor doesn't cover. | Below the canvas, **Variables** lists every `{{placeholder}}` detected in the template, **Preview with sample data** gives you an input per variable and renders the result live, and **Use in API** shows the `POST /emails` body for sending this template. Saving stores the rendered HTML plus the editor document, so reopening a template gives you back the visual editor rather than raw markup. Editing the HTML mode directly and saving drops the stored editor document — the HTML you wrote stays authoritative. # Domain Setup URL: https://eusend.dev/docs/domains Add your own sending domain to sign outbound emails with your DKIM key, improving deliverability and brand trust. Add your own sending domain to sign outbound emails with your DKIM key, improving deliverability and brand trust. eusend auto-generates a 2048-bit RSA DKIM keypair for your domain. `POST /domains` | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `string` | Your domain name (e.g. acme.com). Must be at least 3 characters. | ```bash title="add domain" curl -X POST https://api.eusend.dev/domains \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "acme.com" }' ``` ```json title="response — 201 Created" { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "name": "acme.com", "dkim": { "type": "TXT", "name": "eusend._domainkey.acme.com", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgK..." }, "dmarc": { "type": "TXT", "name": "_dmarc.acme.com", "value": "v=DMARC1; p=none; rua=mailto:dmarc@acme.com" } } ``` After creating the domain, add the DNS records shown in the next section, then call the verify endpoint. `POST /domains/:id/verify` Trigger DKIM verification. eusend checks your DNS records and updates the domain status to `verified` or `failed`. The rest of the domains surface: `GET /domains` List domains. `GET /domains/:id` Get domain details. `DELETE /domains/:id` Delete domain. # DMARC URL: https://eusend.dev/docs/domains/dmarc Tell receiving mail servers how to handle messages that fail authentication — protecting your domain from spoofing. DMARC lets you tell receiving mail servers how to handle messages that fail authentication checks for your domain — protecting your domain from being used in phishing and spoofing attacks. ## How it works with eusend [#how-it-works-with-eusend] DMARC passes when either DKIM or SPF is aligned with your sending domain — it doesn't require both. Because eusend signs every outbound email with your own DKIM key (set up during domain verification), DKIM alignment passes automatically. You can add a DMARC record to your domain straight away without any additional infrastructure changes. ## Add a DMARC record [#add-a-dmarc-record] Add the following TXT record to your domain's DNS. Start with a `p=none` policy so you can monitor results before enforcing anything. | Type | Host / Name | Value | | ----- | ----------------- | ---------------------------------------------- | | `TXT` | `_dmarc.acme.com` | `v=DMARC1; p=none; rua=mailto:dmarc@acme.com;` | > [!NOTE] > Replace `dmarc@acme.com` with a real address you control. Receiving servers send aggregate reports > to this address — XML digests of authentication results across all email from your domain, > typically once a day. ## Tightening the policy [#tightening-the-policy] Once you've confirmed your emails are passing DMARC (check the `Authentication-Results` header in any received email, or wait for aggregate reports), you can switch to a stricter policy: | Policy | Effect | | -------------- | ----------------------------------------------------------------------------- | | `p=none` | No action taken. Reports are still sent. Use this while monitoring. | | `p=quarantine` | Failing messages go to spam. A good middle ground once delivery is confirmed. | | `p=reject` | Failing messages are bounced outright. Maximum protection. | > [!TIP] > Only move to `p=reject` once you're certain all legitimate email from your domain passes DKIM or > SPF. Any service sending on your behalf — transactional email, marketing tools, CRMs — needs to be > authenticated before you enforce rejection. ## DMARCbis: the updated standard [#dmarcbis-the-updated-standard] In May 2026 DMARC was revised and promoted to a full IETF standard — **DMARCbis** (RFC 9989–9991, replacing the original RFC 7489). Nothing you've already published breaks: the record eusend generates uses only long-stable tags and is fully DMARCbis-compatible. A few tags did change, though. ### Deprecated tags — don't add them [#deprecated-tags--dont-add-them] | Tag | Status | | ----- | ------------------------------------------------------------------------------------- | | `pct` | Removed. Percentage-based enforcement is gone; use `t=y` (below) for testing instead. | | `rf` | Removed. Reports are XML-only. | | `ri` | Removed. Receivers choose their own report interval. | If an older tutorial tells you to add `pct=100`, skip it — it's a no-op that some validators now flag. ### `np` — block non-existent subdomains [#np--block-non-existent-subdomains] The headline DMARCbis addition is `np`, a policy for mail from **subdomains that don't exist**. Attackers love spoofing addresses like `billing.acme.com` even when you've never created that subdomain. Because a non-existent subdomain never sends legitimate mail, you can set `np=reject` safely — even while your main policy is still `p=none` for monitoring: | Type | Host / Name | Value | | ----- | ----------------- | --------------------------------------------------------- | | `TXT` | `_dmarc.acme.com` | `v=DMARC1; p=none; np=reject; rua=mailto:dmarc@acme.com;` | `np` only applies to subdomains with no DNS records at all. A sending subdomain you've verified in eusend (it carries a DKIM record, so it "exists") is unaffected and follows your `sp`/`p` policy instead. ### `t` — testing mode [#t--testing-mode] `t=y` marks a policy as testing: receivers evaluate `p`/`sp`/`np` and send reports but take no action, the way `pct=0` used to. Use it to trial a stricter policy without risking delivery, then remove it to enforce. > [!NOTE] > These tags are optional hardening. The record eusend gives you at domain verification already > follows the safe `p=none` → `p=quarantine` → `p=reject` path; adding `np=reject` is the one change > worth making early, since it costs nothing and closes the subdomain-spoofing gap. # DNS Records URL: https://eusend.dev/docs/domains/dns-records Add these records to your domain's DNS to enable DKIM signing and improve deliverability. Add these records to your domain's DNS to enable DKIM signing and improve deliverability. ## DKIM record [#dkim-record] The DKIM public key is returned when you add a domain. Add it as a TXT record: | Type | Host / Name | Value | | ----- | ---------------------------- | ------------------------------------- | | `TXT` | `eusend._domainkey.acme.com` | `v=DKIM1; k=rsa; p=` | ## SPF [#spf] **You do not need to add an SPF record on your root domain, and you should not.** SPF authenticates the envelope sender (the bounce address), not the `From:` header. Mail we send carries our own bounce domain, so an SPF record on `acme.com` is never consulted for it — it authorises nothing on your behalf. It can also do harm. A domain may publish only **one** SPF record; a second one makes SPF fail for *every* service sending from that domain, including your own mailbox provider. If you already have an SPF record for Google Workspace, Microsoft 365 or similar, leave it exactly as it is. If you want SPF to align with your domain for DMARC, add the two optional Return-Path records below instead. They live on the `send.` subdomain and cannot collide with your existing SPF. ## DMARC record [#dmarc-record] Recommended. It tells inbox providers what to do with mail that fails authentication, and gives you reports on what is being sent in your name. | Type | Host / Name | Value | | ----- | ----------------- | --------------------------------------------- | | `TXT` | `_dmarc.acme.com` | `v=DMARC1; p=none; rua=mailto:dmarc@acme.com` | Start at `p=none`, which only collects reports and changes nothing about delivery. Move to `quarantine` and then `reject` once the reports show your legitimate mail is authenticating. ## Return-Path records (optional) [#return-path-records-optional] Both are optional. Your domain sends fine without them — it just uses our shared bounce address, which means DMARC passes on DKIM alone. Publish **both** and we switch your mail to a bounce address on your own domain. SPF then passes *and* aligns with your `From:` domain, so DMARC passes on both mechanisms instead of one, and delayed bounces come back to us per-domain so failed addresses are suppressed automatically. | Type | Host / Name | Value | Priority | | ----- | --------------- | ------------------------------------- | -------- | | `TXT` | `send.acme.com` | `v=spf1 include:_spf.eusend.dev ~all` | — | | `MX` | `send.acme.com` | `feedback.eusend.dev` | `10` | This SPF record is safe: it sits on the `send.` subdomain, which has no mail of its own, so it cannot conflict with the SPF record on your root domain. We detect these automatically — publish them and alignment switches on at the next verification, with no action in the dashboard. > [!NOTE] > DNS changes can take up to 48 hours to propagate, though they typically take under an hour. Once > records are live, call `POST /domains/:id/verify` to trigger verification. # Batch Send URL: https://eusend.dev/docs/emails/batch Send up to 100 emails in a single request. Each email is independent — failures are per-email. `POST /emails/batch` ## Request body [#request-body] An array of up to 100 email objects. Each object has the same schema as [POST /emails](/docs/emails/send), except that `attachments` and `scheduled_at` are not supported here — send or schedule those individually via `POST /emails`. ```bash title="request" curl -X POST https://api.eusend.dev/emails/batch \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '[ { "from": "noreply@acme.com", "to": "alice@example.com", "subject": "Receipt #1001", "html": "

Thanks, Alice!

", "tags": { "category": "receipt" } }, { "from": "noreply@acme.com", "to": "bob@example.com", "subject": "Receipt #1002", "html": "

Thanks, Bob!

" } ]' ``` ## Response [#response] One result per input email, in the same order: `data[i]` describes `emails[i]`. A queued email carries its `id`; an email that could not be queued carries `error` and `code` instead (for example an unverified sender domain, all recipients suppressed, or an exhausted quota). Failed items never fail the rest of the batch — retry just those. ```json title="response — 201 Created" { "data": [ { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d" }, { "error": "All recipients are suppressed", "code": "ALL_SUPPRESSED" } ] } ``` > [!WARNING] > Batch sends count toward your daily and monthly limits. A batch of 50 emails uses 50 of your > quota. # Email Details URL: https://eusend.dev/docs/emails/details Get a single email and its full event history — opens, clicks, delivery, bounces. `GET /emails/:id` ```bash title="request" curl "https://api.eusend.dev/emails/9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d" \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" ``` ```json title="response — 200 OK" { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "from": "noreply@acme.com", "to": ["alice@example.com"], "subject": "Your order has shipped", "status": "delivered", "tags": { "category": "shipping_update" }, "scheduledAt": null, "createdAt": "2026-05-20T10:00:00.000Z", "events": [ { "type": "sent", "createdAt": "2026-05-20T10:00:01.000Z" }, { "type": "delivered", "createdAt": "2026-05-20T10:00:03.000Z" }, { "type": "opened", "createdAt": "2026-05-20T10:05:12.000Z" }, { "type": "clicked", "createdAt": "2026-05-20T10:05:30.000Z" } ] } ``` # List Emails URL: https://eusend.dev/docs/emails/list Retrieve a paginated list of emails sent from your organization. `GET /emails` ## Query parameters [#query-parameters] | Parameter | Type | Description | | --- | --- | --- | | `status` | `string` | Filter by status: queued, scheduled, canceled, sending, sent, delivered, bounced, complained, suppressed, failed. | | `from` | `string` | Filter by sender email address. | | `to` | `string` | Filter by recipient email address. | | `tag` | `string` | Filter by tag. "category:password_reset" matches that exact pair; a bare "category" matches any email carrying the tag. Repeat the parameter to require several. See Tags. | | `cursor` | `string (UUID)` | Pagination cursor — pass next_cursor from the previous response. | | `limit` | `number` | Results per page. Default: 10. Max: 100. | ```bash title="request" curl "https://api.eusend.dev/emails?status=delivered&limit=10" \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" ``` ```json title="response — 200 OK" { "data": [ { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "from": "noreply@acme.com", "to": ["alice@example.com"], "subject": "Your order has shipped", "status": "delivered", "tags": { "category": "shipping_update" }, "testMode": false, "createdAt": "2026-05-20T10:00:00.000Z" } ], "next_cursor": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d" } ``` # Scheduling URL: https://eusend.dev/docs/emails/scheduling Schedule a transactional email up to 30 days ahead, then reschedule or cancel it before it sends. Schedule a transactional email up to 30 days ahead by passing `scheduled_at` to [POST /emails](/docs/emails/send). Until it sends, the email sits in status `scheduled` and can be rescheduled or canceled. ## Schedule a send [#schedule-a-send] `scheduled_at` accepts an ISO 8601 timestamp or a natural-language time like `"in 1 hour"` or `"tomorrow at 9am"`, parsed server-side (relative phrasings resolve in UTC). It must resolve to a time in the future and at most 30 days out. All send checks — domain verification, suppression, and your daily and monthly limits — run at schedule time, and the send is debited from your monthly quota immediately (canceling refunds it). Recipients who get suppressed between scheduling and the send time are still dropped at send time. ```bash title="request" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "noreply@acme.com", "to": "alice@example.com", "subject": "Your trial ends tomorrow", "html": "

Reminder: your trial ends in 24 hours.

", "scheduled_at": "2026-07-03T09:00:00Z" }' ``` ```json title="response — 201 Created" { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d" } ``` ## Reschedule [#reschedule] `PATCH /emails/:id` Move a scheduled email to a new time. Only emails still in status `scheduled` can be rescheduled — once sending has started this returns `409 CONFLICT`. | Parameter | Type | Description | | --- | --- | --- | | `scheduled_at` (required) | `string (ISO 8601 or natural language)` | The new send time — an ISO 8601 timestamp or natural language like "in 1 hour" (parsed server-side). Must resolve to the future, at most 30 days out. | ```bash title="request" curl -X PATCH https://api.eusend.dev/emails/9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-07-04T09:00:00Z" }' ``` ```json title="response — 200 OK" { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "status": "scheduled", "scheduled_at": "2026-07-04T09:00:00.000Z" } ``` ## Cancel [#cancel] `POST /emails/:id/cancel` Cancel a scheduled email before it sends. The email moves to the terminal status `canceled` and the send is refunded to your monthly quota. Like reschedule, this returns `409 CONFLICT` once sending has started. ```bash title="request" curl -X POST https://api.eusend.dev/emails/9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d/cancel \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" ``` ```json title="response — 200 OK" { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "status": "canceled" } ``` > [!NOTE] > Scheduling is per-email and not available on the batch endpoint. For scheduling a campaign to an > audience, use [Broadcasts](/docs/broadcasts) — they have their own `scheduled_at`. # Send an Email URL: https://eusend.dev/docs/emails/send Send a single transactional email. Returns immediately with an email ID; delivery is asynchronous. `POST /emails` ## Request body [#request-body] | Parameter | Type | Description | | --- | --- | --- | | `from` (required) | `string` | Sender address. Accepts a bare email or a display name, e.g. "Acme ". Must be from a verified domain. | | `to` (required) | `string \| string[]` | Recipient email address(es). Maximum 50. | | `subject` | `string` | Email subject line. Required unless using a template that defines one. | | `html` | `string` | HTML body of the email. | | `text` | `string` | Plain-text body. Used as fallback when HTML is not supported. | | `template_id` | `string (UUID)` | ID of a saved template. The template supplies the HTML body — an html you pass alongside it is ignored — and its subject, unless you pass a subject of your own. A text part you pass is still used. | | `variables` | `object` | Template variables to interpolate. Used with template_id. | | `cc` | `string \| string[]` | CC recipients. Maximum 50. | | `bcc` | `string \| string[]` | BCC recipients. Maximum 50. | | `reply_to` | `string \| string[]` | Reply-To address(es). Maximum 50. | | `headers` | `object` | Custom email headers as key-value pairs. | | `tags` | `object \| object[]` | Labels for filtering your email log and routing webhook events, e.g. {"category": "password_reset"}. The [{ "name": ..., "value": ... }] array form is accepted too. Up to 10 tags; names and values may contain ASCII letters, numbers, underscores and dashes. See Tags. | | `track_opens` | `boolean` | Open tracking via a 1×1 pixel. Omit to use your organization default (on unless changed under Settings → General → Email tracking); set false to opt out of this send. | | `track_clicks` | `boolean` | Click tracking by rewriting links. Omit to use your organization default; set false to opt out of this send. | | `attachments` | `object[]` | File attachments. Up to 20 per message, 10 MB combined. Each item: filename (string) plus exactly one of content (base64-encoded file bytes) or path (a public URL we fetch at send time), optional content_type (inferred from the filename or the fetched response when omitted), and optional content_id to embed the file inline via cid:. | | `scheduled_at` | `string (ISO 8601 or natural language)` | Schedule the send for a future time, at most 30 days out. Accepts an ISO 8601 timestamp or a natural-language time like "in 1 hour" or "tomorrow at 9am" (parsed server-side). The email is created with status "scheduled" and sends at that time. Reschedule with PATCH /emails/:id or cancel with POST /emails/:id/cancel — see Scheduling. | > [!NOTE] > At least one of > > `html` > > , > > `text` > > , or > > `template_id` > > is required. ## Example — plain HTML [#example--plain-html] ```bash title="request" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "noreply@acme.com", "to": ["alice@example.com", "bob@example.com"], "subject": "Your order has shipped", "html": "

Your order #1234 is on its way!

", "text": "Your order #1234 is on its way!", "track_opens": true, "track_clicks": true }' ``` ```json title="response — 201 Created" { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d" } ``` ## Example — with an attachment [#example--with-an-attachment] Attach a file by passing its bytes base64-encoded as `content`, or point `path` at a public URL and we fetch it at send time. Set `content_id` to embed an image inline and reference it from your HTML with ``. ```bash title="request" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "billing@acme.com", "to": "alice@example.com", "subject": "Your invoice", "html": "

Thanks! Your invoice is attached.

", "attachments": [ { "filename": "invoice.pdf", "content": "JVBERi0xLjQKJ...base64...", "content_type": "application/pdf" }, { "filename": "logo.png", "path": "https://cdn.acme.com/logo.png" } ] }' ``` ## Idempotency [#idempotency] Pass an `Idempotency-Key` request header to make retries safe. If a request with the same key has already been accepted for your organisation, the original email ID is returned immediately and no second send is queued — regardless of how many times you retry. | Parameter | Type | Description | | --- | --- | --- | | `Idempotency-Key` | `string (header)` | Any unique string up to 255 characters. A UUID per send is a good default. Omit the header to skip idempotency checks. | ```bash title="request" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Idempotency-Key: a4f3c2b1-dead-beef-0000-000000000001" \ -H "Content-Type: application/json" \ -d '{ "from": "noreply@acme.com", "to": "alice@example.com", "subject": "Your receipt", "html": "

Thanks for your order!

" }' ``` > [!NOTE] > A duplicate key returns `HTTP 200` with the original email ID. The first successful send always > returns `HTTP 201`. ## Example — with a template [#example--with-a-template] ```bash title="request" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "noreply@acme.com", "to": "alice@example.com", "template_id": "550e8400-e29b-41d4-a716-446655440000", "variables": { "first_name": "Alice", "order_id": "1234", "tracking_url": "https://track.example.com/1234" } }' ``` # Statuses URL: https://eusend.dev/docs/emails/statuses Every email moves through a lifecycle of statuses as it's processed and delivered. Every email moves through a lifecycle of statuses as it's processed and delivered. | Status | Meaning | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `queued` | Email has been accepted and is waiting to be processed by the send worker. | | `scheduled` | Email was created with `scheduled_at` and is waiting for its send time. It can still be rescheduled or canceled. | | `canceled` | A scheduled email was canceled before its send time. Terminal — it will not send, and the send is refunded to your monthly quota. | | `sending` | Email is being handed to the mail server for delivery. | | `sent` | The mail server accepted the message and it is en route to the recipient. | | `delivered` | Delivery confirmed. | | `bounced` | Email hard-bounced — the receiving server permanently rejected it (e.g. invalid address). The address is automatically suppressed. Transient (soft) failures are retried and do not set this status. | | `complained` | Recipient reported the email as spam. Address is automatically suppressed. | | `suppressed` | The recipient address is on the [suppression list](/docs/emails/suppressions) — email was not sent. | | `failed` | Unrecoverable send failure after all retry attempts. | # Suppressions URL: https://eusend.dev/docs/emails/suppressions The addresses your organization will not send to — read, add, import, export, and remove them. Every organization has a suppression list: addresses we refuse to send to. A send to a suppressed address is skipped silently and the email is recorded with status `suppressed`; if every recipient of a send is suppressed, the request fails with `ALL_SUPPRESSED`. Addresses land on the list two ways: | Reason | Added by | | ----------- | ---------------------------------------------------------------------------------- | | `bounce` | Automatically, on a hard bounce (a permanent rejection from the receiving server). | | `complaint` | Automatically, when a recipient reports the message as spam. | | `manual` | By you, via the API or the [dashboard](https://eusend.dev/emails/suppressions). | The list is per-organization and applies across every API key and sending domain you own. It is never shared between organizations. > [!WARNING] > Removing an address that hard-bounced or complained lets you mail it again. Doing that repeatedly > is what damages a sender's reputation — remove an entry when you know the address was fixed or the > complaint was a mistake, not to retry a list that is failing. Suppression applies to live sending only. Test-mode API keys can read the list but cannot modify it. ## List suppressions [#list-suppressions] `GET /suppressions` | Parameter | Type | Description | | --- | --- | --- | | `email` | `string` | Filter to addresses containing this substring. Pass a domain ("@acme.com") to see every suppressed address there. | | `reason` | `string` | Filter by reason: bounce, complaint, or manual. | | `cursor` | `string (UUID)` | Pagination cursor — pass next_cursor from the previous response. | | `limit` | `number` | Results per page. Default: 50. Max: 100. | ```bash title="request" curl "https://api.eusend.dev/suppressions?reason=bounce&limit=50" \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" ``` ```json title="response — 200 OK" { "data": [ { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "email": "invalid@example.com", "reason": "bounce", "created_at": "2026-05-20T10:00:00.000Z" } ], "next_cursor": null } ``` ## Add an address [#add-an-address] `POST /suppressions` | Parameter | Type | Description | | --- | --- | --- | | `email` (required) | `string` | The address to suppress. | | `reason` | `string` | bounce, complaint, or manual. Defaults to manual. | ```bash title="request" curl -X POST https://api.eusend.dev/suppressions \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "email": "opted-out@example.com" }' ``` Returns `201` with the new entry. If the address is already suppressed the call returns `200` with the **existing** entry — an add never overwrites the reason an address was suppressed for, so a manual add cannot mask a real bounce. ## Import a list [#import-a-list] `POST /suppressions/batch` Up to 1000 addresses per call. Items may be bare strings or objects, so a column lifted straight out of a CSV works as-is. > [!NOTE] > Addresses you add by hand — single adds and imports together — are capped at 100,000 per > organization; past it the call returns `403` with code `PLAN_LIMIT_EXCEEDED`. Bounces and > complaints are never refused, so the list always keeps recording addresses we must not mail again. > Email `support@eusend.dev` if you genuinely need a larger manual list. ```bash title="request" curl -X POST https://api.eusend.dev/suppressions/batch \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "emails": [ "one@example.com", { "email": "two@example.com", "reason": "complaint" } ] }' ``` ```json title="response — 201 Created" { "count": 2, "already_suppressed": 0, "duplicates": 0 } ``` `count` is what was written, `already_suppressed` was on the list before, and `duplicates` is how many rows the payload repeated. The three add up to the number of items you sent. Migrating from another provider? Export their suppression list and import it here **before** your first send, so addresses that already bounced or complained don't get a fresh attempt from a new IP. The dashboard has a CSV importer that handles the export formats from SendGrid, Mailgun, Postmark, and SES. ## Remove an address [#remove-an-address] `DELETE /suppressions/:id` The path accepts either the entry `id` or the address itself, so you can un-suppress from your own records without looking up an id first. ```bash title="request" curl -X DELETE https://api.eusend.dev/suppressions/invalid@example.com \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" ``` ```json title="response — 200 OK" { "deleted": 1 } ``` Returns `404` if the address is not on the list. ## Export [#export] `GET /suppressions/export` Returns the whole list as CSV (`email,reason,created_at`), streamed — safe to call on a list of any size. ```bash title="request" curl "https://api.eusend.dev/suppressions/export" \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -o suppressions.csv ``` # Tags URL: https://eusend.dev/docs/emails/tags Label your sends to filter the email log and route webhook events by category, customer, or anything else you track. Tags are key-value labels you attach to a send. They exist for two jobs: * **Filtering your log.** "Show me every password-reset send from the last 7 days" is one click in the dashboard, or one query parameter on `GET /emails`. * **Routing webhooks.** Every `email.*` event for a send carries its tags, so your handler can branch on `payload.tags.category` instead of looking the email up first. ## Adding tags to a send [#adding-tags-to-a-send] Pass `tags` as an object: ```bash title="request" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "noreply@acme.com", "to": "alice@example.com", "subject": "Reset your password", "html": "

Here is your reset link.

", "tags": { "category": "password_reset", "tier": "pro" } }' ``` The `[{ "name": ..., "value": ... }]` array form works identically, so a payload written against Resend's API sends here unchanged: ```json { "tags": [ { "name": "category", "value": "password_reset" }, { "name": "tier", "value": "pro" } ] } ``` Responses and webhook payloads always return the object form. Tags work on `POST /emails` and on every item of `POST /emails/batch`. | Parameter | Type | Description | | --- | --- | --- | | `Maximum tags per email` | `10` | Applies to each item of a batch send independently. | | `Allowed characters` | `A–Z a–z 0–9 _ -` | Names and values alike. No spaces, colons, dots, or non-ASCII characters — a value like an email address or a timestamp will be rejected, so hash or slugify those first. | | `Maximum name length` | `64 characters` | | | `Maximum value length` | `256 characters` | | > [!NOTE] > Tags are for grouping sends, not for carrying data. Something high-cardinality — a user ID, an > order number — makes a filter that matches exactly one email, which the email ID already does > better. Tag the *kind* of mail, and keep the identifier in your own system. ## Filtering the log [#filtering-the-log] In the dashboard, tags render as chips on the email list and detail pages. Clicking one filters the log to it. Over the API, pass `tag` to `GET /emails`: ```bash # every send tagged category=password_reset curl "https://api.eusend.dev/emails?tag=category:password_reset" \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" # every send carrying a category tag at all, whatever its value curl "https://api.eusend.dev/emails?tag=category" \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" # repeat the parameter to require several — this matches both curl "https://api.eusend.dev/emails?tag=category:welcome&tag=tier:pro" \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" ``` `tag` combines with the other filters (`status`, `from`, `to`) and with cursor pagination. ## Tags in webhooks [#tags-in-webhooks] Every `email.*` event carries the sending email's tags, as an object. Sends made without tags carry `"tags": {}` rather than omitting the field, so a handler can read `payload.tags.category` without guarding first. ```json title="email.delivered" { "type": "email.delivered", "email_id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "recipients": ["alice@example.com"], "tags": { "category": "password_reset", "tier": "pro" }, "timestamp": "2026-08-06T10:24:31.000Z" } ``` This makes a single webhook endpoint enough for several kinds of mail: ```ts app.post('/webhooks/eusend', (req, res) => { const { type, tags } = req.body if (type === 'email.bounced' && tags.category === 'password_reset') { // A reset mail that bounced is a locked-out user — page someone. alertOnCriticalBounce(req.body) } res.sendStatus(200) }) ``` ## Tags over SMTP [#tags-over-smtp] If you relay through the SMTP bridge rather than the API, add an `X-Eusend-Tag` header. It may repeat, and one header may carry several comma-separated pairs: ``` X-Eusend-Tag: category=password_reset X-Eusend-Tag: tier=pro, region=eu ``` Pairs that don't fit the character rules are dropped and the message still sends — unlike the API, SMTP has no useful way to hand back a validation error, and a bounced invoice is a worse outcome than an untagged one. # Authentication URL: https://eusend.dev/docs/getting-started/authentication Authenticate with API keys in the Authorization header. All API requests must include an API key in the `Authorization` header. ```bash Authorization: Bearer eu_live_xxxxxxxxxxxx ``` ## Key types [#key-types] | Parameter | Type | Description | | --- | --- | --- | | `eu_live_...` | `string` | Production key. Emails are actually sent. | | `eu_test_...` | `string` | Test key. Emails are queued but not delivered. Use for development and CI. | > [!TIP] > Test mode keys replay the full event lifecycle — and fire the same webhooks — as live keys, > without delivering to the recipient's inbox. By default a send simulates `email.sent` → > `email.delivered` → `email.opened` → `email.clicked` (opens and clicks follow the message's > tracking settings). To simulate failures, send to `bounced@…` for a bounce or `complained@…` for a > complaint. Ideal for integration testing. ## Permissions [#permissions] Every key carries a permission, chosen when you create it. It defaults to `full_access`. | Parameter | Type | Description | | --- | --- | --- | | `full_access` | `string` | Can create, read, update and delete every resource. The default. | | `sending_access` | `string` | Can only send emails. Every other endpoint returns 403 FORBIDDEN. | A `sending_access` key may call exactly these endpoints — everything else, including reading your email logs, is refused: | Method | Endpoint | | | ------- | -------------------- | --------------------------- | | `POST` | `/emails` | Send an email | | `POST` | `/emails/batch` | Send up to 100 emails | | `PATCH` | `/emails/:id` | Reschedule a scheduled send | | `POST` | `/emails/:id/cancel` | Cancel a scheduled send | SMTP submission is covered too: a `sending_access` key works as an SMTP password exactly like a full-access one, because the SMTP gateway relays through `POST /emails`. > [!NOTE] > Reads are deliberately excluded. `GET /emails/:id` returns the rendered body, the full recipient > list and the delivery event history — that's the half of a leaked key worth stealing. Give your > application server a `sending_access` key, and keep `full_access` for tooling you control. ## Restricting a key to one domain [#restricting-a-key-to-one-domain] A `sending_access` key can be pinned to a single sending domain with `domain_id`. Sends from any other domain are rejected with `403 FORBIDDEN`, including the shared onboarding sandbox domain. Omit `domain_id` and the key can send from any of your verified domains. ```bash curl -X POST https://api.eusend.dev/api-keys \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Billing service", "permission": "sending_access", "domain_id": "5e9f2c1a-..." }' ``` `domain_id` is only valid together with `sending_access` — pairing it with `full_access` returns `400 VALIDATION_ERROR`, since a full-access key can mint itself an unrestricted key anyway. The restriction applies to test-mode keys as well, so a scoped key behaves the same in CI as it will in production. > [!WARNING] > Deleting a domain also revokes every key restricted to it. A restriction that quietly widened to > "all domains" when its domain went away would be a silent privilege escalation, so the key goes > with it. ## Creating API keys [#creating-api-keys] API keys are managed in the **API Keys** section of the dashboard, which prompts for the permission and domain restriction when you create one. You can create multiple keys and revoke them individually. Free plan allows 1 key; paid plans allow unlimited keys. # Quick Start URL: https://eusend.dev/docs/getting-started/quick-start Send your first email in three steps. ## 1. Get your API key [#1-get-your-api-key] Sign up at [eusend.dev/signup](https://eusend.dev/signup), then navigate to **API Keys** in the dashboard and create a new key. Your key starts with `eu_live_` for production or `eu_test_` for test mode. Test mode allows you to use the API while preventing actual email delivery — ideal for development and testing. ## 2. Verify your sending domain [#2-verify-your-sending-domain] To send to real recipients, you must [add and verify your domain](/docs/domains) in the dashboard. This sets up DKIM signing so your emails are authenticated and deliverable. Emails from unverified domains will be rejected. Verification usually completes within minutes of adding the DNS records. > [!TIP] > No DNS access handy? Skip this step for now: send from `onboarding@sandbox.eusend.dev` and eusend > will deliver it — to your own account email only. It's a shared sandbox for trying the API, capped > per day; verify your own domain to send to anyone. ## 3. Send an email [#3-send-an-email] ```bash title="request" curl -X POST https://api.eusend.dev/emails \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": "recipient@example.com", "subject": "Hello from eusend", "html": "

Hello!

Your first email via eusend.

" }' ``` ```json title="response" { "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d" } ``` > [!NOTE] > Prefer a native library? Official SDKs wrap the same API: [Node.js](/docs/sdks/nodejs), > [Python](/docs/sdks/python), and [Go](/docs/sdks/go). > [!TIP] > Prefer a UI? Everything above can be done without touching the API — see the [Dashboard > Overview](/docs/dashboard/overview) for a guided walkthrough. # MCP server URL: https://eusend.dev/docs/mcp Connect eusend to any AI coding tool over the Model Context Protocol — send email and manage domains, audiences, and broadcasts from Claude, Cursor, Codex, and other MCP clients. The **Model Context Protocol (MCP)** lets AI tools call external services through a typed set of tools. eusend runs an MCP server so assistants like Claude Code, Claude Desktop, Cursor, and Codex can send email and manage domains, audiences, and broadcasts on your behalf — using your API key, through the exact same pipeline as the HTTP API. DKIM signing, tracking, suppression, rate limits, and your sending ceilings all apply identically. > [!NOTE] > Using Claude Code? The [eusend plugin](/docs/skill) installs this server *and* a skill that > teaches Claude how eusend behaves — verified senders, idempotent retries, webhook signatures — in > one command. The tools below let an assistant act on your account; the skill is what stops it > writing the integration wrong. There are two ways to connect, and they use the same tools underneath: - [**Remote (hosted)**](#remote-hosted) — Zero install — point your client at a URL. Best for getting started. - [**Local (stdio)**](#local-stdio) — Runs on your machine via npx. Your key never leaves your computer. ## How authentication works [#how-authentication-works] Every tool call is authorized with one of your eusend API keys — the same key you use for the HTTP API. Nothing new to provision. * **Remote:** your client sends the key on each request as an `Authorization: Bearer` header. The hosted server holds no key of its own; it simply forwards authenticated calls to the API, so it can never do anything your key can't. * **Local:** the key is read from the `EUSEND_API_KEY` environment variable in your own MCP config and never leaves your machine. Most tools need a `full_access` key. A [`sending_access` key](/docs/getting-started/authentication#permissions) can only send, so listing domains, managing audiences and every other tool will return `403 FORBIDDEN` — useful if you want an assistant that can send mail and nothing else. > [!NOTE] > Use an `eu_test_` key while wiring things up — calls are accepted and tracked but never delivered. > Swap in an `eu_live_` key to send for real. ## Remote (hosted) [#remote-hosted] Endpoint: `https://mcp.eusend.dev/mcp`. No installation — add the URL to your client along with an `Authorization` header carrying your key. ### Claude Code [#claude-code] ```bash title="shell" claude mcp add --transport http eusend https://mcp.eusend.dev/mcp \ --header "Authorization: Bearer eu_live_xxxxxxxxxxxx" ``` ### Cursor, Windsurf, and other JSON-configured clients [#cursor-windsurf-and-other-json-configured-clients] Add a remote server entry to the client's MCP config (e.g. `.cursor/mcp.json`): ```json title="mcp.json" { "mcpServers": { "eusend": { "url": "https://mcp.eusend.dev/mcp", "headers": { "Authorization": "Bearer eu_live_xxxxxxxxxxxx" } } } } ``` ### Codex [#codex] ```bash title="shell" codex mcp add eusend --url https://mcp.eusend.dev/mcp ``` Then set the `Authorization: Bearer eu_live_…` header for the `eusend` server in your Codex MCP configuration so tool calls are authenticated. ## Local (stdio) [#local-stdio] The local server runs as a subprocess your client launches on demand, via [`@eusend_dev/mcp`](/docs/sdks/nodejs). Your key stays in your own config and is never sent to eusend as part of setup. Requires Node.js. ### Claude Code [#claude-code-1] ```bash title="shell" claude mcp add eusend \ --env EUSEND_API_KEY=eu_live_xxxxxxxxxxxx \ -- npx -y @eusend_dev/mcp ``` ### Claude Desktop, Cursor, and other JSON-configured clients [#claude-desktop-cursor-and-other-json-configured-clients] ```json title="mcp.json" { "mcpServers": { "eusend": { "command": "npx", "args": ["-y", "@eusend_dev/mcp"], "env": { "EUSEND_API_KEY": "eu_live_xxxxxxxxxxxx", "EUSEND_FROM": "hello@yourdomain.com" } } } } ``` > [!NOTE] > Setting `EUSEND_FROM` pins a default sender, so the assistant doesn't have to guess an address on > every send. It must be on a [verified domain](/docs/domains). ## Available tools [#available-tools] | Parameter | Type | Description | | --- | --- | --- | | `Emails` | `4 tools` | send_email, list_emails, get_email, cancel_email — send transactional mail and inspect delivery. | | `Domains` | `3 tools` | list_domains, get_domain, create_domain — add a sending domain and read back its DNS records. | | `Audiences & contacts` | `4 tools` | list_audiences, create_audience, list_contacts, create_contact — manage contact lists. | | `Broadcasts` | `4 tools` | list_broadcasts, get_broadcast, create_broadcast, send_broadcast — create and send a broadcast to an audience. | | `Suppressions` | `2 tools` | list_suppressions, create_suppression — see which addresses are blocked and why, and stop sending to one. | > [!WARNING] > Destructive actions — deleting domains, audiences, contacts, or API keys — are intentionally > **not** exposed to the assistant. Broadcasts are also two steps: `create_broadcast` only drafts, > and `send_broadcast` is a separate call, so a bulk send is never a single accidental action. > Un-suppressing is absent for the same reason: suppressing stops mail, but removing an entry > re-enables sending to an address that bounced or complained, and doing that in bulk is what wrecks > a sender's reputation. ## The sender address [#the-sender-address] Like every other way to send through eusend, the `from` address must use a domain you have verified. Sends from an unverified domain fail with `DOMAIN_NOT_VERIFIED` — see [Domain Setup](/docs/domains) to add one. Pin a default with `EUSEND_FROM` (local) so the assistant always has a valid sender to use. # API Reference URL: https://eusend.dev/docs/reference/api-reference Every endpoint at a glance. All endpoints are prefixed with https://api.eusend.dev. All endpoints are prefixed with `https://api.eusend.dev`. All authenticated endpoints require `Authorization: Bearer `. ## Emails [#emails] | Method | Endpoint | Description | | ------- | -------------------- | ----------------------------------------------------- | | `POST` | `/emails` | Send a single email (schedule with `scheduled_at`) | | `POST` | `/emails/batch` | Send up to 100 emails | | `GET` | `/emails` | List emails (filter by `status`, `from`, `to`, `tag`) | | `GET` | `/emails/:id` | Get email + event history | | `PATCH` | `/emails/:id` | Reschedule a scheduled email | | `POST` | `/emails/:id/cancel` | Cancel a scheduled email | ## Suppressions [#suppressions] | Method | Endpoint | Description | | -------- | ---------------------- | ---------------------------------- | | `GET` | `/suppressions` | List suppressed addresses | | `POST` | `/suppressions` | Suppress an address | | `POST` | `/suppressions/batch` | Import up to 1,000 addresses | | `GET` | `/suppressions/export` | Download the whole list as CSV | | `DELETE` | `/suppressions/:id` | Un-suppress by entry id or address | ## Templates [#templates] | Method | Endpoint | Description | | -------- | ------------------------ | ------------------------------- | | `POST` | `/templates` | Create a template | | `GET` | `/templates` | List templates | | `GET` | `/templates/:id` | Get a template | | `GET` | `/templates/:id/preview` | Render a preview with variables | | `PATCH` | `/templates/:id` | Update a template | | `DELETE` | `/templates/:id` | Delete a template | ## Broadcasts [#broadcasts] | Method | Endpoint | Description | | -------- | ------------------------ | --------------------------------------- | | `POST` | `/broadcasts` | Create a broadcast (draft) | | `GET` | `/broadcasts` | List broadcasts | | `GET` | `/broadcasts/:id` | Get broadcast | | `PATCH` | `/broadcasts/:id` | Update draft / schedule broadcast | | `POST` | `/broadcasts/:id/send` | Send, schedule, or resume broadcast | | `POST` | `/broadcasts/:id/cancel` | Cancel scheduled or in-flight broadcast | | `DELETE` | `/broadcasts/:id` | Delete a draft or cancelled broadcast | ## Audiences [#audiences] | Method | Endpoint | Description | | -------- | ------------------------------------ | ----------------------------------- | | `POST` | `/audiences` | Create an audience | | `GET` | `/audiences` | List audiences | | `DELETE` | `/audiences/:id` | Delete an audience | | `POST` | `/audiences/:id/contacts` | Add / upsert a contact | | `POST` | `/audiences/:id/contacts/batch` | Batch upsert up to 1,000 contacts | | `GET` | `/audiences/:id/contacts` | List contacts | | `GET` | `/audiences/:id/contacts/:contactId` | Get a contact | | `PATCH` | `/audiences/:id/contacts/:contactId` | Update contact (name, subscription) | | `DELETE` | `/audiences/:id/contacts/:contactId` | Remove a contact | ## Domains [#domains] | Method | Endpoint | Description | | -------- | --------------------- | ------------------------- | | `POST` | `/domains` | Register a domain | | `GET` | `/domains` | List domains | | `GET` | `/domains/:id` | Get domain details | | `DELETE` | `/domains/:id` | Delete domain | | `POST` | `/domains/:id/verify` | Trigger DKIM verification | ## API Keys [#api-keys] | Method | Endpoint | Description | | -------- | --------------- | ----------------- | | `POST` | `/api-keys` | Create an API key | | `GET` | `/api-keys` | List API keys | | `DELETE` | `/api-keys/:id` | Revoke an API key | `POST /api-keys` accepts `permission` (`full_access`, the default, or `sending_access`) and, on a `sending_access` key only, `domain_id` to restrict it to one sending domain. See [Authentication](/docs/getting-started/authentication) for what each permission reaches. ## Webhooks [#webhooks] | Method | Endpoint | Description | | -------- | --------------- | ------------------------------- | | `POST` | `/webhooks` | Create a webhook | | `GET` | `/webhooks` | List webhooks | | `GET` | `/webhooks/:id` | Get webhook + recent deliveries | | `PATCH` | `/webhooks/:id` | Update a webhook | | `DELETE` | `/webhooks/:id` | Delete a webhook | ## System [#system] | Method | Endpoint | Description | | ------ | --------- | ---------------------- | | `GET` | `/health` | Health check (no auth) | # Error Codes URL: https://eusend.dev/docs/reference/error-codes All errors follow a consistent JSON format with a human-readable message and a machine-readable code. All errors follow a consistent JSON format with a human-readable message and a machine-readable code. ```json title="error response shape" { "error": "Invalid API key", "code": "UNAUTHORIZED" } ``` | Code | Status | Description | | -------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UNAUTHORIZED` | 401 | API key is missing, invalid, or revoked. | | `FORBIDDEN` | 403 | API key does not have permission for this action. | | `NOT_FOUND` | 404 | The requested resource does not exist. | | `VALIDATION_ERROR` | 400 | Request body failed validation. Check the error message for details. | | `CONFLICT` | 409 | Resource already exists (e.g. duplicate domain). | | `RATE_LIMITED` | 429 | Too many requests. Back off and retry. | | `DAILY_LIMIT_EXCEEDED` | 429 | Daily send ceiling reached. New accounts ramp up over their first \~7 days (paid); resets at midnight UTC. | | `PLAN_LIMIT_EXCEEDED` | 403 | Action not allowed on your current plan (e.g. domain limit reached). | | `DOMAIN_NOT_VERIFIED` | 403 | The sender domain is not verified for your organisation. Add and verify the domain before sending. | | `ALL_SUPPRESSED` | 422 | All recipient addresses are on the suppression list. | | `SENDING_SUSPENDED` | 403 | Account suspended due to a high bounce or complaint rate in the last 7 days. Contact [support@eusend.dev](mailto:support@eusend.dev) to reinstate. | | `MONTHLY_LIMIT_EXCEEDED` | 429 | Monthly send budget reached. With metered billing off (the default) this is your plan quota — enable it in billing settings to keep sending. With it on, this is the hard ceiling at twice your quota, which support must raise. | | `LIST_SEND_HELD` | 403 | This send goes past the 500-recipient allowance an unreviewed account has for list sending. Recipients within the allowance were delivered; the rest waits for a one-time review. Retrying will not clear it; contact [support@eusend.dev](mailto:support@eusend.dev). | | `BROADCAST_HELD` | 403 | This broadcast used its unreviewed allowance and the remainder is waiting on a review. It resumes from where it stopped once cleared — unlike a paused broadcast, sending again will not restart it. | | `SERVICE_PAUSED` | 503 | Sending is temporarily paused platform-wide. Retry after a short delay. | | `ATTACHMENT_STORAGE_ERROR` | 503 | An attachment could not be stored. The send did not happen — retry the request. | | `BAD_REQUEST` | 400 | The request was malformed and could not be processed. | | `PAYLOAD_TOO_LARGE` | 413 | The request body exceeds the 16 MB limit. Note that base64-encoded attachments are \~33% larger than the underlying files. | | `INTERNAL_ERROR` | 500 | An unexpected server error occurred. Retry after a short delay. | # Plans & Limits URL: https://eusend.dev/docs/reference/plans All plans include EU-only email infrastructure, DKIM support, webhooks, and the full API. All plans include EU-only email infrastructure, DKIM support, webhooks, and the full API. | | Free | Lite | Starter | Pro | Scale | | -------------------- | ------- | ------------- | ------------- | ------------- | ------------- | | Price | €0 | €15/mo | €29/mo | €99/mo | €279/mo | | Emails / month | 3,000 | 50,000 | 100,000 | 600,000 | 2,000,000 | | Extra emails | — | €0.60 / 1,000 | €0.50 / 1,000 | €0.35 / 1,000 | €0.25 / 1,000 | | Daily limit | 100 | Ramps\* | Ramps\* | Ramps\* | Ramps\* | | Domains | 1 | 3 | 10 | 50 | Unlimited | | API keys | 1 | Unlimited | Unlimited | Unlimited | Unlimited | | Stored contacts | 1,000 | 10,000 | 25,000 | 100,000 | 500,000 | | Log retention | 30 days | 30 days | 30 days | 90 days | 90 days | | Attachment retention | 30 days | 30 days | 30 days | 30 days | 30 days | The contact cap counts every contact stored across all your audiences, not per audience. Adding one past it returns `403` with code `PLAN_LIMIT_EXCEEDED`; existing contacts are never deleted when you hit it. Reaching the monthly quota stops sending and returns `429` with code `MONTHLY_LIMIT_EXCEEDED`. Metered overage is opt-in per organization in **Settings → Billing**: with it on, transactional sending continues past the quota at the plan's per-1,000 rate and the excess appears on the next invoice. That charge is **capped at the price difference to the next plan** — going over never costs more than upgrading would have — and sending pauses once the cap is reached. Broadcasts are excluded either way and always stop at the plan quota, because list mail affects shared deliverability. Log retention covers the delivery record (status, timestamps, addresses, subject, and events) and the rendered HTML and text body shown in the dashboard preview. Both expire together — for the full window everything on an email's detail page stays viewable, and after it, all of it is deleted rather than archived. Attachments are kept for 30 days on every plan, so on Pro and Scale they expire before the log entry does. Past that window the entry still lists each attachment's filename, size, and content type, but the file itself is gone: the dashboard shows it as expired, and requesting it returns `410`. \*Paid plans graduate to a higher, usage-based daily ceiling once established (no fixed per-tier number, but not unlimited). New accounts ramp up over their first \~7 days — see [Rate Limits](/docs/reference/rate-limits) for the schedule. Plans are managed in the **Settings → Billing** section of the dashboard. Upgrades take effect immediately; downgrades apply at the end of the billing period. # Rate Limits URL: https://eusend.dev/docs/reference/rate-limits API requests are rate-limited per organization using a sliding window algorithm. API requests are rate-limited per organization using a sliding window algorithm. | | | | ------------- | ---------------- | | **Requests** | 100 | | **Window** | 10 seconds | | **Algorithm** | Sliding window | | **Scope** | Per organization | When the rate limit is exceeded, the API returns a `429 Too Many Requests` response with error code `RATE_LIMITED`. Back off and retry after a short delay. ## Email send limits [#email-send-limits] In addition to the API rate limit, each plan has a monthly email send quota. By default, reaching it stops sending: further requests return a `429` with code `MONTHLY_LIMIT_EXCEEDED` until the quota resets at the start of the next month (UTC). ### Metered billing on overage [#metered-billing-on-overage] If you'd rather keep sending than stop, enable **metered billing** under Settings → Billing. It's off by default, so nobody is ever billed past their plan without asking for it. With it on, transactional sends (`POST /emails` and `/emails/batch`) continue past your quota and the excess is billed on your next invoice: | Plan | Included | Overage | | ------- | --------- | ------------- | | Lite | 50,000 | €0.60 / 1,000 | | Starter | 100,000 | €0.50 / 1,000 | | Pro | 600,000 | €0.35 / 1,000 | | Scale | 2,000,000 | €0.25 / 1,000 | Two things it does **not** do: * **It isn't unlimited.** Sending stops once the overage would have cost the same as the next plan up, so going over never costs more than upgrading would have and a retry loop can't bill without bound. That ceiling returns `429` with the same `MONTHLY_LIMIT_EXCEEDED` code — the message tells you which limit you hit. * **It doesn't cover broadcasts.** List sends always stop at your plan quota, whatever this setting says. You'll get an email the first time you cross into overage in a given month, with the rate and the ceiling. Turning the setting off stops sending at the quota again, but usage already incurred is still billed. Sustained overage usually costs more than the next plan up — if you're regularly past your quota, upgrading is the cheaper move. ## Daily send ramp [#daily-send-ramp] New accounts start with a lower daily ceiling that increases automatically as the account ages and sends cleanly, so a newly created account can't send at full volume on day one. No action is required — after about 7 days paid accounts graduate to a higher, usage-based daily ceiling (derived from your monthly plan and recent volume — not a fixed per-tier number, and never unlimited). The Free plan stays at 100 emails/day. The ceiling counts recipients (to + cc + bcc), the same unit as billing. Exceeding it returns a `429` with code `DAILY_LIMIT_EXCEEDED`; it resets at midnight UTC. | Account age | Daily limit (paid plans) | | --------------------- | ------------------------ | | First 24 hours | 2,000 | | 1–3 days | 10,000 | | 3–7 days | 50,000 | | 7+ days (established) | Usage-based\* | \*Established accounts are bounded by a finite, usage-based ceiling — the larger of your monthly limit spread over 30 days and a multiple of your recent busy-day volume. It grows with genuine sending but is never unlimited, so age alone can't unlock your full monthly quota in a single day. If an account's recent bounce or complaint rate is elevated, its daily ceiling is automatically halved until the rate recovers. Need a higher limit sooner than the ramp allows? Email `support@eusend.dev` and we can raise it manually. ## List send review [#list-send-review] Transactional sending is unaffected by this section — `POST /emails` works from your first minute on a brand-new domain, up to the daily ramp above. Sending to a **list** is different. Until we've reviewed an account, it can reach **500 recipients** across broadcasts and very large batch sends. Most first campaigns fit inside that and go out in full. Past it, the first 500 are delivered and the rest waits for a manual review — broadcasts move to `held`, and batch items beyond the allowance return `403` with code `LIST_SEND_HELD`. You'll get an email when it clears, and held broadcasts resume from exactly where they stopped. The allowance is per account, not per send, and it's a one-time step: once reviewed, it's lifted for good. Accounts sending from a domain registered in the last 30 days keep it until reviewed. We do this because every sender on eusend shares outbound IP reputation. A single list of purchased addresses degrades delivery for everyone, and a 500-recipient sample tells us how a list actually performs — real bounce and complaint rates — rather than making you wait behind a queue while we guess. On a deadline? Email `support@eusend.dev` and we'll clear it. # Go SDK URL: https://eusend.dev/docs/sdks/go Official Go SDK for the eusend API. Zero dependencies, context-aware, and shaped to mirror resend-go. Official Go SDK for the eusend API. Its shape mirrors [`resend-go`](https://github.com/resend/resend-go), so migrating from Resend is largely a `resend` → `eusend` rename. Zero dependencies (standard library only), `context.Context`-aware, and safe for concurrent use. Requires Go 1.21+. - [**Node.js / TypeScript**](/docs/sdks/nodejs) — @eusend_dev/sdk - [**Python**](/docs/sdks/python) — pip install eusend - [**Go**](/docs/sdks/go) — go get github.com/eusend-dev/eusend-go ## Installation [#installation] ```bash go get github.com/eusend-dev/eusend-go ``` ## Getting started [#getting-started] Create a client with your API key. Pass an empty string to read `EUSEND_API_KEY` from the environment. ```go import eusend "github.com/eusend-dev/eusend-go" client := eusend.NewClient("eu_live_...") // or NewClient("") to read EUSEND_API_KEY ``` Every method has a `WithContext` variant that takes a `context.Context` as its first argument (e.g. `client.Emails.SendWithContext(ctx, params)`). The context-free forms use `context.Background()`. Optional pointer fields have helpers: `eusend.Bool(true)`, `eusend.String("x")`. ## Sending an email [#sending-an-email] `From` and `To` are required; provide at least one of `Html`, `Text`, or `TemplateId`. ```go sent, err := client.Emails.Send(&eusend.SendEmailRequest{ // From accepts a bare email or a display-name form: "Acme " From: "hello@yourdomain.com", To: []string{"customer@example.com"}, Subject: "Your order is confirmed", Html: "

Thanks for your order!

", }) if err != nil { log.Fatal(err) } fmt.Println(sent.Id) // 9a8b7c6d-... ``` ### Send options [#send-options] | Field | Type | Description | | ---------------------------- | ------------------- | ------------------------------------------------------------------------------------- | | `From` | `string` | Sender address — bare email or "Name \" | | `To` `Cc` `Bcc` `ReplyTo` | `[]string` | Max 50 each | | `Subject` | `string` | Email subject | | `Html` / `Text` | `string` | HTML / plain-text body | | `TemplateId` | `string` | ID of a saved template | | `Variables` | `map[string]any` | Template variable substitutions (HTML-escaped) | | `Headers` | `map[string]string` | Custom headers; no line breaks in names or values | | `TrackOpens` / `TrackClicks` | `*bool` | Nil uses your [organization default](/docs/tracking); `eusend.Bool(false)` to disable | | `Attachments` | `[]*Attachment` | Up to 20, 10 MB combined. See below. | | `ScheduledAt` | `string` | Schedule for a future time, at most 30 days out | ### Attachments [#attachments] Provide `Content` (raw bytes, base64-encoded on the wire) **or** `Path` (a public URL fetched at send time). Set `ContentId` for an inline ``. ```go pdf, _ := os.ReadFile("invoice.pdf") client.Emails.Send(&eusend.SendEmailRequest{ From: "you@yourdomain.com", To: []string{"customer@example.com"}, Subject: "Your invoice", Html: "

Attached.

", Attachments: []*eusend.Attachment{ {Filename: "invoice.pdf", Content: pdf, ContentType: "application/pdf"}, }, }) ``` ### Idempotent sends [#idempotent-sends] ```go client.Emails.SendWithOptions(ctx, params, &eusend.SendEmailOptions{ IdempotencyKey: "receipt-" + orderID, }) ``` Retrying with the same key never sends a duplicate and returns the original ID. ### Batch send [#batch-send] Send up to 100 emails in a single request. Attachments and scheduling are stripped (not supported on the batch endpoint). Results map positionally to the input: queued items carry `Id`, rejected items carry `Error` and `Code`. ```go res, _ := client.Batch.Send([]*eusend.SendEmailRequest{ {From: "you@yourdomain.com", To: []string{"alice@example.com"}, Subject: "Hi", Html: "

Hi

"}, {From: "you@yourdomain.com", To: []string{"bob@example.com"}, Subject: "Hi", Html: "

Hi

"}, }) for _, r := range res.Data { if r.Id != "" { fmt.Println("queued", r.Id) } else { fmt.Printf("failed: %s (%s)\n", r.Error, r.Code) } } ``` ### Scheduled sends [#scheduled-sends] `ScheduledAt` accepts an ISO 8601 string or natural language like `"in 1 hour"` (at most 30 days out), parsed server-side in UTC. ```go sent, _ := client.Emails.Send(&eusend.SendEmailRequest{ From: "you@yourdomain.com", To: []string{"customer@example.com"}, Subject: "Your trial ends tomorrow", Html: "

Reminder.

", ScheduledAt: "in 1 hour", }) client.Emails.Update(&eusend.UpdateEmailRequest{Id: sent.Id, ScheduledAt: "2026-07-04T09:00:00Z"}) client.Emails.Cancel(sent.Id) ``` ### Retrieve & list emails [#retrieve--list-emails] ```go email, _ := client.Emails.Get("9a8b7c6d-...") fmt.Println(email.Status, email.Events[0].Type) page, _ := client.Emails.List(&eusend.ListEmailsOptions{Status: "delivered", Limit: 50}) for _, e := range page.Data { fmt.Println(e.Id, e.Subject) } // page.NextCursor -> pass as ListEmailsOptions{Cursor: ...} for the next page ``` ## Domains [#domains] ```go created, _ := client.Domains.Create(&eusend.CreateDomainRequest{Name: "yourdomain.com"}) for _, r := range created.Records { // every DNS record to add fmt.Println(r.Type, r.Name, r.Value) } client.Domains.Verify(created.Id) // after publishing the records client.Domains.List() client.Domains.Get(created.Id) client.Domains.Remove(created.Id) ``` ## API keys [#api-keys] ```go key, _ := client.ApiKeys.Create(&eusend.CreateApiKeyRequest{Name: "Production"}) fmt.Println(key.Key) // eu_live_... — returned only once client.ApiKeys.Create(&eusend.CreateApiKeyRequest{Name: "Sandbox", TestMode: true}) // eu_test_... key // Send-only key, restricted to one domain. Omit DomainId for any verified domain. client.ApiKeys.Create(&eusend.CreateApiKeyRequest{ Name: "Billing service", Permission: eusend.PermissionSendingAccess, DomainId: domainId, }) client.ApiKeys.List() // prefixes only client.ApiKeys.Remove(key.Id) ``` ## Templates [#templates] `{{variable}}` placeholders are substituted at send time; values are HTML-escaped. ```go tpl, _ := client.Templates.Create(&eusend.CreateTemplateRequest{ Name: "Welcome email", Subject: "Welcome, {{name}}!", Html: "

Hi {{name}}

Welcome to {{product}}.

", }) client.Emails.Send(&eusend.SendEmailRequest{ From: "you@yourdomain.com", To: []string{"customer@example.com"}, TemplateId: tpl.Id, Variables: map[string]any{"name": "Jane", "product": "Acme"}, }) client.Templates.List() client.Templates.Get(tpl.Id) client.Templates.Update(tpl.Id, &eusend.UpdateTemplateRequest{Subject: eusend.String("New subject")}) client.Templates.Remove(tpl.Id) ``` ## Audiences & contacts [#audiences--contacts] Contact operations are grouped under `Audiences` (they live under a specific audience). ```go audience, _ := client.Audiences.Create(&eusend.CreateAudienceRequest{Name: "Newsletter"}) client.Audiences.CreateContact(audience.Id, &eusend.CreateContactRequest{ Email: "user@example.com", FirstName: "Jane", }) // Bulk upsert (up to 1,000) client.Audiences.BatchCreateContacts(audience.Id, []*eusend.CreateContactRequest{ {Email: "alice@example.com", FirstName: "Alice"}, {Email: "bob@example.com", FirstName: "Bob"}, }) page, _ := client.Audiences.ListContacts(audience.Id, &eusend.ListContactsOptions{ Subscribed: eusend.Bool(true), Search: "gmail.com", }) contact := page.Data[0] client.Audiences.UpdateContact(audience.Id, contact.Id, &eusend.UpdateContactRequest{ Unsubscribed: eusend.Bool(true), }) client.Audiences.GetContact(audience.Id, contact.Id) client.Audiences.RemoveContact(audience.Id, contact.Id) client.Audiences.List() client.Audiences.Remove(audience.Id) ``` ## Suppressions [#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](/docs/emails/suppressions) for the full API. ```go // What's blocked, and why client.Suppressions.List(&eusend.ListSuppressionsOptions{ Reason: eusend.SuppressionReasonBounce, Limit: 50, }) client.Suppressions.List(&eusend.ListSuppressionsOptions{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. client.Suppressions.Create(&eusend.CreateSuppressionRequest{Email: "opted-out@example.com"}) // Import up to 1,000 per call — do this before your first send when migrating res, _ := client.Suppressions.Import([]*eusend.SuppressionImportItem{ {Email: "one@example.com"}, {Email: "two@example.com", Reason: eusend.SuppressionReasonComplaint}, }) fmt.Println(res.Count, res.AlreadySuppressed, res.Duplicates) // Un-suppress by entry id or by address; export the whole list as CSV client.Suppressions.Remove("invalid@example.com") csv, _ := client.Suppressions.Export() ``` ## Broadcasts [#broadcasts] `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, and `{{email}}` are available per recipient, and RFC 8058 one-click unsubscribe headers are added automatically. ```go bc, _ := client.Broadcasts.Create(&eusend.CreateBroadcastRequest{ Name: "May newsletter", AudienceId: audience.Id, From: "Sivert ", Subject: "May update", Html: "

Hi {{first_name}}, your monthly update is here...

", TrackOpens: eusend.Bool(false), // omit to use your organization default }) client.Broadcasts.Send(bc.Id, nil) // send now client.Broadcasts.Send(bc.Id, &eusend.SendBroadcastRequest{ScheduledAt: "2026-06-01T09:00:00Z"}) // or schedule client.Broadcasts.Cancel(bc.Id) client.Broadcasts.List() client.Broadcasts.Get(bc.Id) // includes delivery stats client.Broadcasts.Update(bc.Id, &eusend.UpdateBroadcastRequest{Subject: eusend.String("Updated")}) client.Broadcasts.Remove(bc.Id) ``` ## Webhooks [#webhooks] ```go hook, _ := client.Webhooks.Create(&eusend.CreateWebhookRequest{ Url: "https://yourapp.com/webhooks/eusend", Events: []string{"email.sent", "email.delivered", "email.bounced", "email.complained"}, // or: Events: []string{"*"} to receive everything }) fmt.Println(hook.Secret) // signing secret — store it securely, only returned once client.Webhooks.List() client.Webhooks.Get(hook.Id) // includes recent deliveries client.Webhooks.Update(hook.Id, &eusend.UpdateWebhookRequest{Events: []string{"email.bounced"}}) client.Webhooks.Remove(hook.Id) ``` ### Verifying webhook signatures [#verifying-webhook-signatures] Every delivery is signed with HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`: ```go import ( "crypto/hmac" "crypto/sha256" "encoding/base64" ) func verify(r *http.Request, body []byte, secret string) bool { signed := r.Header.Get("webhook-id") + "." + r.Header.Get("webhook-timestamp") + "." + string(body) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signed)) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(r.Header.Get("webhook-signature")), []byte(expected)) } ``` ## Error handling [#error-handling] Every method returns `(result, error)`. Any non-2xx response, and any network failure, is an `*eusend.Error`. On a `429`, `RetryAfter`, `RateLimitReset`, and `RateLimitRemaining` are populated from the response headers. ```go sent, err := client.Emails.Send(params) if err != nil { var apiErr *eusend.Error if errors.As(err, &apiErr) { fmt.Println(apiErr.Code) // "MONTHLY_LIMIT_EXCEEDED" fmt.Println(apiErr.Message) // "Monthly send limit exceeded" fmt.Println(apiErr.StatusCode) // 429 (0 for a network failure) if apiErr.Code == eusend.CodeMonthlyLimitExceeded { // back off and retry later } } return } ``` | Code constant | Wire value | Status | | -------------------------- | -------------------------- | ------------------- | | `CodeUnauthorized` | `UNAUTHORIZED` | 401 | | `CodeForbidden` | `FORBIDDEN` | 403 | | `CodeNotFound` | `NOT_FOUND` | 404 | | `CodeValidationError` | `VALIDATION_ERROR` | 400 | | `CodeBadRequest` | `BAD_REQUEST` | 400 | | `CodePayloadTooLarge` | `PAYLOAD_TOO_LARGE` | 413 | | `CodeConflict` | `CONFLICT` | 409 | | `CodeRateLimited` | `RATE_LIMITED` | 429 | | `CodeMonthlyLimitExceeded` | `MONTHLY_LIMIT_EXCEEDED` | 429 | | `CodeDailyLimitExceeded` | `DAILY_LIMIT_EXCEEDED` | 429 | | `CodePlanLimitExceeded` | `PLAN_LIMIT_EXCEEDED` | 403 | | `CodeDomainNotVerified` | `DOMAIN_NOT_VERIFIED` | 403 | | `CodeSendingSuspended` | `SENDING_SUSPENDED` | 403 | | `CodeListSendHeld` | `LIST_SEND_HELD` | 403 | | `CodeBroadcastHeld` | `BROADCAST_HELD` | 403 | | `CodeAllSuppressed` | `ALL_SUPPRESSED` | 422 | | `CodeServicePaused` | `SERVICE_PAUSED` | 503 | | `CodeAttachmentStorageErr` | `ATTACHMENT_STORAGE_ERROR` | 503 | | `CodeInternalError` | `INTERNAL_ERROR` | 500 | | `CodeApplicationError` | `application_error` | — (network failure) | > [!TIP] > View the full source and changelog on [GitHub](https://github.com/eusend-dev/eusend-go). # Node.js SDK URL: https://eusend.dev/docs/sdks/nodejs 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. - [**Node.js / TypeScript**](/docs/sdks/nodejs) — @eusend_dev/sdk - [**Python**](/docs/sdks/python) — pip install eusend - [**Go**](/docs/sdks/go) — go get github.com/eusend-dev/eusend-go ## Installation [#installation] ```bash npm install @eusend_dev/sdk # or bun add @eusend_dev/sdk ``` ## Getting started [#getting-started] Create a client with your API key. The key can also be read from the `EUSEND_API_KEY` environment variable. ```ts import { Eusend } from '@eusend_dev/sdk' const client = new Eusend('eu_live_...') // or: const client = new Eusend() // reads EUSEND_API_KEY ``` ## Sending an email [#sending-an-email] Every method returns a `{ data, error, headers }` union. On success `error` is `null`; on failure `data` is `null`. ```ts const { data, error } = await client.emails.send({ from: 'hello@yourdomain.com', to: 'customer@example.com', subject: 'Your order is confirmed', html: '

Thanks for your order!

', text: 'Thanks for your order!', }) if (error) { console.error(error.name, error.message) // 'VALIDATION_ERROR' ... } else { console.log(data.id) // 9a8b7c6d-... } ``` ### Send options [#send-options] | Field | Type | Description | | ------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `from` | `string` | Sender address — bare email or "Name \" | | `to` | `string \| string[]` | Recipient(s) | | `cc` | `string \| string[]` | CC recipient(s) | | `bcc` | `string \| string[]` | BCC recipient(s) | | `replyTo` | `string \| string[]` | Reply-to address(es) | | `subject` | `string` | Email subject | | `html` | `string` | HTML body | | `react` | `React.ReactElement` | A React Email component. The SDK renders to HTML locally before sending. Requires `react` and `@react-email/render` as peer dependencies. | | `text` | `string` | Plain text body | | `templateId` | `string` | ID of a saved template | | `variables` | `Record` | Template variable substitutions | | `headers` | `Record` | Custom email headers | | `trackOpens` | `boolean` | Track open events. Omit to use your [organization default](/docs/tracking) | | `trackClicks` | `boolean` | Track click events. Omit to use your [organization default](/docs/tracking) | | `attachments` | `Attachment[]` | 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:`. | | `scheduledAt` | `string \| Date` | Schedule 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 [#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. ```tsx import { WelcomeEmail } from './emails/welcome' await client.emails.send({ from: 'hello@yourdomain.com', to: 'user@example.com', subject: 'Welcome', react: , }) ``` Requires `react` and `@react-email/render` as peer dependencies: `npm install react @react-email/render`. ### Idempotent sends [#idempotent-sends] Pass an `idempotencyKey` to safely retry without sending duplicates. ```ts const { data } = await client.emails.send( { from: 'you@domain.com', to: 'user@example.com', subject: 'Receipt', html: '

Thanks

' }, { idempotencyKey: `receipt-${orderId}` }, ) ``` ### Batch send [#batch-send] Send up to 100 emails in a single request. ```ts const { data } = await client.batch.send([ { from: 'you@domain.com', to: 'alice@example.com', subject: 'Hi Alice', html: '

Hi Alice

' }, { from: 'you@domain.com', to: 'bob@example.com', subject: 'Hi Bob', html: '

Hi Bob

' }, ]) console.log(data?.data) // [{ id: '9a8b7c6d-...' }, { id: '1f2e3d4c-...' }] ``` ### Scheduled sends [#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`. ```ts // Schedule for tomorrow morning const { data } = await client.emails.send({ from: 'you@domain.com', to: 'user@example.com', subject: 'Your trial ends tomorrow', html: '

Reminder: your trial ends in 24 hours.

', 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 [#retrieve--list-emails] ```ts // 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 [#domains] ```ts // 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: '...' } // Trigger DNS verification await client.domains.verify(domainId) // List / get / delete await client.domains.list() await client.domains.get(domainId) await client.domains.delete(domainId) ``` ## API Keys [#api-keys] ```ts // 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 [#templates] ```tsx // Create an HTML template with {{variable}} placeholders const { data } = await client.templates.create({ name: 'Welcome email', subject: 'Welcome, {{name}}!', html: '

Hi {{name}}

Welcome to {{product}}.

', }) // 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: , }) // 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 [#audiences--contacts] ```ts // 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) await client.audiences.batchCreateContacts(aud!.id, { contacts: [ { email: 'alice@example.com', firstName: 'Alice' }, { email: 'bob@example.com', firstName: 'Bob' }, ], }) // List with filters await client.audiences.listContacts(aud!.id, { subscribed: true, search: 'gmail.com' }) // 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 [#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](/docs/emails/suppressions) for the full API. ```ts // 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 [#broadcasts] ```tsx // 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: , // SDK renders to HTML locally // or: html: '

Hi {{first_name}}...

' // or: templateId: '7c9e6679-7425-40de-944b-e07fc1f90ae7' }) // 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 [#webhooks] ```ts // 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 [#verifying-webhook-signatures] Every delivery is signed with HMAC-SHA256. Verify before processing: ```ts import { createHmac, timingSafeEqual } from 'crypto' async function verifyWebhook(req: Request, secret: string): Promise { 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 [#error-handling] ```ts 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 } ``` | 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 | | `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 (resets midnight UTC) | | `PLAN_LIMIT_EXCEEDED` | 403 | Feature not available on your plan | | `DOMAIN_NOT_VERIFIED` | 403 | Sender domain is not verified | | `ALL_SUPPRESSED` | 422 | All recipients are on the suppression list | | `SENDING_SUSPENDED` | 403 | Account suspended — 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 | | `SERVICE_PAUSED` | 503 | Sending temporarily paused platform-wide | | `ATTACHMENT_STORAGE_ERROR` | 503 | An attachment could not be stored — retry | | `BAD_REQUEST` | 400 | Malformed request | | `PAYLOAD_TOO_LARGE` | 413 | Request body exceeds the 16 MB limit | | `INTERNAL_ERROR` | 500 | Server error | | `application_error` | null | Network failure — request never reached the server | ## TypeScript [#typescript] The SDK ships with full type definitions. All request options, response shapes, and error codes are typed. ```ts import type { SendEmailOptions, Email, EmailStatus, EusendError, EusendResponse, } from '@eusend_dev/sdk' ``` > [!TIP] > View the full source and changelog on [npm](https://www.npmjs.com/package/@eusend_dev/sdk). # Python SDK URL: https://eusend.dev/docs/sdks/python Official Python SDK for the eusend API. Zero HTTP dependencies, typed, and shaped to mirror resend-python. Official Python SDK for the eusend API. Its shape mirrors [`resend-python`](https://github.com/resend/resend-python), so migrating from Resend is largely a `resend` → `eusend` rename. Zero HTTP dependencies (built on the standard library), typed, and ships `py.typed`. Requires Python 3.8+. - [**Node.js / TypeScript**](/docs/sdks/nodejs) — @eusend_dev/sdk - [**Python**](/docs/sdks/python) — pip install eusend - [**Go**](/docs/sdks/go) — go get github.com/eusend-dev/eusend-go ## Installation [#installation] ```bash pip install eusend ``` ## Getting started [#getting-started] Configure a module-level API key, then call the resource classes directly. The key can also be read from the `EUSEND_API_KEY` environment variable. ```python import eusend eusend.api_key = 'eu_live_...' # or set EUSEND_API_KEY ``` Responses are dicts with **snake\_case** keys — access fields with `email['id']`. On failure, methods raise `eusend.EusendError` (see [Error handling](#error-handling)). ## Sending an email [#sending-an-email] `from` and `to` are required; provide at least one of `html`, `text`, or `template_id`. ```python email = eusend.Emails.send({ # `from` accepts a bare email or a display-name form: "Acme " 'from': 'hello@yourdomain.com', 'to': 'customer@example.com', 'subject': 'Your order is confirmed', 'html': '

Thanks for your order!

', 'text': 'Thanks for your order!', }) print(email['id']) # 9a8b7c6d-... ``` ### Send options [#send-options] | Key | Type | Description | | ------------------------------ | ------------------ | ------------------------------------------------------- | | `from` | `str` | Sender address — bare email or "Name \" | | `to` | `str \| list[str]` | Recipient(s), max 50 | | `cc` / `bcc` / `reply_to` | `str \| list[str]` | Max 50 each | | `subject` | `str` | Email subject | | `html` / `text` | `str` | HTML / plain-text body | | `template_id` | `str` | ID of a saved template | | `variables` | `dict` | Template variable substitutions (HTML-escaped) | | `headers` | `dict[str, str]` | Custom headers; no line breaks in names or values | | `track_opens` / `track_clicks` | `bool` | Omit to use your [organization default](/docs/tracking) | | `attachments` | `list[dict]` | Up to 20, 10 MB combined. See below. | | `scheduled_at` | `str` | Schedule for a future time, at most 30 days out | At least one of `html`, `text`, or `template_id` is required. ### Attachments [#attachments] Each attachment is a dict. `content` accepts raw `bytes` (base64-encoded for you) or an already-base64 `str`; alternatively pass `path` (a public URL fetched at send time). Set `content_id` for an inline ``. ```python with open('invoice.pdf', 'rb') as f: eusend.Emails.send({ 'from': 'you@yourdomain.com', 'to': 'customer@example.com', 'subject': 'Your invoice', 'html': '

Attached.

', 'attachments': [ {'filename': 'invoice.pdf', 'content': f.read(), 'content_type': 'application/pdf'}, ], }) ``` ### Idempotent sends [#idempotent-sends] Pass an `options` dict with an `idempotency_key` to safely retry without duplicating. ```python eusend.Emails.send( {'from': 'you@yourdomain.com', 'to': 'customer@example.com', 'subject': 'Receipt', 'html': '

Thanks

'}, options={'idempotency_key': f'receipt-{order_id}'}, ) ``` ### Batch send [#batch-send] Send up to 100 emails in a single request. Attachments and scheduling are not supported on the batch endpoint and are stripped from each item. The result maps positionally to the input: queued items carry `id`, rejected items carry `error` and `code`. ```python res = eusend.Batch.send([ {'from': 'you@yourdomain.com', 'to': 'alice@example.com', 'subject': 'Hi', 'html': '

Hi

'}, {'from': 'you@yourdomain.com', 'to': 'bob@example.com', 'subject': 'Hi', 'html': '

Hi

'}, ]) for item in res['data']: print(item.get('id') or f"{item['code']}: {item['error']}") ``` ### Scheduled sends [#scheduled-sends] `scheduled_at` accepts an ISO 8601 string or natural language like `"in 1 hour"` (at most 30 days out), parsed server-side in UTC. While the email's status is `scheduled` you can reschedule or cancel it. ```python sent = eusend.Emails.send({ 'from': 'you@yourdomain.com', 'to': 'customer@example.com', 'subject': 'Your trial ends tomorrow', 'html': '

Reminder: your trial ends in 24 hours.

', 'scheduled_at': 'in 1 hour', }) eusend.Emails.update({'id': sent['id'], 'scheduled_at': '2026-07-04T09:00:00Z'}) # reschedule eusend.Emails.cancel(sent['id']) # cancel ``` ### Retrieve & list emails [#retrieve--list-emails] ```python email = eusend.Emails.get('9a8b7c6d-...') print(email['status']) # 'delivered' print(email['events'][0]['type']) page = eusend.Emails.list({'status': 'delivered', 'limit': 50}) print(page['data']) # list of emails print(page['next_cursor']) # pass as {'cursor': ...} for next page ``` ## Domains [#domains] ```python created = eusend.Domains.create('yourdomain.com') for record in created['records']: # every DNS record to add print(record['type'], record['name'], record['value']) eusend.Domains.verify(created['id']) # after publishing the records eusend.Domains.list() eusend.Domains.get(created['id']) eusend.Domains.remove(created['id']) ``` ## API keys [#api-keys] ```python key = eusend.ApiKeys.create({'name': 'Production'}) print(key['key']) # eu_live_... — returned only once eusend.ApiKeys.create({'name': 'Sandbox', 'test_mode': True}) # eu_test_... key # Send-only key, restricted to one domain. Omit domain_id for any verified domain. eusend.ApiKeys.create({ 'name': 'Billing service', 'permission': eusend.SENDING_ACCESS, 'domain_id': domain_id, }) eusend.ApiKeys.list() # prefixes only eusend.ApiKeys.remove(key['id']) ``` ## Templates [#templates] `{{variable}}` placeholders are substituted at send time; values are HTML-escaped. ```python tpl = eusend.Templates.create({ 'name': 'Welcome email', 'subject': 'Welcome, {{name}}!', 'html': '

Hi {{name}}

Welcome to {{product}}.

', }) eusend.Emails.send({ 'from': 'you@yourdomain.com', 'to': 'customer@example.com', 'template_id': tpl['id'], 'variables': {'name': 'Jane', 'product': 'Acme'}, }) eusend.Templates.list() eusend.Templates.get(tpl['id']) eusend.Templates.update(tpl['id'], {'subject': 'New subject'}) eusend.Templates.remove(tpl['id']) ``` ## Audiences & contacts [#audiences--contacts] Contact operations are grouped under `Audiences` (they live under a specific audience). ```python audience = eusend.Audiences.create('Newsletter') eusend.Audiences.create_contact(audience['id'], {'email': 'user@example.com', 'first_name': 'Jane'}) # Bulk upsert (up to 1,000) → {'count': N, 'duplicates': N} eusend.Audiences.batch_create_contacts(audience['id'], [ {'email': 'alice@example.com', 'first_name': 'Alice'}, {'email': 'bob@example.com', 'first_name': 'Bob'}, ]) page = eusend.Audiences.list_contacts(audience['id'], {'subscribed': True, 'search': 'gmail.com'}) contact = page['data'][0] eusend.Audiences.update_contact(audience['id'], contact['id'], {'unsubscribed': True}) eusend.Audiences.get_contact(audience['id'], contact['id']) eusend.Audiences.remove_contact(audience['id'], contact['id']) eusend.Audiences.list() eusend.Audiences.remove(audience['id']) ``` ## Suppressions [#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](/docs/emails/suppressions) for the full API. ```python # What's blocked, and why eusend.Suppressions.list({"reason": "bounce", "limit": 50}) eusend.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. eusend.Suppressions.create({"email": "opted-out@example.com"}) # Import up to 1,000 per call — do this before your first send when migrating. # (Named import_list because `import` is a Python keyword.) res = eusend.Suppressions.import_list([ "one@example.com", {"email": "two@example.com", "reason": "complaint"}, ]) print(res["count"], res["already_suppressed"], res["duplicates"]) # Un-suppress by entry id or by address; export the whole list as CSV bytes eusend.Suppressions.remove("invalid@example.com") csv = eusend.Suppressions.export() ``` ## Broadcasts [#broadcasts] `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, and `{{email}}` are available per recipient, and RFC 8058 one-click unsubscribe headers are added automatically. ```python bc = eusend.Broadcasts.create({ 'name': 'May newsletter', 'audience_id': audience['id'], 'from': 'Sivert ', 'subject': 'May update', 'html': '

Hi {{first_name}}, your monthly update is here...

', }) eusend.Broadcasts.send(bc['id']) # send now eusend.Broadcasts.send(bc['id'], {'scheduled_at': '2026-06-01T09:00:00Z'}) # or schedule eusend.Broadcasts.cancel(bc['id']) eusend.Broadcasts.list() eusend.Broadcasts.get(bc['id']) # includes delivery stats eusend.Broadcasts.update(bc['id'], {'subject': 'Updated subject'}) eusend.Broadcasts.remove(bc['id']) ``` ## Webhooks [#webhooks] ```python hook = eusend.Webhooks.create({ 'url': 'https://yourapp.com/webhooks/eusend', 'events': ['email.sent', 'email.delivered', 'email.bounced', 'email.complained'], # or: 'events': ['*'] to receive everything }) print(hook['secret']) # signing secret — store it securely, only returned once eusend.Webhooks.list() eusend.Webhooks.get(hook['id']) # includes recent deliveries eusend.Webhooks.update(hook['id'], {'events': ['email.bounced']}) eusend.Webhooks.remove(hook['id']) ``` ### Verifying webhook signatures [#verifying-webhook-signatures] Every delivery is signed with HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`: ```python import base64 import hashlib import hmac def verify(headers, body: bytes, secret: str) -> bool: signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.{body.decode()}" mac = hmac.new(secret.encode(), signed.encode(), hashlib.sha256) expected = 'v1,' + base64.b64encode(mac.digest()).decode() return hmac.compare_digest(headers['webhook-signature'], expected) ``` ## Error handling [#error-handling] Any non-2xx response raises a subclass of `eusend.EusendError`. Network failures that never reach the server raise `ApplicationError` (with `status_code == None`). Branch on `e.code`. ```python import eusend from eusend import EusendError, RateLimitError try: eusend.Emails.send({'from': 'you@yourdomain.com', 'to': 'customer@example.com', 'subject': 'Hi', 'html': '

Hi

'}) except RateLimitError as e: print(e.code) # 'MONTHLY_LIMIT_EXCEEDED' print(e.status_code) # 429 except EusendError as e: print(e.code, e.message, e.status_code) ``` Exception classes — all subclasses of `EusendError`: `MissingApiKeyError`, `InvalidApiKeyError`, `ValidationError`, `NotFoundError`, `RateLimitError`, `ApplicationError`. | Code | Status | Exception | | ---------------------------------- | ------ | ------------------------------------ | | `UNAUTHORIZED` | 401 | `InvalidApiKeyError` | | `FORBIDDEN` | 403 | `EusendError` | | `NOT_FOUND` | 404 | `NotFoundError` | | `VALIDATION_ERROR` / `BAD_REQUEST` | 400 | `ValidationError` | | `PAYLOAD_TOO_LARGE` | 413 | `ValidationError` | | `CONFLICT` | 409 | `EusendError` | | `RATE_LIMITED` | 429 | `RateLimitError` | | `MONTHLY_LIMIT_EXCEEDED` | 429 | `RateLimitError` | | `DAILY_LIMIT_EXCEEDED` | 429 | `RateLimitError` | | `PLAN_LIMIT_EXCEEDED` | 403 | `EusendError` | | `DOMAIN_NOT_VERIFIED` | 403 | `EusendError` | | `ALL_SUPPRESSED` | 422 | `EusendError` | | `SENDING_SUSPENDED` | 403 | `EusendError` | | `LIST_SEND_HELD` | 403 | `EusendError` | | `BROADCAST_HELD` | 403 | `EusendError` | | `SERVICE_PAUSED` | 503 | `EusendError` | | `ATTACHMENT_STORAGE_ERROR` | 503 | `EusendError` | | `INTERNAL_ERROR` | 500 | `ApplicationError` | | `application_error` | — | `ApplicationError` (network failure) | > [!TIP] > View the full source and changelog on [GitHub](https://github.com/eusend-dev/eusend-python). # Claude Code plugin URL: https://eusend.dev/docs/skill Install the official eusend plugin so Claude knows how eusend actually behaves — verified senders, idempotent retries, webhook signatures, and which errors to retry — before it writes a line of code. The [MCP server](/docs/mcp) gives an assistant tools: it can send mail, add a domain, read your delivery log. What it can't do is tell the assistant how eusend *behaves* — that the sender domain must be verified, that a retry without an `Idempotency-Key` double-sends, that a webhook signature covers the raw request body and not the parsed JSON. That's what a **skill** is for. The eusend plugin ships one, plus the MCP server, in a single install. ## Install [#install] ```bash title="shell" /plugin marketplace add eusend-dev/eusend-skill /plugin install eusend@eusend ``` Then set your API key so the bundled MCP server can authenticate: ```bash title="shell" export EUSEND_API_KEY=eu_live_xxxxxxxxxxxx export EUSEND_FROM=hello@yourdomain.com # optional — pins a default sender ``` > [!NOTE] > The skill itself needs no key. Only the MCP server does. Use an `eu_test_` key while you're wiring > things up — sends are accepted, recorded, and fire the full webhook lifecycle, but never reach a > real inbox. ## Using it [#using-it] Claude loads the skill on its own when a task involves eusend — a `@eusend_dev/sdk` import, an `EUSEND_API_KEY` in your env file, or just a prompt that mentions us. To invoke it directly, type `/eusend:eusend`. Things it's built for: ```text Add password reset emails to this app using eusend Why is this send returning DOMAIN_NOT_VERIFIED? Write a webhook handler for eusend bounces Set up the DNS records for acme.com ``` ## What it knows [#what-it-knows] The skill is written around the mistakes that actually reach production, not a restatement of the API reference: | Parameter | Type | Description | | --- | --- | --- | | `Verified senders` | `DOMAIN_NOT_VERIFIED` | The from address must be on a verified domain, and no retry fixes it. Includes the sandbox sender and its one real constraint — it only delivers to your own account email. | | `Idempotent retries` | `Idempotency-Key` | Derive the key from a domain object, not a UUID minted per attempt — which is the version that makes every retry a fresh send. | | `Webhook signatures` | `raw body` | Verify against the bytes received, in constant time, checking length first. Working handlers for Express, Next.js route handlers, Flask, and net/http. | | `The SPF record not to add` | `DNS` | A second SPF record on your root domain breaks SPF for every other service sending from it, including your own Google Workspace mail. eusend authenticates with DKIM and does not need one. | | `Retry policy` | `error codes` | Which codes are transient, which need a longer horizon, and which are terminal. ALL_SUPPRESSED is the system working, not an incident. | | `Per-language failure shapes` | `SDKs` | The Node SDK returns { data, error } and never throws on an API error — an unchecked await silently drops every failed send. Python raises; Go returns an error. | Detail lives in reference files the assistant loads only when it needs them, so the always-on cost is about 260 tokens. ## Other agents [#other-agents] [Agent Skills](https://agentskills.io) is an open standard, not a Claude format. This skill is written to it — spec-standard frontmatter only, no Claude-specific syntax in the body — so it works in Cursor, GitHub Copilot, VS Code, Codex, Gemini CLI, OpenCode, Goose, Amp, Roo Code and the rest of the skills-compatible ecosystem. Only the install differs. `/plugin` is Claude Code's marketplace format; elsewhere you drop the skill folder somewhere the agent scans. Cursor, Codex and Copilot all read `.agents/skills/`, so one copy covers all three: ```bash title="shell" git clone https://github.com/eusend-dev/eusend-skill mkdir -p .agents/skills cp -r eusend-skill/plugins/eusend/skills/eusend .agents/skills/ ``` Use `~/.agents/skills/` to install it once for every project instead. Agents that scan elsewhere — `.github/skills/` for Copilot, `.cursor/skills/` for Cursor — work the same way. > [!NOTE] > The [MCP server](/docs/mcp) is independent of the skill and equally portable — it speaks plain > MCP, so Cursor, Codex, Claude Desktop and any other MCP client can connect to it. Only the > bundled-in-one-install convenience is specific to Claude Code. > [!TIP] > Found something the skill gets wrong? That's a bug, and the repository takes issues. A skill that > confidently teaches the wrong thing is worse than no skill at all. # SMTP URL: https://eusend.dev/docs/smtp Send through eusend over SMTP instead of the HTTP API — a drop-in transport for anything that already speaks SMTP, from WordPress and Supabase to Django, Laravel, and Rails. Send through eusend over SMTP instead of the HTTP API — a drop-in transport for anything that already speaks SMTP: WordPress, Supabase Auth, Django, Laravel, Rails, Ghost, and most off-the-shelf software. Messages submitted over SMTP run through the exact same pipeline as the API, so DKIM signing, open and click tracking, suppression, and your sending limits all apply identically. ## Connection settings [#connection-settings] | Parameter | Type | Description | | --- | --- | --- | | `Host` (required) | `smtp.eusend.dev` | The SMTP submission server. | | `Port` (required) | `465` | Implicit TLS (SMTPS) — the connection is encrypted from the first byte. STARTTLS (587) and plain SMTP (25) are not supported. | | `Security` (required) | `SSL / TLS` | Implicit TLS on 465. Not STARTTLS. Choose "SSL" or "TLS" if your client asks. | | `Username` (required) | `eusend` | Always the literal string "eusend" — not your email address and not your key. | | `Password` (required) | `eu_live_… / eu_test_…` | Any of your eusend API keys. The key goes in the password field. | > [!WARNING] > The username is always the literal word `eusend`; your API key goes in the **password** field. > Putting the key in the username is the most common cause of a `535` authentication failure. ## Integration guides [#integration-guides] Step-by-step setup for the most common platforms. Every one uses the same host, port, username, and password above — only the location of the settings screen differs. - [**WordPress**](/docs/smtp/wordpress) — WP Mail SMTP / FluentSMTP - [**Supabase Auth**](/docs/smtp/supabase) — Custom SMTP for auth emails - [**Django**](/docs/smtp/django) — EMAIL_BACKEND settings - [**Laravel**](/docs/smtp/laravel) — config/mail.php + .env - [**Ruby on Rails**](/docs/smtp/rails) — Action Mailer delivery - [**Nodemailer**](/docs/smtp/nodemailer) — Node.js transport - [**Ghost**](/docs/smtp/ghost) — Transactional mail config - [**PHP (PHPMailer)**](/docs/smtp/phpmailer) — Plain PHP sending ## The sender address [#the-sender-address] The message `From` address must use a domain you have verified in eusend — the same rule as the API. Mail from an unverified domain is rejected at submission with a `535` carrying the reason in its text. The local part (before the `@`) can be anything. See [Domain Setup](/docs/domains) to add and verify a domain. ## Test and live keys [#test-and-live-keys] A key prefixed `eu_test_` is accepted and tracked but never delivered — ideal while you wire up an integration. Swap in an `eu_live_` key to send for real. ## Quick check — swaks (CLI) [#quick-check--swaks-cli] Before wiring SMTP into an application, confirm your key and sender domain work with a one-line send from the terminal: ```bash title="shell" swaks \ --server smtp.eusend.dev:465 --tlsc \ --auth-user eusend --auth-password eu_live_xxxxxxxxxxxx \ --from hello@yourdomain.com \ --to alice@example.com \ --header 'Subject: SMTP test' \ --body 'Hello from eusend over SMTP.' ``` ## Limits and behavior [#limits-and-behavior] | Parameter | Type | Description | | --- | --- | --- | | `Recipients` | `max 50 each` | Up to 50 addresses each across To, Cc, and Bcc. | | `Message size` | `max 15 MB` | The whole message on the wire, MIME encoding included. Larger messages are rejected with a 552. | | `Attachments` | `max 20, 10 MB` | Up to 20 attachments, 10 MB combined once decoded — the same limits as the API. Over either one, the message is rejected with a 552 rather than delivered without the files. | | `Sending limits` | `shared` | The same rate limit, monthly plan limit, and progressive daily sending ceiling as the HTTP API apply to SMTP submissions. | | `Tracking & events` | `identical` | Opens, clicks, bounces, complaints, and suppression all apply — an SMTP send appears in your dashboard and analytics like any other email. | ## Troubleshooting [#troubleshooting] | Parameter | Type | Description | | --- | --- | --- | | `535` | `auth or sender rejected` | Wrong username or invalid key — the username must be the literal "eusend" and the API key goes in the password field. An unverified From domain also lands here, so read the response text before assuming the credentials are wrong. | | `550` | `rejected` | The message itself was refused — every recipient is on your suppression list, or the content failed validation. | | `452` | `rate limited` | A rate limit or your daily sending ceiling was hit. Retry later. | | `421` | `temporary` | Temporary server error or too many simultaneous connections — retry with backoff. | # Django SMTP URL: https://eusend.dev/docs/smtp/django Configure Django's email backend to send through eusend over SMTP — password resets, notifications, and any send_mail call, DKIM-signed and tracked. Django's SMTP email backend works with eusend out of the box — no extra packages. Set five settings and every `send_mail`, `EmailMessage`, password-reset, and `django.contrib.auth` email routes through eusend, DKIM-signed and tracked. ## settings.py [#settingspy] ```python title="settings.py" EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = 'smtp.eusend.dev' EMAIL_PORT = 465 EMAIL_USE_SSL = True # implicit TLS — not EMAIL_USE_TLS EMAIL_HOST_USER = 'eusend' # always the literal string "eusend" EMAIL_HOST_PASSWORD = os.environ['EUSEND_API_KEY'] # eu_live_… # Must be an address on a domain verified in eusend DEFAULT_FROM_EMAIL = 'Acme ' ``` > [!WARNING] > Use `EMAIL_USE_SSL = True` (implicit TLS on port 465), **not** `EMAIL_USE_TLS`. Setting both > raises `ValueError: EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive`. ## Send an email [#send-an-email] ```python from django.core.mail import send_mail send_mail( subject='Your order has shipped', message='Order #1234 is on its way.', from_email=None, # falls back to DEFAULT_FROM_EMAIL recipient_list=['alice@example.com'], html_message='

Order #1234 is on its way.

', ) ``` Test it from the shell without touching a view: ```bash python manage.py shell -c "from django.core.mail import send_mail; \ send_mail('Test', 'Body', None, ['you@example.com'])" ``` ## Connection reference [#connection-reference] | Setting | Value | | --------------------- | ------------------------------------------------ | | `EMAIL_HOST` | `smtp.eusend.dev` | | `EMAIL_PORT` | `465` | | `EMAIL_USE_SSL` | `True` | | `EMAIL_HOST_USER` | `eusend` | | `EMAIL_HOST_PASSWORD` | your eusend API key (`eu_live_…`) | | `DEFAULT_FROM_EMAIL` | an address on a [verified domain](/docs/domains) | Use an `eu_test_` key while developing — sends are accepted and tracked but never delivered. See the [SMTP overview](/docs/smtp) for limits and status codes. # Ghost SMTP URL: https://eusend.dev/docs/smtp/ghost Configure your Ghost blog to send transactional email — member sign-in links, password resets, staff invites — through eusend over SMTP. Ghost sends two kinds of email: bulk newsletters (which require Mailgun) and transactional mail — member sign-in links, password resets, and staff invites. eusend handles the transactional mail through Ghost's `mail` SMTP configuration, DKIM-signed and tracked. ## config.production.json [#configproductionjson] Ghost is configured through its config file (or the equivalent environment variables). Add a `mail` block: ```json title="config.production.json" { "mail": { "transport": "SMTP", "from": "Acme ", "options": { "host": "smtp.eusend.dev", "port": 465, "secure": true, "auth": { "user": "eusend", "pass": "eu_live_xxxxxxxxxxxx" } } } } ``` The `from` address must be on a domain you have [verified in eusend](/docs/domains). ## As environment variables [#as-environment-variables] If you run Ghost via Docker or a managed host, the same settings map to environment variables: ```ini mail__transport=SMTP mail__from=no-reply@yourdomain.com mail__options__host=smtp.eusend.dev mail__options__port=465 mail__options__secure=true mail__options__auth__user=eusend mail__options__auth__pass=eu_live_xxxxxxxxxxxx ``` Restart Ghost after changing the config. > [!WARNING] > Keep `"secure": true` with port `465` (implicit TLS). Ghost's mail transport is Nodemailer under > the hood, so `secure: false` would attempt STARTTLS on the wrong port and fail. ## Verify [#verify] In Ghost admin, go to **Settings → Staff** and send yourself an invite, or trigger a member sign-in link — both are transactional and go through eusend. The message appears in your eusend dashboard. > [!NOTE] > Ghost still requires Mailgun specifically for **bulk newsletter** sending — that path is separate > from transactional mail and is not configurable to other providers. eusend covers everything under > the `mail` block above. # Laravel SMTP URL: https://eusend.dev/docs/smtp/laravel Set up Laravel's mail configuration to send through eusend over SMTP — Mailables, notifications, and queued mail, DKIM-signed and tracked. Laravel ships with an SMTP mail transport, so sending through eusend is a matter of environment variables — no package to install. Every `Mail::send`, Mailable, and notification then routes through eusend, DKIM-signed and tracked. ## .env [#env] ```ini title=".env" MAIL_MAILER=smtp MAIL_HOST=smtp.eusend.dev MAIL_PORT=465 MAIL_ENCRYPTION=ssl # implicit TLS on 465 MAIL_USERNAME=eusend # always the literal string "eusend" MAIL_PASSWORD=eu_live_xxxxxxxxxxxx MAIL_FROM_ADDRESS=no-reply@yourdomain.com # must be a verified domain MAIL_FROM_NAME="Acme" ``` `config/mail.php` already reads these variables — no edit needed there. > [!WARNING] > Use `MAIL_ENCRYPTION=ssl` with port `465` (implicit TLS). Do not use `tls` on 465; STARTTLS (587) > is not supported. ## Send an email [#send-an-email] ```php use Illuminate\Support\Facades\Mail; Mail::raw('Order #1234 is on its way.', function ($message) { $message->to('alice@example.com') ->subject('Your order has shipped'); }); ``` After editing `.env`, clear the cached config so the new values take effect: ```bash php artisan config:clear ``` ## Connection reference [#connection-reference] | Variable | Value | | ------------------- | ------------------------------------------------ | | `MAIL_HOST` | `smtp.eusend.dev` | | `MAIL_PORT` | `465` | | `MAIL_ENCRYPTION` | `ssl` | | `MAIL_USERNAME` | `eusend` | | `MAIL_PASSWORD` | your eusend API key (`eu_live_…`) | | `MAIL_FROM_ADDRESS` | an address on a [verified domain](/docs/domains) | Use an `eu_test_` key while building — sends are tracked but not delivered. See the [SMTP overview](/docs/smtp) for limits and status codes. # Nodemailer SMTP URL: https://eusend.dev/docs/smtp/nodemailer Send email from Node.js through eusend with Nodemailer over SMTP — implicit TLS on port 465, DKIM-signed and tracked. Nodemailer is the standard way to send email from Node.js. Point its SMTP transport at eusend and every message is DKIM-signed, tracked, and subject to the same limits as the HTTP API. ## Install [#install] ```bash npm install nodemailer ``` ## Transport [#transport] ```ts title="mailer.ts" import nodemailer from 'nodemailer' export const transport = nodemailer.createTransport({ host: 'smtp.eusend.dev', port: 465, secure: true, // implicit TLS — encrypted from the first byte auth: { user: 'eusend', // always the literal string "eusend" pass: process.env.EUSEND_API_KEY, // eu_live_… }, }) ``` > [!WARNING] > `secure: true` selects implicit TLS on port 465. Do not set `secure: false` (that expects STARTTLS > on 587, which eusend does not support). ## Send an email [#send-an-email] ```ts await transport.sendMail({ from: 'Acme ', // must be a verified domain to: 'alice@example.com', subject: 'Your order has shipped', html: '

Order #1234 is on its way.

', text: 'Order #1234 is on its way.', }) ``` Attachments, `cc`, `bcc`, `replyTo`, and custom `headers` all work as usual — Nodemailer builds the MIME message and eusend relays it. To [tag](/docs/emails/tags) a relayed send for log filtering and webhook routing, add an `X-Eusend-Tag` header: ```ts await transport.sendMail({ from: 'Acme ', to: 'alice@example.com', subject: 'Reset your password', html: '

Here is your reset link.

', headers: { 'X-Eusend-Tag': 'category=password_reset, tier=pro' }, }) ``` ## Verify the connection [#verify-the-connection] ```ts await transport.verify() // resolves if the host, port, and auth are correct ``` ## Connection reference [#connection-reference] | Option | Value | | ----------- | ------------------------------------------------ | | `host` | `smtp.eusend.dev` | | `port` | `465` | | `secure` | `true` | | `auth.user` | `eusend` | | `auth.pass` | your eusend API key (`eu_live_…`) | | `from` | an address on a [verified domain](/docs/domains) | > [!NOTE] > For a richer typed API — batch sends, scheduling, templates, idempotency — use the [Node.js > SDK](/docs/sdks/nodejs) over the HTTP API instead. SMTP is best when a library or framework > already speaks it. # PHP (PHPMailer) SMTP URL: https://eusend.dev/docs/smtp/phpmailer Send email from plain PHP through eusend using PHPMailer over SMTP — implicit TLS on port 465, DKIM-signed and tracked. For a PHP app without a framework, [PHPMailer](https://github.com/PHPMailer/PHPMailer) is the standard SMTP client. Point it at eusend and your mail is DKIM-signed, tracked, and subject to the same limits as the HTTP API. ## Install [#install] ```bash composer require phpmailer/phpmailer ``` ## Send an email [#send-an-email] ```php title="send.php" use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\Exception; require 'vendor/autoload.php'; $mail = new PHPMailer(true); try { $mail->isSMTP(); $mail->Host = 'smtp.eusend.dev'; $mail->SMTPAuth = true; $mail->Username = 'eusend'; // the literal string "eusend" $mail->Password = getenv('EUSEND_API_KEY'); // eu_live_… $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; // implicit TLS $mail->Port = 465; // From must be on a domain verified in eusend $mail->setFrom('no-reply@yourdomain.com', 'Acme'); $mail->addAddress('alice@example.com'); $mail->isHTML(true); $mail->Subject = 'Your order has shipped'; $mail->Body = '

Order #1234 is on its way.

'; $mail->AltBody = 'Order #1234 is on its way.'; $mail->send(); } catch (Exception $e) { echo "Send failed: {$mail->ErrorInfo}"; } ``` > [!WARNING] > Use `ENCRYPTION_SMTPS` with port `465` (implicit TLS), **not** `ENCRYPTION_STARTTLS`. STARTTLS on > 587 is not supported. ## Connection reference [#connection-reference] | Property | Value | | ------------ | ------------------------------------------------ | | `Host` | `smtp.eusend.dev` | | `Port` | `465` | | `SMTPSecure` | `PHPMailer::ENCRYPTION_SMTPS` | | `Username` | `eusend` | | `Password` | your eusend API key (`eu_live_…`) | | `setFrom()` | an address on a [verified domain](/docs/domains) | Use an `eu_test_` key while developing — sends are tracked but not delivered. See the [SMTP overview](/docs/smtp) for limits and status codes. # Rails SMTP URL: https://eusend.dev/docs/smtp/rails Configure Action Mailer to deliver through eusend over SMTP — mailers, Devise emails, and any deliver_now/deliver_later, DKIM-signed and tracked. Action Mailer uses SMTP for delivery, so pointing Ruby on Rails at eusend is a one-block configuration change. Every mailer, plus Devise confirmation and password-reset emails, then routes through eusend, DKIM-signed and tracked. ## config/environments/production.rb [#configenvironmentsproductionrb] ```ruby title="config/environments/production.rb" config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { address: 'smtp.eusend.dev', port: 465, user_name: 'eusend', # the literal string "eusend" password: ENV['EUSEND_API_KEY'], # eu_live_… authentication: :plain, tls: true, # implicit TLS on 465 enable_starttls_auto: false } # Must be an address on a domain verified in eusend config.action_mailer.default_options = { from: 'Acme ' } ``` > [!WARNING] > Use `tls: true` (implicit TLS on port 465), not `enable_starttls_auto`. On some Ruby/`net-smtp` > versions the option is `ssl: true` — both select implicit TLS. ## Send an email [#send-an-email] ```ruby class OrderMailer < ApplicationMailer def shipped(order) mail(to: order.email, subject: 'Your order has shipped') end end OrderMailer.shipped(order).deliver_now # or deliver_later ``` Test the connection from the Rails console: ```ruby ActionMailer::Base.mail( from: 'no-reply@yourdomain.com', to: 'you@example.com', subject: 'SMTP test', body: 'Hello from eusend.' ).deliver_now ``` ## Connection reference [#connection-reference] | Setting | Value | | ----------- | ------------------------------------------------ | | `address` | `smtp.eusend.dev` | | `port` | `465` | | `tls` | `true` | | `user_name` | `eusend` | | `password` | your eusend API key (`eu_live_…`) | | `from` | an address on a [verified domain](/docs/domains) | Use an `eu_test_` key in development — sends are tracked but not delivered. See the [SMTP overview](/docs/smtp) for limits and status codes. # Supabase SMTP URL: https://eusend.dev/docs/smtp/supabase Send Supabase Auth emails — confirmations, magic links, password resets — through eusend by configuring custom SMTP in your Supabase project. Supabase's built-in email service is rate-limited and meant only for testing — in production you must bring your own SMTP provider for auth emails (confirmations, magic links, password resets, invites). Pointing Supabase at eusend gives you DKIM-signed delivery on your own domain, with every auth email visible in your eusend dashboard. ## Connection values [#connection-values] | Parameter | Type | Description | | --- | --- | --- | | `Host` (required) | `smtp.eusend.dev` | The submission server. | | `Port` (required) | `465` | Implicit TLS. STARTTLS (587) is not supported. | | `Username` (required) | `eusend` | The literal string "eusend". | | `Password` (required) | `eu_live_…` | Any eusend API key. | | `Sender email` (required) | `no-reply@yourdomain.com` | Must be a verified domain. | ## Setup [#setup] ### Open SMTP settings [#open-smtp-settings] In the Supabase dashboard go to **Authentication → Emails → SMTP Settings** and toggle on **Enable Custom SMTP**. ### Enter the connection [#enter-the-connection] ```text title="Supabase → Custom SMTP" Host: smtp.eusend.dev Port: 465 Username: eusend Password: Sender email: no-reply@yourdomain.com (must be a verified domain) Sender name: Your App ``` Save. ### Send a test email [#send-a-test-email] Use Supabase's **Send test email** button — it exercises the whole path end to end. A delivered test confirms your key and sender domain are correct. > [!NOTE] > The **sender email** must be an address on a domain you have [verified in eusend](/docs/domains). > An unverified sender domain and a wrong username or key both come back as a `535` — the response > text says which. ## Adjust the rate limit [#adjust-the-rate-limit] Supabase caps auth email throughput separately from your SMTP provider. Under **Authentication → Rate Limits**, raise **"Rate limit for sending emails"** to match your expected sign-up volume — otherwise Supabase, not eusend, becomes the bottleneck. eusend's own [sending limits](/docs/reference/rate-limits) still apply on top. ## Note on password recovery links [#note-on-password-recovery-links] Auth emails are generated and sent by Supabase itself; eusend is only the transport. Customize the templates under **Authentication → Emails → Templates** in Supabase. Everything eusend adds — DKIM, tracking, suppression — is applied to whatever Supabase hands off. # WordPress SMTP URL: https://eusend.dev/docs/smtp/wordpress Send reliable WordPress email — password resets, WooCommerce receipts, form notifications — through eusend's SMTP relay using WP Mail SMTP or FluentSMTP. By default WordPress sends mail with PHP's `mail()` function, which is unauthenticated and lands in spam or silently disappears. Pointing WordPress at eusend's SMTP relay fixes deliverability for password resets, WooCommerce order receipts, contact-form notifications, and every other email your site sends — all DKIM-signed and tracked in your eusend dashboard. This guide uses the free **WP Mail SMTP** plugin. **FluentSMTP** and **Post SMTP** work identically — the connection values below are the same for any of them. ## Connection values [#connection-values] | Parameter | Type | Description | | --- | --- | --- | | `SMTP Host` (required) | `smtp.eusend.dev` | The submission server. | | `Encryption` (required) | `SSL` | Implicit TLS. Not TLS/STARTTLS. | | `SMTP Port` (required) | `465` | Implicit TLS. STARTTLS (587) is not supported. | | `Authentication` (required) | `On` | Enable "Auto TLS" and authentication. | | `SMTP Username` (required) | `eusend` | The literal string "eusend". | | `SMTP Password` (required) | `eu_live_…` | Any eusend API key. | ## Setup [#setup] ### Install WP Mail SMTP [#install-wp-mail-smtp] In WordPress admin go to **Plugins → Add New**, search for **WP Mail SMTP by WPForms**, install it, and activate. A **WP Mail SMTP** item appears in the sidebar. ### Choose the "Other SMTP" mailer [#choose-the-other-smtp-mailer] Open **WP Mail SMTP → Settings**. Under **Mailer**, pick **Other SMTP**. ### Set the From address [#set-the-from-address] Set **From Email** to an address on a domain you have [verified in eusend](/docs/domains) — for example `no-reply@yourdomain.com`. Set **From Name** to your site name, and tick **Force From Email** so plugins can't override it with an unverified address. ### Enter the SMTP connection [#enter-the-smtp-connection] Fill in the connection fields: ```text title="WP Mail SMTP → Other SMTP" SMTP Host: smtp.eusend.dev Encryption: SSL SMTP Port: 465 Authentication: On SMTP Username: eusend SMTP Password: ``` Save. ### Send a test email [#send-a-test-email] Open the **Email Test** tab, enter your own address, and send. A delivered test confirms the whole path. The message also shows up in your eusend dashboard. > [!WARNING] > Set **From Email** to a verified domain and enable **Force From Email**. If WooCommerce or a form > plugin sends from an unverified domain, eusend rejects it with a `535` whose text names the > domain. ## WooCommerce [#woocommerce] No extra configuration is needed. Once WP Mail SMTP routes mail through eusend, WooCommerce order confirmations, invoices, and shipping notifications go through the same relay automatically — just make sure the WooCommerce **"From" address** (WooCommerce → Settings → Emails) is also on your verified domain. ## Troubleshooting [#troubleshooting] | Symptom | Cause | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SMTP Error: Could not authenticate` (535) | Username isn't the literal `eusend`, the API key is wrong, or the From address is on an unverified domain — the response text says which. The key goes in the **password** field. | | Mail rejected with `550` | Every recipient is on your suppression list. | | Test times out on port 465 | Your host blocks outbound 465. There is no alternate port — ask your host to open it, or send through the [HTTP API](/docs/emails/send) instead. | See the [SMTP overview](/docs/smtp) for limits and the full status-code table. # Email Templates URL: https://eusend.dev/docs/templates Create reusable email templates by submitting rendered HTML, personalized at send time via {{variable}} placeholders. Create reusable email templates by submitting rendered HTML — write them by hand or render a React Email component locally with `@react-email/render` and submit the result. Templates are stored server-side and personalized at send time via `{{variable}}` placeholders. ## Creating templates [#creating-templates] Templates can be created in the **Templates** section of the dashboard, which provides a live-preview editor. They can also be created via the API. ## API endpoints [#api-endpoints] `POST /templates` | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `string` | Display name for the template. | | `subject` (required) | `string` | Default email subject. Supports {{variable}} placeholders. Can be overridden per send. | | `html` (required) | `string` | HTML content. Supports {{variable}} placeholders. Variable values are HTML-escaped at render time, so put HTML structure in the template itself — not in variable values. | > [!NOTE] > A template holds a subject and an HTML body only — there is no stored plain-text part. Pass `text` > on the send itself if you want one. ```bash title="create template" curl -X POST https://api.eusend.dev/templates \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Order Confirmation", "subject": "Your order #{{order_id}} is confirmed", "html": "

Hi {{first_name}}!

Order #{{order_id}} confirmed.

" }' ``` ```json title="response — 201 Created" { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Order Confirmation", "createdAt": "2026-05-20T10:00:00.000Z" } ``` The rest of the template CRUD surface: `GET /templates` List all templates. `GET /templates/:id` Get a template. `PATCH /templates/:id` Update a template. `DELETE /templates/:id` Delete a template. # Open & Click Tracking URL: https://eusend.dev/docs/tracking Track email opens and link clicks, set the default for your whole organization, or turn tracking off. All tracking requests pass through EU infrastructure — no data is sent to US services. eusend can track email opens and link clicks. All tracking requests pass through EU infrastructure — no data is sent to US services. ## How a setting is resolved [#how-a-setting-is-resolved] Three levels, most specific first: 1. **The `track_opens` / `track_clicks` flag on the send**, when you pass one. 2. **Your organization default**, in the dashboard under **Settings → General → Email tracking**. 3. **On**, which is what the organization default ships as. Omitting a flag is not the same as passing `false`. Omitting it defers to your organization default; `false` turns tracking off for that send whatever the default says. The organization default is the only level that reaches every send path — [SMTP](/docs/smtp) messages have nowhere to carry a per-send flag, so for SMTP that setting *is* the control. Broadcasts resolve it once, when the broadcast is created, and keep that answer. ## Open tracking [#open-tracking] eusend injects a 1×1 transparent pixel into the HTML body, and when the recipient's email client loads the image, eusend records an `opened` event. > [!WARNING] > Open tracking is inherently imprecise. Email clients that pre-fetch images (like Apple Mail with > Mail Privacy Protection) may record false opens. Many clients also block remote images by default. ## Click tracking [#click-tracking] All `href` links in the HTML body are rewritten to pass through eusend's tracking endpoint; when clicked, a `clicked` event is recorded and the recipient is immediately redirected to the original URL. With click tracking off, your original URLs are delivered untouched. ## What we record [#what-we-record] An open stores the requesting **user agent**. A click stores the **destination URL** and the id of the link. Neither stores the recipient's IP address, and we derive no location from it. Events are deleted with the email log they belong to — 30 days on Free, Lite and Starter, 90 on Pro and Scale. See [plans](/docs/reference/plans). ## Turning tracking off [#turning-tracking-off] For a single send, pass either flag as `false`: ```json { "from": "hello@acme.com", "to": "user@example.com", "subject": "...", "html": "...", "track_opens": false, "track_clicks": false } ``` For every send from your organization — API, SMTP and broadcasts alike — turn the defaults off under **Settings → General → Email tracking**. A send that passes an explicit flag still overrides it. For one broadcast, use the **Tracking** checkboxes in the composer, or pass the flags when creating it through the API: ```json { "name": "March newsletter", "audience_id": "aud_...", "from": "hello@acme.com", "subject": "...", "html": "...", "track_opens": false, "track_clicks": false } ``` Turning tracking off does not affect unsubscribe links, which broadcasts always carry. ## Consent [#consent] You are the controller for the people you email, so whether you have a lawful basis to track them is your decision, not ours. In much of the EU a tracking pixel needs consent under the ePrivacy Directive (Article 5(3)), separately from whatever basis you rely on to send the message at all — a distinction that catches people out most often on marketing broadcasts. The settings above exist so that decision can live in one place rather than on every call. ## Analytics dashboard [#analytics-dashboard] Aggregate open and click stats are visualised in the **Analytics** section of the dashboard, with charts broken down by time period, status, and domain. # Webhooks URL: https://eusend.dev/docs/webhooks Subscribe to email events and receive real-time HTTP POST notifications at your endpoint. Subscribe to email events and receive real-time HTTP POST notifications at your endpoint. Webhook deliveries are retried up to 3 times with exponential back-off on failure. `POST /webhooks` | Parameter | Type | Description | | --- | --- | --- | | `url` (required) | `string` | Public HTTP(S) endpoint to receive events. Private, loopback, and internal addresses are rejected — both at creation and again (after DNS resolution) before each delivery. | | `events` (required) | `string[]` | Event types to subscribe to. Use "*" to subscribe to all events. | ```bash title="create webhook" curl -X POST https://api.eusend.dev/webhooks \ -H "Authorization: Bearer eu_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.com/webhooks/eusend", "events": ["email.delivered", "email.bounced", "email.complained"] }' ``` ```json title="response — 201 Created" { "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://yourapp.com/webhooks/eusend", "events": ["email.delivered", "email.bounced", "email.complained"], "secret": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABC", "createdAt": "2026-05-20T10:00:00.000Z" } ``` > [!NOTE] > Store the `secret` securely — you'll need it to verify incoming webhook signatures. If you lose > it, retrieve it again with `GET /webhooks/:id/secret`, or view it on the webhook's page in the > dashboard. > [!NOTE] > Your endpoint must respond directly with a `2xx` status. Redirects (`3xx`) are not followed and > count as a failed delivery. The rest of the webhooks surface: `GET /webhooks` List webhooks. `GET /webhooks/:id` Get webhook + recent deliveries. `PATCH /webhooks/:id` Update url / events. `GET /webhooks/:id/secret` Retrieve the signing secret. `POST /webhooks/:id/rotate-secret` Replace the signing secret and return the new one. It takes effect immediately — the previous secret stops verifying on the next delivery, so update your endpoint first unless you are rotating because the old secret leaked. `DELETE /webhooks/:id` Delete webhook. # Events URL: https://eusend.dev/docs/webhooks/events Subscribe to any combination of these event types, or use * to receive all of them. Subscribe to any combination of these event types, or use `*` to receive all of them. | Event | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `email.sent` | Email was accepted and is in transit. | | `email.delivered` | Delivery confirmed by the receiving mail server. | | `email.bounced` | Email hard-bounced (permanent failure) and the address was suppressed. The payload includes `bounce_type` plus the receiving server's verbatim reason — see [Reading a bounce](#reading-a-bounce). Transient/soft failures are retried and do not fire this event. | | `email.complained` | Recipient marked the email as spam. | | `email.opened` | Recipient opened the email (requires `track_opens`). Fires once per email. | | `email.clicked` | Recipient clicked a tracked link (requires `track_clicks`). Payload includes `link_id` and `url`. | | `*` | Wildcard — subscribes to all current and future event types. | ## Payload structure [#payload-structure] ```json title="example payload (email.delivered)" { "type": "email.delivered", "email_id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "recipients": ["alice@example.com"], "tags": { "category": "shipping_update" }, "timestamp": "2026-05-20T10:00:03.000Z" } ``` > [!NOTE] > Every payload includes `type`, `email_id`, `tags`, and `timestamp`. Some events add fields: > `provider_message_id` (sent), `recipients` (delivered, bounced, complained), `bounce_type` + > `diagnostic` (bounced), and `link_id` + `url` (clicked). Deliveries from a test-mode send also > carry `test_mode: true`. ## Reading a bounce [#reading-a-bounce] `bounce_type` is our own classifier's bucket, and it is frequently `Uncategorized` — a real rejection we have no specific rule for. It is not enough on its own to tell an invalid mailbox from a reputation block, so a bounce payload also carries the receiving server's own words: ```json title="example payload (email.bounced)" { "type": "email.bounced", "email_id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d", "bounce_type": "Uncategorized", "smtp_code": 554, "diagnostic": "Your access to this mail system has been rejected due to poor reputation of a domain used in message transfer", "recipients": ["alice@example.com"], "tags": {}, "timestamp": "2026-05-20T10:00:03.000Z" } ``` | Field | Notes | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `smtp_code` | Numeric SMTP reply code (`550`, `554`, …). Absent when the bounce arrived asynchronously as a DSN rather than on the SMTP session. | | `diagnostic` | The remote server's verbatim reason, trimmed to 500 characters. Absent when the remote sent no text. | | `dsn_status` | Dotted status (`5.1.1`) — present only on asynchronous DSN bounces, in place of `smtp_code`. | > [!WARNING] > Treat both as opaque text for humans and logs. Wording varies by provider and changes without > notice, so match on `bounce_type` or `smtp_code` when you need to branch in code — never on > `diagnostic`. `tags` carries whatever [tags](/docs/emails/tags) the send was created with, as an object — `{}` when it had none, never absent — so you can route on `payload.tags.category` without checking the field exists first. # Security URL: https://eusend.dev/docs/webhooks/security Every webhook delivery includes a Svix-compatible signature so you can verify it originated from eusend. Every webhook delivery includes a signature so you can verify it originated from eusend. The signature scheme is Svix-compatible. ## Headers [#headers] | Parameter | Type | Description | | --- | --- | --- | | `webhook-id` | `string` | Unique delivery ID for this event. | | `webhook-timestamp` | `string` | Unix timestamp (seconds) of delivery time. | | `webhook-signature` | `string` | HMAC-SHA256 signature prefixed with v1,. | ## Verification [#verification] The signature is computed as `HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{body}")`, then base64-encoded and prefixed with `v1,`. The `webhook-id` and `webhook-timestamp` values are the ones sent in the request headers (each delivery has a unique `webhook-id`). ```js title="node.js verification" import { createHmac, timingSafeEqual } from 'crypto' function verifyWebhook(req, secret) { const id = req.headers['webhook-id'] const timestamp = req.headers['webhook-timestamp'] const signature = req.headers['webhook-signature'] const body = req.rawBody // must be the raw string, not parsed JSON const signed = `${id}.${timestamp}.${body}` const expected = 'v1,' + createHmac('sha256', secret).update(signed).digest('base64') const received = Buffer.from(signature ?? '', 'utf8') const digest = Buffer.from(expected, 'utf8') // timingSafeEqual throws on a length mismatch, so check that first — a missing // or truncated header is exactly the case that would otherwise crash the handler. if (received.length !== digest.length || !timingSafeEqual(received, digest)) { throw new Error('Invalid webhook signature') } return JSON.parse(body) } ``` > [!TIP] > Always compare signatures with a constant-time comparison — a plain `===` leaks how much of the > signature matched. In Node.js that's `crypto.timingSafeEqual()`, guarded by the length check > above.