Links endpoints
Create, list, fetch, update, upsert and delete Muro short links through the API.
These endpoints manage the short links in your account. All paths are relative to https://app.muroanalytics.com/api/v1 and need an Authorization: Bearer header (see Authentication, limits and errors).
| Method | Path | What it does |
|---|---|---|
POST | /links | Create a link |
GET | /links | List links |
GET | /links/{id} | Get a link |
PATCH | /links/{id} | Update a link |
DELETE | /links/{id} | Delete a link |
POST | /links/upsert | Create or return a link |
Click stats for links are on Stats endpoints.
The link object
{
"id": "lnk_3f2c9a1e-8b7d-4c61-9e0a-5d4b2f1c7a90",
"short_url": "https://muro.ink/ph-launch",
"destination_url": "https://yourapp.com/?utm_source=producthunt&utm_medium=community&utm_campaign=launch",
"title": "Product Hunt launch",
"slug": "ph-launch",
"external_id": "campaign-42",
"comments": "Main launch link for the PH post.",
"expires_at": 1793534400000,
"expiration_url": "https://yourapp.com/launch-ended",
"tags": [
{ "id": "tag_6a1d0c2b-4e8f-4b3a-9c7d-1e2f3a4b5c6d", "name": "launch", "color": "indigo" }
],
"click_count": 284,
"created_at": 1791734400000
}| Field | Type | Description |
|---|---|---|
id | string | The link's ID, starting with lnk_. |
short_url | string | The full short link, https://muro.ink/{slug}. |
destination_url | string | Where the link redirects, with any UTM parameters already merged in. |
title | string | The link's name. |
slug | string | The part after muro.ink/. Never changes. |
external_id | string | Your own ID for the link. Present only when set. |
comments | string | Private notes. Present only when set. |
expires_at | number | When the link expires, in Unix milliseconds. Present only when set. |
expiration_url | string | Where the link redirects after it expires. Present only when set. |
tags | array | The link's tags, each { id, name, color }, sorted by name. Always present, [] when untagged. |
click_count | number | All-time clicks. |
created_at | number | When the link was created, in Unix milliseconds. |
Addressing a link by your own ID
Everywhere a path takes {id}, you can pass either the link's lnk_… ID or ext_ followed by its external_id. A link created with "external_id": "campaign-42" can be fetched at /links/ext_campaign-42. The ext_ prefix is only a path convention: the stored external_id is campaign-42. URL-encode external IDs that contain characters like /, ? or spaces.
Create a link
POST /links
Body
| Field | Type | Required | Rules |
|---|---|---|---|
title | string | Yes | Trimmed, 1–200 characters. |
destination_url | string | Yes | An absolute http:// or https:// URL, up to 2,048 characters. localhost, private network addresses and muro.ink aren't allowed. Checked against Google Safe Browsing. |
slug | string | No | Letters, numbers, - and _, 1–64 characters, case-sensitive, not a reserved word. Must be free on muro.ink. Omit it for a random 6-character slug. |
utm | object | No | Any of source, medium, campaign, content, term, each a string up to 200 characters. Merged into destination_url as utm_* parameters, replacing any with the same name. The final URL must stay within 2,048 characters. |
tag_ids | string[] | No | IDs of existing tags to attach. |
tag_names | string[] | No | Tag names to attach. Matched to your tags ignoring case; missing ones are created and count toward your tag limit. |
comments | string or null | No | Private notes, up to 2,048 characters after trimming. Line breaks and tabs are kept; other control characters are refused. |
expires_at | number or null | No | Unix milliseconds, in the future and at most 10 years ahead. See Link expiration. |
expiration_url | string or null | No | Where to redirect after expires_at. Same rules as destination_url. Needs expires_at. |
external_id | string | No | Your own ID for the link, trimmed, 1–255 characters. Unique among your live links. |
A link can have at most 10 tags in total across tag_ids and tag_names.
Example
curl -X POST https://app.muroanalytics.com/api/v1/links \
-H "Authorization: Bearer muro_lnk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"title": "Product Hunt launch",
"destination_url": "https://yourapp.com",
"slug": "ph-launch",
"external_id": "campaign-42",
"utm": { "source": "producthunt", "medium": "community", "campaign": "launch" },
"tag_names": ["launch"]
}'Response
201 Created with the link object:
{
"id": "lnk_3f2c9a1e-8b7d-4c61-9e0a-5d4b2f1c7a90",
"short_url": "https://muro.ink/ph-launch",
"destination_url": "https://yourapp.com/?utm_source=producthunt&utm_medium=community&utm_campaign=launch",
"title": "Product Hunt launch",
"slug": "ph-launch",
"external_id": "campaign-42",
"tags": [
{ "id": "tag_6a1d0c2b-4e8f-4b3a-9c7d-1e2f3a4b5c6d", "name": "launch", "color": "indigo" }
],
"click_count": 0,
"created_at": 1791734400000
}Errors
| Status | Code | When |
|---|---|---|
400 | INVALID_JSON | The body isn't valid JSON. |
403 | LINK_LIMIT_REACHED | You're at your plan's link limit. |
403 | PLAN_LIMIT_REACHED | tag_names would create more tags than your plan allows. |
404 | TAG_NOT_FOUND | A tag_ids entry isn't one of your tags. |
409 | SLUG_TAKEN | The slug is already in use. |
409 | EXTERNAL_ID_TAKEN | Another live link of yours has that external_id. |
422 | SLUG_INVALID | The slug has invalid characters, is too long, or is reserved. |
422 | VALIDATION_ERROR | A field breaks a rule, for example a missing title, a blocked or flagged destination, more than 10 tags, or an expires_at in the past. field names the field. |
List links
GET /links
Returns your live links, newest first, one page at a time.
Query parameters
| Parameter | Default | Description |
|---|---|---|
page | 1 | The page to return. |
per_page | 20 | Links per page, 1–100. |
tag_ids | Comma-separated tag IDs, up to 20. Returns links that carry any of them. |
Example
curl "https://app.muroanalytics.com/api/v1/links?page=1&per_page=50&tag_ids=tag_6a1d0c2b-4e8f-4b3a-9c7d-1e2f3a4b5c6d" \
-H "Authorization: Bearer muro_lnk_xxxxxxxxxxxx"Response
200 OK:
{
"links": [
{
"id": "lnk_3f2c9a1e-8b7d-4c61-9e0a-5d4b2f1c7a90",
"short_url": "https://muro.ink/ph-launch",
"destination_url": "https://yourapp.com/?utm_source=producthunt&utm_medium=community&utm_campaign=launch",
"title": "Product Hunt launch",
"slug": "ph-launch",
"external_id": "campaign-42",
"tags": [
{ "id": "tag_6a1d0c2b-4e8f-4b3a-9c7d-1e2f3a4b5c6d", "name": "launch", "color": "indigo" }
],
"click_count": 284,
"created_at": 1791734400000
}
],
"page": 1,
"per_page": 50,
"total": 1,
"link_limit": 500
}| Field | Description |
|---|---|
links | The link objects on this page. click_count is all-time. |
page, per_page | The page returned and its size. |
total | How many live links match the filter. |
link_limit | Your plan's link limit, or null if unlimited. |
Errors: 422 VALIDATION_ERROR if you pass more than 20 tag_ids. See Pagination.
Get a link
GET /links/{id}
{id} is a lnk_… ID or ext_{external_id}.
curl https://app.muroanalytics.com/api/v1/links/ext_campaign-42 \
-H "Authorization: Bearer muro_lnk_xxxxxxxxxxxx"Returns 200 OK with the link object, or 404 LINK_NOT_FOUND if no live link in your account matches.
Update a link
PATCH /links/{id}
Send only the fields you want to change. Omitted fields stay as they are.
Body
| Field | Type | Effect |
|---|---|---|
title | string | Replaces the title. Same rules as create. |
destination_url | string | Replaces the whole destination URL. Re-checked against Google Safe Browsing. |
utm | object | Merged into the new destination_url. Only applies when you also send destination_url. |
tag_ids, tag_names | string[] | Replace the link's full tag set. Send [] to remove all tags. Omit both to leave tags alone. |
comments | string or null | Replaces the notes. null or an empty string clears them. |
expires_at | number or null | Sets a new expiry (in the future). null removes the expiry and its fallback. |
expiration_url | string or null | Sets the fallback. null or an empty string removes just the fallback. Needs an expiry on the link. |
The slug can't be changed. external_id can't be changed here, but upsert can set it.
Example
curl -X PATCH https://app.muroanalytics.com/api/v1/links/ext_campaign-42 \
-H "Authorization: Bearer muro_lnk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"title": "Product Hunt launch (day 2)",
"tag_names": ["launch", "producthunt"],
"expires_at": null
}'Response
200 OK with the updated link object. The change applies to the short link right away.
Errors are the same as create, plus 404 LINK_NOT_FOUND.
Delete a link
DELETE /links/{id}
curl -X DELETE https://app.muroanalytics.com/api/v1/links/lnk_3f2c9a1e-8b7d-4c61-9e0a-5d4b2f1c7a90 \
-H "Authorization: Bearer muro_lnk_xxxxxxxxxxxx"Returns 204 No Content. The short link stops working (it returns "not found"), its tags are detached, and its slug and external_id become free to reuse. A deleted link can't be restored, and no longer counts toward your link limit.
Errors: 404 LINK_NOT_FOUND.
Create or return a link (upsert)
POST /links/upsert
Creates a link, or returns the one you already have for the same destination. It's safe to call repeatedly, so a script can ask for "the link for this URL" without listing your links first.
The body is the same as create. Muro looks for one of your live links whose final destination (after utm is merged in) exactly matches. Send the same destination_url and utm each time to get the same link. A different utm_campaign gives a different link.
- No match: creates the link. Returns
201 Createdwith"created": true. - Match: returns the existing link with
200 OKand"created": false. On a match:titlereplaces the stored title.external_id, if sent, is set on the link. If omitted, the stored one is kept.tag_ids/tag_names,comments,expires_atandexpiration_urlfollow the same rules as update: omitted means unchanged.slugis ignored. It only applies when the link is created.
Example
curl -X POST https://app.muroanalytics.com/api/v1/links/upsert \
-H "Authorization: Bearer muro_lnk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "title": "Pricing", "destination_url": "https://yourapp.com/pricing" }'First call:
{
"id": "lnk_9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"short_url": "https://muro.ink/kEYaz0",
"destination_url": "https://yourapp.com/pricing",
"title": "Pricing",
"slug": "kEYaz0",
"tags": [],
"click_count": 0,
"created_at": 1791734400000,
"created": true
}Later calls with the same destination return the same link, with "created": false and its current click_count.
Errors are the same as create. 403 LINK_LIMIT_REACHED can only happen when a new link would be created.