Skip to content
Last updated

Python SDK

The official Python wrapper for the Mailjet API is published on PyPI as mailjet-rest and developed at mailjet/mailjet-apiv3-python.

Compatibility

  • Python >= 3.10, < 3.15
  • Runtime dependency: requests >= 2.33.0
  • Build backend: setuptools, wheel, setuptools-scm
  • Test dependency: pytest >= 9.0.3

Installation

python -m venv venv
source venv/bin/activate
pip install mailjet-rest

From source

git clone https://github.com/mailjet/mailjet-apiv3-python
cd mailjet-apiv3-python
pip install .

On Unix platforms, conda and make are also supported:

make install

For development

# minimal environment
make dev
conda activate mailjet

# full dev environment
make dev-full
conda activate mailjet-dev

The repository also ships a management script that wraps the common tasks:

./manage.sh env_setup     # conda environment and pre-commit hooks
./manage.sh test_all      # unit and integration tests
./manage.sh perf_bench    # performance profilers
./manage.sh format        # formatting
./manage.sh lint          # linting

Authentication

export MJ_APIKEY_PUBLIC='your api key'
export MJ_APIKEY_PRIVATE='your api secret'
export MJ_CONTENT_TOKEN='your bearer token'   # optional, Content API v1

Quick start

Use the client as a context manager. This pools and closes the underlying TCP connections for you and prevents resource leaks:

import os
from mailjet_rest import Client

api_key = os.environ.get("MJ_APIKEY_PUBLIC", "")
api_secret = os.environ.get("MJ_APIKEY_PRIVATE", "")

with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
    data = {
        "Messages": [
            {
                "From": {"Email": "pilot@example.com", "Name": "Mailjet Pilot"},
                "To": [{"Email": "passenger1@example.com", "Name": "Passenger 1"}],
                "Subject": "Your email flight plan!",
                "TextPart": "Welcome to Mailjet! May the delivery force be with you!",
            }
        ]
    }
    result = mailjet.send.create(data=data)
    print(result.status_code)

Warning: if you do not use the context manager, you must call mailjet.close() on shutdown to release the sockets.

Configuration

Overrides can be passed when the client is created, or per call:

with Client(
    auth=(api_key, api_secret),
    version="v3.1",
    api_url="https://api.us.mailjet.com/",
    timeout=30,
) as mailjet:
    # override the timeout for a single heavy request
    result = mailjet.contact.get(timeout=60)

API versioning

VersionScope
v3Email API
v3.1Send API v3.1 (latest send version)
v1Content API — templates, blocks, images

Most Email API endpoints sit under v3, which is the default. For anything else, set version.

Base URL

The default base domain is api.mailjet.com. Accounts on Mailjet's US architecture must set https://api.us.mailjet.com:

with Client(auth=(api_key, api_secret), api_url="https://api.us.mailjet.com/") as mailjet:
    ...

Building resource paths

Python identifiers cannot contain slashes or dashes, so paths are translated:

  • replace a slash / with an underscore _
  • replace a dash - by capitalising the next letter

statistics/link-click therefore becomes statistics_linkClick:

with Client(auth=(api_key, api_secret)) as mailjet:
    filters = {"CampaignId": "xxxxxxx"}
    result = mailjet.statistics_linkClick.get(filters=filters)
    print(result.json())

For the Content API (v1), sub-actions are routed with slashes (for example contents/lock), and data_images maps specifically to /v1/data/images to support media uploads.

Error handling

Network-level exceptions are wrapped. Standard HTTP errors such as 404 or 400 do not raise — they return the requests.Response so you can inspect status_code and .json():

from mailjet_rest import CriticalApiError, TimeoutError, ApiError

try:
    result = mailjet.contact.get()
    if result.status_code != 200:
        print(f"API Error: {result.status_code} - {result.text}")

except TimeoutError:
    print("The request to the Mailjet API timed out.")
except CriticalApiError as e:
    print(f"Network connection failed: {e}")

Logging and debugging

The SDK plugs into the standard logging library. If your payload carries identifiers such as CustomID, Campaign or TemplateID, they are extracted automatically and injected into the log line as a Trace context, so local errors can be correlated with your Mailjet dashboard analytics:

import logging
from mailjet_rest import Client

logging.getLogger("mailjet_rest.client").setLevel(logging.DEBUG)
logging.basicConfig(format="%(levelname)s - %(message)s")

with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
    mailjet.send.create(
        data={
            "Messages": [
                {
                    "From": {"Email": "pilot@example.com"},
                    "To": [{"Email": "passenger@example.com"}],
                    "CustomID": "Promo_Black_Friday",
                }
            ]
        }
    )

Console output includes the trace: DEBUG - Sending Request: POST ... | Trace: [CustomID=Promo_Black_Friday]

IDE autocompletion

The SDK dispatches URLs dynamically through __getattr__. To avoid accidentally dispatching internal Python methods as API requests, accessing private attributes or removed properties (such as client.auth) raises an explicit AttributeError instead of firing a ghost request.

