API Reference
Base URL: https://open.utm.bar/v1
Authentication: Authorization: Bearer sk_live_xxxx or X-API-Key: sk_live_xxxx
Short Links
Create a Short Link
POST /v1/links
Scopes required: links:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
original_url | string | ✅ | The destination URL |
title | string | — | Display name for the link |
custom_code | string | — | Custom short code (a-z A-Z 0-9 - _, 2–64 chars) |
external_id | string | — | Your internal reference ID, stored as-is (max 255 chars) |
expires_at | ISO 8601 | — | Expiry datetime (null = never) |
utm_source | string | — | UTM source parameter |
utm_medium | string | — | UTM medium parameter |
utm_campaign | string | — | UTM campaign parameter |
utm_term | string | — | UTM term parameter |
utm_content | string | — | UTM content parameter |
campaign_id | string | — | Assign to a campaign (campaign UUID) |
domain_id | string | — | Custom domain ID (from POST /v1/domains) |
curl -X POST https://open.utm.bar/v1/links \
-H "Authorization: Bearer sk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{
"original_url": "https://example.com/page",
"title": "My Link",
"custom_code": "launch-2026",
"utm_source": "twitter",
"utm_medium": "social"
}'
Response 201 Created
{
"data": {
"id": "...",
"code": "launch-2026",
"short_url": "https://utm.bar/launch-2026",
"original_url": "https://example.com/page",
"title": "My Link",
"is_active": true,
"click_count": 0,
"external_id": "order_98765",
"expires_at": null,
"domain_id": null,
"domain": null,
"campaign_id": null,
"utm_source": "twitter",
"utm_medium": "social",
"created_at": "2026-05-25T00:00:00Z"
}
}
Use
domain_idto bind a custom domain — see Custom Domains. When set,short_urlwill use your custom domain instead ofutm.bar.custom_codeconflicts return409 Conflict.
List Short Links
GET /v1/links
Scopes required: links:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
page_size | integer | 20 | Items per page (max 100) |
q | string | — | Search by title or original URL |
is_active | boolean | — | Filter by active/disabled status |
curl "https://open.utm.bar/v1/links?page=1&page_size=20" \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
{
"data": {
"items": [ /* array of link objects */ ],
"total": 42,
"page": 1,
"page_size": 20
}
}
Get a Single Link
GET /v1/links/:id
Scopes required: links:read
curl https://open.utm.bar/v1/links/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer sk_live_xxxx"
The path parameter is the link id (UUID) returned at creation time.
Response 200 OK — returns a single link object.
Get a Link by External ID
GET /v1/links/external/:external_id
Scopes required: links:read
Look up a short link by the external_id you provided at creation time. Semantics match link ID lookup. Returns 404 if no match is found.
curl https://open.utm.bar/v1/links/external/order_98765 \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
{
"data": {
"id": "...",
"code": "launch-2026",
"short_url": "https://utm.bar/launch-2026",
"original_url": "https://example.com/page",
"external_id": "order_98765",
"is_active": true,
"click_count": 42,
"domain_id": null,
"domain": null,
"campaign_id": null,
"created_at": "2026-05-25T00:00:00Z"
}
}
Update a Link
PATCH /v1/links/:id
Scopes required: links:write
original_urlandcodecannot be changed after creation.
Request Body (all fields optional)
| Field | Type | Description |
|---|---|---|
title | string | New display name |
expires_at | ISO 8601 / null | Update or clear expiry |
utm_source | string | Update UTM source |
utm_medium | string | Update UTM medium |
utm_campaign | string | Update UTM campaign |
utm_term | string | Update UTM term |
utm_content | string | Update UTM content |
curl -X PATCH https://open.utm.bar/v1/links/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer sk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "title": "Updated Title", "expires_at": "2026-12-31T23:59:59Z" }'
Response 200 OK — returns the updated link object.
Disable a Link
POST /v1/links/:id/disable
Scopes required: links:delete
curl -X POST https://open.utm.bar/v1/links/550e8400-e29b-41d4-a716-446655440000/disable \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
Enable a Link
POST /v1/links/:id/enable
Scopes required: links:write
curl -X POST https://open.utm.bar/v1/links/550e8400-e29b-41d4-a716-446655440000/enable \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
Delete a Link
DELETE /v1/links/:id
Scopes required: links:delete
Deletion is permanent. Consider disabling instead if you may need the link later.
curl -X DELETE https://open.utm.bar/v1/links/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer sk_live_xxxx"
Response 204 No Content
Get Link Statistics
GET /v1/links/:id/stats
Scopes required: links:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
start | YYYY-MM-DD | Last 7 days | Start date (inclusive) |
end | YYYY-MM-DD | Today | End date (inclusive) |
tz | IANA timezone | UTC | Timezone for day boundaries |
granularity | string | day | Aggregation period: day / week / month / year |
The date field in daily is always YYYY-MM-DD and represents the start of the period (week = Monday, month = 1st, year = Jan 1st).
curl "https://open.utm.bar/v1/links/550e8400-e29b-41d4-a716-446655440000/stats?start=2026-01-01&end=2026-05-25&granularity=month&tz=Asia/Shanghai" \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
{
"data": {
"total_clicks": 1024,
"unique_clicks": 876,
"tz": "Asia/Shanghai",
"granularity": "month",
"daily": [
{ "date": "2026-01-01", "clicks": 200, "unique_clicks": 180 },
{ "date": "2026-02-01", "clicks": 180, "unique_clicks": 160 }
],
"countries": [
{ "name": "China", "clicks": 600 },
{ "name": "United States", "clicks": 200 }
],
"devices": [
{ "name": "Mobile", "clicks": 700 },
{ "name": "Desktop", "clicks": 300 }
],
"browsers": [
{ "name": "Chrome", "clicks": 500 }
],
"oses": [
{ "name": "Android", "clicks": 400 }
],
"referers": [
{ "name": "Direct", "clicks": 300 },
{ "name": "https://twitter.com", "clicks": 150 }
]
}
}
Batch Link Statistics
POST /v1/links/batch-stats
Scopes required: links:read
Fetch click stats for up to 50 links in a single request. Response format matches the single-link stats endpoint.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
ids | string[] | ✅ | Array of link IDs (UUIDs, max 50) |
start | YYYY-MM-DD | — | Start date, defaults to last 7 days |
end | YYYY-MM-DD | — | End date, defaults to today |
tz | IANA timezone | — | Timezone for day boundaries, defaults to UTC |
granularity | string | — | Aggregation period: day (default) / week / month / year |
curl -X POST https://open.utm.bar/v1/links/batch-stats \
-H "Authorization: Bearer sk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{
"ids": ["550e8400-e29b-41d4-a716-446655440000", "660e8400-e29b-41d4-a716-446655440001"],
"start": "2026-05-01",
"end": "2026-05-25",
"tz": "Asia/Shanghai",
"granularity": "week"
}'
Response 200 OK
{
"data": {
"start": "2026-05-01",
"end": "2026-05-25",
"tz": "Asia/Shanghai",
"granularity": "week",
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"total_clicks": 1024,
"unique_clicks": 876,
"daily": [
{ "date": "2026-04-28", "clicks": 200, "unique_clicks": 180 }
],
"countries": [{ "name": "China", "clicks": 600 }],
"devices": [{ "name": "Mobile", "clicks": 700 }],
"browsers": [{ "name": "Chrome", "clicks": 500 }],
"oses": [{ "name": "Android", "clicks": 400 }],
"referers": [{ "name": "Direct", "clicks": 300 }]
},
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"total_clicks": 0,
"unique_clicks": 0,
"daily": [],
"countries": [], "devices": [], "browsers": [], "oses": [], "referers": []
}
]
}
}
IDs that are not found or lack permission will include an
"error"field and will not affect other results in the batch.
Campaigns
Campaigns group multiple short links together so you can track the aggregate performance of a single marketing push.
Create a Campaign
POST /v1/campaigns
Scopes required: campaigns:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Campaign name |
description | string | — | Optional description |
base_url | string | ✅ | The destination site domain |
external_id | string | — | Your internal campaign reference (max 255 chars, unique per team) |
curl -X POST https://open.utm.bar/v1/campaigns \
-H "Authorization: Bearer sk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Q2 Growth Campaign",
"base_url": "https://example.com",
"external_id": "campaign_q2_2026"
}'
Response 201 Created
{
"data": {
"id": "...",
"name": "Q2 Growth Campaign",
"description": "",
"base_url": "https://example.com",
"external_id": "campaign_q2_2026",
"total_links": 0,
"total_clicks": 0,
"created_at": "2026-05-25T00:00:00Z",
"updated_at": "2026-05-25T00:00:00Z"
}
}
List Campaigns
GET /v1/campaigns
Scopes required: campaigns:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 20 | Items per page (max 100) |
Response 200 OK — items is an array of campaign objects with total, page, and page_size.
Get a Campaign
GET /v1/campaigns/:id
Scopes required: campaigns:read
Response 200 OK — returns a single campaign object.
Get a Campaign by External ID
GET /v1/campaigns/external/:external_id
Scopes required: campaigns:read
Look up a campaign by the external_id provided at creation time. Returns 404 if not found.
Delete a Campaign
DELETE /v1/campaigns/:id
Scopes required: campaigns:write
Deleting a campaign does not delete its links — they become campaign-less.
Response 204 No Content
Get Campaign Links
GET /v1/campaigns/:id/links
Scopes required: campaigns:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 50 | Items per page (max 200) |
Response 200 OK — same structure as the list links endpoint.
Get Campaign Statistics
GET /v1/campaigns/:id/stats
Scopes required: campaigns:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer | 30 | Number of days to look back (max 365) |
curl "https://open.utm.bar/v1/campaigns/campaign-uuid/stats?days=30" \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
{
"data": {
"campaign_id": "...",
"days": 30,
"total_clicks": 3840,
"unique_clicks": 2910,
"daily": [
{ "date": "2026-05-01", "clicks": 120, "unique_clicks": 95 }
],
"links": [
{
"short_code": "launch-tw",
"utm_source": "twitter",
"utm_medium": "social",
"utm_campaign": "launch-2026",
"clicks": 1200
}
],
"sources": [
{ "name": "twitter", "clicks": 1200 },
{ "name": "email", "clicks": 980 }
],
"mediums": [
{ "name": "social", "clicks": 1200 },
{ "name": "cpc", "clicks": 980 }
]
}
}
Custom Domains
Custom domains let you use your own domain name (e.g. s.example.com) for short links instead of the default utm.bar. After binding and verifying a domain, you can pass domain_id when creating a short link to use that custom domain in the short_url.
Create a Domain
POST /v1/domains
Scopes required: domains:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | ✅ | Domain name to bind (e.g. s.example.com or example.com) |
- Subdomain (e.g.
s.example.com): verified via CNAME record pointing to the platform's public domain. - Apex domain (e.g.
example.com): verified via A record pointing to the platform's public IP.
curl -X POST https://open.utm.bar/v1/domains \
-H "Authorization: Bearer sk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "domain": "s.example.com" }'
Response 201 Created
{
"data": {
"id": "d_abc123...",
"domain": "s.example.com",
"status": "pending",
"is_apex": false,
"verification_type": "cname",
"verification_value": "redirect.utm.bar",
"created_at": "2026-07-03T00:00:00Z"
}
}
List Domains
GET /v1/domains
Scopes required: domains:read
Returns all domains bound to your team, newest first.
curl https://open.utm.bar/v1/domains \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
{
"data": {
"items": [
{
"id": "d_abc123...",
"domain": "s.example.com",
"status": "active",
"is_apex": false,
"verification_type": "cname",
"verification_value": "redirect.utm.bar",
"verified_at": "2026-07-03T01:00:00Z",
"cert_expires_at": "2026-10-01T00:00:00Z",
"created_at": "2026-07-03T00:00:00Z"
}
]
}
}
Get a Domain
GET /v1/domains/:id
Scopes required: domains:read
curl https://open.utm.bar/v1/domains/d_abc123... \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK — returns a single domain object.
Delete a Domain
DELETE /v1/domains/:id
Scopes required: domains:write
Deleting a domain binding does not delete the short links associated with it. They will fall back to showing the default
utm.bardomain.
curl -X DELETE https://open.utm.bar/v1/domains/d_abc123... \
-H "Authorization: Bearer sk_live_xxxx"
Response 204 No Content
Verify Domain DNS
POST /v1/domains/:id/verify
Scopes required: domains:write
Triggers a DNS verification check for the domain. If the required DNS record is configured correctly, the domain will be marked as verified and SSL certificate issuance will begin in the background.
Domain status lifecycle:
pending→ (DNS matched) →verifying→ (SSL issued) →active
curl -X POST https://open.utm.bar/v1/domains/d_abc123.../verify \
-H "Authorization: Bearer sk_live_xxxx"
Response 200 OK
{
"data": {
"matched": true,
"message": "DNS verified successfully. SSL certificate is being issued in the background."
}
}
If DNS is not yet configured:
{
"data": {
"matched": false,
"message": "DNS records not yet configured. Please set up the required DNS record and try again."
}
}
Rate Limits
Limits apply per team across all API keys.
| Request type | Limit |
|---|---|
| Write (create / update / disable / delete) | 60 / minute |
| Read (list / get / stats) | 300 / minute |
When a limit is exceeded, the API returns 429 Too Many Requests with these headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1748140860
Retry-After: 30
Permission Scopes
| Scope | Grants |
|---|---|
links:read | List links, get single link, statistics, batch stats |
links:write | Create links, update metadata, enable links |
links:delete | Disable and delete links |
campaigns:read | List campaigns, get campaign details / links / stats |
campaigns:write | Create campaigns, delete campaigns |
domains:read | List domains, get domain details |
domains:write | Create domains, delete domains, verify DNS |
Error Responses
All errors use the same structure:
{
"error": "error_code",
"message": "Human-readable description",
"request_id": "req_abc123"
}
| Status | error | Meaning |
|---|---|---|
400 | invalid_request | Validation failed |
401 | unauthorized | Missing or invalid API key |
403 | forbidden | Key lacks required scope |
404 | not_found | Resource does not exist |
409 | conflict | Custom code already in use |
429 | rate_limited | Too many requests |
500 | internal_error | Server error |