# Platform API

The Platform API lets your own backend manage Detour resources and pull analytics without going through the dashboard. It covers three areas:

- short links and the parameters they carry,
- the app's link settings,
- analytics exports in JSON, CSV or XLSX.

Every endpoint is authenticated with a secret API key. The base URL is `https://api.godetour.link`.

## Authentication

Each request carries two headers: a secret API key as a Bearer token, and the ID of the app it applies to. A request missing either one is rejected with `401`.

### API key types

Detour provides two types of API keys:

| Key Type    | Purpose                                   | Use Case                          |
| :---------- | :---------------------------------------- | :-------------------------------- |
| Publishable | Identify your app, used in SDKs           | Client-side SDK implementations   |
| Secret      | Full platform management & administration | Server-side API calls, automation |

### Generating a secret key

Secret keys are issued per app, from the **API configuration** tab of the app you want to manage:

1. Open the app in the dashboard and go to **API configuration**.
2. In the **Secret API Key** card, click **Generate secret key**.
3. Copy the key from the dialog and store it somewhere safe.

:::caution
The key is shown **once**, in that dialog only. Detour stores only a hash of it, so it cannot be retrieved or revealed later. If you lose it, generate a new one.
:::

The card afterwards shows only whether a key is active and when it was generated. To replace a key, use **Regenerate secret key**: the previous key is revoked the moment the new one is issued, so any server-side integration still using the old key stops working immediately. Plan the rollout before you regenerate. Generating and regenerating keys requires the **Owner** or **Admin** role.

### Authentication headers

Include both headers in every request:

```http
Authorization: Bearer <SECRET_API_KEY>
X-App-ID: <YOUR_APP_ID>
```

- `Authorization` — the app's secret key, as a Bearer token.
- `X-App-ID` — the app's ID (a UUID), from the same **API configuration** tab. A value that is not a UUID is rejected with `401`.

<details>
<summary>Example request</summary>

```bash
curl -X GET https://api.godetour.link/api/manage/short-links \
  -H "Authorization: Bearer 1234567890abc..." \
  -H "X-App-ID: your-app-id"
```

</details>

:::note
Secret keys must be stored securely on your server. Never expose them in client-side code or commit them to version control.
:::

---

## Short links

Short links wrap the app's main link together with a set of custom parameters, which the SDK returns to your app after a deferred or direct open.

:::note
The `url` field returned by these endpoints is built from your organization's link host. The examples below use the default `<org_slug>.godetour.link` subdomain, but if your organization has a [custom domain](https://detour.swmansion.com/docs/platform/advanced/custom-domain) configured, short link URLs are returned on that domain instead.
:::

### List short links

```http
GET /api/manage/short-links
```

Returns every short link belonging to the app, newest first. The endpoint takes no query parameters.

**Status codes** — `200`, `401`, `429`, `500`

<details>
<summary>Example request</summary>

```bash
curl -X GET https://api.godetour.link/api/manage/short-links \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app"
```

</details>

<details>
<summary>Example response</summary>

```json
[
  {
    "id": "short_link_uuid",
    "hash": "abc123",
    "url": "https://acme-corp.godetour.link/abc123",
    "parameters": "{\"campaign\":\"summer_sale\"}",
    "name": "Summer Sale Campaign",
    "link_id": "link_uuid",
    "created_at": "2026-06-16T10:30:00.000Z"
  }
]
```

</details>

---

### Create a short link

```http
POST /api/manage/short-links
```

Creates a short link on the app's main link and returns it, including the public URL.

**Request body**

```json
{
  "parameters": "{\"campaign\":\"summer_sale\",\"utm_source\":\"email\"}",
  "name": "Summer Sale Email Campaign"
}
```

**Required fields**

<PropertyList>
  <Property name="parameters" type={"string"} required>
    JSON string of custom parameters (cannot be empty)
  </Property>
</PropertyList>

**Optional fields**

<PropertyList>
  <Property name="name" type={"string"}>
    User-friendly name for the short link.
  </Property>
</PropertyList>

**Status codes** — `201`, `400`, `401`, `402`, `404`, `429`, `500`

- `402` — the Free plan limit of 50 short links per app is reached. Paid plans have no limit.
- `404` — the app has no link yet.

<details>
<summary>Example request</summary>

```bash
curl -X POST https://api.godetour.link/api/manage/short-links \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app" \
  -H "Content-Type: application/json" \
  -d '{
    "parameters": "{\"campaign\":\"summer_sale\"}",
    "name": "Summer Sale"
  }'
```

</details>

<details>
<summary>Example response</summary>

