URLs
Create, list, update, and delete short URLs — single and bulk — and pull per-link click analytics.
/api/v1/urls is the workhorse of the Shortnd API. It covers single-link CRUD, bulk operations on up to 100 links per request, and per-link click analytics.
Scopes: urls:read (list / read / clicks), urls:write (create / update / delete / bulk).
Create a single URL
curl -X POST https://shortnd.com/api/v1/urls \
-H "Authorization: Bearer $SHORTND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"targetUrl": "https://acme.com/campaigns/welcome",
"customSlug": "welcome",
"domainId": "domain_uuid"
}'const res = await fetch('https://shortnd.com/api/v1/urls', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SHORTND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
targetUrl: 'https://acme.com/campaigns/welcome',
customSlug: 'welcome',
domainId: 'domain_uuid',
}),
});
const { data } = await res.json();import os, requests
res = requests.post(
"https://shortnd.com/api/v1/urls",
headers={
"Authorization": f"Bearer {os.environ['SHORTND_API_KEY']}",
"Content-Type": "application/json",
},
json={
"targetUrl": "https://acme.com/campaigns/welcome",
"customSlug": "welcome",
"domainId": "domain_uuid",
},
)
res.raise_for_status()
data = res.json()["data"]body := strings.NewReader(`{"targetUrl":"https://acme.com/campaigns/welcome","customSlug":"welcome","domainId":"domain_uuid"}`)
req, _ := http.NewRequest("POST", "https://shortnd.com/api/v1/urls", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SHORTND_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)$ch = curl_init("https://shortnd.com/api/v1/urls");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("SHORTND_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"targetUrl" => "https://acme.com/campaigns/welcome",
"customSlug" => "welcome",
"domainId" => "domain_uuid",
]),
]);
$data = json_decode(curl_exec($ch), true)["data"];Request body
Every field except targetUrl is optional.
| Field | Type | Notes |
|---|---|---|
targetUrl | string (URL) | Required. Up to 8 KB. Must be http:// or https://. |
customSlug | string | 3–50 chars, [A-Za-z0-9-], no leading/trailing hyphen. Omit to let Shortnd mint a random short code. |
title | string | Surfaced in dashboard + analytics. |
description | string | Long-form note. |
category | string | Free-form tag for dashboard grouping. |
tags | string[] | Up to 20. |
domainId | UUID | Routes the link through a custom domain. Omit for the platform domain (shortnd.com). The domain must be active in the calling org. |
redirectType | enum | 301 / 302 / 307 / 308. Default 307. |
expiresAt | ISO-8601 | Auto-deactivates the link at this time. |
isPasswordProtected | bool | When true, supply password. |
password | string | Required when isPasswordProtected is true. |
utmSource, utmMedium, utmCampaign, utmTerm, utmContent | string | Server-side UTM injection. |
customUtmParams | record | Extra UTM-style key/value pairs. |
sourceDomain | string | Hint for attribution flows. |
Response
{
"success": true,
"data": {
"id": "12345",
"shortCode": "abc123",
"targetUrl": "https://acme.com/campaigns/welcome",
"customSlug": "welcome",
"organizationId": "org_uuid",
"createdAt": "2026-05-08T10:30:00Z"
}
}List & paginate
curl 'https://shortnd.com/api/v1/urls?limit=50&offset=0' \
-H "Authorization: Bearer $SHORTND_API_KEY"const res = await fetch('https://shortnd.com/api/v1/urls?limit=50&offset=0', {
headers: { Authorization: `Bearer ${process.env.SHORTND_API_KEY}` },
});
const { data } = await res.json();import os, requests
res = requests.get(
"https://shortnd.com/api/v1/urls",
params={"limit": 50, "offset": 0},
headers={"Authorization": f"Bearer {os.environ['SHORTND_API_KEY']}"},
)
data = res.json()["data"]req, _ := http.NewRequest("GET", "https://shortnd.com/api/v1/urls?limit=50&offset=0", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SHORTND_API_KEY"))
res, err := http.DefaultClient.Do(req)$ch = curl_init("https://shortnd.com/api/v1/urls?limit=50&offset=0");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SHORTND_API_KEY")],
]);
$data = json_decode(curl_exec($ch), true)["data"];| Query | Default | Max |
|---|---|---|
limit | 25 | 100 |
offset | 0 | — |
Results are ordered by createdAt desc and scoped to the calling organization.
Read, update, soft-delete a single URL
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/urls/{id} | urls:read |
PATCH | /api/v1/urls/{id} | urls:write |
DELETE | /api/v1/urls/{id} | urls:write |
PATCH accepts every create field except customSlug (slugs are immutable after creation), plus isActive for pausing without deletion. DELETE is a soft delete — the row is marked inactive and excluded from redirects.
Bulk operations
All three bulk routes share /api/v1/urls/bulk and accept up to 100 items per request. Failures are reported per item; successful items are not rolled back. The api_call meter is charged once per HTTP request, not once per URL — buyers shouldn't be punished for batching.
POST /api/v1/urls/bulk — bulk create
curl -X POST https://shortnd.com/api/v1/urls/bulk \
-H "Authorization: Bearer $SHORTND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"urls": [
{ "targetUrl": "https://example.io/a" },
{ "targetUrl": "https://example.io/b", "customSlug": "promo-2026" },
{ "targetUrl": "https://example.io/c", "title": "Summer launch", "tags": ["launch", "2026"] }
]
}'await fetch('https://shortnd.com/api/v1/urls/bulk', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SHORTND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
urls: [
{ targetUrl: 'https://example.io/a' },
{ targetUrl: 'https://example.io/b', customSlug: 'promo-2026' },
{ targetUrl: 'https://example.io/c', title: 'Summer launch', tags: ['launch', '2026'] },
],
}),
});import os, requests
requests.post(
"https://shortnd.com/api/v1/urls/bulk",
headers={"Authorization": f"Bearer {os.environ['SHORTND_API_KEY']}"},
json={
"urls": [
{"targetUrl": "https://example.io/a"},
{"targetUrl": "https://example.io/b", "customSlug": "promo-2026"},
{"targetUrl": "https://example.io/c", "title": "Summer launch", "tags": ["launch", "2026"]},
]
},
).raise_for_status()payload := []byte(`{"urls":[
{"targetUrl":"https://example.io/a"},
{"targetUrl":"https://example.io/b","customSlug":"promo-2026"},
{"targetUrl":"https://example.io/c","title":"Summer launch","tags":["launch","2026"]}
]}`)
req, _ := http.NewRequest("POST", "https://shortnd.com/api/v1/urls/bulk", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SHORTND_API_KEY"))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)$ch = curl_init("https://shortnd.com/api/v1/urls/bulk");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("SHORTND_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"urls" => [
["targetUrl" => "https://example.io/a"],
["targetUrl" => "https://example.io/b", "customSlug" => "promo-2026"],
["targetUrl" => "https://example.io/c", "title" => "Summer launch", "tags" => ["launch", "2026"]],
],
]),
]);
curl_exec($ch);Response:
{
"success": true,
"data": {
"requested": 3,
"createdCount": 2,
"failedCount": 1,
"created": [
{ "index": 0, "id": "12345", "shortCode": "abc123", "targetUrl": "...", "customSlug": null }
],
"errors": [
{ "index": 1, "error": "Slug already taken", "code": "SLUG_CONFLICT" }
]
}
}Per-item error codes:
SLUG_CONFLICT—customSlugis already taken on the destination domain.QUOTA_EXCEEDED— org has hit its plan's link bundle.DOMAIN_INVALID— referenceddomainIdis not active in this org.CREATE_FAILED— generic catch-all (validation, internal error). Inspecterrorfor details.
If every item fails, the response status is 207 Multi-Status to make partial-failure visible without breaking happy-path tooling.
PATCH /api/v1/urls/bulk — bulk update
Each item references the target URL by id and validates against the same schema as PATCH /api/v1/urls/{id}.
curl -X PATCH https://shortnd.com/api/v1/urls/bulk \
-H "Authorization: Bearer $SHORTND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"updates": [
{ "id": 1, "title": "New title", "isActive": true },
{ "id": 2, "tags": ["promo", "summer-2026"] }
]
}'await fetch('https://shortnd.com/api/v1/urls/bulk', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.SHORTND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
updates: [
{ id: 1, title: 'New title', isActive: true },
{ id: 2, tags: ['promo', 'summer-2026'] },
],
}),
});import os, requests
requests.patch(
"https://shortnd.com/api/v1/urls/bulk",
headers={"Authorization": f"Bearer {os.environ['SHORTND_API_KEY']}"},
json={
"updates": [
{"id": 1, "title": "New title", "isActive": True},
{"id": 2, "tags": ["promo", "summer-2026"]},
]
},
).raise_for_status()payload := []byte(`{"updates":[
{"id":1,"title":"New title","isActive":true},
{"id":2,"tags":["promo","summer-2026"]}
]}`)
req, _ := http.NewRequest("PATCH", "https://shortnd.com/api/v1/urls/bulk", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SHORTND_API_KEY"))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)$ch = curl_init("https://shortnd.com/api/v1/urls/bulk");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "PATCH",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("SHORTND_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"updates" => [
["id" => 1, "title" => "New title", "isActive" => true],
["id" => 2, "tags" => ["promo", "summer-2026"]],
],
]),
]);
curl_exec($ch);Per-item error codes: NOT_FOUND (id not in this org) / UPDATE_FAILED.
DELETE /api/v1/urls/bulk — bulk soft-delete
Provide ids via either ?ids=1,2,3 or a JSON body { "ids": [1, 2, 3] }.
curl -X DELETE 'https://shortnd.com/api/v1/urls/bulk?ids=1,2,3' \
-H "Authorization: Bearer $SHORTND_API_KEY"await fetch('https://shortnd.com/api/v1/urls/bulk?ids=1,2,3', {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.SHORTND_API_KEY}` },
});import os, requests
requests.delete(
"https://shortnd.com/api/v1/urls/bulk",
params={"ids": "1,2,3"},
headers={"Authorization": f"Bearer {os.environ['SHORTND_API_KEY']}"},
).raise_for_status()req, _ := http.NewRequest("DELETE", "https://shortnd.com/api/v1/urls/bulk?ids=1,2,3", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SHORTND_API_KEY"))
http.DefaultClient.Do(req)$ch = curl_init("https://shortnd.com/api/v1/urls/bulk?ids=1,2,3");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "DELETE",
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SHORTND_API_KEY")],
]);
curl_exec($ch);Cross-org rows and missing rows are silently skipped and surface in skippedIds. Soft-delete sets isActive=false.
{
"success": true,
"data": {
"requested": 3,
"deletedCount": 2,
"skippedCount": 1,
"deletedIds": ["1", "2"],
"skippedIds": ["3"]
}
}Per-URL click analytics
GET /api/v1/urls/{id}/clicks returns geo, device, browser, OS, and referrer breakdowns plus a bucketed time series for a single org-owned URL. Bot traffic is excluded by default (is_bot = false).
curl 'https://shortnd.com/api/v1/urls/12345/clicks?since=2026-04-08T00:00:00Z&groupBy=day&limit=10' \
-H "Authorization: Bearer $SHORTND_API_KEY"const url = new URL('https://shortnd.com/api/v1/urls/12345/clicks');
url.search = new URLSearchParams({
since: '2026-04-08T00:00:00Z',
groupBy: 'day',
limit: '10',
}).toString();
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SHORTND_API_KEY}` },
});
const { data } = await res.json();import os, requests
res = requests.get(
"https://shortnd.com/api/v1/urls/12345/clicks",
params={"since": "2026-04-08T00:00:00Z", "groupBy": "day", "limit": 10},
headers={"Authorization": f"Bearer {os.environ['SHORTND_API_KEY']}"},
)
data = res.json()["data"]req, _ := http.NewRequest("GET",
"https://shortnd.com/api/v1/urls/12345/clicks?since=2026-04-08T00:00:00Z&groupBy=day&limit=10",
nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SHORTND_API_KEY"))
res, err := http.DefaultClient.Do(req)$qs = http_build_query([
"since" => "2026-04-08T00:00:00Z",
"groupBy" => "day",
"limit" => 10,
]);
$ch = curl_init("https://shortnd.com/api/v1/urls/12345/clicks?$qs");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SHORTND_API_KEY")],
]);
$data = json_decode(curl_exec($ch), true)["data"];| Query | Default | Notes |
|---|---|---|
since | 30 days ago | ISO-8601 |
until | now | ISO-8601 |
groupBy | day | hour or day |
limit | 10 | Top-N for each dimension breakdown (max 50) |
Response shape:
{
"success": true,
"data": {
"urlId": "12345",
"shortCode": "abc123",
"since": "2026-04-08T00:00:00Z",
"until": "2026-05-08T10:30:00Z",
"totals": { "totalClicks": 1840, "uniqueClicks": 1502, "uniqueIps": 1311 },
"breakdowns": {
"country": [{ "country": "US", "clicks": 920 }, { "country": "KE", "clicks": 410 }],
"device": [{ "deviceType": "mobile", "clicks": 1100 }, { "deviceType": "desktop", "clicks": 700 }],
"browser": [{ "browser": "Chrome", "clicks": 1380 }],
"os": [{ "os": "iOS", "clicks": 612 }],
"referrer":[{ "referrerDomain": "twitter.com", "referrerType": "social", "clicks": 220 }]
},
"timeseries": [
{ "bucket": "2026-04-08T00:00:00Z", "clicks": 80, "uniques": 71 }
]
}
}For org-wide totals across every URL, use GET /api/v1/analytics/overview.
Custom slug rules
- Platform-domain slugs (
shortnd.com/<slug>) are globally unique. - Custom-domain slugs (
<sub>.<your-domain>/<slug>) are unique within that domain only — the samewelcomeslug can exist onacme.comand onexample.iowithout conflict. - Reserved slugs (e.g.,
api,dashboard,admin) are blocked.
URL events on webhooks
Subscribe to url.created, url.updated, url.click_threshold, and url.expired on POST /api/v1/webhooks to react to URL lifecycle events in your own system. See Webhooks for the signature scheme and retry table.