Create and manage short links, tags and folders, and read the same click analytics as your dashboard — from your own code.
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.
https://my.vitravia.app/api/v1Your 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" }'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:
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.
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 }
}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"
}
}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.
X-RateLimit-Limit (your budget), X-RateLimit-Remaining and X-RateLimit-Reset (seconds). A 429 adds Retry-After.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"]
}
}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.
{
"data": [
{ "id": 3, "hostname": "go.acme.com", "type": "custom", "verified": true, "isDefault": true, "createdAt": "…" }
]
}A short link and where it points.
{
"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.
Newest first. All filters are optional and combine.
active, inactive or all (default).false (default), true or all.createdAt, updatedAt or slug; prefix - for descending. Default -createdAt.curl "https://my.vitravia.app/api/v1/links?tag=7&status=active&limit=50" -H "Authorization: Bearer $VITRAVIA_API_KEY"One link.
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.
curl "https://my.vitravia.app/api/v1/links/lookup?domain=go.acme.com&slug=spring" -H "Authorization: Bearer $VITRAVIA_API_KEY"Creates a link and returns it with 201. The workspace's plan limit on links applies.
http or https URL, up to 2048 characters.- 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.source, medium, campaign, term, content. Workspace defaults fill any you leave out.301, 302, 307 or 308. Inherits the domain’s default when omitted.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" }
}'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.
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" } }'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.
{ "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.
In the order the dashboard shows them. Paginated.
One folder.
Any of name, description, parent. parent: null moves the folder to the top level. A folder that has sub-folders cannot be nested.
Deletes the folder only. Its links stay, unfiled; its sub-folders move to the top level.
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.
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).
24h (hourly), 7d (default), 30d, all or custom. all begins when the link was created, up to 730 days back.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 }
]
}
}Click totals for up to 100 links at once — the numbers in the dashboard's links table. Lifetime totals unless you pass a range.
not_found, rather than reporting it as zero clicks.24h, 7d, 30d, all or custom. Without it, or with all, the totals are for each link's whole life.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.
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.
domain and slug together.24h (hourly), 7d (default), 30d, all or custom. all begins when the oldest link in it was created, up to 365 days back.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.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.
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
},
…
]
}
}One dimension, ranked: where the clicks came from, or on what.
domain and slug together.24h (hourly), 7d (default), 30d, all or custom. all begins when the oldest link in it was created, up to 365 days back.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.country, referrer or device.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.