# The VitraVia API

> Create and manage short links, tags and folders, and read the same click analytics as your dashboard — from your own code.

This is the Markdown copy of https://vitravia.app/docs/api. The two are built from the same source.

## Getting started

The VitraVia API lets your own code do what the dashboard does: create and edit short links, organise them with tags and folders, and read their click analytics. It is a JSON-over-HTTPS REST API. Every request is made on behalf of one workspace, chosen by the key you send.

| Name | Description |
| --- | --- |
| Base URL | `https://my.vitravia.app/api/v1` |
| Format | JSON request and response bodies, UTF-8. |
| Keys | Created in the dashboard under [your profile menu → API keys](https://my.vitravia.app/developers). |

Your first request — who am I, and what can this key do:

```sh
curl https://my.vitravia.app/api/v1/me \
  -H "Authorization: Bearer $VITRAVIA_API_KEY"
```

And your first link:

```sh
curl -X POST https://my.vitravia.app/api/v1/links \
  -H "Authorization: Bearer $VITRAVIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "destination": "https://acme.com/sale", "slug": "spring" }'
```

## Authentication

Send your key in the `Authorization` header of every request, as a bearer token. Keys start with `vv_`. A key is shown once, when it is created — store it in a secret manager or an environment variable, and never ship it in browser or mobile code.

```
Authorization: Bearer vv_…
```

**A key acts as the member who created it.** It carries their role in the workspace, re-checked on every request: if they leave the workspace or become a viewer, their keys stop working. Viewers cannot create keys. A key can also be limited to fewer permissions than its creator has, with scopes:

**Scopes**

| Name | Description |
| --- | --- |
| `links:read` | Read links |
| `links:write` | Write links |
| `tags:read` | Read tags |
| `tags:write` | Write tags |
| `folders:read` | Read folders |
| `folders:write` | Write folders |
| `stats:read` | Read stats |
| `domains:read` | Read domains |

Keys can be given an expiry when created, and revoked at any time from the dashboard. Revoking is immediate and cannot be undone. Owners and admins can revoke any key in their workspace; members can revoke their own.

## Requests & errors

A successful response wraps its payload in `data`. Lists add `pagination`. `DELETE` answers `204 No Content`.

```json
{
  "data": [ … ],
  "pagination": { "page": 1, "limit": 25, "totalDocs": 112, "totalPages": 5, "hasNextPage": true }
}
```

**Pagination**

| Name | Type | Description |
| --- | --- | --- |
| `page` | integer | Page number, from 1. Default 1. |
| `limit` | integer | Items per page, 1–100. Default 25. |

A failure carries an `error` object. `code` is stable — branch on it. `message` is written for people and may change. `field` names the offending input when there is one.

```json
{
  "error": {
    "code": "validation_error",
    "message": "That slug is already taken on this domain.",
    "field": "slug"
  }
}
```

**Error codes**

| Name | Type | Description |
| --- | --- | --- |
| `bad_request` | 400 | Malformed JSON, an unknown field, or a bad query parameter. |
| `unauthorized` | 401 | No key, or a key that is unknown, revoked or expired. |
| `forbidden` | 403 | The key lacks the scope, or its creator lacks the role — or the workspace has hit its link limit. |
| `not_found` | 404 | No such record in this workspace. |
| `conflict` | 409 | A tag or folder with that name already exists. |
| `validation_error` | 422 | A field value was refused. See field and message. |
| `rate_limited` | 429 | Too many requests. Wait for Retry-After seconds. |
| `internal_error` | 500 | Something went wrong on our side. Retry, and tell us if it persists. |
| `unavailable` | 503 | Analytics are temporarily unreachable. Safe to retry. |

Bodies are strict: sending a field an endpoint does not accept is a `bad_request`, not silently ignored. That way a client never believes it changed something it did not. An update with an empty body is refused for the same reason.

**Rate limits**

| Name | Description |
| --- | --- |
| Budget | Requests per minute, per key, set by your plan: 60 on Free, 600 on Pro, 6,000 on Business. A workspace can be granted a different one, so read the header rather than this table. |
| Cost | Most requests cost one. Analytics over a set of links costs more the more links and days it reads — up to 30 — and the remaining budget counts down by what it cost. A refused request costs nothing. |
| Headers | Every response carries `X-RateLimit-Limit` (your budget), `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds). A 429 adds `Retry-After`. |

## Me

### `GET /me`

The workspace this key belongs to, the member it acts as, and its scopes. Needs no scope.

```json
{
  "data": {
    "workspace": { "id": 1, "name": "Acme", "plan": "pro", "timezone": "Europe/Paris" },
    "user": { "id": 9, "name": "Ada Lovelace", "role": "admin" },
    "scopes": ["links:read", "links:write", "stats:read"]
  }
}
```

## Domains

The hosts your links can live on: your workspace's own domains, plus the shared one every workspace can use. Domains are added and verified in the dashboard.

### `GET /domains`

Requires scope `domains:read`.

```json
{
  "data": [
    { "id": 3, "hostname": "go.acme.com", "type": "custom", "verified": true, "isDefault": true, "createdAt": "…" }
  ]
}
```

## Links

A short link and where it points.

```json
{
  "id": 482,
  "shortUrl": "https://go.acme.com/spring",
  "domain": { "id": 3, "hostname": "go.acme.com" },
  "slug": "spring",
  "destination": "https://acme.com/sale",
  "finalUrl": "https://acme.com/sale?utm_source=newsletter",
  "title": "Spring sale",
  "folder": { "id": 12, "name": "Campaigns" },
  "tags": [{ "id": 7, "name": "launch", "color": "amber" }],
  "utm": { "source": "newsletter", "medium": null, "campaign": null, "term": null, "content": null },
  "active": true,
  "status": "active",
  "expiresAt": null,
  "archived": false,
  "archivedAt": null,
  "redirectType": null,
  "preview": { "title": "Spring sale", "description": "…", "imageUrl": "https://…" },
  "createdAt": "2026-09-20T10:14:03.512Z",
  "updatedAt": "2026-09-20T10:14:05.020Z"
}
```

`status` is `active`, `expiring` (expires within 14 days) or `expired` (past its expiry, or switched off). `finalUrl` is the destination with the UTM parameters applied — what visitors are actually sent to.

### `GET /links`

Requires scope `links:read`.

Newest first. All filters are optional and combine.

| Name | Type | Description |
| --- | --- | --- |
| `q` | string | Search in slug, title and destination. |
| `domain` | id | Only links on this domain. |
| `folder` | id | Only links in this folder. |
| `tag` | id | Only links with this tag. |
| `status` | string | `active`, `inactive` or `all` (default). |
| `archived` | string | `false` (default), `true` or `all`. |
| `sort` | string | `createdAt`, `updatedAt` or `slug`; prefix `-` for descending. Default `-createdAt`. |
| `page, limit` |  | See pagination. |

```sh
curl "https://my.vitravia.app/api/v1/links?tag=7&status=active&limit=50" -H "Authorization: Bearer $VITRAVIA_API_KEY"
```

### `GET /links/:id`

Requires scope `links:read`.

One link.

### `GET /links/lookup`

Requires scope `links:read`.

One link, found by its short URL rather than its id — for when what you have is `go.acme.com/spring`. Returns the same object as `GET /links/:id`, whose `id` then works with every other route.

| Name | Type | Description |
| --- | --- | --- |
| `domain` | string | **Required.** The hostname, or the domain's id. |
| `slug` | string | **Required.** The code after the host, exactly as it appears. |

```sh
curl "https://my.vitravia.app/api/v1/links/lookup?domain=go.acme.com&slug=spring" -H "Authorization: Bearer $VITRAVIA_API_KEY"
```

### `POST /links`

Requires scope `links:write`.

Creates a link and returns it with `201`. The workspace's plan limit on links applies.

| Name | Type | Description |
| --- | --- | --- |
| `destination` | string | **Required.** A full `http` or `https` URL, up to 2048 characters. |
| `domain` | id | A verified domain of this workspace, or the shared one. Defaults to the workspace’s default domain. |
| `slug` | string | The code after the host: 1–64 letters, numbers, `-` and `_`. Generated when omitted. Must be free on that domain, and not one of the reserved words. On the shared domain the minimum is 5 characters. |
| `title` | string | Up to 200 characters. Filled from the page title when omitted. |
| `folder` | id | A folder in this workspace. |
| `tags` | id[] | Tags in this workspace, up to 50. |
| `utm` | object | `source`, `medium`, `campaign`, `term`, `content`. Workspace defaults fill any you leave out. |
| `active` | boolean | Default true. False stops the redirect. |
| `expiresAt` | datetime | ISO 8601, in the future. The link stops resolving after it. |
| `redirectType` | string | `301`, `302`, `307` or `308`. Inherits the domain’s default when omitted. |

```sh
curl -X POST https://my.vitravia.app/api/v1/links \
  -H "Authorization: Bearer $VITRAVIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "destination": "https://acme.com/sale",
    "slug": "spring",
    "tags": [7],
    "utm": { "source": "newsletter" }
  }'
