Repository navigation
Webhooks
Webhook management is done through ServerClient. You will need a server API token.
import postmark
client = postmark.ServerClient("your-server-token")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())async def main():
wh = await client.webhooks.get(webhook_id=1234)
print(f"URL: {wh.url}")
print(f"Stream: {wh.message_stream}")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 NoneAn unverified trigger receives no events. See Verify 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())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.
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},
},
)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.
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'sIncludeContentsetting and every other trigger stay as they are. -
http_authandhttp_headersreplace the stored values when you pass them. Passhttp_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 extraGETper edit. If you mock the HTTP layer in your own tests, mock thatGETtoo. -
The endpoint is verified again on every edit, using the stored credentials. If it does not answer, the edit raises
ValidationExceptionand nothing is saved. Useverify=Falseto save without checking:
wh = await client.webhooks.edit(webhook_id=1234, url="https://new.example.com/hook", verify=False)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 checkresult.success. Each entry inresult.resultshastrigger_type,success,status_codeandmessage. - A
status_codeof0means your endpoint could not be reached at all (DNS, connection or TLS failure). - If no trigger is enabled there is nothing to test:
successisFalse,resultsis empty andmessagesays so. - Once every enabled trigger succeeds, their
statusbecomes"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).
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_msandaverage_retry_response_time_msareNonewhen there was no traffic, or no retries, in the window. -
slow_countandvery_slow_countcount the requests that were slow, or very slow, to respond.
async def main():
await client.webhooks.delete(webhook_id=1234)
print("Deleted")