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.
| Requirement | Detail |
|---|---|
| Authorization | Bearer mk_... |
| links:read | Required to list or retrieve a link |
| links:write | Required to create, update, or delete a link |
| Key owner’s role | A 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. |
| Plan | The API is a Growth feature. Keys stop working if the workspace returns to the free plan. |
| Rate limit | 60 requests per minute per key. Over the limit returns 429. |
The link object
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.
| Field | Type | Description |
|---|---|---|
| id | string | Unique link identifier |
| slug | string | The path portion of the short URL |
| originalUrl | string | The destination the short URL redirects to |
| title | string | null | Internal label for the link |
| domainId | string | null | Custom domain the link lives on. null means the default domain. |
| status | string | ACTIVE or DISABLED. Deleted links are never returned. |
| redirectType | string | TEMPORARY (302) or PERMANENT (301) |
| expiresAt | string | null | ISO 8601 timestamp after which the link stops resolving |
| neverExpires | boolean | True when the link has no expiry at all |
| expiredUrl | string | null | Where an expired link sends visitors. null means it shows the MASK expired page. |
| clickLimit | number | null | Stops resolving once this many clicks are served |
| startAt | string | null | ISO 8601 timestamp before which the link is not yet live |
| endAt | string | null | ISO 8601 timestamp after which the link stops being live |
| clicks | number | Recorded clicks. Analytics are batched, so this can trail live traffic. |
| hasPassword | boolean | Whether visitors must enter a password before the redirect |
| tags | object[] | Workspace tags attached to the link, each { id, name, color } |
| previewTitle | string | null | Custom social preview title |
| previewDescription | string | null | Custom social preview description |
| previewImage | string | null | Custom 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 |
| createdAt | string | ISO 8601 creation timestamp |
| updatedAt | string | ISO 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.
{
"data": { "id": "lnk_...", "slug": "a1b2c3", "originalUrl": "https://example.com/page" },
"meta": { "ignoredFields": ["expires_at"] }
}{
"error": "Unknown field: expires_at. This endpoint accepts: clickLimit, endAt, expiredUrl, expiresAt, neverExpires, password, previewDescription, previewImage, previewTitle, redirectType, slug, startAt, status, tagIds, title, url."
}| Status | Meaning |
|---|---|
| 400 | A field is missing, malformed, or not recognised |
| 401 | Missing, revoked, expired, or non-paid API key |
| 403 | The 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 |
| 404 | No such link in this workspace |
| 409 | The slug is taken or reserved |
| 429 | Rate limit exceeded |
| 207 | The 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 |
List links
Retrieve a page of links in the workspace, newest first. Deleted links are excluded, and the total counts exactly what the list can return.
/api/v1/linksQuery parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | integer | 1 | Page number, starting at 1 |
| limit | integer | 20 | Links per page, up to 100 |
curl "https://mask.pk/api/v1/links?page=1&limit=10" \
-H "Authorization: Bearer mk_live_abc123def456"{
"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 }
}Create a link
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.
/api/v1/linksRequest body
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | Destination URL. Must be http or https. |
| slug | string | No | Custom slug, lowercased. Generated when omitted. 409 if taken or reserved. |
| domainId | string | No | Id of an active custom domain in this workspace. Defaults to the shared domain. |
| title | string | No | Internal label. Trimmed, and stored truncated at 200 characters. |
| redirectType | string | No | TEMPORARY (default) or PERMANENT |
| expiresAt | string | null | No | ISO 8601 expiry. Omit or send null for a permanent link. |
| expiredUrl | string | null | No | Where 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. |
| clickLimit | number | null | No | Whole number of 1 or more |
| startAt | string | null | No | ISO 8601 timestamp the link goes live |
| endAt | string | null | No | ISO 8601 timestamp the link stops being live |
| password | string | null | No | Visitors must enter this before the redirect. Stored hashed and never returned. |
| tagIds | string[] | No | Ids of tags in this workspace, up to 25. Create tags first, then attach them here. |
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"
}'{
"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"
}
}Retrieve a link
Fetch one link by id. Returns the same object the list returns.
/api/v1/links/:idcurl https://mask.pk/api/v1/links/clx8f2h9a0001 \
-H "Authorization: Bearer mk_live_abc123def456"{
"data": {
"id": "clx8f2h9a0001",
"slug": "launch-2026",
"originalUrl": "https://example.com/my-page",
"status": "ACTIVE",
"redirectType": "PERMANENT",
"clicks": 142,
"hasPassword": false,
"tags": []
}
}Update a link
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.
/api/v1/links/:idRequest body
Accepts every create field except domainId, which is fixed for the life of the link, plus these:
| Field | Type | Description |
|---|---|---|
| status | string | ACTIVE or DISABLED. A link MASK disabled for a policy review cannot be re-enabled here. |
| slug | string | Renames the link. The old address stops resolving immediately, so every copy already shared will break. |
| neverExpires | boolean | Set 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. |
| expiredUrl | string | null | Send 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. |
| password | string | null | Send null to remove password protection |
| tagIds | string[] | Replaces the whole tag set. Send [] to clear it. |
| previewTitle | string | null | With previewDescription and previewImage, sets the social card the link unfurls as |
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"
}'{
"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"
}
}Delete a link
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.
/api/v1/links/:idcurl -X DELETE https://mask.pk/api/v1/links/clx8f2h9a0001 \
-H "Authorization: Bearer mk_live_abc123def456"{
"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.