Documentation menu

Links API

Create, retrieve, update, and delete short links. The Links API supports custom slugs, custom domains, scheduling, expiry, click limits, password protection, and tags.

Authentication and limits

Send your API key as a bearer token. Keys belong to one workspace and only ever see that workspace: a link in another workspace responds 404, the same answer as a link that does not exist.

RequirementDetail
AuthorizationBearer mk_...
links:readRequired to list or retrieve a link
links:writeRequired to create, update, or delete a link
Key owner’s roleA key acts as the person who created it. Updating a link needs that person to still be a workspace member (any role except Analyst); deleting one needs Owner or Admin — the same roles the dashboard requires. If their role changes or they leave the workspace, those calls return 403 while the key keeps working for everything else.
PlanThe API is a Growth feature. Keys stop working if the workspace returns to the free plan.
Rate limit60 requests per minute per key. Over the limit returns 429.

A link is a short URL that redirects to a destination. The password is never returned in any response, in any form. Only hasPassword tells you one is set.

FieldTypeDescription
idstringUnique link identifier
slugstringThe path portion of the short URL
originalUrlstringThe destination the short URL redirects to
titlestring | nullInternal label for the link
domainIdstring | nullCustom domain the link lives on. null means the default domain.
statusstringACTIVE or DISABLED. Deleted links are never returned.
redirectTypestringTEMPORARY (302) or PERMANENT (301)
expiresAtstring | nullISO 8601 timestamp after which the link stops resolving
neverExpiresbooleanTrue when the link has no expiry at all
expiredUrlstring | nullWhere an expired link sends visitors. null means it shows the MASK expired page.
clickLimitnumber | nullStops resolving once this many clicks are served
startAtstring | nullISO 8601 timestamp before which the link is not yet live
endAtstring | nullISO 8601 timestamp after which the link stops being live
clicksnumberRecorded clicks. Analytics are batched, so this can trail live traffic.
hasPasswordbooleanWhether visitors must enter a password before the redirect
tagsobject[]Workspace tags attached to the link, each { id, name, color }
previewTitlestring | nullCustom social preview title
previewDescriptionstring | nullCustom social preview description
previewImagestring | nullCustom social preview image URL. Must be an https .png, .jpg, .jpeg, .gif or .webp hosted by MASK — an image on another host is refused with 400 rather than stored and then dropped from the card
createdAtstringISO 8601 creation timestamp
updatedAtstringISO 8601 last update timestamp

Errors and unknown fields

Every failure returns a single error string describing what to change.

Unrecognised fields are handled differently by the two generations of endpoint, because only one of them can change without breaking code that already works. POST /api/v1/links has always ignored keys it does not understand, so it still does — but it now NAMES them in meta.ignoredFields on the 201, so a typo is visible in the same response instead of surfacing weeks later as “the password protection isn’t working”. The /api/v1/links/{id} endpoints are new and have no such history: they reject an unrecognised field with 400 and list the fields they do accept.

Response: 201 Created — a key the create endpoint did not recognise
{
  "data": { "id": "lnk_...", "slug": "a1b2c3", "originalUrl": "https://example.com/page" },
  "meta": { "ignoredFields": ["expires_at"] }
}
Response: 400 Bad Request — PATCH /api/v1/links/{id}
{
  "error": "Unknown field: expires_at. This endpoint accepts: clickLimit, endAt, expiredUrl, expiresAt, neverExpires, password, previewDescription, previewImage, previewTitle, redirectType, slug, startAt, status, tagIds, title, url."
}
StatusMeaning
400A field is missing, malformed, or not recognised
401Missing, revoked, expired, or non-paid API key
403The key lacks the scope, the plan does not include the feature, or — on update and delete — the key’s owner no longer holds the workspace role the action needs
404No such link in this workspace
409The slug is taken or reserved
429Rate limit exceeded
207The change was applied but a follow-up step did not finish — a delete whose cleanup is incomplete, or a rename whose slug-registry entry could not be moved. The body carries result with the detail; the link itself needs no further action from you

Retrieve a page of links in the workspace, newest first. Deleted links are excluded, and the total counts exactly what the list can return.

GET/api/v1/links

Query parameters

ParameterTypeDefaultDescription
pageinteger1Page number, starting at 1
limitinteger20Links per page, up to 100
Request
curl "https://mask.pk/api/v1/links?page=1&limit=10" \
  -H "Authorization: Bearer mk_live_abc123def456"
Response: 200 OK
{
  "data": [
    {
      "id": "clx8f2h9a0001",
      "slug": "launch-2026",
      "originalUrl": "https://example.com/my-page",
      "title": "Launch campaign",
      "domainId": null,
      "status": "ACTIVE",
      "redirectType": "TEMPORARY",
      "expiresAt": null,
      "neverExpires": true,
      "expiredUrl": null,
      "clickLimit": null,
      "startAt": null,
      "endAt": null,
      "clicks": 142,
      "hasPassword": false,
      "tags": [{ "id": "clx8tag0001", "name": "campaign-q1", "color": "#2563EB" }],
      "previewTitle": null,
      "previewDescription": null,
      "previewImage": null,
      "createdAt": "2026-03-13T10:00:00.000Z",
      "updatedAt": "2026-03-13T10:00:00.000Z"
    }
  ],
  "meta": { "page": 1, "limit": 10, "total": 1 }
}

Only url is required. Every other field is optional: with none of them, the slug is generated, the link never expires, and it redirects with a 302.

