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

FieldTypeRequiredDescription
original_urlstringThe destination URL
titlestringDisplay name for the link
custom_codestringCustom short code (a-z A-Z 0-9 - _, 2–64 chars)
external_idstringYour internal reference ID, stored as-is (max 255 chars)
expires_atISO 8601Expiry datetime (null = never)
utm_sourcestringUTM source parameter
utm_mediumstringUTM medium parameter
utm_campaignstringUTM campaign parameter
utm_termstringUTM term parameter
utm_contentstringUTM content parameter
campaign_idstringAssign to a campaign (campaign UUID)
domain_idstringCustom 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_id to bind a custom domain — see Custom Domains. When set, short_url will use your custom domain instead of utm.bar. custom_code conflicts return 409 Conflict.


List Short Links

GET /v1/links

Scopes required: links:read

Query Parameters

ParameterTypeDefaultDescription
pageinteger1Page number
page_sizeinteger20Items per page (max 100)
qstringSearch by title or original URL
is_activebooleanFilter 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_url and code cannot be changed after creation.

Request Body (all fields optional)

FieldTypeDescription
titlestringNew display name
expires_atISO 8601 / nullUpdate or clear expiry
utm_sourcestringUpdate UTM source
utm_mediumstringUpdate UTM medium
utm_campaignstringUpdate UTM campaign
utm_termstringUpdate UTM term
utm_contentstringUpdate 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

ParameterTypeDefaultDescription
startYYYY-MM-DDLast 7 daysStart date (inclusive)
endYYYY-MM-DDTodayEnd date (inclusive)
tzIANA timezoneUTCTimezone for day boundaries
granularitystringdayAggregation 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

FieldTypeRequiredDescription
idsstring[]Array of link IDs (UUIDs, max 50)
startYYYY-MM-DDStart date, defaults to last 7 days
endYYYY-MM-DDEnd date, defaults to today
tzIANA timezoneTimezone for day boundaries, defaults to UTC
granularitystringAggregation 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

FieldTypeRequiredDescription
namestringCampaign name
descriptionstringOptional description
base_urlstringThe destination site domain
external_idstringYour 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

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger20Items per page (max 100)

Response 200 OKitems 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

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger50Items 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

ParameterTypeDefaultDescription
daysinteger30Number 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

FieldTypeRequiredDescription
domainstringDomain 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.bar domain.

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 typeLimit
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

ScopeGrants
links:readList links, get single link, statistics, batch stats
links:writeCreate links, update metadata, enable links
links:deleteDisable and delete links
campaigns:readList campaigns, get campaign details / links / stats
campaigns:writeCreate campaigns, delete campaigns
domains:readList domains, get domain details
domains:writeCreate domains, delete domains, verify DNS

Error Responses

All errors use the same structure:

{
  "error": "error_code",
  "message": "Human-readable description",
  "request_id": "req_abc123"
}
StatuserrorMeaning
400invalid_requestValidation failed
401unauthorizedMissing or invalid API key
403forbiddenKey lacks required scope
404not_foundResource does not exist
409conflictCustom code already in use
429rate_limitedToo many requests
500internal_errorServer error