Developer documentation

The uhm.co API: create and manage links from other software, with click statistics for each one.

Authentication

An API key is generated from the dashboard's API keys screen, available on the Pro plan and up. Each key carries only the scopes granted to it when it was created. A request authenticates with an Authorization header carrying the key as a bearer credential.

Authorization: Bearer uhm_live_...

Scopes

  • links:read list and read links.
  • links:write create, update, and delete links.
  • analytics:read read click statistics.

/v1/me requires no scope. A key that cannot describe itself makes a misconfiguration impossible to diagnose from an integration's own settings screen.

Server-side only, by design

This API sends no CORS headers, on purpose, and that is not an oversight to report. A key is a bearer credential: whoever holds it can create links and read analytics for the entire workspace. Placing one in browser JavaScript publishes it to every visitor who loads the page. The missing CORS headers are what make a browser physically unable to call this API, rather than a note asking it not to. Every request must originate from a server, never from code a visitor's browser executes.

Publishing without duplicating

reference is an identifier supplied by the caller, up to 255 characters, naming whatever the link stands for on the caller's own side; a CMS post, for example. Creating a link with a reference that already exists in the workspace returns the stored link with status 200 and creates nothing new. A genuine create returns 201, so an integration can tell the two apart without comparing timestamps.

Worked example: a CMS publishing a post

  1. A site publishes post 142. The plugin sends POST /v1/links with reference "wp:example-com:142" and the post's URL as destination_url. No link exists yet for that reference, so one is created and the response is 201.
  2. The author edits the post and republishes it. The plugin sends the same POST again, same reference, same destination. A link already exists for that reference, so nothing new is created; the response is 200 and returns the same link, same id.
  3. The site's permalink structure changes and the post now lives at a different address. The plugin sends PATCH /v1/links/{id} with the new destination_url. reference cannot be changed by a PATCH: it is how the plugin recognises a link it made before, and letting an edit move it would let one call silently orphan another.

Endpoints

Every endpoint other than /v1/me is scoped to the workspace the key belongs to. A link that exists but belongs to another workspace responds identically to one that does not exist at all.

MethodPathScopeDescription
GET/v1/menoneThe key, its workspace, and the remaining allowance.
GET/v1/linkslinks:readList links, paginated.
POST/v1/linkslinks:writeCreate a link, or return the existing link for a reference.
GET/v1/links/{id}links:readRead one link.
PATCH/v1/links/{id}links:writeUpdate a link.
DELETE/v1/links/{id}links:writeDelete a link.
GET/v1/links/{id}/statsanalytics:readClick statistics for one link.

Query parameters: GET /v1/links

page_size
Page size. Default 50, capped at 200.
cursor
Opaque pagination cursor, taken from a previous page's next_cursor. Omitted for the first page.
reference
Restrict the page to links carrying this reference.

Query parameters: GET /v1/links/{id}/stats

days
Trailing window, in whole days. Default 30, maximum 1095.

Errors

Every error response has the same shape: { "error": { "code", "message", "docs_url" } }. code is what an integration should branch on. message is a developer-facing debug string, English only regardless of the workspace's own language: it is written for a developer reading a server log, not for a customer reading a screen, and that is deliberate rather than an oversight. The dashboard and this page are both fully translated, like everything else customer-facing. docs_url points back to the matching row below.

CodeStatusMeaning
invalid_request400A request field is missing or malformed.
invalid_slug400The requested slug uses characters that are not allowed.
destination_rejected400The destination URL was rejected before any link was created. The reason is included as reason: a self-reference, an IP-literal host, a chained shortener, and a handful of other patterns extremely unlikely to name a real destination.
unauthenticated401The key is missing, unknown, revoked, or expired.
quota_exceeded402The monthly link allowance is spent and no credit remains. Links already made keep redirecting; this only blocks new creation.
insufficient_scope403The key does not carry the scope this endpoint requires. The required scope is named as required_scope.
feature_not_available403The field used (custom slug, password, or an expiry date) is not included on the workspace's current plan. Which one is named as feature.
api_not_included403The workspace's plan does not include API access at all. The plan that does is named as required_plan.
workspace_banned403The workspace has been banned from creating or editing links.
trial_expired403The workspace's trial has ended and no plan is active.
domain_not_verified403domain_id names a domain that has not finished verification yet.
not_found404No such link, or it belongs to another workspace. Also returned when domain_id does not name one of the workspace's own domains.
slug_taken409That slug is already in use.
slug_reserved409That slug is reserved and cannot be used.
destination_unsafe422The destination was flagged by the safety scan.
rate_limited429Too many requests in the current window. The number of seconds to wait is named as Retry-After.
internal_error500The request could not be completed on our side.
api_not_configured503The API is not configured on this deployment.

Rate limits

Limits apply per key, per minute, counted separately for reads and writes. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 additionally carries Retry-After.

PlanReads / minWrites / min
Pro60060
Business3000300

Machine-readable description

The full API is described as OpenAPI 3.1, generated from the same routes documented above.

View openapi.json