Python SDK

Official Python SDK for Posthawk. Built on httpx with typed dataclass responses. Works with Django, FastAPI, Flask, and any Python 3.8+ project.

SDKpip install posthawk

Install the SDK from PyPI. The only runtime dependency is httpx for HTTP communication.

pip install posthawk
pip install posthawk
SDKPosthawk(api_key, *, base_url=None, timeout=30.0, max_retries=2)

Initialize the client and send your first email. Pass your API key as the first (positional) argument. base_url, timeout (seconds, default 30.0), and max_retries (default 2 → 3 total attempts) are keyword-only. POST requests are only auto-retried when an idempotency_key is supplied.

Returns

Posthawk

Posthawk(api_key, *, base_url=None, timeout=30.0, max_retries=2)
from posthawk import Posthawk

# Cloud (default base URL: https://api.posthawk.dev)
client = Posthawk("ck_live_...")

# Self-hosted, with custom timeout and retry budget
client = Posthawk(
    "ck_live_...",
    base_url="https://api.yourdomain.com",
    timeout=30.0,
    max_retries=2,
)

result = client.emails.send(
    from_email="hello@yourdomain.com",
    to="user@example.com",
    subject="Welcome!",
    html="<h1>Hello!</h1>",
)

if result.error:
    print(result.error.message)
else:
    print("Sent!", result.data.job_id)
SDKclient.emails.send(**params)

Send an email immediately, or schedule it for later by including scheduled_for. Returns PosthawkResponse[SendEmailResponse]. At least one of html, text, or template_id is required. Note: use from_email instead of from (which is a Python reserved word).

Returns

PosthawkResponse

Parameters

from_emailstrrequired

Sender email address (maps to "from" in the API)

tostr | list[str]required

Recipient email address(es)

ccstr | list[str]optional

CC recipient(s)

bccstr | list[str]optional

BCC recipient(s)

subjectstrrequired

Email subject line

htmlstroptional

HTML email body

textstroptional

Plain text email body

template_idstroptional

Posthawk template ID

variablesdict[str, str]optional

Template variable substitution

headersdict[str, str]optional

Custom email headers (e.g. List-Unsubscribe)

scheduled_forstr | datetimeoptional

Schedule for later (ISO 8601 string or datetime object)

timezonestroptional

IANA timezone for scheduled time

metadatadict[str, Any]optional

Custom metadata attached to the email

tagsdict[str, Any]optional

Custom tags for filtering and search

reply_tostroptional

Reply-to email address

idempotency_keystroptional

Sent as the Idempotency-Key header for safe retries (response cached 24h). Required for the SDK to auto-retry a failed POST.

client.emails.send(**params)
result = client.emails.send(
    from_email="hello@yourdomain.com",
    to=["user@example.com", "other@example.com"],
    subject="Welcome to Acme",
    html="<h1>Welcome!</h1><p>Thanks for signing up.</p>",
    metadata={"user_id": "usr_123"},
    tags={"campaign": "onboarding"},
    idempotency_key="550e8400-e29b-41d4-a716-446655440000",
)

# Schedule for later
from datetime import datetime, timedelta, timezone

result = client.emails.send(
    from_email="hello@yourdomain.com",
    to="user@example.com",
    subject="Reminder",
    text="Don't forget your meeting!",
    scheduled_for=datetime.now(timezone.utc) + timedelta(hours=24),
)
SDKclient.emails.get(job_id)

Get the delivery status of a previously queued email by its job ID.

Returns

PosthawkResponse

Parameters

job_idstrrequired

Job ID returned from the send method

client.emails.get(job_id)
result = client.emails.get("abc-123-def")

if result.data:
    print(result.data.status)     # pending | processing | completed | failed
    print(result.data.result)     # EmailJobResult(message_id=..., email_log_id=...)
SDKclient.scheduled.list(**params)

List scheduled emails with optional filtering by status and pagination.

Returns

PosthawkResponse

Parameters

statusstroptional

Filter by status: scheduled, sent, cancelled, failed

limitintoptional

Pagination limit

offsetintoptional

Pagination offset

client.scheduled.list(**params)
result = client.scheduled.list(
    status="scheduled",
    limit=10,
)

# result.data.data -> list[ScheduledEmail]
# result.data.total -> int
SDKclient.scheduled.get(id)

Get the details of a specific scheduled email by its ID.

Returns

PosthawkResponse

Parameters

idstrrequired

Scheduled email UUID

client.scheduled.get(id)
result = client.scheduled.get("scheduled-uuid")

if result.data:
    print(result.data.data.scheduled_for)  # ISO 8601 datetime
    print(result.data.data.status)         # scheduled | sent | cancelled