Because of that dynamic dispatch, type checkers may report endpoints like client.contact.create as Any. Under strict typing you can safely ignore those specific calls.

Typed payloads

Strict payload builders

Import the TypedDict schemas for IDE autocomplete and static checking instead of debugging 400 Bad Request responses caused by a typo:

from mailjet_rest.types import SendV31Payload, SendV31Message

message: SendV31Message = {
    "From": {"Email": "pilot@example.com", "Name": "Mailjet Pilot"},
    "To": [{"Email": "passenger1@example.com", "Name": "Passenger 1"}],
    "Subject": "Your flight plan!",
    "TextPart": "Dear passenger, welcome to Mailjet!",
}

payload: SendV31Payload = {"Messages": [message]}

mailjet.send.create(data=payload)

MessageBuilder and SendPayloadBuilder

For complex messages, the fluent builders handle structure, attachment encoding and validation:

from mailjet_rest import Client
from mailjet_rest.builders import MessageBuilder, SendPayloadBuilder

message = (
    MessageBuilder()
    .set_sender("pilot@example.com", "Mailjet Pilot")
    .add_recipient("passenger@example.com", "John Doe")
    .add_cc("copilot@example.com")
    .set_subject("Your Boarding Pass")
    .set_content(html="<h3>Welcome aboard!</h3>")
    .attach_file("tickets/pass.pdf")     # encoded via a memory-efficient chunked streamer
    .attach_inline("assets/logo.png")
    .build()
)

payload = SendPayloadBuilder().add_message(message).set_sandbox_mode(True).build()

with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
    result = mailjet.send.create(data=payload)
    print(result.status_code)

Send API examples

Send a basic email

data = {
    "Messages": [
        {
            "From": {"Email": "pilot@example.com", "Name": "Mailjet Pilot"},
            "To": [{"Email": "passenger1@example.com", "Name": "Passenger 1"}],
            "Subject": "Your email flight plan!",
            "TextPart": "Dear passenger 1, welcome to Mailjet!",
            "HTMLPart": "<h3>Dear passenger 1, welcome to Mailjet!</h3>",
        }
    ]
}

with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
    result = mailjet.send.create(data=data)

Send with a Mailjet template

Pass Variables as a plain Python dictionary:

data = {
    "Messages": [
        {
            "From": {"Email": "pilot@example.com", "Name": "Mailjet Pilot"},
            "To": [{"Email": "passenger1@example.com", "Name": "Passenger 1"}],
            "TemplateID": 1234567,
            "TemplateLanguage": True,
            "Subject": "Your email flight plan!",
            "Variables": {"name": "John Doe", "custom_data": "Welcome aboard!"},
        }
    ]
}

with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
    result = mailjet.send.create(data=data)

REST request examples

The examples below assume an open session: with Client(auth=(api_key, api_secret)) as mailjet:

POST — create an object

data = {"Email": "passenger@example.com"}
result = mailjet.contact.create(data=data)

POST — endpoints with an action

data = {
    "ContactsLists": [
        {"ListID": list_id_1, "Action": "addnoforce"},
        {"ListID": list_id_2, "Action": "addforce"},
    ]
}
result = mailjet.contact_managecontactslists.create(id=contact_id, data=data)

Sandbox mode for local development

dry_run=True intercepts every state-changing request (POST, PUT, DELETE) and returns a mock 200 OK, so local runs cannot reach real recipients:

with Client(auth=(api_key, api_secret), dry_run=True) as dry_run_client:
    dry_run_client.contact.create(data={"Email": "real_user@example.com"})

GET — all objects

result = mailjet.contact.get()

GET — a single object

result = mailjet.contact.get(id=contact_id)

GET — with filters

filters = {
    "limit": 40,
    "offset": 50,
    "sort": "Email desc",
    "IsExcludedFromCampaigns": "false",
}
result = mailjet.contact.get(filters=filters)

Many list endpoints accept limit, offset and sort:

ParameterTypeMeaningDefault
limitintnumber of objects returned (max 1000)10
offsetintstarting position in the result list0
sortstrproperty plus ASC or DESC; not available for every propertyID asc

Lazy pagination

.stream() returns a generator and manages limit, offset and network pagination for you — no while loop, and memory-safe on large lists:

for contact in mailjet.contact.stream(chunk_size=500):
    print(contact["Email"])

PUT — update an object

A PUT in the Mailjet API behaves like a PATCH: only the properties you send are updated, and non-mandatory properties can be omitted.

data = {
    "Data": [
        {"Name": "first_name", "value": "John"},
        {"Name": "last_name", "value": "Smith"},
    ]
}
result = mailjet.contactdata.update(id=contact_id, data=data)

DELETE — remove an object

A successful DELETE returns 204 No Content with no response body.

result = mailjet.template.delete(id=template_id)

Email API ecosystem

Webhooks

Subscribe to real-time events (open, click, bounce, …) through the eventcallbackurl resource:

data = {
    "EventType": "open",
    "Url": "https://www.example.com/webhook",
    "Status": "alive",
}
result = mailjet.eventcallbackurl.create(data=data)

