# Webhooks

Webhooks let Tinylytics send a JSON `POST` to your application when selected events happen. Use them to pipe downtime alerts, live hits, kudos, custom events, AI insights, and content issues into your own systems without polling the API.

Webhooks are available on all paid plans. Tinylytics only creates outbound webhook events when the site owner has an active subscription, the webhook is active, the event type is selected, and the underlying write is accepted.

## 1. Quick Start <span id="quick-start"></span>

1. Open **Account Settings**.
2. Choose **Webhooks**.
3. Create a webhook with a public HTTP or HTTPS URL. HTTPS is recommended.
4. Select the events you want to receive.
5. Save the webhook and copy the signing secret.
6. Use **Send Test** from the webhook edit screen to confirm your endpoint and signature verification.

Your endpoint should return a `2xx` response as soon as it accepts the delivery. Do any slower processing in a background job on your side.

Treat webhook delivery as at-least-once. Store `X-Tinylytics-Delivery`, or the nested `hit.id` or `kudo.uid`, if duplicate processing would be a problem.

## 2. Delivery Contract <span id="delivery-contract"></span>

Tinylytics sends each delivery as an HTTP `POST` with a JSON body.

| Property | Value |
| --- | --- |
| Method | `POST` |
| Content type | `application/json` |
| Timeout | 30 seconds |
| Open timeout | 10 seconds |
| Success response | Any `2xx` status |
| Failed response | Network errors, unsafe URLs, timeouts, `3xx`, `4xx`, or `5xx` responses |
| Retry limit | Up to 10 failed attempts |

Each delivery includes these headers:

| Header | Description |
| --- | --- |
| `Content-Type` | Always `application/json`. |
| `X-Tinylytics-Event` | Event type, such as `new_hit` or `monitor_down`. |
| `X-Tinylytics-Delivery` | Unique delivery id. This is the `WebhookEvent` id and can be used for idempotency. |
| `X-Tinylytics-Timestamp` | Unix timestamp for the delivery attempt. |
| `X-Signature` | HMAC SHA-256 signature in the format `sha256=<hex digest>`. |

Retries use this schedule after failed attempts: 30 seconds, 1 minute, 5 minutes, 15 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, and 12 hours.

Tinylytics stores recent deliveries under the webhook's **Events** page. You can inspect the payload, response code, response body, error message, attempt count, and retry status there. Failed events can be retried manually from that page.

## 3. Event Directory <span id="event-directory"></span>

| Event | When it fires | Payload key |
| --- | --- | --- |
| `monitor_down` | An uptime monitor reports the site as down. | `monitor`, `downtime` |
| `monitor_up` | An uptime monitor reports the site as recovered. | `monitor`, `downtime` |
| `new_hit` | Tinylytics accepts a live hit from the collector script, tracking pixel, API create endpoint, or API batch create endpoint. Batch creates emit one webhook event per accepted hit. | `hit` |
| `new_kudo` | Tinylytics accepts a browser kudo, API kudo, or verified Webmention like. | `kudo` |
| `new_event` | Tinylytics accepts a custom analytics event from the collector script or API create/batch endpoints. Batch creates emit one webhook event per accepted event. | `analytics_event` |
| `new_insight` | Tinylytics creates a new AI insight for the site. | `insight` |
| `content_issue` | Content monitoring records a new or changed `broken_link` or `mixed_content` issue. | `check` |

Existing webhooks only receive the event types you selected. Edit a webhook and select any new types you want.

Tinylytics does not emit hit, kudo, or custom-event webhooks for ignored paths, ignored visitors, spam-suppressed writes, rate-limited writes, imports, seeds, cron-generated data, reimports, manual model creates, or kudo deletes. Spelling issues do not emit `content_issue`.

## 4. Payload Reference <span id="payload-reference"></span>

Every event uses the same top-level wrapper.

| Field | Type | Description |
| --- | --- | --- |
| `event` | string | The event type. Matches `X-Tinylytics-Event`. |
| `timestamp` | string | ISO 8601 timestamp for when Tinylytics created the webhook payload. |
| `site` | object | Site metadata. |

The `site` object has this shape:

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | Tinylytics site id. |
| `name` | string | Display name for the site. |
| `url` | string | Site URL. |
| `dashboard_url` | string | Tinylytics dashboard URL for the site. |

### `monitor_down`

```json
{
  "event": "monitor_down",
  "timestamp": "2026-05-14T12:00:00Z",
  "site": {
    "id": 123,
    "name": "Example Blog",
    "url": "https://example.com",
    "dashboard_url": "https://tinylytics.app/sites/123"
  },
  "monitor": {
    "id": 456,
    "status": "down"
  },
  "downtime": {
    "started_at": "2026-05-14T11:58:00Z",
    "started_at_formatted": "May 14, 2026 at 11:58",
    "ended_at": null,
    "ended_at_formatted": null,
    "duration_seconds": 120,
    "duration_formatted": "2 minutes",
    "error": "Connection timeout"
  }
}
```

### `monitor_up`