SDKclient.scheduled.cancel(id)

Cancel a scheduled email before it sends. Only works for emails that have not yet been processed.

Returns

PosthawkResponse

Parameters

idstrrequired

Scheduled email UUID

client.scheduled.cancel(id)
result = client.scheduled.cancel("scheduled-uuid")
# result.data.message -> "Scheduled email cancelled successfully"
SDKclient.scheduled.reschedule(id, scheduled_for=...)

Change the send time of a scheduled email. Accepts a datetime object or ISO 8601 string.

Returns

PosthawkResponse

Parameters

idstrrequired

Scheduled email UUID

scheduled_forstr | datetimerequired

New send time (ISO 8601 or datetime object)

client.scheduled.reschedule(id, scheduled_for=...)
client.scheduled.reschedule(
    "scheduled-uuid",
    scheduled_for="2026-04-01T10:00:00Z",
)
SDKclient.scheduled.send_now(id)

Send a scheduled email immediately, before its scheduled time. Internally reschedules to now + 1s so the full audit trail is preserved.

Returns

PosthawkResponse

Parameters

idstrrequired

Scheduled email UUID

client.scheduled.send_now(id)
result = client.scheduled.send_now("scheduled-uuid")
# result.data.message -> "Email rescheduled to send immediately"
SDKclient.newsletters.*

Manage newsletters, subscribers, and issues. Create newsletters, add subscribers, draft issues, and send them to all active subscribers.

Returns

PosthawkResponse

client.newsletters.*
# Create a newsletter
result = client.newsletters.create(
    slug="weekly-digest",
    name="Weekly Digest",
    double_opt_in=True,
)
nl_id = result.data.id

# List all newsletters
result = client.newsletters.list()

# Get a specific newsletter
result = client.newsletters.get("newsletter-id")

# Update settings
client.newsletters.update("newsletter-id", status="paused")
SDKclient.newsletters.add_subscriber()

Add, list, and manage subscribers for a newsletter. Honors the newsletter's double_opt_in setting. Re-subscribing an existing email is idempotent.

Returns

PosthawkResponse

client.newsletters.add_subscriber()
# Add a subscriber
result = client.newsletters.add_subscriber(
    "newsletter-id",
    email="user@example.com",
    name="Alex",
    metadata={"source": "landing-page"},
)

# List subscribers (filterable by status)
result = client.newsletters.list_subscribers(
    "newsletter-id",
    status="active",
    limit=50,
)

# Get a single subscriber
result = client.newsletters.get_subscriber("newsletter-id", "subscriber-id")

# Unsubscribe a subscriber
client.newsletters.unsubscribe("newsletter-id", "subscriber-id")

# Resend confirmation email
client.newsletters.resend_confirmation("newsletter-id", "subscriber-id")

# Permanently delete a subscriber
client.newsletters.delete_subscriber("newsletter-id", "subscriber-id")
SDKclient.newsletters.create_issue()

Draft and send newsletter issues. Issues are saved as drafts until you call send_issue. Schedule with schedule_issue, cancel a scheduled/in-flight send with cancel_issue, preview with send_test_issue, and pull delivery + engagement stats with get_issue_stats.

Returns

PosthawkResponse

client.newsletters.create_issue()
# Create a draft issue (body params are html_body / text_body)
result = client.newsletters.create_issue(
    "newsletter-id",
    subject="Issue #5: New features",
    preheader="Everything shipped this month",
    html_body="<h1>What shipped</h1><p>...</p>",
    text_body="What shipped\n...",
)
issue_id = result.data.id

# Update the draft before sending
client.newsletters.update_issue(
    "newsletter-id",
    issue_id,
    subject="Issue #5: New features this month",
)

# Send a test render to yourself first (all three args positional)
client.newsletters.send_test_issue("newsletter-id", issue_id, "you@yourdomain.com")

# Send to all active subscribers
result = client.newsletters.send_issue("newsletter-id", issue_id)
# result.data.recipient_count -> number of emails dispatched

# Or schedule it for later (scheduled_for is a positional ISO 8601 string)
client.newsletters.schedule_issue("newsletter-id", issue_id, "2026-06-20T14:00:00Z")

# Cancel a scheduled or in-flight send
client.newsletters.cancel_issue("newsletter-id", issue_id)
# result.data.status, result.data.alreadyQueued

# Get the issue (recipient_count, open_count, click_count live on the issue)
result = client.newsletters.get_issue("newsletter-id", issue_id)
# result.data.recipient_count, result.data.open_count, result.data.click_count

