Getting started
Python SDK
posthaste is the official client for Python. It has no third-party dependencies — urllib and hmac, nothing else — so it installs instantly, adds nothing to your lockfile and brings no transitive supply chain to audit. Requires Python 3.9 or newer, and ships a py.typed marker so mypy and pyright read every annotation.
Install
pip install posthaste-email
# uv add posthaste-email
# poetry add posthaste-email
# Installs as posthaste-email, imports as posthaste. The short name on
# PyPI belongs to an unrelated 2013 OpenStack tool.Send an email
import os
from posthaste import Posthaste
posthaste = Posthaste(api_key=os.environ["POSTHASTE_API_KEY"])
sent = posthaste.emails.send(
from_="Acme <[email protected]>",
to="[email protected]",
subject="Your receipt",
html="<p>Thanks for your order.</p>",
text="Thanks for your order.",
idempotency_key=f"receipt-{order_id}",
)
sent["id"] # 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
sent["status"] # 'queued' | 'duplicate'
sent["duplicate"] # False — a new send, not an idempotency replayfrom_ has the trailing underscore because from is a Python keyword. It is the only field named differently for that reason, and the SDK maps it back to from on the wire. Everything else is snake_case and becomes the API’s camelCase automatically — reply_to, list_unsubscribe, idempotency_key, scheduled_at.
status tells you which kind of success this was. A new send answers 202 with status: "queued"; a replay of the same idempotency_key answers 200 with status: "duplicate" and the id of the message we accepted the first time. Both are success, and the SDK surfaces the difference as sent["duplicate"] rather than hiding it — if you count, bill or log per send, that distinction is the difference between an accurate number and a drifting one. It is read from the body rather than from the HTTP status, so a proxy that normalises a 202 to a 200 cannot change the answer.
idempotency_key is a field in the JSON body, and the SDK maps it there. The Idempotency-Key HTTP header is never read by any endpoint — see why.
Snake in, camel out
Requests are Python: snake_case keyword arguments. Responses come back as the API’s own JSON, unchanged — camelCase keys and all.
That asymmetry is deliberate. Rewriting a response means deciding what to do with a field this SDK version has never heard of, and every answer is bad: drop it and you lose data the API sent you; keep it under its original name and the object is half-translated. The response is the API’s document, and a document is not ours to rewrite. What the SDK adds instead is a TypedDict for every shape, so your editor and type checker know the keys without anything being reshaped at runtime.
Configuring the client
posthaste = Posthaste(
api_key=os.environ["POSTHASTE_API_KEY"], # required
base_url="https://api.posthastemail.dev", # default
timeout=30.0, # per ATTEMPT, not per call
max_retries=2, # retries after the first attempt
max_retry_delay=60.0, # longest Retry-After it will sit through
headers={"x-trace-id": trace_id}, # added to every request
)| Option | Notes |
|---|---|
| api_key | Required. A ph_live_ key, sent as a bearer token. Server-side only — a key carries no user identity and must never reach a browser. Getting a key → |
| base_url | Defaults to https://api.posthastemail.dev. Point it at your own deployment when self-hosting; trailing slashes are stripped. |
| timeout | Seconds, per attempt — a call that retries twice may take longer than this. 30 by default; None disables it. |
| max_retries | Retries after the first attempt. Two by default; 0 turns retrying off entirely. |
| transport | Anything satisfying posthaste.http.Transport. It is a Protocol, so nothing has to inherit from it. |
The key is never printed. It is not in repr(client), not in any exception message, and not in a formatted traceback — including when a server echoes it back inside a 401. That matters more than it sounds: a credential escapes through a repr pasted into a ticket and through a traceback an error reporter uploaded far more often than through a deliberate log line.
import httpx
from posthaste import Posthaste
from posthaste.http import HttpResponse
class HttpxTransport:
def __init__(self, **kwargs):
# follow_redirects stays OFF. A redirect would re-send the bearer
# token to whatever host the Location names.
self._client = httpx.Client(follow_redirects=False, **kwargs)
def request(self, method, url, headers, body, timeout):
response = self._client.request(
method, url, headers=headers, content=body, timeout=timeout
)
return HttpResponse(response.status_code, response.headers, response.content)
posthaste = Posthaste(
api_key=key,
transport=HttpxTransport(proxy="http://proxy.internal:8080"),
)Errors
Every failure raises a PosthasteError, including the ones that never reached a server. Under that base there is a real exception hierarchy, because that is what a Python caller reaches for: except RateLimited reads better than an if on a string, and it is harder to get wrong. Each exception still carries status, type, message, fields when the server sent them, retry_after_seconds when the server said, and body for the extra keys a refusal carries.
from posthaste import (
ContentBlockedError,
DomainNotVerifiedError,
PosthasteError,
QuotaExhausted,
RateLimited,
SuppressedError,
)
try:
posthaste.emails.send(from_=sender, to=recipient, text=body)
except SuppressedError as error:
# Permanent. error.suppression.reason is 'hard_bounce' | 'complaint'
# | 'spam_trap' | 'manual' | 'unsubscribe'.
drop(error.suppression)
except DomainNotVerifiedError:
ask_them_to_publish_the_dkim_record()
except ContentBlockedError as error:
# error.check names the check; error.findings is the whole lint report,
# warnings included, so one fix pass can address everything.
show(error.findings)
except RateLimited as error:
# Transient, measured in seconds. platform_paused is OUR daily ceiling,
# not this account's — it frees on its own and no plan change touches it.
retry_after(error.retry_after_seconds or 60)
except QuotaExhausted as error:
# Exhausted allowance — hours or days away. Not a retry, an alert.
alert_ops(error)
except PosthasteError as error:
log.warning("posthaste refused: %s %s", error.status, error.type)RateLimited and QuotaExhausted are siblings, deliberately — never parent and child. Four different refusals arrive as 429. rate_limited and platform_paused are transient and measured in seconds — the first is your request rate, the second is the platform’s own daily send ceiling, which is ours rather than yours. The other two, daily_limit_reached and monthly_limit_reached, are exhausted quota with a Retry-After measured in hours or days. If either class caught the other, an except written for a hiccup would swallow the refusal that needs a person — and telling a customer to upgrade for a ceiling that is not theirs would be wrong twice over. The error reference lists every type.
A refusal reason added to the API after your SDK version still arrives as a typed exception: the class is chosen from the HTTP status when the type is unrecognised, never from a KeyError. Two types are synthesised by the SDK and never sent by the API — APIConnectionError (the request never opened) and APITimeoutError (it opened and never answered). Both carry status == 0. An unhandled 500, which does not use the error envelope, becomes a ServerError with the server’s message intact rather than a crash inside the SDK.
Retries
The SDK retries 408, 429 and 5xx, and connection failures, with exponential backoff and full jitter, honouring Retry-After. Two rules make that safe rather than merely automatic.
- Quota is never retried in process.
daily_limit_reachedandmonthly_limit_reachedare raised immediately with the wait attached, so you can queue, delay or alert instead of hammering a wall the calendar has to move before it opens. - Nothing is repeated that repeating could duplicate. A send is retried only when you supplied an
idempotency_key, andwebhooks.createis never retried — a duplicate endpoint would receive every event twice, for ever. Reads, deletes,domains.create(a duplicate is a409) andsuppressions.create(an upsert) are all safe, and are retried.
A Retry-After longer than max_retry_delay — 60 seconds by default — is honoured by not retrying. A quota that frees at midnight is not something to block a request handler on, and sleeping through it would look like a hang, so the exception comes back to you with the wait attached and you decide.
Pagination
Every list on the API pages, and every one of them takes parameters. Each resource carries three methods rather than one: list for a single page, auto_paginate for a generator over every page, and list_all to drain that generator into a list up to a max_items ceiling you pass — 1,000 by default.
# One page.
page = posthaste.messages.list(limit=50, status="bounced")
page["data"] # list[MessageSummary]
page["hasMore"]
page["nextCursor"] # pass as `before` on the next request
# Every page, as an ordinary for loop. Breaking out stops fetching.
for message in posthaste.messages.auto_paginate(status="bounced"):
suppress(message["to"])
# Or into a list, with a ceiling you choose. 1,000 by default.
recent = posthaste.messages.list_all(max_items=500, status="bounced")
# EVERY list has the same three, including the ones that send no hasMore at
# all — domains, webhooks, API keys and invoices. This is all sixty invoices,
# not the first fifty.
invoices = posthaste.billing.list_all_invoices()
# Billing history is the odd one out: it pages FORWARD from a sequence number
# in `after`, oldest first. Hence the different names — the helper knows.
for event in posthaste.billing.auto_paginate_history():
record(event["seq"], event["type"])auto_paginate is a real generator, so an ordinary for loop holds one page in memory at a time and break stops fetching. The ceiling on list_all is not garnish: draining an unbounded list into memory is how a convenience helper becomes an incident on the one account with four million messages, so the caller chooses the bound.
Neither stopping rule is correct on its own, which is why the helper exists. while next_cursor: never terminates on the endpoints that return a non-null cursor on their last page: the loop asks for the page after the last one, gets an empty page carrying the same cursor, and spins for ever without erroring. And if not page.get("hasMore"): break stops after ONE page on /v1/domains, /v1/webhooks, /v1/api-keys, /v1/billing/invoices and /v1/billing/history, because those send no hasMore at all — the read is None, which is falsy, which looks exactly like “that was everything”. auto_paginate consults both signals and treats each as authoritative only where the server actually sends it. The full explanation →
Verifying webhooks
verify_webhook parses the signature header by key rather than positionally (v1= exists so a v2= can be added beside it), compares in constant time, and rejects a timestamp more than 300 seconds old or more than 300 seconds in the future — a future timestamp is not clock skew to be generous about, it is an attacker buying an unlimited replay window.
import json, os
from flask import Flask, request
from posthaste import DELIVERY_ID_HEADER, SIGNATURE_HEADER, verify_webhook
app = Flask(__name__)
@app.post("/hooks/posthaste")
def posthaste_webhook():
# THE RAW BYTES. request.get_json() would hand you an object, and the
# signature covers the bytes we sent — not the object they decode to.
result = verify_webhook(
request.get_data(),
request.headers.get(SIGNATURE_HEADER),
os.environ["POSTHASTE_WEBHOOK_SECRET"],
)
if not result:
# 'malformed_header' | 'unsupported_version' | 'timestamp_too_old'
# | 'timestamp_in_future' | 'signature_mismatch'
app.logger.warning("rejected webhook: %s", result.reason)
return "", 400
event = json.loads(request.get_data())
# Deduplicate on the delivery id — a retry is not a second event.
enqueue(request.headers.get(DELIVERY_ID_HEADER), event)
# Acknowledge fast; do the work elsewhere. We time out after 10 seconds.
return "", 204Verify over the raw bytes. A body that has been parsed and re-serialised will never verify — json.loads followed by json.dumps is not a byte-level round trip, and the signature covers the bytes we sent, not the object they decode to. This is the single most common reason verification “mysteriously” fails, and no amount of correct key handling rescues it.
The result is truthy when valid, so if not result: reads the way you would write it, and result.reason is there when you want to log why. parse_webhook_event verifies and JSON-parses in one step, returning None on any failure, for the common case where a bad delivery just gets a 400. The signing secret is the one returned once when the endpoint was created — webhooks in full →
The Python verifier is tested against signatures produced by the signer that signs your deliveries, not by a second copy of itself — checked-in fixtures generated from it. A verifier that only agrees with its own signing code would pass every test while failing the one case that matters.
Types
from posthaste.types import (
Message,
MessageStatus,
MessageSummary,
SendEmailResult,
Suppression,
WebhookEvent,
)
def record(message: MessageSummary) -> None:
...
# Ships a py.typed marker, so mypy and pyright read every annotation.
# Without it a type checker treats the whole package as Any — silently.Every response shape is a TypedDict in posthaste.types, and the message statuses, event types, suppression reasons and scopes are exported as tuples you can validate against. The package ships py.typed, which is what makes any of it visible: without that marker a type checker treats a third-party package as Any and says nothing.
Every method
account.me() GET /v1/me
account.verify() GET /v1/account/verify
account.usage() GET /v1/usage
domains.create(name) POST /v1/domains
domains.list(...) / auto_paginate / list_all GET /v1/domains
domains.verify(domain_id) POST /v1/domains/:id/verify
domains.delete(domain_id) DELETE /v1/domains/:id
domains.setup(domain_id) GET /v1/domains/:id/setup
domains.connect_cloudflare(domain_id, ...) POST /v1/domains/:id/cloudflare
domains.disconnect_cloudflare() DELETE /v1/account/cloudflare
emails.send(...) POST /v1/emails
emails.send_batch(messages) POST /v1/emails/batch
emails.cancel_schedule(message_id) DELETE /v1/emails/:id/schedule
messages.list(...) / auto_paginate / list_all GET /v1/messages
messages.get(message_id) GET /v1/messages/:id
messages.download_attachment(msg, att) GET /v1/messages/:id/attachments/:attachmentId
messages.stats(...) GET /v1/stats/messages
suppressions.list(...) / auto_paginate / … GET /v1/suppressions
suppressions.create(address, reason=...) POST /v1/suppressions
suppressions.delete(address) DELETE /v1/suppressions/:address
webhooks.create(url, event_types=...) POST /v1/webhooks
webhooks.list(...) / auto_paginate / list_all GET /v1/webhooks
webhooks.delete(webhook_id) DELETE /v1/webhooks/:id
templates.list() / get / versions / preview GET /v1/templates…
templates.create(...) / update / delete POST /v1/templates…
streams.list() GET /v1/streams
streams.create(slug=..., name=...) POST /v1/streams
api_keys.list(...) / auto_paginate / list_all GET /v1/api-keys
billing.get() GET /v1/billing
billing.history(...) / auto_paginate_history GET /v1/billing/history
billing.invoices(...) / auto_paginate_… GET /v1/billing/invoices
billing.invoice(invoice_id) GET /v1/billing/invoices/:idThe surface matches the TypeScript SDK method for method, so a team running both does not have to hold two mental models. Every list appears three times, because list returns one page and the other two return all of them; the two on billing are named for their list rather than for the resource — list_all_invoices and list_all_history — since one resource owns two.
Deliberately absent: creating and revoking API keys, and everything else that requires a signed-in person rather than a key — checkout, plan changes, profile edits, team management. Those endpoints refuse a bearer token outright, and a method that can only ever raise PermissionDeniedError is worse than no method. The platform operator API under /admin/ is not part of the public surface at all.
NextGo SDK →The official client for Go: no third-party dependencies, context on every call, errors that work with errors.Is, pagination a for loop can drain, and webhook verification over raw bytes.