Skip to main content
Link trackingBy SendURL team22 min read

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.

Make an API key in the console under API keys, put it in an environment variable, and install requests (pip install requests):

Shell
export SENDURL_API_KEY="snu_..."   # never commit it
shorten.pyPython
import 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"])
Output (test link on a local stack)Text
https://snu.to/x7Kp2 5d8f2a1c-7b3e-4c9d-a0e1-6f2b8c4d1e7a

Leave 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:

201 Created (trimmed)JSON
{
  "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.

sendurl.pyPython
"""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 429 for 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 POST can'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's null. There is no offset (sending one is a 400).

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:

links.csvText
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 poster

The 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.

bulk_create.pyPython
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"], ""])
python3 bulk_create.py links.csvText
created https://snu.to/autumn-sale-ig-bio
created https://snu.to/autumn-sale-email
created https://snu.to/6I8dN

Run 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.

Second runText
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/dYbqD

created.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.

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:

Python
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:

One click (from GET /links/:id/clicks, trimmed)JSON
{
  "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:

  • source is "qr" for a scan of the link's QR code and null otherwise. How to track QR code scans explains the ?q flag behind it.
  • country is a two-letter code from Cloudflare's IP lookup (T1 for Tor, null when unknown). city is always null: 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:

The end of a pageJSON
"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:

Python
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:

export_clicks.pyPython
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']})")
python3 export_clicks.py autumn-sale-ig-bio (test link on a local stack, simulated clicks)Text
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):

summary.pyPython
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())
python3 summary.py autumn-sale-ig-bio-clicks.csv (test link on a local stack, simulated clicks)Text
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     3

Note 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:

sendurl.mjsJavaScript
// 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):

csv.mjsJavaScript
// 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:

bulk-create.mjsJavaScript
// 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:

export-clicks.mjsJavaScript
// 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})`);
Shell
node bulk-create.mjs links.csv
node export-clicks.mjs autumn-sale-ig-bio

Errors you should handle

Every error from the API has the same shape, with issues added for validation errors:

400 validation_errorJSON
{
  "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.

StatuserrorWhenWhat to do
400validation_errorA field breaks a rule: bad URL, slug with a space, past expiresAt, limit over 10, an offsetFix the input; issues[].path names the field
400invalid_jsonThe body isn't valid JSONSend json= (Python) or JSON.stringify (JS)
401missing_tokenNo Authorization headerCheck the environment variable is set
401invalid_api_keyUnknown, revoked or expired keyMake a new key; don't retry
401invalid_tokenThe Bearer value doesn't start with snu_, so it was checked as a console session and failedCheck you copied the whole key, snu_ included
403link_limit_reachedThe workspace used all its links (100 on Free, 1,000 on Pro)Stop the batch; the body adds limit and current
404link_not_foundNo such link in this key's workspace or projectCheck the id; a project key only sees its project
409slug_takenThe custom slug is in use anywhere on SendURLTry another slug; for a random slug, send again
429none (HTML)Over 50 requests in 10 seconds from your IP, from CloudflareWait 10 seconds or Retry-After, then retry
503service_unavailableThe key couldn't be checked just thenRetry 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_KEY from 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 Authorization header, 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:

SendURLBitlyDub
Free plan100 links per workspace, API included5 links a month, 1,000 API requests a month25 new links a month, API included
Click dataEach click as its own record, on every planCounts grouped by time, country, city, device or referrer; /cities and /devices can answer 402 UPGRADE_REQUIREDGET /analytics grouped counts (Pro and up); individual click events from GET /events on Business ($90/month) and up
CityNever recordedYes, on plans that include itYes
Custom domainsNo, every link is on snu.toFrom the Growth planYes, 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 error code
  • Requests paced under 50 per 10 seconds per IP
  • Pagination by nextCursor until it's null; no offset, limit at most 10
  • slug_taken handled: 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