# Webhook events

Webhook deliveries are at-least-once. Every delivery body is the exact JSON
envelope below. The sender does not add IDs from extracted page content: IDs in
`data` are created by Price Monitor.

## Common envelope

```json
{
  "id": "evt_job_123_1",
  "type": "change.detected",
  "apiVersion": "2026-08-01",
  "createdAt": "2026-08-21T12:00:00.000Z",
  "tenantId": "ten_123",
  "data": {}
}
```

`id` is the stable business event ID. `type` is one of the six event types
below. `createdAt` is ISO 8601 UTC. `tenantId` identifies the workspace. The
`data` object is type-specific; additive fields can be introduced without a
version bump.

## Event payloads

These are the payloads emitted by the runtime. Optional properties are marked
with `?`; omitted optional properties are not sent as `null`.

### `product.resolved`

```json
{
  "product": {
    "id": "prd_123",
    "clientReference": "catalog-123",
    "title": "Atlas headphones",
    "price": {
      "amountMinor": 12999,
      "currency": "EUR",
      "formatted": "€129.99"
    },
    "stockStatus": "in_stock",
    "observedAt": "2026-08-21T12:00:00.000Z"
  },
  "observationId": "obs_123"
}
```

`product.id` is required. `clientReference`, `title`, `price`,
`stockStatus`, `observedAt`, and `observationId` are included when the runtime
has those values. The price object contains `amountMinor`, `currency`, and may
contain `formatted`.

### `watch.created`

```json
{
  "watchId": "wat_123",
  "watch": {
    "id": "wat_123",
    "productId": "prd_123",
    "name": "Competitor price",
    "status": "active",
    "nextRunAt": "2026-08-21T13:00:00.000Z"
  }
}
```

`watchId` and `watch.id`, `watch.name`, `watch.status`, and `watch.nextRunAt`
are emitted. `watch.productId` is emitted when the watch has a product.

### `change.detected`

```json
{
  "change": {
    "id": "chg_123",
    "summary": "price.amountMinor changed"
  },
  "product": {
    "id": "prd_123",
    "clientReference": "catalog-123",
    "price": {
      "amountMinor": 12999,
      "currency": "EUR",
      "formatted": "€129.99"
    },
    "stockStatus": "in_stock",
    "shippingCost": {
      "amountMinor": 500,
      "currency": "EUR",
      "formatted": "€5.00"
    },
    "seller": { "name": "Example Shop" },
    "observedAt": "2026-08-21T12:00:00.000Z"
  },
  "changes": [
    {
      "path": "price.amountMinor",
      "before": 14900,
      "after": 12999,
      "deltaPercent": -12.76
    }
  ]
}
```

`change.id` and `change.summary` are required. `product.id` and
`product.observedAt` are required. The other product properties are included
when present. Each `changes` entry contains `path`, `before`, and `after`, and
may contain `deltaPercent`.

### `check.failed`

```json
{
  "watchId": "wat_123",
  "runId": "run_123",
  "category": "UNSUPPORTED_CONTENT"
}
```

The runtime emits `watchId`, `runId`, and the failure `category`.

### `watch.degraded`

```json
{ "watchId": "wat_123" }
```

The event is emitted when repeated check failures move a customer watch to the
degraded state.

### `watch.recovered`

```json
{ "watchId": "wat_123" }
```

The event is emitted when a degraded watch completes a successful check.

## Verification (Node.js)

The sender provides `Webhook-Id`, `Webhook-Timestamp`, `Webhook-Signature`,
and `User-Agent: Monito-Webhooks/1.0`. Verify before parsing the body. The
signed message is `${Webhook-Timestamp}.${exact raw request body}` and the
signature is `v1=` followed by lowercase hexadecimal HMAC-SHA256.

```js
import crypto from "node:crypto";

export function verifyWebhook(rawBody, headers, secret) {
  const webhookId = headers.get("Webhook-Id");
  const timestamp = headers.get("Webhook-Timestamp");
  const presented = headers.get("Webhook-Signature");
  if (
    typeof rawBody !== "string" ||
    !webhookId ||
    !timestamp ||
    !/^\d{10}$/.test(timestamp) ||
    !presented ||
    !/^v1=[0-9a-f]{64}$/.test(presented) ||
    !secret
  )
    return false;
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > 300) return false;
  const expected = `v1=${crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex")}`;
  const expectedBytes = Buffer.from(expected, "utf8");
  const presentedBytes = Buffer.from(presented, "utf8");
  return crypto.timingSafeEqual(expectedBytes, presentedBytes);
}
```

Capture the raw request bytes/string before JSON parsing or re-serialization.
The `Webhook-Id` is required for receiver validation but is not part of the
signed message. Reject missing or malformed headers, signatures with the
wrong length/format, and timestamps more than five minutes from the current
Unix time. Save the secret returned by `POST /v1/webhook-endpoints` immediately.
It is not returned by later reads. An idempotent replay of the creation request
with the same API key, idempotency key, and exact request body returns the original
secret during the 24-hour replay window.

A valid receiver must return a 2xx only after signature verification and
durable event-ID deduplication. Return 401 for missing, stale, malformed, or
incorrect signatures, and return 400 for a verified body that is not valid
JSON. Never parse and then re-serialize the body before verification.

## Minimal receiver (Node.js)

This Node.js HTTP example bounds the body before verification.
Replace the in-memory `seenEventIds` set with a durable store shared by all
receiver instances; keep the event ID as the idempotency key.
Save the verifier block above as `verify-webhook.js` (or keep it in the same
module) before running this receiver.

```js
import http from "node:http";
import { verifyWebhook } from "./verify-webhook.js";

