Skip to content

Webhooks

zeroth.service.webhooks

Webhook delivery system for Zeroth platform.

Provides webhook subscription management, delivery lifecycle tracking, HMAC-SHA256 payload signing, and dead-letter handling.

DeliveryStatus

Bases: StrEnum

Lifecycle states of a webhook delivery attempt.

EscalationAction

Bases: StrEnum

Actions to take when an approval SLA expires.

WebhookDeadLetter

Bases: BaseModel

A delivery that has been moved to the dead-letter queue.

Preserves all delivery metadata for later inspection or manual retry.

WebhookDelivery

Bases: BaseModel

Tracks a single webhook delivery attempt.

Created when an event fires. A worker claims it (PENDING/FAILED -> DELIVERING) then resolves it: DELIVERING -> DELIVERED on success, or DELIVERING -> FAILED -> ... -> DEAD_LETTER once retries are exhausted.

WebhookEventPayload

Bases: BaseModel

Standard envelope for webhook event data.

Wraps the event-specific data with common metadata fields for consistent parsing on the receiving end.

WebhookEventType

Bases: StrEnum

Types of events that can trigger webhook deliveries.

WebhookSubscription

Bases: BaseModel

A registered webhook endpoint for a specific deployment.

Tracks which event types should be delivered to which URL, along with the shared secret used for HMAC signing.

WebhookRepository

WebhookRepository(
    database: AsyncDatabase,
    scope_context: ScopeContext | NullWorkspaceScopeContext,
)

Saves and loads webhook subscriptions, deliveries, and dead-letter entries.

Provides full CRUD for subscriptions, delivery lifecycle management (enqueue, claim, mark delivered/failed, dead-letter), and dead-letter queries.

create_subscription async

create_subscription(
    sub: WebhookSubscription,
    *,
    audit_record: NodeAuditRecord | None = None,
    audit_repository: AuditRepository | None = None,
) -> WebhookSubscription

Persist a new webhook subscription and return it.

get_subscription async

get_subscription(
    subscription_id: str,
) -> WebhookSubscription | None

Look up a subscription by ID. Returns None if not found.

list_subscriptions async

list_subscriptions(
    deployment_ref: str | None = None,
) -> list[WebhookSubscription]

Return subscriptions, optionally filtered by deployment and/or tenant.

list_subscriptions_for_event async

list_subscriptions_for_event(
    deployment_ref: str, event_type: WebhookEventType
) -> list[WebhookSubscription]

Return active subscriptions for a deployment matching the given event type.

deactivate_subscription async

deactivate_subscription(
    subscription_id: str,
    *,
    audit_record: NodeAuditRecord | None = None,
    audit_repository: AuditRepository | None = None,
) -> None

Set a subscription to inactive.

delete_subscription async

delete_subscription(subscription_id: str) -> None

Hard-delete a subscription.

enqueue_delivery async

enqueue_delivery(
    delivery: WebhookDelivery,
) -> WebhookDelivery

Persist a new delivery with PENDING status.

enqueue_deliveries async

enqueue_deliveries(
    deliveries: Sequence[WebhookDelivery],
    *,
    audit_records: Sequence[NodeAuditRecord] | None = None,
    audit_repository: AuditRepository | None = None,
) -> list[WebhookDelivery]

Persist one source event's fan-out and audits under one commit.

get_delivery async

get_delivery(delivery_id: str) -> WebhookDelivery | None

Return one delivery by ID for delivery verification and inspection.

list_deliveries async

list_deliveries(
    subscription_id: str | None = None,
    limit: int = 50,
    subscription_ids: Sequence[str] | None = None,
) -> list[WebhookDelivery]

Return safe delivery records after applying subscription scope in the query.

claim_pending_delivery async

claim_pending_delivery(
    *, lease_seconds: float = 30.0
) -> ClaimedWebhookDelivery | None

Claim the oldest delivery that is due for an attempt.

Atomically leases the delivery so the polling worker cannot double-claim (and thus double-deliver) it: within one transaction the chosen row is flipped to DELIVERING and its next_attempt_at is pushed lease_seconds into the future, so a subsequent claim won't see it until the lease lapses. The The compare-and-swap predicate includes all observed lease state, so concurrent transactions cannot both claim the same generation. A claim covers three due cases:

  • PENDING -- reached its first-attempt time;
  • FAILED -- its retry backoff has elapsed (the actual retry path);
  • DELIVERING -- its lease expired, i.e. a worker died mid-delivery.

Returns the claimed delivery and opaque generation, or None.

mark_delivered async

mark_delivered(
    delivery_id: str,
    generation: int,
    *,
    audit_record: NodeAuditRecord | None = None,
    audit_repository: AuditRepository | None = None,
) -> bool