```json
{
  "id": "short_link_uuid",
  "hash": "abc123",
  "url": "https://acme-corp.godetour.link/abc123",
  "parameters": "{\"campaign\":\"summer_sale\",\"utm_source\":\"email\"}",
  "name": "Summer Sale Email Campaign",
  "link_id": "link_uuid",
  "created_at": "2026-06-16T10:35:00.000Z"
}
```

</details>

---

### Update a short link

```http
PATCH /api/manage/short-links
```

Replaces the parameters, and optionally the name, of an existing short link. The hash and URL stay the same, so links already distributed keep working.

**Request body**

```json
{
  "id": "short_link_uuid",
  "parameters": "{\"campaign\":\"fall_sale\"}",
  "name": "Fall Sale Campaign"
}
```

**Required fields**

<PropertyList>
  <Property name="id" type={"string"} required>
    UUID of the short link to update.
  </Property>
  <Property name="parameters" type={"string"} required>
    Updated JSON string of custom parameters.
  </Property>
</PropertyList>

**Optional fields**

<PropertyList>
  <Property name="name" type={"string"}>
    Updated user-friendly name.
  </Property>
</PropertyList>

**Status codes** — `200`, `400`, `401`, `404`, `429`, `500`

<details>
<summary>Example request</summary>

```bash
curl -X PATCH https://api.godetour.link/api/manage/short-links \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "short_link_uuid",
    "parameters": "{\"campaign\":\"fall_sale\"}",
    "name": "Fall Sale Campaign"
  }'
```

</details>

<details>
<summary>Example response</summary>

```json
{
  "id": "short_link_uuid",
  "hash": "abc123",
  "url": "https://acme-corp.godetour.link/abc123",
  "parameters": "{\"campaign\":\"fall_sale\"}",
  "name": "Fall Sale Campaign",
  "link_id": "link_uuid",
  "created_at": "2026-06-16T10:35:00.000Z"
}
```

</details>

---

## Link settings

Every app has one main link. Its settings decide where a click is redirected and which parameters are forwarded.

### Get link settings

```http
GET /api/manage/links
```

Returns the app's main link: its destination, its fallback, and how query parameters are passed through. The endpoint takes no query parameters.

**Response fields**

<PropertyList>
  <Property name="id" type={"string"}>
    UUID of the link.
  </Property>
  <Property name="url" type={"string"}>
    Primary redirect URL.
  </Property>
  <Property name="redirect_url" type={"string"}>
    Fallback redirect URL.
  </Property>
  <Property name="params_pass_strategy" type={"string"}>
    How parameters are passed: `"all"`, `"none"`, or `"specific"`
  </Property>
  <Property name="specific_params" type={"array | null"}>
    If set, only these parameters are passed through.
  </Property>
  <Property name="created_at" type={"string"}>
    Link creation timestamp (ISO 8601)
  </Property>
  <Property name="app_hash" type={"string"}>
    Hash identifier used in short link URLs.
  </Property>
</PropertyList>

**Status codes** — `200`, `401`, `404`, `429`, `500`

- `404` — the app has no link.

<details>
<summary>Example request</summary>

```bash
curl -X GET https://api.godetour.link/api/manage/links \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app"
```

</details>

<details>
<summary>Example response</summary>

```json
{
  "id": "link_uuid",
  "url": "https://example.com",
  "redirect_url": "https://fallback.example.com",
  "params_pass_strategy": "all",
  "specific_params": null,
  "created_at": "2026-06-16T10:00:00.000Z",
  "app_hash": "x9y8z7"
}
```

</details>

---

## Analytics

### Export analytics

```http
GET /api/analytics/stats
```

Exports the same figures the dashboard's Analytics section shows, over a date range you choose. The response is always sent as a file download, in the format given by `format`.

**Query parameters**