const secret = process.env.MONITO_WEBHOOK_SECRET;
const seenEventIds = new Set(); // replace with a durable, expiring store
const inFlightEventIds = new Map();
const MAX_BODY_BYTES = 1_048_576;
class BodyTooLargeError extends Error {}

function readRawBody(request) {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks = [];
    request.on("data", (chunk) => {
      size += chunk.length;
      if (size > MAX_BODY_BYTES) {
        chunks.length = 0;
        request.pause(); // keep the socket usable long enough to send 413
        reject(new BodyTooLargeError("body too large"));
        return;
      }
      chunks.push(chunk);
    });
    request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    request.on("error", reject);
  });
}

async function handleEvent(event) {
  console.log("accepted", event.type, event.id);
}

http
  .createServer(async (request, response) => {
    if (request.method !== "POST" || request.url !== "/webhooks/monito") {
      response.writeHead(404).end();
      return;
    }
    try {
      const rawBody = await readRawBody(request);
      if (
        !secret ||
        !verifyWebhook(rawBody, new Headers(request.headers), secret)
      ) {
        response.writeHead(401).end();
        return;
      }
      const event = JSON.parse(rawBody);
      if (
        !event ||
        typeof event !== "object" ||
        Array.isArray(event) ||
        typeof event.id !== "string" ||
        event.id.length === 0
      ) {
        response.writeHead(400).end();
        return;
      }
      if (seenEventIds.has(event.id)) {
        response.writeHead(204).end();
        return;
      }
      const existing = inFlightEventIds.get(event.id);
      if (existing) {
        await existing;
      } else {
        const processing = (async () => {
          await handleEvent(event);
          seenEventIds.add(event.id);
        })();
        inFlightEventIds.set(event.id, processing);
        try {
          await processing;
        } finally {
          inFlightEventIds.delete(event.id);
        }
      }
      response.writeHead(204).end();
    } catch (error) {
      response
        .writeHead(
          error instanceof SyntaxError
            ? 400
            : error instanceof BodyTooLargeError
              ? 413
              : 500,
          { Connection: "close" },
        )
        .end();
    }
  })
  .listen(8080);
```

## Delivery, retries, replay, and deduplication

Delivery records move through `pending`, `sending`, `succeeded`, or `failed`.
The `attempt`, `responseCode`, `durationMs`, `safeResponseExcerpt`, and retry
timestamps are available from the delivery API. Any HTTP 2xx marks the attempt
successful and resets the endpoint failure count.

Non-2xx responses and network failures retry at most seven attempts. The
backoff is immediate, then approximately 1 minute, 5 minutes, 30 minutes, 2
hours, 12 hours, and 24 hours, with bounded jitter. A final failure is retained
as `failed` with a `dead_letter` reason. A `410 Gone` is terminal immediately,
is retained with an `endpoint_gone` reason, and disables the endpoint. An
endpoint is also automatically disabled after 20 consecutive failed attempts.
Any successful 2xx resets that counter to zero; a `410 Gone` disables the
endpoint immediately regardless of the counter.

Use `POST /v1/webhook-deliveries/{id}/replay` with `webhooks:replay` to replay a
delivery retained within the 30-day maximum replay window. Workspace retention
settings can remove a record sooner. Replay creates a new delivery ID but
preserves the original event ID and payload.

Events do not have an ordering guarantee across watches, endpoints, retries, or
replays. Changes to an endpoint URL, subscriptions, or signing secret take effect
immediately. Queued work bound to an older configuration is rejected at the final
pre-send check. A send that already passed that check may still complete using
the old URL and secret. Old deliveries are not automatically retargeted or signed
with the new secret. Coordinate the receiver update with secret rotation and allow
for sends already in progress; the sender provides no dual-secret grace period.

Because delivery is at-least-once, deduplicate in the consumer by the
envelope's stable `id` (and do not use the delivery ID as the business key).
The same event can have multiple delivery attempts and can be replayed without
creating a second business event.