Parse API

Route inbound email for a domain to your own webhook:

data = {"Url": "https://www.example.com/mj_parse.php"}
result = mailjet.parseroute.create(data=data)

Segmentation

Build expressions that filter contacts dynamically:

data = {
    "Description": "Will send only to contacts under 35 years of age.",
    "Expression": "(age<35)",
    "Name": "Customers under 35",
}
result = mailjet.contactfilter.create(data=data)

Statistics

filters = {
    "CounterSource": "APIKey",
    "CounterTiming": "Message",
    "CounterResolution": "Lifetime",
}
result = mailjet.statcounters.get(filters=filters)

Content API

The Content API (v1) manages templates, tokens and images. Set version="v1" and authenticate with Basic Auth or a bearer token. The SDK adds the required /REST/ prefix for most resources automatically and maps data_images to /data/.

Generate a token

with Client(auth=(api_key, api_secret), version="v1") as client:
    data = {
        "Name": "My Access Token",
        "Permissions": ["read_template", "create_template"],
    }
    result = client.token.create(data=data)

Upload an image

Use the data_images resource and remove the default Content-Type header so requests can generate the multipart boundaries:

import base64

b64_string = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
image_bytes = base64.b64decode(b64_string)

files_payload = {
    "metadata": (None, '{"name": "logo.png", "Status": "open"}', "application/json"),
    "file": ("logo.png", image_bytes, "image/png"),
}

result = client.data_images.create(headers={"Content-Type": None}, files=files_payload)

Lock template content

Sub-actions are routed with slashes — template_contents_lock becomes template/{id}/contents/lock:

result = client.template_contents_lock.create(id=template_id)

Security guardrails

The SDK ships active protections based on defence-in-depth principles:

  • SSRF and open redirects — automatic redirects are disabled and hostname validation is enforced.
  • CRLF injection — header injection through compromised bearer tokens or custom headers is blocked.
  • Downgrade attacks — TLS 1.2 minimum is enforced via a custom SecureHTTPAdapter.
  • Idempotency fingerprinting — SHA-256 Idempotency-Key headers are generated for POST, PUT and DELETE to prevent duplicate mutations.
  • SpamGuard analysis — a pre-flight HTML analyser blocks XSS vectors (<script>, onerror=) before dispatch.
  • Secret obfuscation — a custom SecretAuth transport adapter and redacting filter keep credentials out of tracebacks, logs and memory dumps.
  • Punycode IDN support — internationalised domain names are normalised to prevent homograph attacks.

The vulnerability disclosure policy and supported versions are in SECURITY.md.

Local-first validation

Rather than waiting for a server round trip, strictly typed models (such as SendV31Payload) catch mass assignment and invalid formats locally in microseconds.

Runtime security (PEP 578)

For SecOps environments the SDK acts as a sensor, emitting native sys.audit events for outbound network egress and explicit TLS bypass attempts. Opt in to pipe them into your logging infrastructure:

from mailjet_rest import Client, Config

cfg = Config(enable_security_audit=True)

with Client(auth=(api_key, api_secret), config=cfg) as mailjet:
    ...

Network resilience and retries

A JitterRetry connection-pooling policy handles transient drops, 5xx errors and 429 rate limits using exponential backoff with randomised full jitter, which avoids a thundering herd on Mailjet's upstream during high-throughput batching.

Retries are configured out of the box, but you can mount your own adapter:

import random
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter
from mailjet_rest import Client


class CustomJitterRetry(Retry):
    """Stateless retry policy with full jitter."""

    def get_backoff_time(self) -> float:
        backoff = super().get_backoff_time()
        return random.uniform(0, backoff) if backoff > 0 else 0


custom_retry_strategy = CustomJitterRetry(
    total=5,
    backoff_factor=0.5,
    status_forcelist=[429, 500, 502, 503, 504],
    raise_on_status=False,  # let the client raise Mailjet exceptions instead
)

with Client(auth=(api_key, api_secret)) as mailjet:
    mailjet.session.mount("https://", HTTPAdapter(max_retries=custom_retry_strategy))
    result = mailjet.contact.get()

Performance

Since v1.6.0 the SDK is optimised for high concurrency and memory-constrained environments such as AWS Lambda: __slots__ for memory density, immutable MappingProxyType headers for zero-allocation merging, and O(1) dynamic endpoint caching. Benchmarks and profiling instructions are in the performance guide.

Deprecation warnings

Legacy arguments (ensure_ascii, data_encoding), obsolete helpers (parse_response) and ambiguous routing (v1 with /template) do not break your code. The request still executes, but a non-breaking DeprecationWarning is emitted so you can migrate gradually.

Executable README

The repository ships a script that creates, exercises and cleans up all the resources used in the examples — a quick way to verify credentials and network access:

python samples/smoke_readme_runner.py

Further samples live in /samples.

Contribute

The wrapper is open source and MIT-licensed. Fork the repository, branch, implement your fix or feature, document it, and open a pull request at mailjet/mailjet-apiv3-python. Documentation improvements belong in the API documentation repo.