Shortnd Docs

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.

FieldTypeNotes
targetUrlstring (URL)Required. Up to 8 KB. Must be http:// or https://.
customSlugstring3–50 chars, [A-Za-z0-9-], no leading/trailing hyphen. Omit to let Shortnd mint a random short code.
titlestringSurfaced in dashboard + analytics.
descriptionstringLong-form note.
categorystringFree-form tag for dashboard grouping.
tagsstring[]Up to 20.
domainIdUUIDRoutes the link through a custom domain. Omit for the platform domain (shortnd.com). The domain must be active in the calling org.
redirectTypeenum301 / 302 / 307 / 308. Default 307.
expiresAtISO-8601Auto-deactivates the link at this time.
isPasswordProtectedboolWhen true, supply password.
passwordstringRequired when isPasswordProtected is true.
utmSource, utmMedium, utmCampaign, utmTerm, utmContentstringServer-side UTM injection.
customUtmParamsrecordExtra UTM-style key/value pairs.
sourceDomainstringHint 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"];
QueryDefaultMax
limit25100
offset0

Results are ordered by createdAt desc and scoped to the calling organization.

Read, update, soft-delete a single URL

MethodPathScope
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_CONFLICTcustomSlug is already taken on the destination domain.
  • QUOTA_EXCEEDED — org has hit its plan's link bundle.
  • DOMAIN_INVALID — referenced domainId is not active in this org.
  • CREATE_FAILED — generic catch-all (validation, internal error). Inspect error for 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"];
QueryDefaultNotes
since30 days agoISO-8601
untilnowISO-8601
groupBydayhour or day
limit10Top-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 same welcome slug can exist on acme.com and on example.io without 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.