The VitraVia API

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

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.

Base URLhttps://my.vitravia.app/api/v1
FormatJSON request and response bodies, UTF-8.
KeysCreated in the dashboard under your profile menu → API keys.

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

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

And your first link:

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
links:readRead links
links:writeWrite links
tags:readRead tags
tags:writeWrite tags
folders:readRead folders
folders:writeWrite folders
stats:readRead stats
domains:readRead 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.

{
  "data": [ … ],
  "pagination": { "page": 1, "limit": 25, "totalDocs": 112, "totalPages": 5, "hasNextPage": true }
}
Pagination
pageintegerPage number, from 1. Default 1.
limitintegerItems 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.

{
  "error": {
    "code": "validation_error",
    "message": "That slug is already taken on this domain.",
    "field": "slug"
  }
}
Error codes
bad_request400Malformed JSON, an unknown field, or a bad query parameter.
unauthorized401No key, or a key that is unknown, revoked or expired.
forbidden403The key lacks the scope, or its creator lacks the role — or the workspace has hit its link limit.
not_found404No such record in this workspace.
conflict409A tag or folder with that name already exists.
validation_error422A field value was refused. See field and message.
rate_limited429Too many requests. Wait for Retry-After seconds.
internal_error500Something went wrong on our side. Retry, and tell us if it persists.
unavailable503Analytics 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
BudgetRequests 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.
CostMost 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.
HeadersEvery 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.

{
  "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/domainsscope domains:read
{
  "data": [
    { "id": 3, "hostname": "go.acme.com", "type": "custom", "verified": true, "isDefault": true, "createdAt": "…" }
  ]
}

Tags

{ "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/tagsscope tags:read

Alphabetical. Paginated.

GET/tags/:idscope tags:read

One tag.

POST/tagsscope tags:write
namestringRequired. Up to 64 characters.
colorstringDefault slate.
PATCH/tags/:idscope tags:write

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

DELETE/tags/:idscope tags:write

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

Folders

{ "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/foldersscope folders:read

In the order the dashboard shows them. Paginated.

GET/folders/:idscope folders:read

One folder.

POST/foldersscope folders:write
namestringRequired. Up to 64 characters.
descriptionstringUp to 500 characters.
parentidA top-level folder to nest this one under.
PATCH/folders/:idscope 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/:idscope 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/analyticsscope 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).

rangestring24h (hourly), 7d (default), 30d, all or custom. all begins when the link was created, up to 730 days back.
from, todateFor 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.
curl "https://my.vitravia.app/api/v1/links/482/analytics?range=30d" -H "Authorization: Bearer $VITRAVIA_API_KEY"

{
  "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/clicksscope 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.

idsstringRequired. 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.
rangestringOptional. 24h, 7d, 30d, all or custom. Without it, or with all, the totals are for each link's whole life.
from, todateFor 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.
curl "https://my.vitravia.app/api/v1/analytics/clicks?ids=482,483&range=7d" -H "Authorization: Bearer $VITRAVIA_API_KEY"

{
  "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/timelinescope 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.

tagidEvery link carrying this tag.
folderidEvery link in this folder.
domainstringEvery link of yours on this domain — a hostname or an id.
linkidOne 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.
rangestring24h (hourly), 7d (default), 30d, all or custom. all begins when the oldest link in it was created, up to 365 days back.
from, todateFor 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.
intervalstringhour, 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.

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"

{
  "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/breakdownscope stats:read

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

tagidEvery link carrying this tag.
folderidEvery link in this folder.
domainstringEvery link of yours on this domain — a hostname or an id.
linkidOne 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.
rangestring24h (hourly), 7d (default), 30d, all or custom. all begins when the oldest link in it was created, up to 365 days back.
from, todateFor 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.
bystringRequired. country, referrer or device.
limitintegerRows 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.

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"

{
  "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.

Ready to make your first call?
Create a key in the dashboard, give it only the permissions it needs, and start with GET /me.
Create an API key