eusend
SDKs

Python SDK

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, so migrating from Resend is largely a resendeusend rename. Zero HTTP dependencies (built on the standard library), typed, and ships py.typed. Requires Python 3.8+.

Installation

pip install eusend

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.

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).

Sending an email

from and to are required; provide at least one of html, text, or template_id.

email = eusend.Emails.send({
    # `from` accepts a bare email or a display-name form: "Acme <you@yourdomain.com>"
    'from': 'hello@yourdomain.com',
    'to': 'customer@example.com',
    'subject': 'Your order is confirmed',
    'html': '<p>Thanks for your order!</p>',
    'text': 'Thanks for your order!',
})

print(email['id'])  # 9a8b7c6d-...

Send options

KeyTypeDescription
fromstrSender address — bare email or "Name <email>"
tostr | list[str]Recipient(s), max 50
cc / bcc / reply_tostr | list[str]Max 50 each
subjectstrEmail subject
html / textstrHTML / plain-text body
template_idstrID of a saved template
variablesdictTemplate variable substitutions (HTML-escaped)
headersdict[str, str]Custom headers; no line breaks in names or values
track_opens / track_clicksboolDefault True
attachmentslist[dict]Up to 20, 10 MB combined. See below.
scheduled_atstrSchedule for a future time, at most 30 days out

At least one of html, text, or template_id is required.

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 <img src="cid:...">.

with open('invoice.pdf', 'rb') as f:
    eusend.Emails.send({
        'from': 'you@yourdomain.com',
        'to': 'customer@example.com',
        'subject': 'Your invoice',
        'html': '<p>Attached.</p>',
        'attachments': [
            {'filename': 'invoice.pdf', 'content': f.read(), 'content_type': 'application/pdf'},
        ],
    })

Idempotent sends

Pass an options dict with an idempotency_key to safely retry without duplicating.

eusend.Emails.send(
    {'from': 'you@yourdomain.com', 'to': 'customer@example.com',
     'subject': 'Receipt', 'html': '<p>Thanks</p>'},
    options={'idempotency_key': f'receipt-{order_id}'},
)

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.

res = eusend.Batch.send([
    {'from': 'you@yourdomain.com', 'to': 'alice@example.com', 'subject': 'Hi', 'html': '<p>Hi</p>'},
    {'from': 'you@yourdomain.com', 'to': 'bob@example.com',   'subject': 'Hi', 'html': '<p>Hi</p>'},
])

for item in res['data']:
    print(item.get('id') or f"{item['code']}: {item['error']}")

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.

sent = eusend.Emails.send({
    'from': 'you@yourdomain.com',
    'to': 'customer@example.com',
    'subject': 'Your trial ends tomorrow',
    'html': '<p>Reminder: your trial ends in 24 hours.</p>',
    '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

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

created = eusend.Domains.create('yourdomain.com')
print(created['dkim']['name'], created['dkim']['value'])  # DNS records to add
print(created['spf'], created['dmarc'])

eusend.Domains.verify(created['id'])   # after publishing the records
eusend.Domains.list()
eusend.Domains.get(created['id'])
eusend.Domains.remove(created['id'])

API keys

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
eusend.ApiKeys.list()                                          # prefixes only
eusend.ApiKeys.remove(key['id'])

Templates

{{variable}} placeholders are substituted at send time; values are HTML-escaped.

tpl = eusend.Templates.create({
    'name': 'Welcome email',
    'subject': 'Welcome, {{name}}!',
    'html': '<h1>Hi {{name}}</h1><p>Welcome to {{product}}.</p>',
})

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

Contact operations are grouped under Audiences (they live under a specific audience).

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}
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'])

Broadcasts

{{first_name}}, {{last_name}}, {{full_name}}, and {{email}} are available per recipient, and RFC 8058 one-click unsubscribe headers are added automatically.

bc = eusend.Broadcasts.create({
    'name': 'May newsletter',
    'audience_id': audience['id'],
    'from': 'Sivert <hello@yourdomain.com>',
    'subject': 'May update',
    'html': '<p>Hi {{first_name}}, your monthly update is here...</p>',
})

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

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

Every delivery is signed with HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}:

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

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.

import eusend
from eusend import EusendError, RateLimitError

try:
    eusend.Emails.send({'from': 'you@yourdomain.com', 'to': 'customer@example.com',
                        'subject': 'Hi', 'html': '<p>Hi</p>'})
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.

CodeStatusException
UNAUTHORIZED401InvalidApiKeyError
FORBIDDEN403EusendError
NOT_FOUND404NotFoundError
VALIDATION_ERROR / BAD_REQUEST400ValidationError
CONFLICT409EusendError
RATE_LIMITED429RateLimitError
MONTHLY_LIMIT_EXCEEDED429RateLimitError
DAILY_LIMIT_EXCEEDED429RateLimitError
PLAN_LIMIT_EXCEEDED403EusendError
DOMAIN_NOT_VERIFIED403EusendError
ALL_SUPPRESSED422EusendError
SENDING_SUSPENDED403EusendError
SERVICE_PAUSED503EusendError
INTERNAL_ERROR500ApplicationError
application_errorApplicationError (network failure)

View the full source and changelog on GitHub.