URL Shortener API in Python: Create Links, Export Clicks
Part of the Link tracking handbook
Shorten URLs from Python and JavaScript with the SendURL REST API: create links, bulk-create from a CSV without hitting rate limits, page through every click and export it to CSV, with the errors you need to handle.
To use a URL shortener API from Python, send a POST with the long URL and your API key, and read the short link from the JSON that comes back. With SendURL it takes ten lines and the requests package; the same API also returns every click as a record you can page through and save as CSV.
This guide goes from a first request to a small, reusable client in Python and JavaScript: create links one at a time or in bulk from a CSV, page through clicks with cursors, export them, summarise them with pandas, and handle errors and rate limits. Every script here was run against the API before publishing; the sample outputs come from test links on a local stack, with simulated clicks. It's part of the link tracking handbook.
Create a short link in Python
Make an API key in the console under API keys, put it in an environment variable, and install requests (pip install requests):
export SENDURL_API_KEY="snu_..." # never commit itimport os
import requests
res = requests.post(
"https://api.sendurl.to/api/v1/links",
headers={"Authorization": f"Bearer {os.environ['SENDURL_API_KEY']}"},
json={"destinationUrl": "https://example.com/autumn-sale"},
timeout=10,
)
res.raise_for_status()
link = res.json()
print(link["shortUrl"], link["id"])https://snu.to/x7Kp2 5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7aLeave out slug and you get a random five-character one. The response is the full link object, including qrUrl (the same link with ?q, which is what its QR code encodes) and the id you need for everything else:
{
"id": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"slug": "x7Kp2",
"destinationUrl": "https://example.com/autumn-sale",
"shortUrl": "https://snu.to/x7Kp2",
"qrUrl": "https://snu.to/x7Kp2?q",
"active": true,
"expiresAt": null,
"passwordProtected": false,
"visitsLimit": 100000,
"createdAt": "2026-10-13T09:12:44.123Z"
}Other fields you can send: slug (your own, 1 to 32 letters, digits, - or _), title, expiresAt (ISO 8601 with Z or an offset, such as 2030-12-31T23:59:59Z), password and projectId. They're all in the Create a link reference.
A small client you can reuse
Real scripts need more than one call: error codes, retries when you're rate-limited, pagination. Save this as sendurl.py next to your scripts; the rest of the Python examples import from it.
"""A small client for the SendURL REST API (https://sendurl.to/docs)."""
import os
import time
import requests
API = "https://api.sendurl.to/api/v1"
MIN_INTERVAL = 0.25 # 4 requests a second, under Cloudflare's 50 per 10 seconds
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SENDURL_API_KEY']}"
_last_request = 0.0
class SendURLError(Exception):
"""An error answer: `code` is the API's `error` field, e.g. "slug_taken"."""
def __init__(self, status, body):
super().__init__(f"{status} {body.get('error')}: {body.get('message')}")
self.status = status
self.code = body.get("error")
self.body = body
def _pause():
global _last_request
wait = _last_request + MIN_INTERVAL - time.monotonic()
if wait > 0:
time.sleep(wait)
_last_request = time.monotonic()
def _retry_after(res, attempt):
try:
return float(res.headers["Retry-After"])
except (KeyError, ValueError):
return 10 * (attempt + 1)
def call(method, path, attempts=5, **kwargs):
"""One API request. Returns the JSON body or raises SendURLError."""
for attempt in range(attempts):
_pause()
res = session.request(method, f"{API}{path}", timeout=10, **kwargs)
# 429 comes from Cloudflare before the API sees the request (so a retry
# can't create a duplicate); 503 means the key couldn't be checked.
if res.status_code in (429, 503) and attempt < attempts - 1:
time.sleep(_retry_after(res, attempt))
continue
if res.ok:
return res.json()
try:
body = res.json()
except ValueError: # not JSON: Cloudflare's 429 page, a proxy error
body = {"error": f"http_{res.status_code}", "message": res.text[:200]}
raise SendURLError(res.status_code, body)
def create_link(destination, slug=None, title=None, expires_at=None):
body = {"destinationUrl": destination}
if slug:
body["slug"] = slug
if title:
body["title"] = title
if expires_at:
body["expiresAt"] = expires_at # ISO 8601 with Z or an offset
try:
return call("POST", "/links", json=body)
except SendURLError as e:
# A random slug can (rarely) collide: send it again. A custom one can't be fixed here.
if e.code == "slug_taken" and not slug:
return call("POST", "/links", json=body)
raise
def get_link(link_id):
return call("GET", f"/links/{link_id}")
def pages(path, key, **params):
"""Every item of a list endpoint, following pagination.nextCursor."""
cursor = None
while True:
query = {**params, "limit": 10}
if cursor:
query["cursor"] = cursor
page = call("GET", path, params=query)
yield from page[key]
cursor = page["pagination"]["nextCursor"]
if not cursor:
return
def all_links(project_id=None):
return pages("/links", "links", **({"projectId": project_id} if project_id else {}))
def all_clicks(link_id):
return pages(f"/links/{link_id}/clicks", "clicks")
def find_link(slug):
"""The link with this slug in your workspace, or None. Slugs are case-sensitive."""
return next((link for link in all_links() if link["slug"] == slug), None)What it does, and why:
- One session, so the key is set once and connections are reused.
- Paced at four requests a second. Cloudflare, in front of the API, allows 50 requests per 10 seconds from one IP address and answers
429for 10 seconds after that (rate limits). Pacing means a long job never trips it. - Retries on 429 and 503 only. A 429 is refused before it reaches the API, so retrying a
POSTcan't create a duplicate. Other errors are raised with the API's own code. - Reads errors safely. Every API error is JSON (
{ "error", "message", "issues"? }), but Cloudflare's 429 page is HTML, so the client checks before parsing. - Follows cursors. Lists come ten at a time with
pagination.nextCursor;pages()keeps asking until it'snull. There is nooffset(sending one is a 400).
Create links in bulk from a CSV
There's no bulk import screen in SendURL; this is how you do it. Start with a sheet of placements, one row per link. slug and title can be empty:
destination,slug,title
https://example.com/autumn-sale?utm_source=instagram&utm_medium=social&utm_campaign=2026-10-autumn-sale&utm_content=bio,autumn-sale-ig-bio,Autumn sale: Instagram bio
https://example.com/autumn-sale?utm_source=newsletter&utm_medium=email&utm_campaign=2026-10-autumn-sale,autumn-sale-email,Autumn sale: newsletter
https://example.com/autumn-sale?utm_source=poster&utm_medium=qr&utm_campaign=2026-10-autumn-sale&utm_content=window,,Autumn sale: shop window posterThe UTM parameters go on the destination, not on the short link: SendURL redirects to the stored URL as is, and anything added to the short link itself isn't passed on. The UTM parameters guide has values that GA4 files correctly.
import csv
import sys
from sendurl import SendURLError, create_link
with open(sys.argv[1], newline="") as src, open("created.csv", "w", newline="") as out:
writer = csv.writer(out)
writer.writerow(["slug", "short_url", "qr_url", "id", "destination", "error"])
for row in csv.DictReader(src):
try:
link = create_link(row["destination"], slug=row.get("slug"), title=row.get("title"))
except SendURLError as e:
print(f"skipped {row.get('slug') or row['destination']}: {e}")
writer.writerow([row.get("slug", ""), "", "", "", row["destination"], e.code])
if e.code == "link_limit_reached":
break # every further row would fail the same way
continue
print(f"created {link['shortUrl']}")
writer.writerow([link["slug"], link["shortUrl"], link["qrUrl"], link["id"], link["destinationUrl"], ""])created https://snu.to/autumn-sale-ig-bio
created https://snu.to/autumn-sale-email
created https://snu.to/6I8dNRun it twice and the second run shows why the results file has an error column: custom slugs are unique across all of SendURL, so the second time they're slug_taken, and the row without a slug simply gets a new random one.
skipped autumn-sale-ig-bio: 409 slug_taken: That slug is already taken — try another.
skipped autumn-sale-email: 409 slug_taken: That slug is already taken — try another.
created https://snu.to/dYbqDcreated.csv holds every short URL and QR URL, ready to paste into a tracking sheet or to hand to a designer. At four requests a second, 100 links take about 25 seconds. The script stops at link_limit_reached: a Free workspace holds 100 links and Pro 1,000, counted across all projects.
Read one link's clicks
Two endpoints give you the numbers for one link. GET /links/:id has the totals, including qrScans; GET /links/:id/stats has clicks per UTC day for the link's whole history:
from sendurl import call, find_link
link = find_link("autumn-sale-ig-bio")
print(link["totalClicks"], "clicks")
print(call("GET", f"/links/{link['id']}")["qrScans"], "of them QR scans")
for day in call("GET", f"/links/{link['id']}/stats"):
print(day["date"], day["count"])GET /links/:id/clicks returns the clicks themselves, newest first. One record looks like this:
{
"id": "a4c6e8f0-1b3d-4e5f-9a7b-2c4d6e8f0a1b",
"linkId": "5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a",
"clickedAt": "2026-10-14T08:03:11.482Z",
"source": "qr",
"country": "DE",
"city": null,
"referer": null,
"ip": "203.0.113.24",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) …",
"device": "mobile",
"deviceVendor": "Apple",
"deviceModel": "iPhone",
"browser": "Mobile Safari",
"browserVersion": "18.6",
"os": "iOS",
"osVersion": "18.6"
}A few things to know before you analyse them:
sourceis"qr"for a scan of the link's QR code andnullotherwise. How to track QR code scans explains the?qflag behind it.countryis a two-letter code from Cloudflare's IP lookup (T1for Tor,nullwhen unknown).cityis alwaysnull: SendURL doesn't record it.- Every click is a row: there's no unique-visitor count and no bot filter. Chat-app link previews and email security scanners that follow the link are in there too; bot clicks and link previews shows how to flag them by user agent.
- The full field list is under the click object. The console's link analytics show the same records without code.
Page through every click
Each page holds at most ten clicks. The response carries pagination.nextCursor; send it back as cursor for the next page, and stop when it's null:
"pagination": {
"limit": 10,
"nextCursor": "WyIyMDI2LTEwLTE0VDA4OjAzOjExLjQ4MjkxM1oiLCJhNGM2ZThmMC0xYjNkLTRlNWYtOWE3Yi0yYzRkNmU4ZjBhMWIiXQ",
"hasMore": true
}The cursor is opaque: don't build or edit one, just pass it on. It marks a position, so clicks that arrive while you page don't shift the pages you haven't read yet. all_clicks() in the client does the loop for you:
from sendurl import all_clicks, find_link
link = find_link("autumn-sale-ig-bio")
scans = sum(1 for click in all_clicks(link["id"]) if click["source"] == "qr")
print(f"{scans} QR scans out of {link['totalClicks']} clicks")Ten clicks per request means a link with 10,000 clicks takes 1,000 requests, a little over four minutes at the client's pace. Fine for a nightly job; for a dashboard, store what you've fetched and read only the new pages (they come newest first, so stop when you reach a click id you already have).
Export clicks to CSV
SendURL has no CSV export button; the API is the export path. This writes one row per click:
import csv
import sys
from sendurl import all_clicks, find_link
FIELDS = ["clickedAt", "source", "country", "device", "os", "browser", "referer", "userAgent", "id"]
slug = sys.argv[1]
link = find_link(slug)
if link is None:
sys.exit(f"no link with the slug {slug} in this workspace")
path = f"{slug}-clicks.csv"
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
writer.writeheader()
count = 0
for click in all_clicks(link["id"]):
writer.writerow(click)
count += 1
print(f"{count} clicks written to {path} (totalClicks: {link['totalClicks']})")25 clicks written to autumn-sale-ig-bio-clicks.csv (totalClicks: 25)The IP address is left out on purpose. In the EU an IP address can be personal data (the Court of Justice said so for dynamic IPs in the Breyer case, 19 October 2016), and you rarely need it in a spreadsheet. Add "ip" to FIELDS if you do, and store the file accordingly.
Summarise with pandas
With the CSV in hand, pandas answers the usual questions in a few lines (pip install pandas):
import sys
import pandas as pd
# keep_default_na=False: otherwise pandas reads Namibia's code "NA" as missing.
df = pd.read_csv(sys.argv[1], keep_default_na=False, na_values=[""])
df["clickedAt"] = pd.to_datetime(df["clickedAt"], utc=True, format="ISO8601")
df["via"] = df["source"].map({"qr": "QR scan"}).fillna("link")
print("Clicks per day (UTC)")
print(df.groupby(df["clickedAt"].dt.date).size().to_string(), "\n")
print("QR scans vs link clicks")
print(df["via"].value_counts().to_string(), "\n")
print("Top countries")
print(df["country"].fillna("unknown").value_counts().head(10).to_string(), "\n")
print("Devices")
print(df["device"].fillna("unknown").value_counts().to_string())Clicks per day (UTC)
clickedAt
2026-10-14 25
QR scans vs link clicks
via
link 17
QR scan 8
Top countries
country
unknown 7
IN 5
DE 5
US 4
NA 4
Devices
device
mobile 15
unknown 7
desktop 3Note the keep_default_na=False: without it pandas turns NA, the country code for Namibia, into a missing value. "unknown" countries are clicks Cloudflare couldn't place, or that never passed through it.
The same in JavaScript
Node 18 or later has fetch built in, so the JavaScript version needs no packages. Save the client as sendurl.mjs:
// A small client for the SendURL REST API. Node 18+ (global fetch), no dependencies.
const API = "https://api.sendurl.to/api/v1";
const KEY = process.env.SENDURL_API_KEY;
if (!KEY) throw new Error("Set SENDURL_API_KEY first");
const MIN_INTERVAL_MS = 250; // 4 requests a second, under Cloudflare's 50 per 10 seconds
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let lastRequest = 0;
export class SendURLError extends Error {
constructor(status, body) {
super(`${status} ${body.error}: ${body.message}`);
this.status = status;
this.code = body.error; // e.g. "slug_taken"
this.body = body;
}
}
export async function call(method, path, { body, query = {}, attempts = 5 } = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
for (let attempt = 0; ; attempt++) {
const wait = lastRequest + MIN_INTERVAL_MS - Date.now();
if (wait > 0) await sleep(wait);
lastRequest = Date.now();
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body && { "Content-Type": "application/json" }),
},
body: body && JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
// 429 is Cloudflare's (not JSON); 503 means the key couldn't be checked. Both are safe to retry.
if ((res.status === 429 || res.status === 503) && attempt < attempts - 1) {
const retryAfter = Number(res.headers.get("retry-after"));
await sleep((retryAfter > 0 ? retryAfter : 10 * (attempt + 1)) * 1000);
continue;
}
const text = await res.text();
if (res.ok) return JSON.parse(text);
let err;
try {
err = JSON.parse(text);
} catch {
err = { error: `http_${res.status}`, message: text.slice(0, 200) };
}
throw new SendURLError(res.status, err);
}
}
export async function createLink({ destinationUrl, slug, title, expiresAt }) {
const body = { destinationUrl, ...(slug && { slug }), ...(title && { title }), ...(expiresAt && { expiresAt }) };
try {
return await call("POST", "/links", { body });
} catch (e) {
// A random slug can (rarely) collide: send it again. A custom one can't be fixed here.
if (e.code === "slug_taken" && !slug) return call("POST", "/links", { body });
throw e;
}
}
/** Every item of a list endpoint, following pagination.nextCursor. */
export async function* pages(path, key, query = {}) {
let cursor;
do {
const page = await call("GET", path, { query: { ...query, limit: 10, cursor } });
yield* page[key];
cursor = page.pagination.nextCursor;
} while (cursor);
}
export const allLinks = (projectId) => pages("/links", "links", { projectId });
export const allClicks = (linkId) => pages(`/links/${linkId}/clicks`, "clicks");
/** The link with this slug in your workspace, or null. Slugs are case-sensitive. */
export async function findLink(slug) {
for await (const link of allLinks()) if (link.slug === slug) return link;
return null;
}Node has no CSV module, so here are two small helpers that handle quoted fields (for anything bigger, use a package such as csv-parse):
// Minimal CSV helpers: quoted fields, "" escapes, CRLF or LF line ends.
export function parseCsv(text) {
const rows = [];
let row = [], field = "", quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = false;
else field += c;
} else if (c === '"') quoted = true;
else if (c === ",") { row.push(field); field = ""; }
else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(field); rows.push(row); row = []; field = "";
} else field += c;
}
if (field || row.length) { row.push(field); rows.push(row); }
const [header, ...data] = rows.filter((r) => r.some((f) => f !== ""));
return data.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ""])));
}
export function toCsvLine(values) {
return values
.map((v) => {
const s = v == null ? "" : String(v);
return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
})
.join(",") + "\n";
}Bulk-create from the same links.csv:
// node bulk-create.mjs links.csv → created.csv
import { readFileSync, writeFileSync } from "node:fs";
import { createLink, SendURLError } from "./sendurl.mjs";
import { parseCsv, toCsvLine } from "./csv.mjs";
let out = toCsvLine(["slug", "short_url", "qr_url", "id", "destination", "error"]);
for (const row of parseCsv(readFileSync(process.argv[2], "utf8"))) {
try {
const link = await createLink({ destinationUrl: row.destination, slug: row.slug, title: row.title });
console.log(`created ${link.shortUrl}`);
out += toCsvLine([link.slug, link.shortUrl, link.qrUrl, link.id, link.destinationUrl, ""]);
} catch (e) {
if (!(e instanceof SendURLError)) throw e;
console.log(`skipped ${row.slug || row.destination}: ${e.message}`);
out += toCsvLine([row.slug, "", "", "", row.destination, e.code]);
if (e.code === "link_limit_reached") break;
}
}
writeFileSync("created.csv", out);And export one link's clicks:
// node export-clicks.mjs autumn-sale-ig-bio → autumn-sale-ig-bio-clicks.csv
import { writeFileSync } from "node:fs";
import { allClicks, findLink } from "./sendurl.mjs";
import { toCsvLine } from "./csv.mjs";
const FIELDS = ["clickedAt", "source", "country", "device", "os", "browser", "referer", "userAgent", "id"];
const slug = process.argv[2];
const link = await findLink(slug);
if (!link) {
console.error(`no link with the slug ${slug} in this workspace`);
process.exit(1);
}
let out = toCsvLine(FIELDS);
let count = 0;
for await (const click of allClicks(link.id)) {
out += toCsvLine(FIELDS.map((f) => click[f]));
count++;
}
writeFileSync(`${slug}-clicks.csv`, out);
console.log(`${count} clicks written to ${slug}-clicks.csv (totalClicks: ${link.totalClicks})`);node bulk-create.mjs links.csv
node export-clicks.mjs autumn-sale-ig-bioErrors you should handle
Every error from the API has the same shape, with issues added for validation errors:
{
"error": "validation_error",
"message": "Invalid request",
"issues": [
{ "code": "custom", "message": "That slug is reserved", "path": ["slug"] }
]
}Branch on error, not on message; the messages are for people and may change.
| Status | error | When | What to do |
|---|---|---|---|
| 400 | validation_error | A field breaks a rule: bad URL, slug with a space, past expiresAt, limit over 10, an offset | Fix the input; issues[].path names the field |
| 400 | invalid_json | The body isn't valid JSON | Send json= (Python) or JSON.stringify (JS) |
| 401 | missing_token | No Authorization header | Check the environment variable is set |
| 401 | invalid_api_key | Unknown, revoked or expired key | Make a new key; don't retry |
| 401 | invalid_token | The Bearer value doesn't start with snu_, so it was checked as a console session and failed | Check you copied the whole key, snu_ included |
| 403 | link_limit_reached | The workspace used all its links (100 on Free, 1,000 on Pro) | Stop the batch; the body adds limit and current |
| 404 | link_not_found | No such link in this key's workspace or project | Check the id; a project key only sees its project |
| 409 | slug_taken | The custom slug is in use anywhere on SendURL | Try another slug; for a random slug, send again |
| 429 | none (HTML) | Over 50 requests in 10 seconds from your IP, from Cloudflare | Wait 10 seconds or Retry-After, then retry |
| 503 | service_unavailable | The key couldn't be checked just then | Retry with backoff |
The full list is under Errors. Set a timeout on every request (both clients use 10 seconds); a request without one can hang a batch job forever.
Keep your key safe
- Environment variables or a secret manager, never source code. Every example here reads
SENDURL_API_KEYfrom the environment. - Use a project key for a script that only needs one project. It can't see or change links in other projects, and it can't list projects (workspaces and projects).
- One key per script or server, named after it, so you can revoke one without breaking the others. A workspace can have 25 active keys.
- Revoke a key that leaked in the console under API keys. Revocation applies on the next request.
- Don't log the
Authorizationheader, and don't paste full responses with keys into tickets.
How this compares with other shortener APIs
As of 15 October 2026, from each vendor's pricing page and API reference:
| SendURL | Bitly | Dub | |
|---|---|---|---|
| Free plan | 100 links per workspace, API included | 5 links a month, 1,000 API requests a month | 25 new links a month, API included |
| Click data | Each click as its own record, on every plan | Counts grouped by time, country, city, device or referrer; /cities and /devices can answer 402 UPGRADE_REQUIRED | GET /analytics grouped counts (Pro and up); individual click events from GET /events on Business ($90/month) and up |
| City | Never recorded | Yes, on plans that include it | Yes |
| Custom domains | No, every link is on snu.to | From the Growth plan | Yes, on the free plan too |
Sources: Bitly pricing, Bitly API reference, Dub pricing, Dub analytics and Dub events. If you need city-level data or your own domain, those are real reasons to pick one of them. If you want every raw click on a free plan, with QR scans labelled, that's what SendURL's API is built for. The free URL shortener API post covers keys and endpoints in more detail.
Checklist
- Key in
SENDURL_API_KEY, never in code; a project key if the script only needs one project - Every request has a timeout
- Status checked before parsing JSON (a 429 is HTML)
- Retries only on 429 and 503, with a wait; everything else raised with its
errorcode - Requests paced under 50 per 10 seconds per IP
- Pagination by
nextCursoruntil it'snull; nooffset,limitat most 10 slug_takenhandled: another slug for custom ones, a resend for random ones- UTMs on the destination URL, never on the short link
- Bulk jobs stop at
link_limit_reached - Exports leave out IP addresses unless you need them; pandas reads with
keep_default_na=False - Bot and preview clicks considered before you report a number
The API reference has every endpoint, and the home page shortens a link without an account if you just want to see one first.
Frequently asked questions
How do I shorten a URL in Python?
Send a POST request with the long URL to a URL shortener's API and read the short URL from the response. With SendURL that is requests.post to https://api.sendurl.to/api/v1/links with a JSON body {"destinationUrl": "..."} and your API key as a Bearer token; the response's shortUrl is the link.
Is there a free URL shortener API?
Yes. SendURL's REST API is on every plan, Free included: 100 links per workspace, with raw click records and cursor pagination. Bitly, Rebrandly and Dub also include API access on their free plans, with monthly caps (Bitly 5, Rebrandly 10, Dub 25 new links a month, as of October 2026), where SendURL's Free plan holds 100 links in total.
How do I get click data from a short link?
Through the shortener's analytics API. SendURL returns every click as its own record (time, QR or not, country, device, browser, OS, referrer, user agent) from GET /links/:id/clicks, ten per page, plus daily counts from GET /links/:id/stats.
Can I export short link clicks to CSV?
SendURL has no export button; the API is the export path. The Python and JavaScript scripts in this guide page through every click of a link and write them to a CSV file.
Start tracking links free.
Create your first short link with click analytics in seconds. No credit card, no sign-up to start.
Shorten a link