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.
pip install posthawkInstall the SDK from PyPI. The only runtime dependency is httpx for HTTP communication.
pip install posthawkPosthawk(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
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)client.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_emailstrrequiredSender email address (maps to "from" in the API)
tostr | list[str]requiredRecipient email address(es)
ccstr | list[str]optionalCC recipient(s)
bccstr | list[str]optionalBCC recipient(s)
subjectstrrequiredEmail subject line
htmlstroptionalHTML email body
textstroptionalPlain text email body
template_idstroptionalPosthawk template ID
variablesdict[str, str]optionalTemplate variable substitution
headersdict[str, str]optionalCustom email headers (e.g. List-Unsubscribe)
scheduled_forstr | datetimeoptionalSchedule for later (ISO 8601 string or datetime object)
timezonestroptionalIANA timezone for scheduled time
metadatadict[str, Any]optionalCustom metadata attached to the email
tagsdict[str, Any]optionalCustom tags for filtering and search
reply_tostroptionalReply-to email address
idempotency_keystroptionalSent as the Idempotency-Key header for safe retries (response cached 24h). Required for the SDK to auto-retry a failed POST.
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),
)client.emails.get(job_id)Get the delivery status of a previously queued email by its job ID.
Returns
PosthawkResponse
Parameters
job_idstrrequiredJob ID returned from the send method
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=...)client.scheduled.list(**params)List scheduled emails with optional filtering by status and pagination.
Returns
PosthawkResponse
Parameters
statusstroptionalFilter by status: scheduled, sent, cancelled, failed
limitintoptionalPagination limit
offsetintoptionalPagination offset
result = client.scheduled.list(
status="scheduled",
limit=10,
)
# result.data.data -> list[ScheduledEmail]
# result.data.total -> intclient.scheduled.get(id)Get the details of a specific scheduled email by its ID.
Returns
PosthawkResponse
Parameters
idstrrequiredScheduled email UUID
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 | cancelledclient.scheduled.cancel(id)Cancel a scheduled email before it sends. Only works for emails that have not yet been processed.
Returns
PosthawkResponse
Parameters
idstrrequiredScheduled email UUID
result = client.scheduled.cancel("scheduled-uuid")
# result.data.message -> "Scheduled email cancelled successfully"client.scheduled.reschedule(id, scheduled_for=...)Change the send time of a scheduled email. Accepts a datetime object or ISO 8601 string.
Returns
PosthawkResponse
Parameters
idstrrequiredScheduled email UUID
scheduled_forstr | datetimerequiredNew send time (ISO 8601 or datetime object)
client.scheduled.reschedule(
"scheduled-uuid",
scheduled_for="2026-04-01T10:00:00Z",
)client.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
idstrrequiredScheduled email UUID
result = client.scheduled.send_now("scheduled-uuid")
# result.data.message -> "Email rescheduled to send immediately"Django / FastAPI / FlaskThe SDK works with any Python web framework. Here are examples for the most popular ones.
# ━━━ 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)result.error / result.dataSDK 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 = 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)client.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
# 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")with Posthawk(...) as client:Use a context manager to automatically close the underlying httpx connection pool when done. This ensures clean resource cleanup.
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 hereclient.contacts.*Manage contacts: create/upsert, list with filters, update, delete, bulk import.
# 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"]},
])client.webhooks.*Manage webhook endpoints for receiving real-time email + newsletter + broadcast events.
# 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")client.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.
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)client.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
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 | Noneclient.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
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.failedclient.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
# 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")client.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
# 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