# Get detailed per-issue delivery + engagement stats
result = client.newsletters.get_issue_stats("newsletter-id", issue_id)
# result.data.delivered, result.data.opened, result.data.clicked, result.data.bounced, ...

# List all issues
result = client.newsletters.list_issues("newsletter-id", status="sent")
SDKDjango / FastAPI / Flask

The SDK works with any Python web framework. Here are examples for the most popular ones.

Django / FastAPI / Flask
# ━━━ FastAPI ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
from fastapi import FastAPI, HTTPException
from posthawk import Posthawk
import os

app = FastAPI()
client = Posthawk(os.environ["POSTHAWK_API_KEY"])

@app.post("/send")
async def send_email(to: str, subject: str, html: str):
    result = client.emails.send(
        from_email="hello@yourdomain.com",
        to=to, subject=subject, html=html,
    )
    if result.error:
        raise HTTPException(status_code=result.error.status_code, detail=result.error.message)
    return {"job_id": result.data.job_id}

# ━━━ Django ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# views.py
from django.http import JsonResponse
from posthawk import Posthawk
from django.conf import settings

client = Posthawk(settings.POSTHAWK_API_KEY)

def send_email(request):
    result = client.emails.send(
        from_email="hello@yourdomain.com",
        to=request.POST["to"],
        subject=request.POST["subject"],
        html=request.POST["html"],
    )
    if result.error:
        return JsonResponse({"error": result.error.message}, status=500)
    return JsonResponse({"job_id": result.data.job_id})

# ━━━ Flask ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
from flask import Flask, request, jsonify
from posthawk import Posthawk
import os

app = Flask(__name__)
client = Posthawk(os.environ["POSTHAWK_API_KEY"])

@app.post("/send")
def send_email():
    data = request.get_json()
    result = client.emails.send(
        from_email="hello@yourdomain.com",
        to=data["to"], subject=data["subject"], html=data["html"],
    )
    if result.error:
        return jsonify(error=result.error.message), 500
    return jsonify(job_id=result.data.job_id)
SDKresult.error / result.data

SDK methods never raise exceptions for API errors. Every call returns a PosthawkResponse with .data and .error attributes. Check .error first, then access .data safely. The only case where the SDK raises is a missing API key in the constructor.

result.error / result.data
result = client.emails.send(
    from_email="hello@yourdomain.com",
    to="user@example.com",
    subject="Test",
    html="<p>Hello</p>",
)

if result.error:
    print(f"Error {result.error.status_code}: {result.error.message}")
    return

# result.data is guaranteed non-None here
print(result.data.job_id)
SDKclient.domains.*

Manage sending domains programmatically — add, verify, enable inbound receiving, configure webhooks, and set sending limits. Ideal for SaaS platforms that let their users add custom sending domains.

Returns

PosthawkResponse

client.domains.*
# Add a domain
result = client.domains.create(
    domain="mail.customer.com",
    region="us-east-1",
)
# result.data.dns_records → DNS records to configure

# List all domains
result = client.domains.list()

# Trigger verification check
result = client.domains.verify("domain-uuid")
# result.data.sending_enabled → True when verified

# Enable inbound receiving
client.domains.enable_receiving("domain-uuid")

# Set a webhook for inbound emails
client.domains.update_webhook(
    "domain-uuid",
    webhook_url="https://yourapp.com/api/incoming-email",
)

# Test the webhook
client.domains.test_webhook("domain-uuid")

# Set per-domain sending limits
client.domains.update_limits(
    "domain-uuid",
    max_daily_emails=1000,
    max_monthly_emails=25000,
)

# Remove limits (None = unlimited)
client.domains.update_limits(
    "domain-uuid",
    max_daily_emails=None,
    max_monthly_emails=None,
)

# Delete a domain
client.domains.delete("domain-uuid")
SDKwith Posthawk(...) as client:

Use a context manager to automatically close the underlying httpx connection pool when done. This ensures clean resource cleanup.

with Posthawk(...) as client:
from posthawk import Posthawk

with Posthawk("ck_live_...") as client:
    result = client.emails.send(
        from_email="hello@yourdomain.com",
        to="user@example.com",
        subject="Hello",
        html="<h1>Hi!</h1>",
    )
    print(result.data.job_id)

# Connection pool is automatically closed here
SDKclient.contacts.*

Manage contacts: create/upsert, list with filters, update, delete, bulk import.

client.contacts.*
# Create or upsert a contact
result = client.contacts.create(
    email="user@example.com",
    name="Jane Doe",
    tags=["newsletter", "beta"],
    metadata={"source": "signup-page"},
)

# List contacts (paginated, optional tag filter)
contacts = client.contacts.list(tag="newsletter", page=1)