```json
{
  "event": "monitor_up",
  "timestamp": "2026-05-14T12:08:00Z",
  "site": {
    "id": 123,
    "name": "Example Blog",
    "url": "https://example.com",
    "dashboard_url": "https://tinylytics.app/sites/123"
  },
  "monitor": {
    "id": 456,
    "status": "up"
  },
  "downtime": {
    "started_at": "2026-05-14T11:58:00Z",
    "started_at_formatted": "May 14, 2026 at 11:58",
    "ended_at": "2026-05-14T12:08:00Z",
    "ended_at_formatted": "May 14, 2026 at 12:08",
    "duration_seconds": 600,
    "duration_formatted": "10 minutes",
    "error": "Connection timeout"
  }
}
```

### `new_hit`

The `hit` object matches the existing create-hit API response shape.

```json
{
  "event": "new_hit",
  "timestamp": "2026-05-14T12:15:00Z",
  "site": {
    "id": 123,
    "name": "Example Blog",
    "url": "https://example.com",
    "dashboard_url": "https://tinylytics.app/sites/123"
  },
  "hit": {
    "id": 789,
    "url": "https://example.com/articles/webhooks",
    "path": "/articles/webhooks",
    "referrer": "https://example.org",
    "country": "US",
    "browser_name": "Safari",
    "platform_name": "macOS",
    "is_mobile": false,
    "source": "newsletter",
    "unique_hash": "4cc1ed6d2f0d",
    "visitor_hash": "9e89d84f3e47",
    "created_at": "2026-05-14T12:15:00.000Z"
  }
}
```

`referrer`, `country`, `browser_name`, `platform_name`, `source`, `unique_hash`, and `visitor_hash` can be `null` depending on the request and privacy rules.

### `new_kudo`

The `kudo` object matches the existing API kudo response shape.

```json
{
  "event": "new_kudo",
  "timestamp": "2026-05-14T12:20:00Z",
  "site": {
    "id": 123,
    "name": "Example Blog",
    "url": "https://example.com",
    "dashboard_url": "https://tinylytics.app/sites/123"
  },
  "kudo": {
    "id": 987,
    "uid": "kudo_d4f96c",
    "path": "/articles/webhooks",
    "created_at": "2026-05-14T12:20:00.000Z"
  }
}
```

### `new_event`

The nested object is named `analytics_event` so it does not collide with the top-level webhook `event` string. `value` comes from the event’s optional value property and may be `null`.

```json
{
  "event": "new_event",
  "timestamp": "2026-05-14T12:25:00Z",
  "site": {
    "id": 123,
    "name": "Example Blog",
    "url": "https://example.com",
    "dashboard_url": "https://tinylytics.app/sites/123"
  },
  "analytics_event": {
    "id": 555,
    "event": "cta.click",
    "value": "hero",
    "url": "https://example.com/pricing",
    "path": "/pricing",
    "referrer": "https://example.org",
    "country": "US",
    "source": "newsletter",
    "unique_hash": "4cc1ed6d2f0d",
    "visitor_hash": "9e89d84f3e47",
    "created_at": "2026-05-14T12:25:00.000Z"
  }
}
```

### `new_insight`

```json
{
  "event": "new_insight",
  "timestamp": "2026-05-14T13:00:00Z",
  "site": {
    "id": 123,
    "name": "Example Blog",
    "url": "https://example.com",
    "dashboard_url": "https://tinylytics.app/sites/123"
  },
  "insight": {
    "id": 42,
    "insights_for_date": "2026-05-14",
    "formatted_insights_date": "May 14, 2026",
    "generated_at": "2026-05-14T13:00:00.000Z",
    "summary": "Traffic stayed steady with a small mid-week lift.",
    "signals": [],
    "context_signals_count": 0,
    "traffic_patterns": "Visits were evenly spread through the week.",
    "best_content": "/articles/webhooks led the week.",
    "recommendations": "Keep publishing on the topics that drew attention."
  }
}
```

### `content_issue`

Fired for new or changed `broken_link` and `mixed_content` checks only.

```json
{
  "event": "content_issue",
  "timestamp": "2026-05-14T13:05:00Z",
  "site": {
    "id": 123,
    "name": "Example Blog",
    "url": "https://example.com",
    "dashboard_url": "https://tinylytics.app/sites/123"
  },
  "check": {
    "id": 77,
    "url": "https://example.com/broken-link",
    "status_code": 404,
    "error_message": "HTTP 404: Not Found",
    "issue_type": "broken_link",
    "checked_at": "2026-05-14T13:05:00.000Z",
    "ignored": false
  }
}
```

Test deliveries from the webhook edit screen include a top-level `"test": true` flag and sample data. They use the same delivery job, headers, signature format, and retry behaviour as live deliveries.

## 5. Signature Verification <span id="signature-verification"></span>

Tinylytics signs the exact JSON request body with the webhook signing secret:

```text
X-Signature: sha256=<hex HMAC SHA-256 digest>
```

Verify the signature before parsing or trusting the JSON. Always compute the HMAC from the raw request body you received. If you enable the content wrapper or use Discord formatting, the signed body is the transformed body that Tinylytics sent.

