Python integration: send transactional email

Updated

An official SDK is available: inboxili on PyPI. The sections below also show a plain requests client, if you prefer your own.

The API is one endpoint, so a Python integration is a small module with a timeout, exceptions that carry the API's error code, and careful retry behaviour.

Use the official package

pip install inboxili
import os
from inboxili import Inboxili

client = Inboxili(os.environ["INBOXILI_API_KEY"])
result = client.emails.send(
    to="ada@example.com",
    from_email="hello@yourdomain.com",
    subject="Welcome, {{first_name}}",
    html_body="<p>Hi {{first_name}}, your account is ready.</p>",
    template_data={"first_name": "Ada"},
)
print(result.status, result.message_id)

The package has no dependencies, supports Python 3.9 and later, raises InboxiliError and InboxiliConnectionError, retries HTTP 429 only, and includes verify_webhook_signature. Source: github.com/inboxili/inboxili-python. Package: inboxili on PyPI.

Or write your own client

Install and configure

pip install requests
export INBOXILI_API_KEY="ik_live_..."

The client

Save as inboxili.py:

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Any

import requests


class InboxiliError(Exception):
    def __init__(self, status: int, code: str, message: str):
        super().__init__(message)
        self.status = status
        self.code = code


@dataclass
class SendResult:
    status: str
    message_id: str | None


class Inboxili:
    def __init__(self, api_key: str | None = None, base_url: str = "https://api.inboxili.com/api/v1", timeout: float = 10.0):
        self.api_key = api_key or os.environ["INBOXILI_API_KEY"]
        self.base_url = base_url
        self.timeout = timeout
        self._session = requests.Session()
        self._session.headers["Authorization"] = f"Bearer {self.api_key}"

    def send(
        self,
        *,
        to: str,
        from_email: str,
        from_name: str | None = None,
        subject: str | None = None,
        html_body: str | None = None,
        text_body: str | None = None,
        template_id: str | None = None,
        template_data: dict[str, Any] | None = None,
    ) -> SendResult:
        payload = {
            "to": to,
            "from_email": from_email,
            "from_name": from_name,
            "subject": subject,
            "html_body": html_body,
            "text_body": text_body,
            "template_id": template_id,
            "template_data": template_data or {},
        }
        payload = {k: v for k, v in payload.items() if v is not None}

        resp = self._session.post(f"{self.base_url}/transactional/send", json=payload, timeout=self.timeout)
        try:
            body = resp.json()
        except ValueError:
            body = {}
        if not resp.ok:
            err = body.get("error", {})
            raise InboxiliError(resp.status_code, err.get("code", "http_error"), err.get("message", resp.reason))
        return SendResult(status=body["status"], message_id=body.get("message_id"))

Send an email

from inboxili import Inboxili, InboxiliError

client = Inboxili()

try:
    result = client.send(
        to="ada@example.com",
        from_email="hello@yourdomain.com",
        from_name="Acme",
        subject="Welcome, {{first_name}}",
        html_body="<p>Hi {{first_name}}, your account is ready.</p>",
        template_data={"first_name": "Ada"},
    )
    print("sent", result.message_id)
except InboxiliError as e:
    if e.code == "sender_not_verified":
        print("Verify the sending domain first:", e)
    else:
        raise

Send from a template

client.send(
    to="ada@example.com",
    from_email="hello@yourdomain.com",
    template_id="00000000-0000-0000-0000-000000000000",
    template_data={"first_name": "Ada"},
)

Error handling

| e.status | e.code | Do | |---|---|---| | 401 | unauthorized | Check the key. Do not retry. | | 403 | forbidden | Check scope and IP rules. | | 422 | validation_error, sender_not_verified, send_failed | Fix the request, or inspect the message. | | 429 | rate_limited | Back off and retry. |

requests can also raise requests.Timeout and requests.ConnectionError. Treat those as "outcome unknown". The API has no idempotency key, so a timed-out send may still have gone out.

import random, time

def send_with_backoff(client, attempts=4, **message):
    for i in range(attempts):
        try:
            return client.send(**message)
        except InboxiliError as e:
            if e.status != 429 or i == attempts - 1:
                raise
            time.sleep(2**i + random.random() * 0.25)

Testing

from unittest.mock import patch, MagicMock
import pytest
from inboxili import Inboxili, InboxiliError

def fake_response(status, body):
    r = MagicMock(ok=status < 400, status_code=status, reason="x")
    r.json.return_value = body
    return r

def test_maps_error():
    c = Inboxili(api_key="ik_live_test")
    with patch.object(c._session, "post", return_value=fake_response(422, {"error": {"code": "sender_not_verified", "message": "no"}})):
        with pytest.raises(InboxiliError) as exc:
            c.send(to="a@b.co", from_email="x@y.co", subject="s", html_body="<p>x</p>")
    assert exc.value.code == "sender_not_verified"

Production notes

  • Load the key from the environment or a secret manager.
  • Send from a task queue (Celery, RQ, Dramatiq), not inside a request handler.
  • Use one key per service.
  • Verify webhooks. The X-Inboxili-Signature header is the hex HMAC-SHA256 of the raw body:
import hashlib, hmac

def verify_webhook(raw_body: bytes, signature_hex: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_hex)

Frequently asked questions

Which library does the client need?
Only requests. Install it with pip install requests.
Is there an async version?
The same request works with httpx.AsyncClient. Swap the session and await the call.

Build with Inboxili

Create a workspace, verify a domain, and make your first API call.

Related