On this page
Getting started
SendURL API
Create and manage short links, and read their clicks, from your own code. The API is REST, speaks JSON and authenticates with an API key from your console.
- Base URL
- https://api.sendurl.to/api/v1
- Format
- JSON in, JSON out, over HTTPS
- Available on
- Every plan, Free included
Getting started
Workspaces, projects and links
Everything you create belongs to a workspace. Every account has one, made at signup with a project called Default. A workspace holds projects, and every link belongs to exactly one project. The API calls these org (as in orgId) and project.
- Your API key decides the workspace. URLs never name it, and nothing outside the key's workspace is visible: another workspace's link is a 404.
- A link created without
projectIdgoes into the Default project (or a project key's own project).PATCH /links/:idwithprojectIdmoves it. - A workspace can have up to 100 projects. Projects can be created through the API and renamed in the console; they can't be deleted.
Plan limits
The link limit belongs to the workspace and counts its links across all projects. Creating one more is a 403 link_limit_reached. GET /me and GET /links report the plan and usage.
| Plan | Links per workspace | API access |
|---|---|---|
| Free | 100 | Included |
| Pro | 1,000 | Included |
Authentication
Authenticate with an API key
Every request needs an API key, sent as a Bearer token in the Authorization header. Without a valid key the API answers 401.
- Create a keyOpen API keys in the console and choose Create key. Give it a name like “Production”.
- Choose its access and expiryWhole workspace or one project (below). Expiry: never, 30 days, 90 days or a year. Keys look like
snu_followed by 32 letters and digits; copy yours from the API keys page. - Send it on every requestAs
Authorization: Bearer snu_…, overhttps://only. Store it in an environment variable such asSENDURL_API_KEY.
Keep keys secret. Call the API from your server, never from a browser or mobile app: anyone who can read the key can use it. If a key leaks, revoke it on the API keys page and create a new one.
Header
Authorization: Bearer $SENDURL_API_KEY{
"error": "invalid_api_key",
"message": "This API key was revoked"
}Workspace keys and project keys
| Endpoint | Workspace key | Project key |
|---|---|---|
| Links: create, list, read, update, delete | Every project. May pass projectId. | Its project only. Another projectId is 403 project_forbidden on writes, 404 project_not_found on GET /links. |
| Clicks and daily stats | Every link of the workspace. | Links of its project. Others are 404. |
GET / POST /projects | Yes. | No: 403 project_key_forbidden. |
GET /me | Yes. | Yes, with its project. |
- A key can't create, list or revoke keys. API keys, listing your workspaces (
GET /orgs), renaming projects or the workspace, your profile and dashboard reports live in the console only; called with a key, those endpoints answer 403session_required. - A revoked key fails from its next request, and an expired key from its expiry: 401
invalid_api_key, withmessage“Invalid API key”, “This API key was revoked” or “This API key has expired”. - A workspace can have 25 active keys at a time.
- Links a key creates are recorded as created by the person who made the key (
userId).
Links
Create a link
POST/api/
Shortens a URL and returns the new link. Leave out slug and SendURL picks a random five-character one.
The link counts against the workspace's link limit, whichever project it lands in.
AccessAny API key. A project key creates links in its own project.
Body parameters
- destinationUrlstringRequired
- The absolute URL visitors are sent to, e.g.
https://example.com/page. - slugstringOptional
- Custom slug: 1–256 letters, digits,
-or_. Unique across SendURL (taken: 409slug_taken), so the example leaves it out. Reserved in any letter case:health,api,docs,robots.txt,favicon.ico,sitemap.xml,_next. Empty or left out: a random slug. - titlestringOptional
- A label for the link in your console and API responses. Up to 255 characters.
- expiresAtstring | nullOptional
- When the link stops redirecting: ISO 8601 with
Zor an offset, e.g.2030-12-31T23:59:59Z. Must be in the future and at most 100 years ahead. Left out ornull: never expires. - passwordstring | nullOptional
- Visitors must type it before they are redirected. 6–128 characters, with no whitespace of any kind (space, tab, line break) at the start or end. Needs an
httporhttpsdestinationUrl. Left out ornull: no password. - projectIduuidOptional
- The project to create the link in. Left out: the key's project (project key) or the workspace's Default project.
Response
201 Created. The new link object.
- Unknown body fields are ignored. Send the body as JSON with
Content-Type: application/json. - The response never contains a password, only
passwordProtected.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | A field breaks a rule; issues says which. |
| 403 | link_limit_reached | The workspace has used its plan's links. The body adds limit and current. |
| 403 | project_forbidden | A project key named a different projectId. |
| 404 | project_not_found | projectId is not a project of this workspace. |
| 409 | slug_taken | Another link already uses that slug. Rarely, a random slug collides with an existing one too: send the request again. |
curl -X POST "https://api.sendurl.to/api/v1/links" \
-H "Authorization: Bearer $SENDURL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"destinationUrl": "https://example.com/blog/launch",
"title": "Launch post",
"expiresAt": "2030-12-31T23:59:59Z"
}'{
"id": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"slug": "x7Kp2",
"destinationUrl": "https://example.com/blog/launch",
"title": "Launch post",
"active": true,
"moderated": false,
"expiresAt": "2030-12-31T23:59:59.000Z",
"passwordProtected": false,
"visitsCount": 0,
"visitsLimit": 100000,
"guestToken": null,
"projectId": "3f6a1d2c-8e4b-4f7a-9c0d-1b2e3a4f5c6d",
"orgId": "9b2e4c1a-5f3d-4e8b-a1c7-2d6f8e0b3a95",
"userId": "e1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
"createdAt": "2026-10-13T09:12:44.123Z",
"updatedAt": "2026-10-13T09:12:44.123Z",
"shortUrl": "https://snu.to/x7Kp2",
"qrUrl": "https://snu.to/x7Kp2?q"
}Links
List links
GET/api/
The workspace's links, newest first, one page at a time. A project key sees its own project only.
Every page also carries summary for the whole scope (all pages, honouring projectId), the workspace's usage against its link limit, and its plan.
AccessAny API key.
Query parameters
- projectIduuidOptional
- Only this project's links. Left out: the whole workspace (a project key: its project). Any other project is 404
project_not_found. - limitintegerOptional
- Page size, 1 to 10. Default 10.
- cursorstringOptional
pagination.nextCursorfrom the previous page. Leave it out for the first page.
Response
200 OK. Rows are link objects without updatedAt and guestToken, plus totalClicks.
summary.totalLinksis every link in scope,summary.activeLinksthose enabled and not expired,summary.totalClicksall their clicks.usage.linksUsedcounts the whole workspace, whateverprojectIdsays: the link limit is per workspace.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | A malformed projectId, limit outside 1–10, a bad cursor, or offset sent. |
| 404 | project_not_found | projectId is outside the key's scope. |
cURL
curl "https://api.sendurl.to/api/v1/links?limit=2" \
-H "Authorization: Bearer $SENDURL_API_KEY"{
"links": [
{
"id": "0e9d8c7b-6a5f-4e3d-9c2b-1a0f9e8d7c6b",
"slug": "fall-sale",
"destinationUrl": "https://example.com/sale",
"title": "Fall sale",
"active": true,
"moderated": false,
"expiresAt": null,
"passwordProtected": false,
"visitsCount": 0,
"visitsLimit": 100000,
"projectId": "7c1e5a9b-2d4f-4e6a-8b3c-0f9d1e2a3b4c",
"orgId": "9b2e4c1a-5f3d-4e8b-a1c7-2d6f8e0b3a95",
"userId": "e1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
"createdAt": "2026-10-14T07:40:02.518Z",
"totalClicks": 0,
"shortUrl": "https://snu.to/fall-sale",
"qrUrl": "https://snu.to/fall-sale?q"
},
{
"id": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"slug": "x7Kp2",
"destinationUrl": "https://example.com/blog/launch",
"title": "Launch post",
"active": true,
"moderated": false,
"expiresAt": "2030-12-31T23:59:59.000Z",
"passwordProtected": false,
"visitsCount": 42,
"visitsLimit": 100000,
"projectId": "3f6a1d2c-8e4b-4f7a-9c0d-1b2e3a4f5c6d",
"orgId": "9b2e4c1a-5f3d-4e8b-a1c7-2d6f8e0b3a95",
"userId": "e1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
"createdAt": "2026-10-13T09:12:44.123Z",
"totalClicks": 42,
"shortUrl": "https://snu.to/x7Kp2",
"qrUrl": "https://snu.to/x7Kp2?q"
}
],
"summary": {
"totalLinks": 12,
"activeLinks": 11,
"totalClicks": 1873
},
"usage": {
"linksUsed": 12,
"linksLimit": 100
},
"plan": {
"name": "Free",
"linksLimit": 100,
"visitsLimitPerLink": 100000
},
"pagination": {
"limit": 2,
"nextCursor": "WyIyMDI2LTEwLTEzVDA5OjEyOjQ0LjEyMzQ1NloiLCI1ZDhmMmExYy03YjNlLTRjOWQtYTBlMS02ZjJiOGM0ZDFlN2EiXQ",
"hasMore": true,
"total": 12
}
}Links
Retrieve a link
GET/api/
One link with its click counts: totalClicks, and qrScans for the clicks that came through its QR code.
AccessAny API key, for links in its scope.
Path parameters
- iduuidRequired
- The link's
id. Anything that isn't a link of the key's scope is 404link_not_found.
Response
200 OK. The link object plus totalClicks and qrScans.
- An API key never receives a link's password, only
passwordProtected. The owner sees the password in the console. - Sent with
Cache-Control: no-store, private.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | link_not_found | No such link in the key's workspace (or, for a project key, its project). |
cURL
curl "https://api.sendurl.to/api/v1/links/5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a" \
-H "Authorization: Bearer $SENDURL_API_KEY"{
"id": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"slug": "x7Kp2",
"destinationUrl": "https://example.com/blog/launch",
"title": "Launch post",
"active": true,
"moderated": false,
"expiresAt": "2030-12-31T23:59:59.000Z",
"passwordProtected": false,
"visitsCount": 42,
"visitsLimit": 100000,
"guestToken": null,
"projectId": "3f6a1d2c-8e4b-4f7a-9c0d-1b2e3a4f5c6d",
"orgId": "9b2e4c1a-5f3d-4e8b-a1c7-2d6f8e0b3a95",
"userId": "e1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
"createdAt": "2026-10-13T09:12:44.123Z",
"updatedAt": "2026-10-13T09:12:44.123Z",
"shortUrl": "https://snu.to/x7Kp2",
"qrUrl": "https://snu.to/x7Kp2?q",
"totalClicks": 42,
"qrScans": 9
}Links
Update a link
PATCH/api/
Changes only the fields you send; at least one is required. The slug can't be changed.
Set active to false to switch a link off without deleting it: its short URL then answers 404 and it keeps its slug and clicks.
AccessAny API key, for links in its scope.
Path parameters
- iduuidRequired
- The link's
id. Anything that isn't a link of the key's scope is 404link_not_found.
Body parameters
- destinationUrlstringOptional
- A new absolute URL to redirect to.
- titlestring | nullOptional
- Up to 255 characters;
nullclears it. - activebooleanOptional
falsedisables the link,trueenables it again.- expiresAtstring | nullOptional
- ISO 8601 with
Zor an offset, in the future, at most 100 years ahead.nullremoves the expiry. - passwordstring | nullOptional
- Sets or replaces the password (same rules as on create);
nullremoves it. - projectIduuidOptional
- Moves the link to another project of the workspace. A project key can only name its own project.
Response
200 OK. The updated link object.
- A link with a password must keep an
httporhttpsdestination: changingdestinationUrlto another scheme is a 400 ondestinationUrl. - A link disabled by moderation can be edited or kept off, but not switched back on.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | No field sent, or a field breaks a rule. |
| 403 | link_moderated | active: true on a link disabled by moderation. |
| 403 | project_forbidden | A project key named a different projectId. |
| 404 | link_not_found | No such link in the key's workspace (or, for a project key, its project). |
| 404 | project_not_found | projectId is not a project of this workspace. |
cURL
curl -X PATCH "https://api.sendurl.to/api/v1/links/5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a" \
-H "Authorization: Bearer $SENDURL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"active": false
}'{
"id": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"slug": "x7Kp2",
"destinationUrl": "https://example.com/blog/launch",
"title": "Launch post",
"active": false,
"moderated": false,
"expiresAt": "2030-12-31T23:59:59.000Z",
"passwordProtected": false,
"visitsCount": 42,
"visitsLimit": 100000,
"guestToken": null,
"projectId": "3f6a1d2c-8e4b-4f7a-9c0d-1b2e3a4f5c6d",
"orgId": "9b2e4c1a-5f3d-4e8b-a1c7-2d6f8e0b3a95",
"userId": "e1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
"createdAt": "2026-10-13T09:12:44.123Z",
"updatedAt": "2026-10-14T10:05:31.904Z",
"shortUrl": "https://snu.to/x7Kp2",
"qrUrl": "https://snu.to/x7Kp2?q"
}Links
Delete a link
DELETE/api/
Deletes the link and its click history for good. Its short URL stops working, and the slug becomes free for anyone to take.
To stop a link but keep its slug, QR code and clicks, update it with active: false instead.
AccessAny API key, for links in its scope.
Path parameters
- iduuidRequired
- The link's
id. Anything that isn't a link of the key's scope is 404link_not_found.
Response
200 OK. A confirmation.
Errors
| Status | Code | Meaning |
|---|---|---|
| 403 | link_moderated | The link was disabled by moderation; it can't be deleted. |
| 404 | link_not_found | No such link in the key's workspace (or, for a project key, its project). |
cURL
curl -X DELETE "https://api.sendurl.to/api/v1/links/5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a" \
-H "Authorization: Bearer $SENDURL_API_KEY"{
"message": "Link deleted"
}Clicks
Daily clicks
GET/api/
The link's clicks per day (UTC), oldest first, over its whole history. Days without clicks are left out. Not paginated.
AccessAny API key, for links in its scope.
Path parameters
- iduuidRequired
- The link's
id. Anything that isn't a link of the key's scope is 404link_not_found.
Response
200 OK. An array of { date, count }.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | link_not_found | No such link in the key's workspace (or, for a project key, its project). |
cURL
curl "https://api.sendurl.to/api/v1/links/5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a/stats" \
-H "Authorization: Bearer $SENDURL_API_KEY"[
{
"date": "2026-10-12",
"count": 14
},
{
"date": "2026-10-13",
"count": 19
},
{
"date": "2026-10-14",
"count": 9
}
]Clicks
List clicks
GET/api/
Every recorded visit of the link, newest first, one page at a time. Use GET /links/:id for the total.
AccessAny API key, for links in its scope.
Path parameters
- iduuidRequired
- The link's
id. Anything that isn't a link of the key's scope is 404link_not_found.
Query parameters
- limitintegerOptional
- Page size, 1 to 10. Default 10.
- cursorstringOptional
pagination.nextCursorfrom the previous page. Leave it out for the first page.
Response
200 OK. Click objects. There is no total here.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | link_not_found | No such link in the key's workspace (or, for a project key, its project). |
| 400 | validation_error | limit outside 1–10, a cursor this API didn't issue, or offset sent. |
cURL
curl "https://api.sendurl.to/api/v1/links/5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a/clicks?limit=1" \
-H "Authorization: Bearer $SENDURL_API_KEY"{
"clicks": [
{
"id": "a4c6e8f0-1b3d-4e5f-9a7b-2c4d6e8f0a1b",
"linkId": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"ip": "203.0.113.24",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1",
"referer": null,
"country": "DE",
"city": null,
"device": "mobile",
"deviceVendor": "Apple",
"deviceModel": "iPhone",
"browser": "Mobile Safari",
"browserVersion": "18.6",
"browserMajor": "18",
"browserType": null,
"engineName": "WebKit",
"engineVersion": "605.1.15",
"os": "iOS",
"osVersion": "18.6",
"cpuArchitecture": null,
"source": "qr",
"clickedAt": "2026-10-14T08:03:11.482Z"
}
],
"pagination": {
"limit": 1,
"nextCursor": "WyIyMDI2LTEwLTE0VDA4OjAzOjExLjQ4MjkxM1oiLCJhNGM2ZThmMC0xYjNkLTRlNWYtOWE3Yi0yYzRkNmU4ZjBhMWIiXQ",
"hasMore": true
}
}Clicks
Retrieve a click
GET/api/
One click of the link, however old.
AccessAny API key, for links in its scope.
Path parameters
- iduuidRequired
- The link's
id. Anything that isn't a link of the key's scope is 404link_not_found. - clickIduuidRequired
- The click's
id, from the clicks list.
Response
200 OK. A click object.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | link_not_found | No such link in the key's workspace (or, for a project key, its project). |
| 404 | click_not_found | No click with that id on this link. |
cURL
curl "https://api.sendurl.to/api/v1/links/5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a/clicks/a4c6e8f0-1b3d-4e5f-9a7b-2c4d6e8f0a1b" \
-H "Authorization: Bearer $SENDURL_API_KEY"{
"id": "a4c6e8f0-1b3d-4e5f-9a7b-2c4d6e8f0a1b",
"linkId": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"ip": "203.0.113.24",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1",
"referer": null,
"country": "DE",
"city": null,
"device": "mobile",
"deviceVendor": "Apple",
"deviceModel": "iPhone",
"browser": "Mobile Safari",
"browserVersion": "18.6",
"browserMajor": "18",
"browserType": null,
"engineName": "WebKit",
"engineVersion": "605.1.15",
"os": "iOS",
"osVersion": "18.6",
"cpuArchitecture": null,
"source": "qr",
"clickedAt": "2026-10-14T08:03:11.482Z"
}Workspace
List projects
GET/api/
The workspace's projects: Default first, then by name (ignoring case), each with its link count.
AccessWorkspace keys only. A project key gets 403 project_key_forbidden.
Query parameters
- limitintegerOptional
- Page size, 1 to 10. Default 10.
- cursorstringOptional
pagination.nextCursorfrom the previous page. Leave it out for the first page.
Response
200 OK. Project objects and pagination with total.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | limit outside 1–10, a cursor this API didn't issue, or offset sent. |
| 403 | project_key_forbidden | Called with a project key. |
cURL
curl "https://api.sendurl.to/api/v1/projects" \
-H "Authorization: Bearer $SENDURL_API_KEY"{
"projects": [
{
"id": "3f6a1d2c-8e4b-4f7a-9c0d-1b2e3a4f5c6d",
"name": "Default",
"isDefault": true,
"linksCount": 9,
"createdAt": "2026-09-02T14:21:07.315Z",
"updatedAt": "2026-09-02T14:21:07.315Z"
},
{
"id": "7c1e5a9b-2d4f-4e6a-8b3c-0f9d1e2a3b4c",
"name": "Marketing",
"isDefault": false,
"linksCount": 3,
"createdAt": "2026-10-01T10:00:00.000Z",
"updatedAt": "2026-10-01T10:00:00.000Z"
}
],
"pagination": {
"limit": 10,
"nextCursor": null,
"hasMore": false,
"total": 2
}
}Workspace
Create a project
POST/api/
Adds a project to the workspace, up to 100. Projects can't be deleted or renamed through the API; renaming is in the console.
AccessWorkspace keys only. A project key gets 403 project_key_forbidden.
Body parameters
- namestringRequired
- 1–100 characters after trimming spaces. Unique in the workspace, ignoring case.
Response
201 Created. The new project.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | A field breaks a rule; issues says which. |
| 403 | project_key_forbidden | Called with a project key. |
| 403 | project_limit_reached | The workspace already has 100 projects. |
| 409 | project_name_taken | A project with that name (in any letter case) exists. |
cURL
curl -X POST "https://api.sendurl.to/api/v1/projects" \
-H "Authorization: Bearer $SENDURL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Marketing"
}'{
"id": "7c1e5a9b-2d4f-4e6a-8b3c-0f9d1e2a3b4c",
"name": "Marketing",
"isDefault": false,
"linksCount": 0,
"createdAt": "2026-10-01T10:00:00.000Z",
"updatedAt": "2026-10-01T10:00:00.000Z"
}Workspace
Check your key
GET/api/
Who the key is: its workspace, its project (null for a workspace key), the plan and how many links the workspace uses. A cheap way to test a key.
AccessAny API key.
Response
200 OK. role is always null for a key. keyPrefix is the key's first 12 characters, never the key.
Errors
Only the 401s every request can get.
cURL
curl "https://api.sendurl.to/api/v1/me" \
-H "Authorization: Bearer $SENDURL_API_KEY"{
"via": "key",
"org": {
"id": "9b2e4c1a-5f3d-4e8b-a1c7-2d6f8e0b3a95",
"name": "Acme's workspace"
},
"role": null,
"project": null,
"plan": {
"name": "Free",
"linksLimit": 100,
"visitsLimitPerLink": 100000
},
"usage": {
"linksUsed": 12
},
"key": {
"id": "b7d9f1a3-c5e7-4f9a-8b1c-3d5e7f9a1b2c",
"name": "Production",
"keyPrefix": "snu_XXXXXXXX",
"expiresAt": null
}
}Reference
The link object
GET /links/:id returns every field below. Create and update return all but totalClicks and qrScans; list rows leave out updatedAt, guestToken and qrScans. Timestamps are ISO 8601 in UTC.
| Field | Type | Description |
|---|---|---|
| id | uuid | The link's id. |
| slug | string | The path of the short URL. |
| shortUrl | string | The short URL to share, e.g. https://snu.to/launch. |
| qrUrl | string | What the link's QR code encodes: shortUrl plus ?q. Visits through it are recorded with source: "qr". |
| destinationUrl | string | Where the short URL redirects (HTTP 302). |
| title | string | null | The link's label. |
| active | boolean | false: switched off; the short URL answers 404. |
| moderated | boolean | true when SendURL disabled the link after an abuse report. It can't be re-enabled or deleted. |
| expiresAt | string | null | UTC expiry. From then on the short URL answers 410 and records no clicks. null: never. |
| passwordProtected | boolean | Whether visitors must enter a password. The password itself is never sent to an API key. |
| visitsCount | integer | Visits counted so far. |
| visitsLimit | integer | The plan's per-link visit limit when the link was made. At the limit the short URL answers 410. |
| projectId | uuid | The project the link belongs to. |
| orgId | uuid | The workspace that owns the link. |
| userId | uuid | null | Who created it: the signed-in user, or the API key's creator. |
| guestToken | null | Always null for links in a workspace. |
| createdAt | string | ISO 8601, UTC. |
| updatedAt | string | ISO 8601, UTC. |
| totalClicks | integer | All clicks. In list rows and GET /links/:id. |
| qrScans | integer | Clicks through the QR code. GET /links/:id only. |
What the short URL does
- Redirects with a 302 while the link is active, not expired and under its visit limit, and records a click.
- Answers a 404 page when the link is switched off (
active: false), a 410 page once it has expired or reachedvisitsLimit. Neither records a click. - With a password, shows a password page first; only the right password redirects and records the click.
?qon the short URL (qrUrl) marks the visit as a QR scan. It is not passed on to the destination.
Reference
The click object
One visit of the short URL that was redirected. Device, browser, engine and OS fields are parsed from the user agent and are null when it doesn't say.
| Field | Type | Description |
|---|---|---|
| id | uuid | The click's id. |
| linkId | uuid | The link that was visited. |
| clickedAt | string | ISO 8601, UTC. |
| source | string | null | "qr" when the visit came through the QR code (?q), else null. |
| country | string | null | Two-letter ISO 3166-1 country code from Cloudflare's geolocation (T1 for Tor), null when unknown. |
| city | null | Not recorded; always null. |
| referer | string | null | The visitor's Referer header, if the browser sent one. |
| ip | string | null | The visitor's IP address. |
| userAgent | string | null | The visitor's User-Agent header. |
| device | string | null | Device type parsed from the user agent: desktop, mobile, tablet, … |
| deviceVendor, deviceModel | string | null | e.g. Apple, iPhone. |
| browser, browserVersion, browserMajor, browserType | string | null | e.g. Chrome, 141.0.0.0, 141. |
| engineName, engineVersion | string | null | Rendering engine, e.g. Blink. |
| os, osVersion | string | null | e.g. Android, 15. |
| cpuArchitecture | string | null | When the user agent says, e.g. amd64. |
Reference
Pagination
Every list (GET /links, GET /links/:id/clicks, GET /projects) is cursor-paginated, at most 10 items per page. Pass pagination.nextCursor back as cursor until hasMore is false.
Query parameters
- limitintegerOptional
- 1 to 10, default 10. Anything else (
0,11,1.5,abc) is 400validation_error. - cursorstringOptional
- Opaque: use it as given. A cursor this API didn't issue is 400
validation_error.
The pagination object
- limit
- The page size used.
- nextCursor
- The next page's
cursor, ornullon the last page. - hasMore
- Whether another page follows.
- total
- Items in scope, on links and projects. Clicks have no
total;GET /links/:idhastotalClicks.
- Pages don't shift when links or clicks are added in between: the cursor marks a position, not a count.
offsetis not supported and is refused with 400validation_error. Other unknown parameters are ignored.
cURL · next page
curl "https://api.sendurl.to/api/v1/links?limit=2&cursor=WyIyMDI2LTEwLTEzVDA5OjEyOjQ0LjEyMzQ1NloiLCI1ZDhmMmExYy03YjNlLTRjOWQtYTBlMS02ZjJiOGM0ZDFlN2EiXQ" \
-H "Authorization: Bearer $SENDURL_API_KEY"Reference
Errors
Errors have a non-2xx status and one JSON shape: error, a stable code to branch on; message, a sentence for people that may be reworded; and, on validation_error only, issues, the list of what failed, each with a path naming the field. Some codes add fields of their own: link_limit_reached carries limit and current.
{
"error": "validation_error",
"message": "Invalid request",
"issues": [
{
"validation": "url",
"code": "invalid_string",
"message": "Invalid url",
"path": [
"destinationUrl"
]
},
{
"validation": "regex",
"code": "invalid_string",
"message": "Slug must be 1-256 characters: letters, digits, '-' or '_'",
"path": [
"slug"
]
}
]
}{
"error": "validation_error",
"message": "Expiry must be in the future",
"issues": [
{
"code": "custom",
"path": [
"expiresAt"
],
"message": "Expiry must be in the future"
}
]
}{
"error": "link_limit_reached",
"message": "You have reached the maximum of 100 links on the Free plan.",
"limit": 100,
"current": 100
}| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | A body field, query parameter or cursor breaks a rule. issues lists them. |
| 400 | invalid_json | The body isn't valid JSON. |
| 400 | bad_request | The request couldn't be read, e.g. a body cut short. The status is the one that fits: 400, or 415 for an unsupported charset or Content-Encoding. |
| 401 | missing_token | No Authorization: Bearer … header. |
| 401 | invalid_token | The Bearer token is neither an API key (snu_…) nor a valid console session. |
| 401 | invalid_api_key | An snu_ key that isn't valid. message: “Invalid API key” (malformed or unknown), “This API key was revoked” or “This API key has expired”. |
| 403 | session_required | A console-only endpoint: API key management, listing workspaces (GET /orgs), renaming projects or the workspace, profile, reports. |
| 403 | project_forbidden | A project key named another project in a write. |
| 403 | project_key_forbidden | A project key called /projects. |
| 403 | link_limit_reached | The workspace has used all its plan's links. |
| 403 | project_limit_reached | The workspace has 100 projects. |
| 403 | link_moderated | Re-enabling or deleting a link disabled by moderation. |
| 404 | link_not_found | No such link in the key's scope. |
| 404 | click_not_found | No such click on that link. |
| 404 | project_not_found | No such project in the workspace (or the key's scope). |
| 404 | not_found | No such endpoint. |
| 409 | slug_taken | The custom slug is in use, or (rarely) a random slug collided: retry. |
| 409 | project_name_taken | A project with that name exists. |
| 413 | payload_too_large | The body is too large. |
| 500 | internal_error | Something failed on our side. |
| 503 | service_unavailable | We couldn't check the key just now. Retry. |
Reference
Rate limits
There is no per-key limit. Cloudflare, in front of the API, allows 50 requests per 10 seconds from one IP address; past that it answers 429 for 10 seconds. That response comes from Cloudflare, not the API, so it isn't in the JSON error shape. Wait and retry with backoff.