# Get single contact
contact = client.contacts.get("contact-uuid")

# Update
client.contacts.update("contact-uuid", tags=["newsletter", "premium"])

# Unsubscribe
client.contacts.update("contact-uuid", unsubscribed=True)

# Delete
client.contacts.delete("contact-uuid")

# Bulk import (up to 1000 at a time) — pass the list positionally
client.contacts.import_contacts([
    {"email": "a@x.com", "name": "A", "tags": ["import"]},
    {"email": "b@x.com", "name": "B", "tags": ["import"]},
])
SDKclient.webhooks.*

Manage webhook endpoints for receiving real-time email + newsletter + broadcast events.

client.webhooks.*
# Create a webhook endpoint
result = client.webhooks.create(
    url="https://yourapp.com/api/webhooks/posthawk",
    events=["delivery", "bounce", "complaint", "open", "click"],
    description="Production webhook",
)
secret = result.data.secret  # Store this — used for signature verification

# List
endpoints = client.webhooks.list()

# Update (add new event types)
client.webhooks.update(
    "webhook-uuid",
    events=["delivery", "bounce", "complaint", "open", "click",
            "newsletter.subscriber.created", "newsletter.issue.sent"],
)

# Test (sends a synthetic event to your URL)
client.webhooks.test("webhook-uuid")

# Delete
client.webhooks.delete("webhook-uuid")
SDKclient.templates.render(...)

Render a template server-side with variable substitution. Useful for previewing before sending or generating HTML for non-email use cases. Both params are keyword-only.

client.templates.render(...)
result = client.templates.render(
    template_id="welcome-template",
    variables={"firstName": "John", "company": "Acme"},
)

print(result.data.html)
print(result.data.text)
print(result.data.subject)
SDKclient.validation.validate(email)

Validate a single email address before sending. Pass the address as a positional string. Returns a deliverability decision, a confidence score, and a per-signal checks breakdown (checks may be None on a cached result).

Returns

PosthawkResponse

client.validation.validate(email)
result = client.validation.validate("user@example.com")

if result.data:
    print(result.data.decision)    # deliverable | risky | undeliverable | unknown
    print(result.data.confidence)  # HIGH | MEDIUM | LOW | UNKNOWN
    print(result.data.checks)      # ValidationChecks | None
SDKclient.emails.batch(messages, *, idempotency_key=None)

Send up to 100 emails in one call. Pass a list of message dicts (same snake_case keys as send, minus scheduling). messages is positional; idempotency_key is keyword-only and sets a batch-wide Idempotency-Key header.

Returns

PosthawkResponse

client.emails.batch(messages, *, idempotency_key=None)
result = client.emails.batch(
    [
        {"from_email": "hello@yourdomain.com", "to": "a@example.com",
         "subject": "Hi A", "html": "<p>A</p>"},
        {"from_email": "hello@yourdomain.com", "to": "b@example.com",
         "subject": "Hi B", "template_id": "welcome", "variables": {"name": "B"}},
    ],
    idempotency_key="550e8400-e29b-41d4-a716-446655440000",
)

# result.data.results -> [{ index, success, jobId?, error? }]
# result.data.total, result.data.queued, result.data.failed
SDKclient.suppressions.*

Manage the suppression list. Add an entry with create (reason "manual"), list all entries, and remove with delete. Removing an entry also wipes the soft-bounce counter.

Returns

PosthawkResponse

client.suppressions.*
# Add a manual suppression (keyword-only args)
result = client.suppressions.create(
    email="user@example.com",
    notes="Requested removal",
)

# List all suppressions (result.data.entries -> list[Suppression])
result = client.suppressions.list()

# Remove a suppression by id (positional)
client.suppressions.delete("suppression-uuid")
SDKclient.broadcasts.*

Create, list, get, send, and cancel one-off broadcasts. create() takes keyword-only args using snake_case (from_email, html_body, recipient_type). list/get/send/cancel take the broadcast id positionally.

Returns

PosthawkResponse

client.broadcasts.*
# Create a draft broadcast
result = client.broadcasts.create(
    name="March Update",
    from_email="hello@yourdomain.com",
    subject="What shipped in March",
    html_body="<h1>This month at Acme</h1>...",
    recipient_type="tag",
    recipient_tag="newsletter",
)
broadcast_id = result.data.id

# List broadcasts (optional status + page)
result = client.broadcasts.list(status="draft")

# Get one
result = client.broadcasts.get(broadcast_id)

# Send
result = client.broadcasts.send(broadcast_id)
# result.data.queued, result.data.message

# Cancel
result = client.broadcasts.cancel(broadcast_id)
# result.data.status, result.data.already_queued