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:readlist and read links.links:writecreate, update, and delete links.analytics:readread 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
- 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.
- 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.
- 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.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/me | none | The key, its workspace, and the remaining allowance. |
| GET | /v1/links | links:read | List links, paginated. |
| POST | /v1/links | links:write | Create a link, or return the existing link for a reference. |
| GET | /v1/links/{id} | links:read | Read one link. |
| PATCH | /v1/links/{id} | links:write | Update a link. |
| DELETE | /v1/links/{id} | links:write | Delete a link. |
| GET | /v1/links/{id}/stats | analytics:read | Click 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.
| Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | A request field is missing or malformed. |
invalid_slug | 400 | The requested slug uses characters that are not allowed. |
destination_rejected | 400 | The 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. |
unauthenticated | 401 | The key is missing, unknown, revoked, or expired. |
quota_exceeded | 402 | The monthly link allowance is spent and no credit remains. Links already made keep redirecting; this only blocks new creation. |
insufficient_scope | 403 | The key does not carry the scope this endpoint requires. The required scope is named as required_scope. |
feature_not_available | 403 | The 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_included | 403 | The workspace's plan does not include API access at all. The plan that does is named as required_plan. |
workspace_banned | 403 | The workspace has been banned from creating or editing links. |
trial_expired | 403 | The workspace's trial has ended and no plan is active. |
domain_not_verified | 403 | domain_id names a domain that has not finished verification yet. |
not_found | 404 | No such link, or it belongs to another workspace. Also returned when domain_id does not name one of the workspace's own domains. |
slug_taken | 409 | That slug is already in use. |
slug_reserved | 409 | That slug is reserved and cannot be used. |
destination_unsafe | 422 | The destination was flagged by the safety scan. |
rate_limited | 429 | Too many requests in the current window. The number of seconds to wait is named as Retry-After. |
internal_error | 500 | The request could not be completed on our side. |
api_not_configured | 503 | The 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.
| Plan | Reads / min | Writes / min |
|---|---|---|
| Pro | 600 | 60 |
| Business | 3000 | 300 |
Machine-readable description
The full API is described as OpenAPI 3.1, generated from the same routes documented above.
View openapi.json