Zmail Docs
operator + agent reference for zmail.zergai.com
source: docs/webhooks.md

Webhooks Guide

Overview

Webhooks provide tenant-scoped event notifications over HTTP POST.

Each endpoint has:

  • URL
  • Secret (optional but recommended)
  • Event filter list (event_types)
  • Active/inactive state

Deliveries are persisted for observability and troubleshooting.

Supported Events

  • message.received
  • message.sent
  • message.delivered
  • message.bounced
  • message.rejected
  • message.complained
  • domain.verified

Endpoint Management

Use /app/api/webhooks with X-Zmail-Session.

  • Create: POST /app/api/webhooks
  • List: GET /app/api/webhooks
  • Get: GET /app/api/webhooks/{id}
  • Update: PATCH /app/api/webhooks/{id}
  • Delete: DELETE /app/api/webhooks/{id}

Delivery History

  • GET /app/api/webhooks/{id}/deliveries?limit=100

Each delivery includes:

  • Event type
  • Attempt number
  • HTTP status code
  • Error text (if failed)
  • Delivery timestamp
  • Event ID

Test Event Endpoint

  • POST /app/api/webhooks/{id}/test
  • Body: { "event_type": "message.received" }

This sends a synthetic event to the selected webhook only.

Signature Headers

When a webhook secret is set, Zmail includes Svix-style headers:

  • svix-id
  • svix-timestamp
  • svix-signature

Current signing format is v1,<base64-hmac-sha256> over:

<svix-id>.<svix-timestamp>.<raw_json_body>

Use constant-time comparison in your verifier.

Retry Behavior

  • Up to 3 attempts per endpoint/event
  • Backoff between attempts
  • Every attempt is logged in webhook_deliveries

Receiver Best Practices

  • Return 2xx quickly; process asynchronously.
  • Make handlers idempotent keyed by event_id.
  • Log payload + headers for debugging.
  • Keep endpoint availability and TLS healthy.

Example Receiver (Python/FastAPI)


from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhooks/zmail")
async def zmail_webhook(request: Request):
    payload = await request.json()
    # TODO: verify signature headers before processing in production
    # TODO: enqueue background job using payload["event_id"] for idempotency
    return {"ok": True}