POST/api/v1/links

Request body

FieldTypeRequiredDescription
urlstringYesDestination URL. Must be http or https.
slugstringNoCustom slug, lowercased. Generated when omitted. 409 if taken or reserved.
domainIdstringNoId of an active custom domain in this workspace. Defaults to the shared domain.
titlestringNoInternal label. Trimmed, and stored truncated at 200 characters.
redirectTypestringNoTEMPORARY (default) or PERMANENT
expiresAtstring | nullNoISO 8601 expiry. Omit or send null for a permanent link.
expiredUrlstring | nullNoWhere the link sends visitors once it has expired. Omit or send null to show the MASK expired page. http or https only, and it may not point back at this link or round a chain of MASK links that returns to it.
clickLimitnumber | nullNoWhole number of 1 or more
startAtstring | nullNoISO 8601 timestamp the link goes live
endAtstring | nullNoISO 8601 timestamp the link stops being live
passwordstring | nullNoVisitors must enter this before the redirect. Stored hashed and never returned.
tagIdsstring[]NoIds of tags in this workspace, up to 25. Create tags first, then attach them here.
Request
curl -X POST https://mask.pk/api/v1/links \
  -H "Authorization: Bearer mk_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/my-page",
    "slug": "launch-2026",
    "title": "Launch campaign",
    "redirectType": "PERMANENT",
    "clickLimit": 500,
    "expiresAt": "2026-12-31T23:59:59.000Z"
  }'
Response: 201 Created
{
  "data": {
    "id": "clx8f2h9a0001",
    "slug": "launch-2026",
    "originalUrl": "https://example.com/my-page",
    "title": "Launch campaign",
    "domainId": null,
    "status": "ACTIVE",
    "redirectType": "PERMANENT",
    "expiresAt": "2026-12-31T23:59:59.000Z",
    "neverExpires": false,
    "expiredUrl": null,
    "clickLimit": 500,
    "startAt": null,
    "endAt": null,
    "clicks": 0,
    "hasPassword": false,
    "tags": [],
    "previewTitle": null,
    "previewDescription": null,
    "previewImage": null,
    "createdAt": "2026-03-13T10:00:00.000Z",
    "updatedAt": "2026-03-13T10:00:00.000Z"
  }
}

Fetch one link by id. Returns the same object the list returns.

GET/api/v1/links/:id
Request
curl https://mask.pk/api/v1/links/clx8f2h9a0001 \
  -H "Authorization: Bearer mk_live_abc123def456"
Response: 200 OK
{
  "data": {
    "id": "clx8f2h9a0001",
    "slug": "launch-2026",
    "originalUrl": "https://example.com/my-page",
    "status": "ACTIVE",
    "redirectType": "PERMANENT",
    "clicks": 142,
    "hasPassword": false,
    "tags": []
  }
}

Change one or more fields. Only the fields you send are touched, and an omitted field is left exactly as it was. Updates take effect on the next click, including for visitors whose redirect was cached.

PATCH/api/v1/links/:id

Request body

Accepts every create field except domainId, which is fixed for the life of the link, plus these:

FieldTypeDescription
statusstringACTIVE or DISABLED. A link MASK disabled for a policy review cannot be re-enabled here.
slugstringRenames the link. The old address stops resolving immediately, so every copy already shared will break.
neverExpiresbooleanSet true to remove the expiry entirely — expiresAt is cleared with it. The two fields are one decision, so sending true together with an expiresAt date is a 400.
expiredUrlstring | nullSend null or "" to remove the fallback and go back to the MASK expired page. Removing one is never gated; setting one is a Growth feature.
passwordstring | nullSend null to remove password protection
tagIdsstring[]Replaces the whole tag set. Send [] to clear it.
previewTitlestring | nullWith previewDescription and previewImage, sets the social card the link unfurls as
Request
curl -X PATCH https://mask.pk/api/v1/links/clx8f2h9a0001 \
  -H "Authorization: Bearer mk_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/updated-page",
    "title": "Launch campaign, revised",
    "expiresAt": "2027-06-30T23:59:59.000Z"
  }'
Response: 200 OK
{
  "data": {
    "id": "clx8f2h9a0001",
    "slug": "launch-2026",
    "originalUrl": "https://example.com/updated-page",
    "title": "Launch campaign, revised",
    "status": "ACTIVE",
    "expiresAt": "2027-06-30T23:59:59.000Z",
    "neverExpires": false,
    "updatedAt": "2026-03-13T12:00:00.000Z"
  }
}

Deletes the link. The short URL stops resolving and the link disappears from every list. Its click history is retained for workspace analytics, and the link itself cannot be restored through the API. Deleting again returns 404. Requires the key’s owner to be an Owner or Admin of the workspace.

DELETE/api/v1/links/:id
Request
curl -X DELETE https://mask.pk/api/v1/links/clx8f2h9a0001 \
  -H "Authorization: Bearer mk_live_abc123def456"
Response: 200 OK
{
  "data": { "id": "clx8f2h9a0001", "deleted": true },
  "result": {
    "outcome": "success",
    "steps": [
      { "key": "link", "label": "Link state", "status": "ok" },
      { "key": "redirect_cache", "label": "Redirect cache", "status": "ok" }
    ]
  }
}

If a follow-up step does not finish, the response is 207 with result.outcome set to partial and the failed step named. The link is deleted either way, so treat 207 as success with a note, not as a failure to retry.