Ingestion
The Python SDK exposes ingestion on client.events. Two methods cover the entire ingestion surface: emit for one event, emit_batch for up to 100. Both accept either dicts or Pydantic models and both return typed result models.
Single events
from pushrail import Pushrail
client = Pushrail(api_key=os.environ["PUSHRAIL_API_KEY"])
result = client.events.emit({
"eventType": "subscription.renewed",
"source": "billing-service",
"customerExternalId": "cust_42",
"payload": {"subscriptionId": "sub_abc", "amountCents": 2900},
# Optional:
# "occurredAt": "2026-05-16T12:00:00Z", # defaults to datetime.now(utc).isoformat()
# "idempotencyKey": "billing:renew:sub_abc:202605", # defaults to UUIDv7
# "correlationId": request.headers.get("x-correlation-id"),
# "metadata": {"source_region": "us-east-1"},
})
The SDK accepts both the wire-format camelCase keys (eventType, customerExternalId) and snake_case (event_type, customer_external_id) via Pydantic field aliases. Mix freely; the wire serialization is always camelCase. Prefer the typed model for code that touches events repeatedly:
from pushrail import EmitEvent
event = EmitEvent(
event_type="subscription.renewed",
source="billing-service",
customer_external_id="cust_42",
payload={"subscriptionId": "sub_abc", "amountCents": 2900},
)
result = client.events.emit(event)
The model gives you IDE completion and catches missing fields before they reach the wire.
Batch emission
result = client.events.emit_batch([
{"eventType": "order.completed", "source": "checkout", "customerExternalId": "c1", "payload": {...}},
{"eventType": "order.completed", "source": "checkout", "customerExternalId": "c2", "payload": {...}},
])
print(result.accepted, result.rejected)
for item in result.events:
if item.status == "rejected":
print("rejected", item.index, item.error)
The server caps a batch at 100 events; if you have more, slice into chunks of 100 on the caller side. See Batch ingest for partial-failure semantics.
Idempotency
Every emit call gets a UUIDv7 idempotency_key when you don't supply one. The generated value is on the returned EmitResult.idempotency_key so you can log it. For deterministic dedup, supply a stable key:
client.events.emit({
"eventType": "order.completed",
"source": "checkout",
"customerExternalId": row.customer_id,
"idempotencyKey": f"order.completed:{row.id}",
"payload": {"orderId": row.id},
})
See Idempotency for the dedup-window semantics.
Error types
The SDK raises a typed hierarchy rooted at PushrailError:
from pushrail import (
Pushrail,
PushrailError,
PushrailValidationError, # 400/422, permanent
PushrailAuthError, # 401/403, permanent
PushrailRateLimitError, # 429, transient, retried automatically
PushrailServerError, # 5xx, transient, retried automatically
PushrailNetworkError, # I/O fail, transient, retried automatically
)
try:
client.events.emit(input)
except PushrailValidationError as err:
# Fix the payload; do not retry.
logger.warning("validation failed: %s", err)
except PushrailAuthError:
# Rotate or fix the key.
raise
except PushrailError:
# Generic catch, includes retried-and-exhausted transient failures.
raise
Each exception carries status_code, response_body, and request_id. Surface request_id in your logs. Pushrail support can look up a delivery by request id in seconds.
The Python SDK uses synchronous HTTP under the hood. For async codebases, wrap calls in a thread executor; a native async client is on the roadmap.