```

### `PATCH /links/:id`

Requires scope `links:write`.

Changes some of a link's fields and returns the updated link. Send only what you want to change. A link's **domain cannot be changed** — create a new link instead.

| Name | Type | Description |
| --- | --- | --- |
| `destination` | string | A full http or https URL. |
| `slug` | string | Same rules as on create, and must be free on the link’s domain. The old short URL stops working. |
| `title` | string \| null | null clears it. |
| `folder` | id \| null | null takes the link out of its folder. |
| `tags` | id[] | Replaces the whole list. [] removes every tag. |
| `utm` | object | Merged with what is stored: only the keys you send change. null clears one. |
| `active` | boolean | The kill switch. |
| `expiresAt` | datetime \| null | null removes the expiry. |
| `redirectType` | string \| null | null inherits the domain default again. |
| `archived` | boolean | Hides the link from lists. Archived links keep redirecting. |

```sh
curl -X PATCH https://my.vitravia.app/api/v1/links/482 \
  -H "Authorization: Bearer $VITRAVIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "destination": "https://acme.com/sale-extended", "utm": { "campaign": "spring-26" } }'
```

### `DELETE /links/:id`

Requires scope `links:write`.

Deletes a link for good: the short URL stops resolving and the slug becomes free again. To hide a link but keep it working, `PATCH` it with `{ "archived": true }` instead. Answers `204`.

## Tags

```json
{ "id": 7, "name": "launch", "slug": "launch", "color": "amber", "createdAt": "…", "updatedAt": "…" }
```

Names are unique per workspace, ignoring case and punctuation — “Launch!” and “launch” are the same tag, and creating the second is a `conflict`. Colours: `slate`, `red`, `orange`, `amber`, `green`, `blue`, `violet`, `pink`.

### `GET /tags`

Requires scope `tags:read`.

Alphabetical. Paginated.

### `GET /tags/:id`

Requires scope `tags:read`.

One tag.

### `POST /tags`

Requires scope `tags:write`.

| Name | Type | Description |
| --- | --- | --- |
| `name` | string | **Required.** Up to 64 characters. |
| `color` | string | Default `slate`. |

### `PATCH /tags/:id`

Requires scope `tags:write`.

`name` and/or `color`. Renaming also updates the slug.

### `DELETE /tags/:id`

Requires scope `tags:write`.

Removes the tag from every link that had it. The links are not touched otherwise.

## Folders

```json
{ "id": 12, "name": "Campaigns", "slug": "campaigns", "description": null, "parent": null, "createdAt": "…", "updatedAt": "…" }
```

Folders nest two levels deep: a top-level folder can hold sub-folders, and those cannot hold any. A link lives in at most one folder. Names are unique per workspace, as for tags.

### `GET /folders`

Requires scope `folders:read`.

In the order the dashboard shows them. Paginated.

### `GET /folders/:id`

Requires scope `folders:read`.

One folder.

### `POST /folders`

Requires scope `folders:write`.

| Name | Type | Description |
| --- | --- | --- |
| `name` | string | **Required.** Up to 64 characters. |
| `description` | string | Up to 500 characters. |
| `parent` | id | A top-level folder to nest this one under. |

### `PATCH /folders/:id`

Requires scope `folders:write`.

Any of `name`, `description`, `parent`. `parent: null` moves the folder to the top level. A folder that has sub-folders cannot be nested.

### `DELETE /folders/:id`

Requires scope `folders:write`.

Deletes the folder only. Its links stay, unfiled; its sub-folders move to the top level.

## Analytics

The same figures as the dashboard's analytics screen. Clicks are split into humans and bots, and buckets are in UTC. Every response carries a `granularity` — not a parameter, but what the window was read at: hours for `24h` and for a `custom` window of a day or two, days otherwise.

Human clicks are further split into `unique` and `repeated`. A click is **repeated** when the same visitor already opened the same link that UTC day, so a visitor who comes back the next day is unique again, and over a week counts once per day they came. It is an estimate: VitraVia sets no cookie and keeps no IP address. It compares a hash that changes every day and is discarded after it. People sharing a network and browser may count as one; someone switching networks may count as two.

### `GET /links/:id/analytics`

Requires scope `stats:read`.

Totals, a time series, the top countries, referrers and devices, and up to 25 of the most recent human clicks. `changePct` compares with the period just before; it is `null` for `all`, or when there was no traffic to compare with. Each breakdown lists up to 25 values, with the long tail folded into `(other)`.

| Name | Type | Description |
| --- | --- | --- |
| `range` | string | `24h` (hourly), `7d` (default), `30d`, `all` or `custom`. `all` begins when the link was created, up to 730 days back. |
| `from, to` | date | For `custom` only: `YYYY-MM-DD`, both days included. A future end is pulled back to today, and a window wider than 730 days is trimmed at the far end rather than refused — the response echoes the `from` and `to` actually used, so read them rather than assuming your own. |

```sh
curl "https://my.vitravia.app/api/v1/links/482/analytics?range=30d" -H "Authorization: Bearer $VITRAVIA_API_KEY"
```

```json
{
  "data": {
    "linkId": 482,
    "range": "30d",
    "from": "2026-08-23T00:00:00.000Z",
    "to": "2026-09-22T00:00:00.000Z",
    "granularity": "day",
    "totals": {
      "clicks": 12483, "humans": 11204, "unique": 8851, "repeated": 2353, "bots": 1279,
      "previousClicks": 10560, "changePct": 18.2
    },
    "series": [
      { "start": "2026-08-23T00:00:00.000Z", "humans": 312, "unique": 247, "repeated": 65, "bots": 41 },
      …
    ],
    "breakdowns": {
      "countries": [ { "value": "DE", "label": "Germany", "clicks": 3914, "share": 34.9 }, … ],
      "referrers": [ { "value": "(direct)", "label": "Direct / none", "clicks": 4301, "share": 38.4 }, … ],
      "devices":   [ { "value": "iPhone", "label": "iPhone", "clicks": 5120, "share": 45.7 }, … ]
    },
    "recent": [
      { "at": "2026-09-21T14:42:07.000Z", "country": "DE", "countryName": "Germany", "device": "iPhone", "referrer": "newsletter.acme.com", "repeated": false }
    ]
  }
}
```

### `GET /analytics/clicks`

Requires scope `stats:read`.

Click totals for up to 100 links at once — the numbers in the dashboard's links table. Lifetime totals unless you pass a `range`.

| Name | Type | Description |
| --- | --- | --- |
| `ids` | string | **Required.** Comma-separated link ids, up to 100. All of them must be links in this workspace — one that is not makes the whole call a `not_found`, rather than reporting it as zero clicks. |
| `range` | string | Optional. `24h`, `7d`, `30d`, `all` or `custom`. Without it, or with `all`, the totals are for each link's whole life. |
| `from, to` | date | For `custom` only: `YYYY-MM-DD`, both days included. A future end is pulled back to today, and a window wider than 730 days is trimmed at the far end rather than refused — the response echoes the `from` and `to` actually used, so read them rather than assuming your own. |

```sh
curl "https://my.vitravia.app/api/v1/analytics/clicks?ids=482,483&range=7d" -H "Authorization: Bearer $VITRAVIA_API_KEY"
```

```json
{
  "data": {
    "range": "7d",
    "from": "2026-09-15T00:00:00.000Z",
    "to": "2026-09-22T00:00:00.000Z",
    "links": [ { "linkId": 482, "clicks": 3120 }, { "linkId": 483, "clicks": 0 } ]
  }
}
```

The two routes below answer for one link **or a set of them** — everything with a tag, in a folder, on a domain, or the whole workspace. Name at most one; name none for the workspace.

A set is priced before it is read: the more links it holds and the wider the window, the more it costs, both against your rate limit and against what one request may read at all. A set too large for the window you asked for is refused with a `bad_request` whose message says how many days would fit — narrow the range, or ask about a smaller set. One link is never refused this way.

### `GET /analytics/timeline`

Requires scope `stats:read`.

Clicks over time, split into unique visits, repeated visits and bots, with totals and the change against the period before. For a set, uniqueness is still per link: a visitor who opens two links in a tag counts once on each.

| Name | Type | Description |
| --- | --- | --- |
| `tag` | id | Every link carrying this tag. |
| `folder` | id | Every link in this folder. |
| `domain` | string | Every link of yours on this domain — a hostname or an id. |
| `link` | id | One link. Or name it by its short URL: `domain` and `slug` together. |
| (none) |  | Name none of the above for every link in the workspace. Name at most one. |
| `range` | string | `24h` (hourly), `7d` (default), `30d`, `all` or `custom`. `all` begins when the oldest link in it was created, up to 365 days back. |
| `from, to` | date | For `custom` only: `YYYY-MM-DD`, both days included. A future end is pulled back to today, and a window wider than 365 days is trimmed at the far end rather than refused — the response echoes the `from` and `to` actually used, so read them rather than assuming your own. |
| `interval` | string | `hour`, `day`, `week` or `month`: how wide each point is. Defaults to the `granularity` the window was read at. `hour` needs a window of two days or less. |

Weeks start on Monday and months on the 1st, in UTC — the same weeks whatever window you ask for, so they compare from one request to the next. The first and last can therefore reach outside the window; those are marked `partial`, and count only the days inside it. `interval` is echoed: ask for hours over a window whose hourly detail has been compacted away and you get days, and the response says so.

```sh
curl "https://my.vitravia.app/api/v1/analytics/timeline?tag=7&range=custom&from=2026-01-01&to=2026-06-30&interval=month" \
  -H "Authorization: Bearer $VITRAVIA_API_KEY"
