Transactional SDK · Python
apex_transactional is the reference Python client for the Transactional Email API. It targets Python 3.10+ and is stdlib-only — no requests, httpx, or pydantic dependency.
It handles:
- Auto-attached
Idempotency-Keyso transparent retries can't double-send. - Exponential backoff with jitter for transient failures (network, 5xx, 429).
- Typed exceptions that mirror the public error contract.
- A sandbox mode flag plus a
sandbox_recipient()helper.
Info
This client is shipped as a reference implementation in the Apex monorepo. It is not currently published on PyPI — the API is small enough that most teams either install it directly from git or vendor the file. If you'd prefer a published Pip package, reach out and we'll graduate it.
Install
Pin to a tag in your requirements.txt or pyproject.toml:
apex-transactional @ git+https://github.com/apex-inc/apex.git@main#subdirectory=packages/apex-transactional-py
Or with pip directly:
pip install "apex-transactional @ git+https://github.com/apex-inc/apex.git@main#subdirectory=packages/apex-transactional-py"
Or just vendor apex_transactional/ into your project — it's ~250 lines of stdlib Python with no hidden runtime magic.
Quickstart
import os
from apex_transactional import ApexTransactionalClient
apex = ApexTransactionalClient(
base_url="https://app.apex.inc",
api_key=os.environ["APEX_TX_KEY"],
)
result = apex.send({
"to": "alex@customer.com",
"subject": "You've been invited",
"html": "<p>Welcome aboard!</p>",
"text": "Welcome aboard!",
"tags": {"product_area": "invite"},
})
print(result.id, result.message_id)
Send shapes
send() accepts two payload shapes — pick whichever fits the use case. The server enforces mutual exclusion (mixing commId with subject/html/text returns 400 validation_error).
Inline shape — when you ship the body in code
Use this for one-off sends where the content lives next to the call site (admin-tools alerts, internal notifications, prototype work).
apex.send({
"to": "alex@customer.com",
"subject": "Receipt #1812",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"tags": {"product_area": "billing"},
})
Template shape — when content lives in the dashboard (recommended)
Reference a workspace TenantCommunication (must have commType == "transactional") by id. The server renders the saved template, applies your variables map as both slot overrides (when keys match slot ids like headline, ctaUrl, subject) and {{key}} mustache substitutions in subject + html + text, and returns the same response shape as inline sends.
The catalog templates ship with {{variable}} defaults — see the API reference for the exact variable names each one expects.
apex.send(
{
"to": user.email,
"commId": "invite",
"variables": {
"inviterName": actor.name,
"orgName": org.name,
"role": "member",
# Always pass URLs as variables. Email clients can't resolve
# relative paths, and our catalog templates use mustache
# placeholders for that reason.
"acceptUrl": f"{os.environ['APP_URL']}/invite/accept?token={invitation.token}",
},
},
idempotency_key=f"invite-{invitation.token}",
)
The template shape gives you:
- Editable copy — operators tweak subject + body in the composer; the next send picks it up immediately. No deploy.
- Brand consistency — workspace brand panel (logo, colors, support email, address) auto-applies.
- Unsubscribe-suppression — transactional comms don't render an Unsubscribe link, regardless of which slots the operator edits.
Configuration
| Parameter | Type | Description |
|---|---|---|
base_urlrequired | str | Apex API origin (no trailing /api/v1/...) |
api_keyrequired | str | Workspace transactional secret. Must start with apex_tx_ |
sandbox | bool | Default sandbox flag for every call (overridable per call) |
timeout | float | Per-request timeout in seconds. Default 30.0 |
max_retries | int | Total attempts on transient failures. Default 3 |
retry_base_delay_ms | int | Base delay for exponential backoff. Default 250 |
retry_max_delay_ms | int | Cap for individual retries. Default 5000 |
transport | callable | Inject custom HTTP transport (tests, alt backends). Signature: (url, method, headers, body, timeout) -> (status, headers, raw_body) |
Idempotency
The client always sends an Idempotency-Key. If you don't supply one, it generates a per-call key via uuid4() and reuses the same key across retry attempts, so the server replays the cached response on the retry instead of double-billing.
apex.send(
{"to": "alex@customer.com", "subject": "Receipt #1812", "text": "..."},
idempotency_key="receipt-1812",
)
Reusing the same key with a different payload raises ApexIdempotencyMismatchError (the API responds 409 idempotency_key_mismatch).
Sandbox
from apex_transactional import ApexTransactionalClient, sandbox_recipient
sandbox = ApexTransactionalClient(
base_url="https://app.apex.inc",
api_key=os.environ["APEX_TX_KEY"],
sandbox=True,
)
sandbox.send({
"to": sandbox_recipient("success"),
"subject": "Smoke test",
"text": "Resolves to status=sent, mode=sandbox",
})
try:
sandbox.send({
"to": sandbox_recipient("bounce"),
"subject": "Bounce simulation",
"text": "ignored",
})
except ApexSendFailedError as e:
assert e.code == "bounce"
Error handling
from apex_transactional import (
ApexAuthError,
ApexIdempotencyMismatchError,
ApexRateLimitError,
ApexSendFailedError,
ApexTimeoutError,
ApexValidationError,
)
try:
apex.send(payload)
except ApexAuthError:
rotate_keys()
except ApexRateLimitError:
enqueue_for_later()
except ApexIdempotencyMismatchError:
regenerate_key_and_retry()
except ApexSendFailedError as e:
log_outcome(e.code) # bounce | suppressed | send_failed
except ApexValidationError:
fix_payload()
except ApexTimeoutError:
timeout_path()
Every exception exposes status, code, request_id, and the raw decoded body.
Retry policy
| Failure | Retried? |
|---|---|
| Network error / TCP / DNS | Yes |
| Timeout | No (single attempt — budget == timeout) |
429 rate-limited | Yes |
5xx server error | Yes |
409 idempotency_in_flight | Yes |
400 / 401 / 409 mismatch / 422 | No |