Muro
API reference

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).

MethodPathWhat it does
POST/linksCreate a link
GET/linksList links
GET/links/{id}Get a link
PATCH/links/{id}Update a link
DELETE/links/{id}Delete a link
POST/links/upsertCreate or return a link

Click stats for links are on Stats endpoints.

{
  "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
}
FieldTypeDescription
idstringThe link's ID, starting with lnk_.
short_urlstringThe full short link, https://muro.ink/{slug}.
destination_urlstringWhere the link redirects, with any UTM parameters already merged in.
titlestringThe link's name.
slugstringThe part after muro.ink/. Never changes.
external_idstringYour own ID for the link. Present only when set.
commentsstringPrivate notes. Present only when set.
expires_atnumberWhen the link expires, in Unix milliseconds. Present only when set.
expiration_urlstringWhere the link redirects after it expires. Present only when set.
tagsarrayThe link's tags, each { id, name, color }, sorted by name. Always present, [] when untagged.
click_countnumberAll-time clicks.
created_atnumberWhen the link was created, in Unix milliseconds.

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.

POST /links

Body

FieldTypeRequiredRules
titlestringYesTrimmed, 1–200 characters.
destination_urlstringYesAn 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.
slugstringNoLetters, 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.
utmobjectNoAny 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_idsstring[]NoIDs of existing tags to attach.
tag_namesstring[]NoTag names to attach. Matched to your tags ignoring case; missing ones are created and count toward your tag limit.
commentsstring or nullNoPrivate notes, up to 2,048 characters after trimming. Line breaks and tabs are kept; other control characters are refused.
expires_atnumber or nullNoUnix milliseconds, in the future and at most 10 years ahead. See Link expiration.
expiration_urlstring or nullNoWhere to redirect after expires_at. Same rules as destination_url. Needs expires_at.
external_idstringNoYour 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

StatusCodeWhen
400INVALID_JSONThe body isn't valid JSON.
403LINK_LIMIT_REACHEDYou're at your plan's link limit.
403PLAN_LIMIT_REACHEDtag_names would create more tags than your plan allows.
404TAG_NOT_FOUNDA tag_ids entry isn't one of your tags.
409SLUG_TAKENThe slug is already in use.
409EXTERNAL_ID_TAKENAnother live link of yours has that external_id.
422SLUG_INVALIDThe slug has invalid characters, is too long, or is reserved.
422VALIDATION_ERRORA 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.

GET /links

Returns your live links, newest first, one page at a time.

Query parameters

ParameterDefaultDescription
page1The page to return.
per_page20Links per page, 1–100.
tag_idsComma-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
}
FieldDescription
linksThe link objects on this page. click_count is all-time.
page, per_pageThe page returned and its size.
totalHow many live links match the filter.
link_limitYour plan's link limit, or null if unlimited.

Errors: 422 VALIDATION_ERROR if you pass more than 20 tag_ids. See Pagination.

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.

PATCH /links/{id}

Send only the fields you want to change. Omitted fields stay as they are.

Body

FieldTypeEffect
titlestringReplaces the title. Same rules as create.
destination_urlstringReplaces the whole destination URL. Re-checked against Google Safe Browsing.
utmobjectMerged into the new destination_url. Only applies when you also send destination_url.
tag_ids, tag_namesstring[]Replace the link's full tag set. Send [] to remove all tags. Omit both to leave tags alone.
commentsstring or nullReplaces the notes. null or an empty string clears them.
expires_atnumber or nullSets a new expiry (in the future). null removes the expiry and its fallback.
expiration_urlstring or nullSets 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 /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.

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 Created with "created": true.
  • Match: returns the existing link with 200 OK and "created": false. On a match:
    • title replaces 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_at and expiration_url follow the same rules as update: omitted means unchanged.
    • slug is 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.

Next steps

On this page