# Webhooks

A webhook sends an app's analytics data to your HTTPS endpoint on a recurring
schedule. On each run, Detour sends a signed JSON payload.

Each webhook belongs to one app and one export type. You manage an organization's
webhooks in the **Webhooks** section of the dashboard sidebar.

---

## Availability

Webhooks are a paid feature. The number of **active** webhooks you can run depends
on your plan:

| Plan       | Active webhooks |
| ---------- | --------------- |
| Free       | 0 (unavailable) |
| Starter    | 3               |
| Scale      | 10              |
| Enterprise | Custom          |

For full plan details, see [Billing & Payments](https://detour.swmansion.com/docs/platform/fundamentals/billing).

- If you try to create a webhook beyond your active limit, it is saved but
  created **paused** (inactive). Activate it later after deactivating another webhook.
- If you **downgrade** to a plan with a lower limit, the oldest active webhooks
  are automatically paused to fit the new limit.

---

## Creating a webhook

When you create a webhook, you set these fields:

<PropertyList>
  <Property name="App" required>
    The app whose analytics data is exported.
  </Property>
  <Property name="Endpoint URL" type={"URL"} required>
    Where Detour sends the payload. It must be a public HTTPS URL on port 443. Detour rejects endpoints that resolve to private or internal addresses.
  </Property>
  <Property name="Export type" type={"Overview | Links | Events"} required>
    The dataset to send.

    - `Overview` — aggregated performance, including link stats, organic and non-organic installs, retention, and top events
    - `Links` — a breakdown by link path and query parameter
    - `Events` — SDK event activity
  </Property>
  <Property name="Schedule" type={"Daily | Weekly | Monthly"} required>
    How often Detour sends the payload. See [Delivery schedule](#delivery-schedule).
  </Property>
  <Property name="Platform" type={"all | ios | android"} required>
    The platform whose data is included.
  </Property>
  <Property name="Filters">
    Optional filters that narrow the exported data.
  </Property>
</PropertyList>

:::caution[The signing secret is shown once]
Store the signing secret when you create the webhook, because you cannot view it
again. If you lose it, rotate it. See [Managing webhooks](#managing-webhooks).
:::

---

## Delivery schedule

Deliveries run on a fixed daily cycle (around **08:00 UTC**). Each delivery covers
the period that has closed:

| Schedule  | Runs on                  | Period covered             |
| --------- | ------------------------ | -------------------------- |
| `Daily`   | Every day                | The previous day           |
| `Weekly`  | Mondays                  | The previous 7 days        |
| `Monthly` | The 1st of each month    | The previous calendar month |

All period boundaries are in UTC.

---

## Payload format

Each delivery is an HTTP `POST` with a JSON body:

```json
{
  "webhook_id": "<your webhook id>",
  "type": "overview | links | events",
  "app_id": "<your app id>",
  "period": {
    "start": "2026-05-01T00:00:00.000Z",
    "end": "2026-06-01T00:00:00.000Z"
  },
  "delivered_at": "2026-06-01T08:00:00.000Z",
  "meta": {
    "total_rows": 4821,
    "returned_rows": 4821,
    "truncated": false,
    "row_limit": 10000
  },
  "data": [ /* rows for the selected export type */ ]
}
```

| Field          | Meaning                                                    |
| -------------- | ---------------------------------------------------------- |
| `webhook_id`   | The webhook subscription this delivery came from           |
| `type`         | The export type, `overview`, `links` or `events`           |
| `app_id`       | The app the exported data belongs to                       |
| `period`       | The window the data covers, as two ISO 8601 UTC timestamps |
| `delivered_at` | When Detour built this delivery, ISO 8601 UTC              |
| `meta`         | Row counts for this delivery, described below              |
| `data`         | The rows, in the shape that matches `type`                 |

The rows in `data` are the same rows a manual export of that view returns.

:::note[Both period bounds are inclusive]
A row is in the delivery when its timestamp is at or after `period.start` and at
or before `period.end`. `end` is the midnight UTC boundary between the reported
period and the current one, so a row timestamped exactly then reaches this
delivery and the next one. Aggregate deliveries by `period.start` to avoid
counting that instant twice.
:::

### `meta`

| Field           | Meaning                                                    |
| --------------- | ---------------------------------------------------------- |
| `total_rows`    | Rows the export matched, before any limit                  |
| `returned_rows` | Rows present in `data`                                     |
| `truncated`     | `true` when `data` holds only the first part of the result |
| `row_limit`     | The per-delivery row cap in force                          |

`meta` is additive. `data` keeps its existing shape and position, so an
integration that reads only `data` needs no change.

---

## Row limits

A single delivery carries at most **10,000 rows**. The limit applies to the HTTP
body Detour sends, not to your data, so you can still get the full result in
other ways.

Check `meta.truncated` on every delivery. When it is `true`, `data` holds the
first `row_limit` rows of a larger result, and `meta.total_rows` says how many
rows there were in total.

When deliveries are truncated, you have three options:

- **Narrow the webhook with filters** — fewer clicks are then in scope.
- **Switch to a shorter schedule** — `Daily` rather than `Weekly` or `Monthly`,
  so each delivery covers a smaller window.
- **Pull the full dataset instead** — the
  [Platform API](https://detour.swmansion.com/docs/platform/advanced/api-management) supports paging and
  parameter-level filtering.

---

## Reading `links` rows

A `links` delivery carries two kinds of row, distinguished by `param_key`:

- **Path rows** (`param_key` is `""`) — the totals for one date, short link and
  path.
- **Parameter rows** — the breakdown of a single query parameter value inside
  that path.

### Already-installed opens

Path rows also carry `already_installed_opens`, the taps that opened an installed
app directly through [Universal Links or App Links](https://detour.swmansion.com/docs/platform/fundamentals/universal-app-links).
These opens are included in `clicks`:

- `clicks` — all opens
- `clicks - already_installed_opens` — web opens only

They appear on path rows only, never on parameter rows. Their `short_link` is
empty when the tap was on a plain deep link, or when the open was recorded before
Detour began attributing these opens.

---

## Verifying the signature

Every request includes an `X-Webhook-Signature` header so you can confirm the
payload came from Detour and wasn't tampered with:

```
X-Webhook-Signature: sha256=<hex digest>
```

The digest is an **HMAC-SHA256** of the **raw request body**, keyed with your
webhook's signing secret. Compute the same HMAC on your side and compare:

```javascript
import { createHmac, timingSafeEqual } from "crypto";

function isValidSignature(rawBody, header, signingSecret) {
  const expected = createHmac("sha256", signingSecret)
    .update(rawBody)
    .digest("hex");
  const received = (header ?? "").replace(/^sha256=/, "");
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

:::caution[Use the raw body]
Always compute the HMAC over the **raw, unparsed** request body. Re-serializing
the JSON can change bytes (key order, whitespace) and break the comparison.
:::

---

## Delivery behavior

- Detour posts only to the configured URL. **Redirects are not followed**.
- The endpoint must respond with a **2xx** status within **10 seconds** for the
  delivery to count as successful.
- **Failed deliveries are not retried** within the same cycle. The next attempt
  is the next scheduled run. Make sure your endpoint is reachable at delivery time.
- The dashboard shows the **last successful delivery** time for each webhook.

---

## Managing webhooks

In the **Webhooks** section you can:

- **Pause or resume** — Resuming counts toward your plan's active-webhook limit.
  At the limit, pause another webhook first.
- **Rotate the signing secret** — The new secret is shown once, and the previous
  one stops working immediately.
- **Delete** — Removes the webhook.

You cannot edit a webhook's URL, schedule, export type or platform. To change
them, delete the webhook and create a new one.

The **Last attempt** column shows when Detour last tried to deliver and whether
it succeeded, so you can tell a webhook that never ran from one that keeps
failing. On failure, the tooltip on the warning icon gives the reason, for
example the HTTP status your endpoint returned.

---

## Related pages

[Analytics](https://detour.swmansion.com/docs/platform/fundamentals/analytics/)
  [Billing & Payments](https://detour.swmansion.com/docs/platform/fundamentals/billing/)
  [Dashboard Walkthrough](https://detour.swmansion.com/docs/platform/fundamentals/dashboard/)