Webhooks
81 events, in 16 families. Every one is emitted by the rail that owns the fact, never reconstructed afterwards.
Signing
Every delivery carries NotifHQ-Signature. The signature covers the timestamp and the raw body, so a captured payload cannot be replayed against you.
Verify against the raw bytes before parsing them. Reject a delivery whose timestamp is outside your tolerance, and compare signatures in constant time.
Ordering and retries
Deliveries are retried with exponential backoff and are not ordered. Use the event's subject and its timestamp to reconcile, and treat every handler as idempotent: a delivery you have already processed will arrive again.
alert events
| Event | When |
|---|---|
alert.raised | A condition became true about a subject and suppression did not withhold it. The first moment anybody could be told. |
alert.acknowledged | Somebody said they have it. This stops the escalation and nothing else: the condition may still hold, and conflating acknowledgement with resolution is how a board shows green while a freezer is still warming. |
alert.cleared | The source reported the condition is no longer true. Observed, not asserted — distinct from closed, which is a person ending it while the source said nothing. |
alert.closed | A person ended the alert without the source clearing it: a false positive, a known fault, equipment out of service. Never merged with cleared, because an auditor asking about an excursion needs to know which of the two happened. |
alert.suppressed | A condition was reported true and no message went out — debounced, below its occurrence threshold, or folded into an alert already open. Emitted because suppression a customer cannot see is indistinguishable from data loss, and 'why did I not get an alert' is the first question asked. |
alert.escalated | Nobody acknowledged in time and the chain moved to its next tier. Carries which tier and who it reached, because the question after an incident is always how far it climbed before somebody answered. |
alert.escalation_exhausted | Every tier has been tried and nothing came back. Emitted rather than falling silent: an alert nobody ever answered is the single most important thing an operations lead can be told, and a system that gives up quietly produces the worst outcome available — nobody was told, and nobody was told that nobody was told. The alert stays RAISED, because exhausting a policy resolves nothing. |
auth events
| Event | When |
|---|---|
api_key.created | A credential now exists. Who holds one is the first question after any incident, and it cannot be answered from a table nobody is watching. |
api_key.revoked | A credential stopped working. Anything caching an authorisation decision needs to hear this, and quickly. |
auth.otp.issued | A code was generated and sent. The rate at which this fires per recipient is the first signal of an enumeration attempt. |
auth.otp.verified | Somebody proved they hold the number. The moment an account takeover either happens or does not. |
auth.otp.failed | A wrong code, an expired challenge, or one already used. Failures clustering on one recipient is the shape of an attack; failures spread across many is the shape of a delivery problem. |
auth.otp.spent | The cap was reached and the challenge is dead. Distinct from expiry, because one is somebody guessing and the other is somebody slow. |
compliance events
| Event | When |
|---|---|
compliance.violation.detected | A control matched. Violation evidence written. |
compliance.retention.executed | Expired records were deleted and the execution evidenced. |
compliance.export.generated | A hash-sealed export artifact was produced. |
dsar.export.requested | A subject access export was requested. |
dsar.export.ready | The export artifact is available behind a short-lived signed URL. |
dsar.deletion.requested | An erasure was requested. |
dsar.deletion.completed | Every plane reported completion. Partial completion is not completion (§15.6). |
contract events
| Event | When |
|---|---|
contract.created | A notification type became describable. Nothing can be sent against it until it is published. |
contract.published | A notification type became sendable. This is the moment a workspace's surface grew. |
contract.archived | A notification type stopped being instantiable. Notifications already sent keep their reference; nothing new gets one. |
payload_schema.created | The field list a notification type validates against now exists. Without it a contract can be published and nothing can be sent. |
document events
| Event | When |
|---|---|
document.received | A file was stored with an immutable source hash. |
document.classified | A class was proposed for a document, or the reason a person has to decide instead. Emitted on BOTH outcomes — an event only for the confident case would make a review queue that fills from nowhere, and the rate at which a classifier declines to answer is the number that says whether it is working. |
document.accepted | A canonical object was emitted. |
domain events
| Event | When |
|---|---|
domain.created | A sending domain was claimed and is not yet verified. Nothing may send from it. |
domain.verified | DNS proved control of a sending domain. Deliverability changes at this moment, which is why it is worth knowing without polling. |
inbound events
| Event | When |
|---|---|
inbound.received | An inbound message was stored with an immutable source hash. |
inbound.parsed | Canonical MIME parsing completed. |
inbound.thread_resolved | The message was bound to a thread, with the resolution method recorded (§17.3). |
inbound.routed | The message was routed to an inbox, webhook or intake rail. |
invoice events
| Event | When |
|---|---|
invoice.received | An invoice-classed document entered the intake rail. |
invoice.duplicate_detected | Supplier, invoice number and total matched an accepted invoice. |
invoice.approved | A human approved the canonical invoice. |
invoice.rejected | A human rejected the canonical invoice. |
membership events
| Event | When |
|---|---|
invitation.sent | Somebody was invited into a workspace. Who can see a workspace is an access fact, and access facts belong in the event stream. |
invitation.revoked | An outstanding invitation stopped being usable. |
notification events
| Event | When |
|---|---|
notification.requested | Accepted at the gateway. Nothing has been decided. |
notification.validated | The payload validated against the contract's schema. |
notification.normalized | Canonicalised, minimised and classified. |
notification.authorized | The policy plane allowed the send, with obligations. |
notification.suppressed | Not delivered, for a registered reason. Evidence, not an error. |
notification.queued | Handed to the delivery rail. |
notification.rendered | A specific template version produced a specific body. |
notification.dispatched | Handed to a transport adapter. |
notification.provider_accepted | The transport returned an opaque acceptance reference. |
notification.sent | Left our control. NOT delivered. |
notification.delivered | A destination accepted it. NOT engaged. Emitted on two paths, which are different facts: by the rail on the send call for a provider whose confirmation is synchronous, and by src/runtime/delivery-receipt.ts when an asynchronous receipt arrives later — the path every email takes, and the one that fired for nobody until the receipt ingest existed. |
notification.bounced | The receiving server rejected it, and said so after we had already been told the message was accepted. Distinct from notification.failed, which is this platform or the provider failing to send: a bounce is a successful send to an address that will not receive, so it is the only one of the two that is evidence about the ADDRESS, and the only one that can justify suppressing it. |
notification.failed | A terminal failure with an error class and code. |
notification.expired | The contract TTL elapsed before delivery. |
notification.cancelled | Cancelled by the caller before dispatch. |
pack events
| Event | When |
|---|---|
pack.installed | A workspace gained a sector or capability pack: its notification types, contracts, schemas and template drafts all arrived at once. The largest single change to what a workspace can send, and it announced nothing. |
preference events
| Event | When |
|---|---|
preference.updated | A recipient preference changed. |
preference.opted_in | The recipient opted into a category on a channel. |
preference.opted_out | The recipient opted out of a category on a channel. |
preference.quiet_hours.updated | The recipient's quiet-hours window changed. |
consent.recorded | A consent with a lawful basis, scope and timestamp was recorded. |
consent.withdrawn | A previously recorded consent was withdrawn. |
recipient events
| Event | When |
|---|---|
recipient.created | A communication counterparty was registered. |
recipient.updated | Recipient attributes changed. |
recipient.channel.added | A destination was added on a channel. |
recipient.channel.verified | A destination was proven reachable and owned. |
recipient.suppressed | A suppression row was created for this recipient. |
template events
| Event | When |
|---|---|
template.created | A template row was created. |
template.generated | A draft was produced by the drafting agent. Advisory only (§1.7). |
template.previewed | A preview was rendered with sample parameters. |
template.approved | A human approved this exact checksum. |
template.published | The version went live and became immutable. |
template.archived | Withdrawn from selection; existing evidence still references it. |
ticket events
| Event | When |
|---|---|
ticket.created | A tracked unit of support work was opened. |
ticket.assigned | An assignee took ownership. |
ticket.tagged | A label or tag was applied. |
ticket.status_changed | The ticket moved between open, waiting and resolved. |
ticket.message.received | An inbound message landed on the ticket. |
ticket.message.sent | An outbound reply was dispatched through the Comms Rail. |
ticket.resolved | The ticket was closed. |
webhook events
| Event | When |
|---|---|
webhook_endpoint.created | Somewhere new is now listening. An endpoint appearing is itself worth recording, not least so a later flood can be traced to when it started. |
webhook_endpoint.updated | Where or whether we deliver changed. |
Subscribing
Add an endpoint in the developer console at dev.notifhq.com, or through POST /v1/webhook-endpoints. Each endpoint chooses the events it wants; an endpoint that starts failing is reported to you rather than silently dropped.