### Rails Example

```ruby
class TinylyticsWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    return head :unauthorized unless valid_signature?

    payload = JSON.parse(request.raw_post)

    case request.headers["X-Tinylytics-Event"]
    when "monitor_down"
      # Notify your incident system.
    when "monitor_up"
      # Resolve your incident.
    when "new_hit"
      # Enqueue analytics processing.
    when "new_kudo"
      # Notify the author or update local counters.
    when "new_event"
      # Handle a custom analytics event.
    when "new_insight"
      # Store or forward the AI insight summary.
    when "content_issue"
      # Open a content-fix ticket.
    end

    head :ok
  end

  private

  def valid_signature?
    signature = request.headers["X-Signature"].to_s
    body = request.raw_post
    expected = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", signing_secret, body)}"

    signature.bytesize == expected.bytesize &&
      ActiveSupport::SecurityUtils.secure_compare(signature, expected)
  end

  def signing_secret
    Rails.application.credentials.dig(:tinylytics, :webhook_signing_secret)
  end
end
```

### Node.js Example

Mount this route before any JSON middleware that would consume the raw body.

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

const app = express();

app.post("/webhooks/tinylytics", express.raw({ type: "application/json" }), (req, res) => {
  const secret = process.env.TINYLYTICS_WEBHOOK_SIGNING_SECRET;
  const signature = req.get("X-Signature") || "";
  const expected = `sha256=${crypto.createHmac("sha256", secret).update(req.body).digest("hex")}`;

  const verified =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!verified) {
    return res.sendStatus(401);
  }

  const eventType = req.get("X-Tinylytics-Event");
  const payload = JSON.parse(req.body.toString("utf8"));

  switch (eventType) {
    case "new_hit":
      // Enqueue analytics processing.
      break;
    case "new_kudo":
      // Notify the author or update local counters.
      break;
    case "new_event":
      // Handle a custom analytics event.
      break;
    case "new_insight":
      // Store or forward the AI insight summary.
      break;
    case "content_issue":
      // Open a content-fix ticket.
      break;
    case "monitor_down":
    case "monitor_up":
      // Route monitor alerts.
      break;
  }

  res.sendStatus(200);
});
```

## 6. Platform And Body Options <span id="platform-options"></span>

The default platform is **Generic**. It receives the JSON payloads shown above.

If **Wrap content** is enabled, Tinylytics wraps the event payload before delivery:

```json
{
  "content": {
    "event": "new_hit",
    "timestamp": "2026-05-14T12:15:00Z",
    "site": {},
    "hit": {}
  }
}
```

Discord webhooks use custom embeds for `monitor_down` and `monitor_up`. Other event types use the generic Discord fallback unless Tinylytics adds a custom Discord format later.

## 7. Common Flows <span id="common-flows"></span>

### n8n

To pipe webhook events into n8n (signature verification, routing, and sample workflow JSON), see [Tinylytics + n8n](/docs/developer/n8n).

### Downtime Alerts

Select `monitor_down` and `monitor_up`. Verify the signature, route `monitor_down` to your alerting or incident tool, and mark the incident resolved when `monitor_up` arrives.

### Live Hit Processing

Select `new_hit`. Tinylytics creates one webhook event after each accepted live hit from the collector script, tracking pixel, API create endpoint, or API batch endpoint. Use `X-Tinylytics-Delivery` for delivery idempotency and `hit.id` for hit idempotency.

### Kudos Notifications

Select `new_kudo`. Tinylytics creates one webhook event after each accepted browser kudo, API kudo, or verified Webmention like. Use `kudo.uid` if you need a stable public identifier.

### Custom Events

Select `new_event`. Tinylytics creates one webhook event after each accepted custom analytics event from the collector script or API. Use `analytics_event.id` for idempotency.

### AI Insights

Select `new_insight`. Tinylytics creates one webhook event after each newly generated insight record.

### Content Issues

Select `content_issue`. Tinylytics creates one webhook event when content monitoring first records a broken link or mixed content issue, or when that issue type changes. Unchanged re-checks do not re-fire.

### API Writes And Webhooks

Successful API hit, kudo, and custom-event creates can emit webhook events. Ignored or rejected writes do not emit webhooks, even if the API request returns a handled response.

## 8. Troubleshooting <span id="troubleshooting"></span>

| Problem | What to check |
| --- | --- |
| No deliveries appear | Confirm the site owner has an active subscription, the webhook is active, and the event type is selected. |
| Test works but live hits do not | Confirm the hit is not ignored, spam-suppressed, imported, seeded, or manually created. |
| Signature mismatch | Verify against the raw request body, not a parsed and re-serialized JSON object. |
| Repeated retries | Return a `2xx` status after accepting the event. Redirects, `4xx`, and `5xx` responses are treated as failures. |
| Endpoint rejected | Use a public HTTP or HTTPS endpoint. Localhost, private network addresses, and unsafe URLs are blocked. |
| Discord output is generic | Only `monitor_down` and `monitor_up` use custom Discord embeds today. |