Skip to main content

pulsepigeon

Official PulsePigeon API client for Python.

Install

pip install pulsepigeon

Quickstart

import os
from pulsepigeon_client import PulsePigeonClient

client = PulsePigeonClient(api_key=os.environ["PULSEPIGEON_API_KEY"])

client.send_message(
    {
        "project_id": "proj_123",
        "from_email": "support@example.com",
        "to": [{"email": "user@example.com"}],
        "subject": "Reset your password",
        "text": "Use the secure reset link in this email.",
    },
    idempotency_key="password-reset-user-123-event-456",
)

Every send should carry an idempotency_key — retrying the same key with the same body returns the original result instead of sending a duplicate.

Personalized batches use per-item idempotency and partial-success results:

result = client.send_batch(
    [
        {
            "idempotency_key": "invoice-123-user-456",
            "message": {
                "project_id": "proj_123",
                "from_email": "billing@example.com",
                "to": [{"email": "user@example.com"}],
                "subject": "Your invoice",
                "text": "Invoice 123 is ready.",
            },
        }
    ]
)

The API accepts at most 100 items and 10 MB of decoded JSON per request. Accepted items replay their original message IDs. Failed items return an HTTP-style status and error code in their own result.

An AsyncPulsePigeonClient with the same methods is available for asyncio-based applications (each call runs the sync request on a worker thread — see the module docstring for why that's the right trade-off for a stdlib-only client):

from pulsepigeon_client import AsyncPulsePigeonClient

client = AsyncPulsePigeonClient(api_key=os.environ["PULSEPIGEON_API_KEY"])
await client.send_message({...}, idempotency_key="...")

API

API-key methods require a key carrying the scope listed below (request scopes when creating the key — POST /v1/api-keys, scopes field — a closed vocabulary: messages:send, reports:read, domains:*, webhooks:*, suppressions:*, contacts:*). Your own session's role bounds which scopes you can request: minting a key with contacts:*, suppressions:*, or webhooks:* requires an owner, admin, or operator session (those scopes reach owner-only routes like GDPR erasure and suppression-list export/import); messages:send, reports:read, and domains:* require only the ordinary role permission their routes already need of a session caller. Template management routes require a dashboard session; template sending accepts a scoped project key.

Project and workspace scoping. A project key is issued against exactly one project. list_messages, search_message_logs, list_events, list_subscriber_lists, and create_subscriber_list are filtered to that project only — a key never sees or creates data in another project. request_subscriber_opt_in additionally checks the target list's own project before dispatching mail, and requires messages:send on top of contacts:* (it sends a real confirmation email — "manage contacts" must not imply "send mail").

Tenant-wide methods require an owner-created workspace key. Project keys continue to reject those calls with 403 api_key_route_not_project_scoped. Workspace keys require an explicit live or test environment and can restrict source IP networks, sender domains, and expiration. The server enforces each restriction on every request.

Method and reference Scope
send_message(payload, idempotency_key=None) messages:send
send_batch(items) messages:send
list_messages() reports:read (project-scoped)
search_message_logs(**filters) reports:read (project-scoped)
list_events(message_id=None) reports:read (project-scoped)
list_bounces(**filters) / get_bounce(bounce_id) reports:read (project-scoped)
activate_bounce(bounce_id) suppressions:* (privileged workspace key)
send_with_template(template, idempotency_key) messages:send
list_templates() / get_template(template_id) / create_template(payload) / delete_template(template_id) dashboard session
preview_template(template_id, variables) / validate_template(template_id, variables) dashboard session
list_domains() reports:read (workspace key)
check_domain(domain_id) domains:* (workspace key)
get_dmarc_ramp(domain_id) / list_dmarc_ramps() reports:read (workspace key)
list_dmarc_sources() reports:read (workspace key)
list_webhook_subscriptions() webhooks:* (workspace key)
upsert_webhook_subscription(endpoint_url=, secret=, enabled=, merge=) webhooks:* (workspace key)
delete_webhook_subscription(subscription_id) webhooks:* (workspace key)
list_webhook_deliveries() webhooks:* (workspace key)
get_webhook_health() / rotate_webhook_secret(subscription_id) webhooks:* (workspace key)
test_webhook(subscription_id) / replay_webhook_delivery(subscription_id, delivery_id) webhooks:* (workspace key)
suppress_recipient(email, reason) suppressions:* (workspace key)
export_suppressions() returns CSV text suppressions:* (workspace key)
import_suppressions(csv_text) suppressions:* (workspace key)
create_subscriber_list(name, project_id=None) contacts:* (project-scoped)
list_subscriber_lists() contacts:* (project-scoped)
request_subscriber_opt_in(list_id, email, jurisdiction="US") contacts:* + messages:send (project-scoped)
delete_contact(email, reason) (GDPR-style erasure) contacts:* (workspace key)
verify_webhook_signature(payload, header, secret, tolerance_seconds=3600) n/a (local, no request)

Errors raise one of PulsePigeonAuthenticationError (401), PulsePigeonPermissionError (403), PulsePigeonNotFoundError (404), PulsePigeonConflictError (409), PulsePigeonValidationError (400/422), PulsePigeonRateLimitError (429, has retry_after_seconds), or PulsePigeonServerError (5xx) — all subclasses of PulsePigeonError (status, body, code). Catch the base class as a fallback.

Verifying webhook deliveries

PulsePigeon-Signature: t=<unix>,v1=<hmac-sha256 hex> is sent with every status-webhook delivery. Verify it before trusting the payload — this is the single most common thing to get wrong by hand: the signature covers a canonical re-serialization of the JSON body (sorted keys, no whitespace), not the raw bytes received over the wire. The signed timestamp is when the event was enqueued for delivery, not when the HTTP request is actually sent — tolerance_seconds (default 3600) has to budget for realistic delivery-queue delay, not just clock skew, or a delayed-but-authentic delivery gets spuriously rejected.

from pulsepigeon_client import verify_webhook_signature

payload = json.loads(raw_body)
if not verify_webhook_signature(payload, signature_header, secret):
    raise ValueError("invalid PulsePigeon webhook signature")

Auth

Create an API key from the PulsePigeon console under API keys (or POST /v1/api-keys with a scopes list) and pass it as api_key. Keys are sent as Authorization: Bearer <key> — never log or commit a real key.

Integration test

tests/test_bearer_auth_integration.py proves the packaged client round-trips against a real, running instance of the API (not mocked) — see that file and its TypeScript counterpart, sdk/typescript/test/integration.test.ts, for what it verifies and its prerequisites (a reachable Postgres). tests/test_client_unit.py covers request-building and error-mapping without a server. Run with pytest -q.

Release files for pulsepigeon 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pulsepigeon 1.0.0
File Size Uploaded
pulsepigeon-1.0.0.tar.gz 14.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pulsepigeon 1.0.0
File Interpreter ABI Platform
pulsepigeon-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 28.1 kB

Release files / pulsepigeon-1.0.0.tar.gz

Download URL pulsepigeon-1.0.0.tar.gz
Size 14.2 kB
Tags Source
SHA-256 checksum
How to use checksums
7fbbdd61d0f38d385755cba221671febbdb3065973c8638cf977deb30451d6b0
BLAKE2b-256 checksum
How to use checksums
424af7a7d8301b96fea4103975ac80f58161f8fc6ec1c7bd034912a6395a0115
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / pulsepigeon-1.0.0-py3-none-any.whl

Download URL pulsepigeon-1.0.0-py3-none-any.whl
Size 13.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
afeea74d46368a796b5d5f5f8ea937795771088fe61e4c8ef4b576fda92cad8c
BLAKE2b-256 checksum
How to use checksums
b26e68e9f7f74df836660a5c71a9ab2ebafdf49d8cbd3f922e472a1f3120ff80
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page