<PropertyList>
  <Property name="type" type={"string"} required>
    Data type: `"overview"`, `"links"`, or `"events"`
  </Property>
  <Property name="startDate" type={"string"} required>
    Start of date range (ISO 8601, for example `2026-06-01T00:00:00Z`)
  </Property>
  <Property name="endDate" type={"string"} required>
    End of date range (ISO 8601, for example `2026-06-30T23:59:59Z`)
  </Property>
  <Property name="format" type={"string"} default={"json"}>
    Export format: `"json"`, `"csv"` or `"xlsx"`
  </Property>
  <Property name="platform" type={"string"} default={"all"}>
    Filter by platform: `"all"`, `"ios"`, or `"android"`
  </Property>
  <Property name="linkId" type={"string"}>
    UUID of a specific link to scope the query. Defaults to the main app link. Relevant for `"links"` and `"overview"` types, ignored for `"events"`
  </Property>
  <Property name="limit" type={"number | \"all\""} default={"1000"}>
    Rows per page, `1`–`1000`, or `"all"` for the complete set in one response. `type=links` and `type=events` only.
  </Property>
  <Property name="offset" type={"number"} default={"0"}>
    Row offset for paging. Not accepted with `limit=all`. `type=links` and `type=events` only.
  </Property>
  <Property name="envelope" type={"boolean"} default={"false"}>
    `format=json` only. Wraps the array as `{ meta, rows }` instead of returning a bare array.
  </Property>
  <Property name="paramKeys" type={"string"}>
    Comma-separated parameter names to keep in the breakdown. `type=links` only.
  </Property>
  <Property name="excludeParams" type={"string"}>
    Comma-separated parameter names to drop from the breakdown. `type=links` only.
  </Property>
</PropertyList>

