Skip to content

Webhooks

Greg Svoboda edited this page Oct 28, 2026 · 2 revisions

Webhooks

Webhook management is done through ServerClient. You will need a server API token.

import postmark

client = postmark.ServerClient("your-server-token")

List Webhooks

import asyncio

async def main():
    webhooks = await client.webhooks.list()

    print(f"Total webhooks: {len(webhooks)}")
    for wh in webhooks:
        print(f"  [{wh.id}] {wh.url}")
        print(f"       Stream:  {wh.message_stream}")
        print(f"       Opens:   {wh.triggers.open.enabled}")
        print(f"       Bounces: {wh.triggers.bounce.enabled}")

asyncio.run(main())

Get a Webhook

async def main():
    wh = await client.webhooks.get(webhook_id=1234)
    print(f"URL:    {wh.url}")
    print(f"Stream: {wh.message_stream}")

Verification status

Postmark checks that your endpoint answers before it starts sending events. Each enabled trigger reports its own status, either "verified" or "unverified". A disabled trigger has no status (None).

async def main():
    wh = await client.webhooks.get(webhook_id=1234)
    print(wh.triggers.delivery.enabled, wh.triggers.delivery.status)  # True verified
    print(wh.triggers.click.enabled, wh.triggers.click.status)        # False None

An unverified trigger receives no events. See Verify a Webhook.

Create a Webhook

Webhooks can be scoped to a specific message stream and configured with individual trigger types.

import asyncio

async def main():
    wh = await client.webhooks.create(
        url="https://example.com/webhook",
        message_stream="outbound",
        triggers={
            "Open": {"Enabled": True, "PostFirstOpenOnly": False},
            "Click": {"Enabled": True},
            "Bounce": {"Enabled": True, "IncludeContent": False},
            "SpamComplaint": {"Enabled": True, "IncludeContent": False},
        },
    )

    print(f"Created webhook ID: {wh.id}")
    print(f"URL:     {wh.url}")
    print(f"Opens:   {wh.triggers.open.enabled}")
    print(f"Bounces: {wh.triggers.bounce.enabled}")

asyncio.run(main())

Verification when you create a webhook

By default Postmark sends a test event for every enabled trigger to your URL before it saves the webhook, and refuses to save it if your endpoint does not answer HTTP 200. The call then raises a ValidationException (error code 1364) and nothing is created. Make sure the endpoint is reachable first, or save the webhook without verifying:

async def main():
    wh = await client.webhooks.create(
        url="https://not-deployed-yet.example.com/webhook",
        message_stream="outbound",
        triggers={"Delivery": {"Enabled": True}},
        verify=False,  # save it now, verify later
    )
    print(wh.triggers.delivery.status)  # "unverified"

A webhook saved with verify=False receives no events until it is verified. Call verify() once your endpoint is up.

Webhook with HTTP Authentication

wh = await client.webhooks.create(
    url="https://example.com/webhook",
    message_stream="outbound",
    http_auth={"Username": "myuser", "Password": "secret"},
    triggers={
        "Bounce": {"Enabled": True, "IncludeContent": True},
    },
)

Securing Your Webhook Receiver

Postmark supports three options for authenticating incoming webhook requests. Use at least one in production.

HTTP Basic Authentication — credentials are verified on every delivery attempt:

wh = await client.webhooks.create(
    url="https://example.com/webhook",
    http_auth={"Username": "myuser", "Password": "secret"},
)

Custom headers — add a shared secret that your receiver checks:

wh = await client.webhooks.create(
    url="https://example.com/webhook",
    http_headers=[{"Name": "X-Webhook-Secret", "Value": "my-shared-secret"}],
)

IP allowlisting — restrict delivery to Postmark's published IP ranges. This is configured in your receiver's firewall or load balancer, not in the SDK.

For Postmark's full security recommendations see the Webhooks Overview.

Edit a Webhook

Change only the settings you pass; everything else keeps its current value.

import asyncio

async def main():
    wh = await client.webhooks.edit(
        webhook_id=1234,
        triggers={
            "Open": {"Enabled": False},
            "Bounce": {"Enabled": True, "IncludeContent": True},
        },
    )
    print(f"Updated: opens={wh.triggers.open.enabled}  bounces={wh.triggers.bounce.enabled}")

asyncio.run(main())

A few things to know about how editing works:

  • Triggers merge field by field. {"Bounce": {"Enabled": False}} changes only that field; the Bounce trigger's IncludeContent setting and every other trigger stay as they are.
  • http_auth and http_headers replace the stored values when you pass them. Pass http_headers=[] to clear the custom headers.
  • One extra request. Postmark requires the URL on every update and verifies the endpoint using only what is in the request, so edit() first reads the current webhook, merges your changes, and sends the complete configuration. That is one extra GET per edit. If you mock the HTTP layer in your own tests, mock that GET too.
  • The endpoint is verified again on every edit, using the stored credentials. If it does not answer, the edit raises ValidationException and nothing is saved. Use verify=False to save without checking:
wh = await client.webhooks.edit(webhook_id=1234, url="https://new.example.com/hook", verify=False)

Verify a Webhook

Send a test event for each enabled trigger to the webhook's URL and see how your endpoint responded.

import asyncio

async def main():
    result = await client.webhooks.verify(webhook_id=1234)

    print(result.message)  # e.g. "All 2 triggers verified successfully"
    if not result.success:
        for r in result.results:
            if not r.success:
                print(f"  {r.trigger_type}: HTTP {r.status_code} - {r.message}")

asyncio.run(main())
  • A failed verification is not an exception. Postmark answers normally with success=False, so check result.success. Each entry in result.results has trigger_type, success, status_code and message.
  • A status_code of 0 means your endpoint could not be reached at all (DNS, connection or TLS failure).
  • If no trigger is enabled there is nothing to test: success is False, results is empty and message says so.
  • Once every enabled trigger succeeds, their status becomes "verified" and events start flowing.
  • This sends real requests to your endpoint. Like every call, it is retried automatically on timeouts and server errors, which would send the test events again, so give a slow endpoint a generous timeout (the default is 5 seconds): postmark.ServerClient("your-server-token", timeout=30).

Webhook Statistics

See how deliveries to your endpoint have been going over the last 24 hours.

import asyncio

async def main():
    stats = await client.webhooks.get_statistics(webhook_id=1234)

    window = stats.time_range
    print(f"{window.start_time:%Y-%m-%d %H:%M} to {window.end_time:%H:%M} ({window.hours} hours)")

    m = stats.metrics
    print(f"{m.total_requests} requests, {m.success_count} ok, {m.failure_count} failed, {m.retry_count} retried")
    print(f"Success rate: {m.success_rate}%")
    if m.average_terminal_response_time_ms is not None:
        print(f"Average response time: {m.average_terminal_response_time_ms} ms")

    # Per trigger, keyed by trigger name ("Open", "Click", "Delivery", ...)
    for name, tm in stats.metrics_by_trigger.items():
        print(f"  {name}: {tm.total_requests} requests, {tm.failure_count} failed")

    # Verification status of each enabled trigger
    print(stats.statuses)  # {"Delivery": "verified", ...}

asyncio.run(main())
  • The 24-hour window is fixed by Postmark; there are no filters.
  • average_terminal_response_time_ms and average_retry_response_time_ms are None when there was no traffic, or no retries, in the window.
  • slow_count and very_slow_count count the requests that were slow, or very slow, to respond.

Delete a Webhook

async def main():
    await client.webhooks.delete(webhook_id=1234)
    print("Deleted")

Further Reading

Clone this wiki locally