pex

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-Key so 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"},
})

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

ParameterTypeDescription
base_urlrequiredstrApex API origin (no trailing /api/v1/...)
api_keyrequiredstrWorkspace transactional secret. Must start with apex_tx_
sandboxboolDefault sandbox flag for every call (overridable per call)
timeoutfloatPer-request timeout in seconds. Default 30.0
max_retriesintTotal attempts on transient failures. Default 3
retry_base_delay_msintBase delay for exponential backoff. Default 250
retry_max_delay_msintCap for individual retries. Default 5000
transportcallableInject 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

FailureRetried?
Network error / TCP / DNSYes
TimeoutNo (single attempt — budget == timeout)
429 rate-limitedYes
5xx server errorYes
409 idempotency_in_flightYes
400 / 401 / 409 mismatch / 422No

See also