Click-level filters are listed separately under [Filtering link analytics](#filtering-link-analytics).

**Plan requirements**

| `type`     | Required Plan |
| :--------- | :------------ |
| `overview` | Free          |
| `links`    | Paid          |
| `events`   | Paid          |

**Response** — one row per day, with the columns for the requested `type`:

| `type`     | Columns                                                                                                                                   |
| :--------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `overview` | `date`, `total_clicks`, `already_installed_opens`, `fallback_redirects`, `organic_installs`, `non_organic_installs`, `top_event`, `retention_rate` |
| `links`    | `date`, `short_link`, `path`, `param_key`, `param_value`, `clicks`, `matched_installs`, `fallbacks`, `already_installed_opens`             |
| `events`   | `date`, `dimensions` (`event_name`, `platform`, `is_retention`), `metrics` (`event_count`, `unique_devices`)                              |

CSV and XLSX exports flatten `dimensions` and `metrics` into plain columns.

#### Reading `links` rows

A `links` export mixes two kinds of row:

| Row           | How to recognize it                     | What it holds                                  |
| :------------ | :-------------------------------------- | :--------------------------------------------- |
| Path row      | `param_key` and `param_value` are `""`  | Totals for one date, short link and path       |
| Parameter row | `param_key` is set                      | One parameter value inside that path           |

:::caution[Sum path rows only]
A URL can carry several parameters, so parameter rows add up to more clicks than
their path row. Use path rows for totals.
:::

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, and filters apply to them. 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.

Summed this way, `type=links` matches `type=overview` and the dashboard for the
same app, period and platform.

#### Status codes

`200`, `400`, `401`, `402`, `403`, `404`, `429`, `500`, `503`

- `400` — `type` is missing or invalid, or a filter or pagination parameter is out of range or does not apply to `type`.
- `402` — `links` and `events` require a paid plan.
- `403` — `linkId` does not belong to this app.
- `404` — the app was not found. Checked for `links` and `events` only.
- `503` — the analytics query timed out. Narrow the date range, or drop high-cardinality parameters with `excludeParams`.

Error bodies on this endpoint carry a machine-readable `code` alongside `error`,
and sometimes a `hint`:

```json
{
  "error": "Analytics query timed out",
  "code": "query_timeout",
  "hint": "Narrow the date range, or use paramKeys/excludeParams to drop high-cardinality parameters such as fbclid."
}
```

:::note[Date ranges]
The dashboard caps its own date picker at 120 days. This endpoint does not enforce that limit, but staying within it keeps exports comparable with what the dashboard shows.
:::

#### Pagination

`type=links` and `type=events` are paged. The default page is 1,000 rows, and the
response always reports whether more rows exist, so a truncated result is always
signalled.

Every format carries the same metadata in response headers, because CSV and XLSX
cannot carry a response envelope:

| Header            | Meaning                                                    |
| :---------------- | :--------------------------------------------------------- |
| `X-Total-Rows`    | Rows in the full result set. Absent when the page is empty. |
| `X-Returned-Rows` | Rows in this response                                       |
| `X-Row-Offset`    | Offset this page started at                                 |
| `X-Row-Limit`     | Page size used. Absent for `limit=all`                      |
| `X-Truncated`     | `true` when more rows follow                                |
| `Link`            | `<next page url>; rel="next"`, when more rows follow        |

All of them are listed in `Access-Control-Expose-Headers`, so a browser-based
consumer reading the API cross-origin can see them.

With `envelope=true` and `format=json` the same values also come back in the body:

```json
{
  "meta": {
    "total_rows": 4821,
    "returned_rows": 1000,
    "limit": 1000,
    "offset": 0,
    "truncated": true,
    "next_offset": 1000
  },
  "rows": [...]
}
```

Follow `next_offset`, or the `Link` header, until `truncated` is `false`.

`limit=all` returns the complete set in a single response instead. It is opt-in
because a large result takes longer to answer than a page, and it rejects
`offset`.

#### Filtering link analytics

Two independent filter layers apply to `type=links`, and they compose. Both are
rejected with `400` on `type=overview` and `type=events` rather than silently
ignored. A dropped filter would read as "no clicks matched", which is
indistinguishable from a real answer.

**Row filters: `paramKeys` and `excludeParams`.** These decide which breakdown rows
come back. They never change the click totals on path rows. Use them to keep a
high-cardinality parameter such as `fbclid` from consuming the whole page:

```bash
curl -X GET "https://api.godetour.link/api/analytics/stats?type=links&startDate=2026-06-01T00:00:00Z&endDate=2026-06-30T23:59:59Z&excludeParams=fbclid,gclid" \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app"
```

**Click filters.** These decide which clicks are counted, so they do change
`clicks_count`. There are four dimensions, `path`, `param_name`, `param_kv` and
`param_value`, each usable with the operators `is`, `contains` and
`starts_with`. Shorthand parameters avoid URL-encoding JSON:

| Parameter                  | Operator parameter    | Dimension     |
| :------------------------- | :-------------------- | :------------ |
| `filterPath`               | `filterPathOp`        | `path`        |
| `filterParamName`          | `filterParamNameOp`   | `param_name`  |
| `filterParamValue`         | `filterParamValueOp`  | `param_value` |
| `filterParam.<key>`        | `filterParamOp`       | `param_kv`    |

Comma-separated values inside one filter are combined with OR. Separate filter
parameters are combined with AND. The operator defaults to `is`.

```bash
curl -X GET "https://api.godetour.link/api/analytics/stats?type=links&startDate=2026-06-01T00:00:00Z&endDate=2026-06-30T23:59:59Z&filterPath=/checkout&filterPathOp=starts_with&filterParam.utm_source=facebook" \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app"
```

The same filters can be sent as JSON in a single `filters` parameter, as an array of
`{ "dimension": { "key": … }, "operator": …, "values": [ … ], "paramKey": … }`
objects. Use that form when a value contains a comma, which the shorthand cannot
express.

At most **20 filters** and **200 filter values** may be combined in one request.

<details>
<summary>More example requests</summary>

Get overview analytics:

```bash
curl -X GET "https://api.godetour.link/api/analytics/stats?type=overview&startDate=2026-06-01T00:00:00Z&endDate=2026-06-30T23:59:59Z" \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app"
```

Export link analytics as CSV (paid):

```bash
curl -X GET "https://api.godetour.link/api/analytics/stats?type=links&format=csv&startDate=2026-06-01T00:00:00Z&endDate=2026-06-30T23:59:59Z" \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app" \
  -o analytics.csv
```

Filter by platform:

```bash
curl -X GET "https://api.godetour.link/api/analytics/stats?type=events&platform=ios&startDate=2026-06-01T00:00:00Z&endDate=2026-06-30T23:59:59Z" \
  -H "Authorization: Bearer a1b2c3d4e5f6..." \
  -H "X-App-ID: my-app"
```

</details>

---

## Rate limits

Limits are counted per app, per minute, in separate buckets:

| Endpoint                          | Requests / minute |
| :-------------------------------- | :---------------- |
| `GET /api/manage/short-links`     | 120               |
| `POST /api/manage/short-links`    | 60                |
| `PATCH /api/manage/short-links`   | 60                |
| `GET /api/manage/links`           | 120               |
| `GET /api/analytics/stats`        | 30                |

A `429` response carries `Retry-After` (seconds), `X-RateLimit-Remaining` and `X-RateLimit-Reset`. Wait for `Retry-After` before retrying.

---

## Error handling

All errors return a JSON response with an `error` field. Each endpoint above lists the codes it returns and explains the ones specific to it.

| Status | Meaning                                   |
| :----- | :---------------------------------------- |
| `400`  | Invalid request body                      |
| `401`  | Missing or invalid API key / App ID       |
| `402`  | Plan limit reached, upgrade required      |
| `403`  | Resource belongs to another app           |
| `404`  | Resource not found                        |
| `429`  | Rate limit exceeded, back off and retry   |
| `500`  | Server error                              |
| `503`  | Analytics query timed out                 |

---

## Support

:::tip[Need help?]
Support is available on the [Detour Discord server](https://discord.gg/tj7uFuymne) and through the [contact form](https://detour.swmansion.com/#contact).
:::