Complete only the currently leased generation.

mark_failed async

mark_failed(
    delivery_id: str,
    generation: int,
    *,
    error: str,
    status_code: int | None,
    retry_delay: float,
    audit_record: NodeAuditRecord | None = None,
    audit_repository: AuditRepository | None = None,
) -> bool

Mark a delivery as failed and schedule the next retry.

The claim already incremented attempt_count; this fenced transition schedules the next retry without allowing an older worker to overwrite it. retry_delay is the final, already-jittered backoff in seconds: the delivery worker owns the backoff policy (see next_retry_delay) and this method persists it verbatim rather than re-deriving it.

dead_letter async

dead_letter(
    delivery_id: str,
    generation: int,
    *,
    dead_letter_id: str | None = None,
    audit_record: NodeAuditRecord | None = None,
    audit_repository: AuditRepository | None = None,
) -> str | None

Move a delivery to the dead-letter table.

Inserts into webhook_dead_letters from delivery data, then updates the delivery status to DEAD_LETTER. Returns the durable dead-letter ID only when this generation wins the fenced transition.

list_dead_letters async

list_dead_letters(
    subscription_id: str | None = None,
    limit: int = 50,
    subscription_ids: Sequence[str] | None = None,
) -> list[WebhookDeadLetter]

Return dead-letter entries, optionally filtered by subscription.

subscription_ids restricts the query to a set of subscriptions so the LIMIT is applied AFTER the tenant scope (audit F8 re-audit) — filtering in Python after a global LIMIT would silently hide a deployment's own rows behind newer foreign ones.

get_dead_letter async

get_dead_letter(
    dead_letter_id: str,
) -> WebhookDeadLetter | None

Look up a single dead-letter entry by ID.

WebhookDeliveryWorker dataclass

WebhookDeliveryWorker(
    repository: WebhookRepository,
    http_client: AsyncClient,
    audit_recorder: ServiceAuditRecorder | None = None,
    poll_interval: float = 2.0,
    max_concurrency: int = 16,
    retry_base_delay: float = 1.0,
    retry_max_delay: float = 300.0,
)

Background worker that polls for pending deliveries and sends HTTP POST requests.

poll_loop async

poll_loop() -> None

Continuously claim and deliver pending webhooks until cancelled.

WebhookService dataclass

WebhookService(
    repository: WebhookRepository,
    default_max_retries: int = 5,
    audit_recorder: ServiceAuditRecorder | None = None,
)

High-level webhook operations: emit events, manage subscriptions, replay dead-letters.

emit_event async

emit_event(
    *,
    event_type: WebhookEventType | str,
    deployment_ref: str,
    tenant_id: str,
    data: dict,
) -> list[WebhookDelivery]

Find active subscriptions matching deployment_ref + event_type, enqueue delivery each.

create_subscription async

create_subscription(
    sub: WebhookSubscription,
    *,
    actor: ActorIdentity | None = None,
) -> WebhookSubscription

Persist a new webhook subscription.

get_subscription async

get_subscription(
    subscription_id: str,
) -> WebhookSubscription | None

Look up a subscription by ID.

list_subscriptions async

list_subscriptions(
    deployment_ref: str | None = None,
) -> list[WebhookSubscription]

List subscriptions, optionally filtered.

list_deliveries async

list_deliveries(
    subscription_id: str | None = None,
    limit: int = 50,
    subscription_ids: Sequence[str] | None = None,
) -> list[WebhookDelivery]

List delivery state without exposing payloads or signing material.

deactivate_subscription async

deactivate_subscription(
    subscription_id: str,
    *,
    actor: ActorIdentity | None = None,
) -> None

Soft-delete a subscription by marking it inactive.

delete_subscription async

delete_subscription(subscription_id: str) -> None

Hard-delete a subscription.

get_dead_letter async

get_dead_letter(
    dead_letter_id: str,
) -> WebhookDeadLetter | None

Look up a single dead-letter entry by ID (for ownership scoping).

replay_dead_letter async

replay_dead_letter(dead_letter_id: str) -> WebhookDelivery

Re-enqueue a dead-letter entry as a new pending delivery.

list_dead_letters async

list_dead_letters(
    subscription_id: str | None = None,
    limit: int = 50,
    subscription_ids: Sequence[str] | None = None,
) -> list[WebhookDeadLetter]

List dead-letter entries.

sign_payload

sign_payload(payload_bytes: bytes, secret: str) -> str

Sign a payload with HMAC-SHA256 and return the hex digest.

Parameters:

Name Type Description Default
payload_bytes bytes

The raw bytes of the webhook payload to sign.

required
secret str

The shared secret string for the subscription.

required

Returns:

Type Description
str

A lowercase hex string of the HMAC-SHA256 signature.