```

```json
{
  "data": {
    "scope": { "type": "tag", "id": 7, "name": "spring", "links": 437 },
    "range": "custom",
    "from": "2026-01-01T00:00:00.000Z",
    "to": "2026-07-01T00:00:00.000Z",
    "granularity": "day",
    "interval": "month",
    "totals": {
      "clicks": 91204, "humans": 84110, "unique": 70412, "repeated": 13698, "bots": 7094,
      "previousClicks": 80112, "changePct": 13.8
    },
    "series": [
      {
        "start": "2026-01-01T00:00:00.000Z", "end": "2026-02-01T00:00:00.000Z",
        "humans": 12840, "unique": 10702, "repeated": 2138, "bots": 1033, "partial": false
      },
      …
    ]
  }
}
```

### `GET /analytics/breakdown`

Requires scope `stats:read`.

One dimension, ranked: where the clicks came from, or on what.

| Name | Type | Description |
| --- | --- | --- |
| `tag` | id | Every link carrying this tag. |
| `folder` | id | Every link in this folder. |
| `domain` | string | Every link of yours on this domain — a hostname or an id. |
| `link` | id | One link. Or name it by its short URL: `domain` and `slug` together. |
| (none) |  | Name none of the above for every link in the workspace. Name at most one. |
| `range` | string | `24h` (hourly), `7d` (default), `30d`, `all` or `custom`. `all` begins when the oldest link in it was created, up to 365 days back. |
| `from, to` | date | For `custom` only: `YYYY-MM-DD`, both days included. A future end is pulled back to today, and a window wider than 365 days is trimmed at the far end rather than refused — the response echoes the `from` and `to` actually used, so read them rather than assuming your own. |
| `by` | string | **Required.** `country`, `referrer` or `device`. |
| `limit` | integer | Rows to return, 1–100. Default 25. |

`share` is a percentage of all clicks in `totals`, which are exact. `coverage` is how much of that the rows returned account for — under 100 only when `limit` cut the list, since the long tail is always there as `(other)`.

Over a set, **country and referrer rankings are approximate**, and `approximate` says so. Each link keeps its own top values and folds the rest into `(other)` before a set is summed, so a value that was never near the top of any one link cannot appear as a row of its own, however much it adds up to across them. Devices, and any ranking for a single link, are exact.

```sh
curl "https://my.vitravia.app/api/v1/analytics/breakdown?domain=go.acme.com&by=referrer&range=30d&limit=3" \
  -H "Authorization: Bearer $VITRAVIA_API_KEY"
```

```json
{
  "data": {
    "scope": { "type": "domain", "id": 3, "name": "go.acme.com", "links": 1204 },
    "range": "30d",
    "from": "2026-08-27T00:00:00.000Z",
    "to": "2026-09-26T00:00:00.000Z",
    "granularity": "day",
    "by": "referrer",
    "totals": { "clicks": 48210, "humans": 45002, "bots": 3208 },
    "approximate": true,
    "coverage": 71.6,
    "values": [
      { "value": "(direct)", "label": "Direct / none", "clicks": 20114, "share": 41.7 },
      { "value": "newsletter.acme.com", "label": "newsletter.acme.com", "clicks": 9870, "share": 20.5 },
      { "value": "(other)", "label": "Other", "clicks": 4530, "share": 9.4 }
    ]
  }
}
```

A set means the links in it **now**: tag a link today and its whole history counts towards the tag. A link in several tags counts towards each of them, so tag totals do not add up to the workspace's.
