Skip to main content

Python SDK

The Python SDK is easiest to use with one mental model: create a temporary inbox, wait for the next email, then read the extracted field you need.

Python 3.9+ is required.

Install

pip install postmx

Super Simple Path

Async

import asyncio

from postmx import PostMX

async def main() -> None:
async with PostMX("pmx_live_...") as postmx:
inbox = await postmx.create_temporary_inbox({
"label": "signup-test",
})

print("Send your app email to:", inbox["email_address"])

message = await postmx.wait_for_message(
inbox["id"],
timeout=30.0,
)

print("OTP:", message["otp"])
print("First link:", message["links"][0]["url"] if message["links"] else None)

asyncio.run(main())

Sync

from postmx import PostMXSync

postmx = PostMXSync("pmx_live_...")
inbox = postmx.create_temporary_inbox({
"label": "signup-test",
})

message = postmx.wait_for_message(inbox["id"])
print(message["otp"])

wait_for_message() returns the latest existing message immediately if the inbox already has one; otherwise it waits for the next incoming email until the timeout.

What Do You Want Back?

The default message already includes extracted fields like otp, links, and intent.

If you already have a message ID, content_mode is just a simple "what do you want back?" choice:

otp_only = await client.get_message("msg_123", content_mode="otp")
print(otp_only["otp"])

links_only = await client.get_message("msg_123", content_mode="links")
print(links_only["links"])

Supported modes:

  • full: full message detail
  • otp: message metadata plus otp
  • links: message metadata plus links
  • text_only: message metadata plus text_body

Create the client

Async client

from postmx import PostMX

client = PostMX("pmx_live_...")

Sync client

from postmx import PostMXSync

client = PostMXSync("pmx_live_...")

Constructor options

OptionTypeDefaultNotes
base_urlstrhttps://api.postmx.coOverride for staged or local environments.
max_retriesint2Retries apply to 429, 500, 502, 503, and 504.
timeoutfloat30.0Request timeout in seconds.

Advanced

Lifecycle controls

inbox = await client.create_inbox({
"label": "checkout-flow",
"lifecycle_mode": "temporary",
"ttl_minutes": 15,
})

ttl_minutes is optional for temporary inboxes. Current API limits are 10 to 60.

List inboxes

result = await client.list_inboxes(limit=20)

print(result["inboxes"])
print(result["page_info"])
print(result["wildcard_address"])

List messages for an inbox

result = await client.list_messages(inbox["id"], limit=10)
print(result["messages"])

List messages for a recipient email

result = await client.list_messages_by_recipient(
"[email protected]",
limit=10,
)
print(result["messages"])

Get a message

message = await client.get_message("msg_123")

print(message["subject"])
print(message["text_body"])
print(message["html_body"])
print(message["otp"])
print(message["links"])

Wait for a message

message = await client.wait_for_message(
inbox["id"],
interval=1.0,
timeout=60.0,
)

Notes:

  • interval defaults to 1.0 seconds.
  • timeout defaults to 60.0 seconds.
  • interval must be at least 0.2 seconds.
  • wait_for_message() returns the latest existing message immediately if the inbox already has one; otherwise it waits for the next incoming email.

Create a webhook

result = await client.create_webhook({
"label": "production-events",
"target_url": "https://example.com/webhooks/postmx",
})

print(result["webhook"]["id"])
print(result["signing_secret"])

Use a final public HTTPS endpoint. The API rejects localhost targets, embedded credentials, and private or reserved IP literals. PostMX does not follow redirects, and each delivery attempt times out after 10 seconds.

Verify a webhook signature

from postmx import verify_webhook_signature

event = verify_webhook_signature(
payload=raw_body,
signature=request.headers.get("x-postmx-signature") or request.headers["postmx-signature"],
timestamp=request.headers["x-postmx-timestamp"],
signing_secret=signing_secret,
)

print(event["type"])
print(event["data"]["message"]["otp"])

Important:

  • Store the returned signing_secret immediately. It is returned once.
  • Pass the raw body as bytes or str.
  • Read X-PostMX-Delivery-Id for attempt tracing and X-PostMX-Event-Id for diagnostics.
  • The expected signature format is v1=<base64url(hmac_sha256(signing_secret, timestamp + "." + raw_body))>.
  • The default timestamp tolerance is 300 seconds.
  • The payload already includes the full message plus extracted fields such as otp, links, and intent.

Error handling

from postmx import PostMXApiError, PostMXNetworkError

try:
await client.get_message("msg_missing")
except PostMXApiError as error:
print(error.status)
print(error.code)
print(error.request_id)
print(error.retry_after_seconds)
except PostMXNetworkError as error:
print(error)

Async vs sync

  • Use PostMX in async web apps, async workers, and async tests.
  • Use PostMXSync in scripts, sync apps, and basic automation.
  • PostMXSync cannot be used inside an already running event loop. In async environments, use PostMX directly.
  • POST requests are safe to retry because the SDK auto-generates an idempotency_key when you do not pass one.

Method reference

PostMX(api_key, *, base_url="https://api.postmx.co", max_retries=2, timeout=30.0)
await client.list_inboxes(*, limit=None, cursor=None)
await client.create_inbox(params, *, idempotency_key=None)
await client.create_temporary_inbox(params, *, idempotency_key=None)
await client.list_messages(inbox_id, *, limit=None, cursor=None)
await client.list_messages_by_recipient(recipient_email, *, limit=None, cursor=None)
await client.get_message(message_id, *, content_mode=None)
await client.create_webhook(params, *, idempotency_key=None)
await client.wait_for_message(inbox_id, *, interval=1.0, timeout=60.0)