API Documentation

Welcome to the zip1.io API documentation. Our API allows you to create and manage short links programmatically. Below you'll find detailed information on available endpoints, request parameters, and response formats.

1. Create Short Link

POST /api/create

This endpoint allows you to create a new short link.

Request Headers:

Headers
Content-Type: application/json

Request Body:

Only url is required — every other field below is optional. This body is valid JSON and can be pasted as-is:

JSON
{
    "url": "https://example.com/very-long-url-that-needs-shortening",
    "alias": "custom-alias",
    "max-clicks": 100,
    "password": "team2024@",
    "expiration-time": "2027-12-31T23:59:00Z",
    "description": "<your internal note, e.g. order 1234>"
}

Parameters:

Parameter Type Required Description
url string Yes The long URL to be shortened
alias string No Custom alias for the short link: letters, numbers, hyphens, or underscores; alternatively, up to 15 emoji. Anything past the first 12 characters is silently truncated, so read short_url from the response rather than assuming you got the alias you asked for. Any other character is rejected with 400.
max-clicks integer No Maximum number of allowed clicks. Must be a whole number — negative values and non-numeric strings are rejected with 400, and 0 is ignored (the link is created without a limit).
password string No Password to protect the link. At least 8 characters, including a letter, a digit, and one @ or . (those two cannot sit next to each other).
expiration-time string No ISO 8601 datetime with timezone (e.g. 2026-12-31T23:59:00Z) at which the link stops redirecting. Must be at least 5 minutes in the future.
description string No Optional private note for your own integration, up to 100 characters. Not shown on the link's public pages. Keep it free of personal data you wouldn't want stored. Returned in the create response.

Example Request:

cURL
curl -X POST https://zip1.io/api/create \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://github.com/username/awesome-project",
    "alias": "awesome",
    "max-clicks": 1000,
    "description": "<your internal note, e.g. order 1234>"
  }'

Success Response:

JSON • Status: 200 OK
{
    "short_url": "https://zip1.io/awesome",
    "created_at": "2026-01-15T10:30:45.482913+00:00",
    "description": "<your internal note, e.g. order 1234>"
}

description is an optional private note for your own integration, up to 100 characters. It is not shown on the link's public pages. Keep it free of personal data you wouldn't want stored. This response includes it only when you sent one. created_at is an ISO 8601 UTC timestamp with microseconds and a +00:00 offset — parse it, don't string-match it. An emoji alias comes back percent-encoded in short_url.

Error Responses:

JSON • Status: 400 Bad Request
{
    "message": "Start the address with https:// — try https://example.com",
    "reason": "no_scheme"
}

message explains the problem in plain English. reason is a stable machine-readable code (empty, no_scheme, scheme_typo, unsupported_scheme, email, own_domain, ip_address, not_public, no_tld, whitespace, too_long, malformed, blocked, destination-velocity, unsafe-destination, redirect-destination, unresolved-destination, duplicate, not-saved) — match on this rather than the wording. A rejected alias, password, max-clicks, expiration-time or description also returns 400, but with a message only and no reason.

JSON • Status: 403 Forbidden
{
    "message": "That is already a short link. Paste the final destination instead.",
    "reason": "blocked"
}

A destination on the abuse blocklist returns 403. So do destination-velocity, unsafe-destination, redirect-destination and unresolved-destination below; every other reason comes with a 400.

JSON • Status: 403 Forbidden
{
    "message": "Too many links to this destination are being created right now, so we have paused it. This is about the destination, not you — try again in an hour.",
    "reason": "destination-velocity"
}

This one is temporary and not about you. We measure how fast links to each destination are being created per hour, because that is what separates a phishing campaign from a busy customer. Cross the line and the destination pauses until the hour rolls over. A bulk integration is counted per exact destination URL, so creating thousands of distinct links under one domain never trips it. Retry after an hour rather than immediately, and if you hit it routinely tell us at /contact.

JSON • Status: 403 Forbidden
{
    "message": "Google Safe Browsing lists this destination for distributing unwanted software, so we cannot shorten it. If you think that is wrong, tell us at /report and we will look at it.",
    "reason": "unsafe-destination",
    "threat": "UNWANTED_SOFTWARE"
}

A destination Google Safe Browsing lists as unsafe also returns 403, with reason unsafe-destination and a threat field (MALWARE, SOCIAL_ENGINEERING, UNWANTED_SOFTWARE or POTENTIALLY_HARMFUL_APPLICATION). The check fails open, so it never rejects a link when Safe Browsing is unreachable.

JSON • Status: 403 Forbidden
{
    "message": "This link redirects to a destination we block (https://verify.example-phish.live/aQ6E), so we cannot shorten it. Shortening another shortener hides where the link really goes. If you think that is wrong, tell us at /report and we will look at it.",
    "reason": "redirect-destination",
    "target": "https://verify.example-phish.live/aQ6E"
}

We follow up to three redirects from the URL you send and check where it actually lands. If that is a destination we block, the submission is refused with 403, reason redirect-destination and a target field naming the hop we objected to. This is usually not about you — most people who hit it were sent a short link by someone else and are passing it on. Send the final destination instead of a link that points at it. A hostname that does not resolve is a different refusal (unresolved-destination, below). A destination that resolves but then times out or errors is not rejected on that account, and this check never runs on a URL already refused by one of the checks above.

JSON • Status: 403 Forbidden
{
    "message": "This link points at a hostname that does not resolve (https://nope.invalid/x), so we cannot shorten it. If the domain was just registered, wait until DNS is delegated and try again. If you think that is wrong, tell us at /report and we will look at it.",
    "reason": "unresolved-destination",
    "target": "https://nope.invalid/x"
}

A hostname that DNS cannot resolve — NXDOMAIN, a name with no address, or a lookup that fails — is refused with 403, reason unresolved-destination and a target field naming the hop that did not resolve. No short link is created. Retry once the name resolves. The lookup cannot tell a name that will never exist apart from a brief resolver failure, so both are refused; the result is not cached, so the next request asks again. A hostname that resolves to a private or loopback address is not this error.

JSON • Status: 409 Conflict
{
    "message": "Alias already exists",
    "reason": "duplicate"
}

duplicate comes back only when you chose the alias yourself. If you sent none and a generated code collided, the collision is ours to retry, not yours.

JSON • Status: 503 Service Unavailable
{
    "message": "We could not save your link just now. Please try again in a moment.",
    "reason": "not-saved"
}

not-saved means the link was not created — a write to our database failed. No short_url is returned, because a URL we handed back without storing would 404 the first time anyone opened it. Safe to retry.

JSON • Status: 400 Bad Request
{
    "message": "Invalid JSON data"
}

Returned for an empty JSON object {}. Two malformed-request cases do not produce JSON at all: a body that is not parseable JSON returns a plain 400 HTML page, and a request sent without the Content-Type: application/json header returns a 415 HTML page. Check the status code before parsing a response as JSON.

Check a URL

POST /api/validate-url

Ask whether POST /api/create would accept a destination, without creating a link. The homepage form calls this while you edit. You can call it too.

Send the same url field as create. A URL create would accept comes back 200:

JSON • Status: 200 OK
{
    "valid": true,
    "url": "https://example.com/very-long-url-that-needs-shortening"
}

A missing https:// that we can correct comes back with that corrected url and "corrected": true. Put that value in the create call.

A destination create would refuse for abuse comes back 403, with valid: false and the same reason, message, and extra fields (threat, target) as create. That covers the blocklist (blocked), destination-velocity, unsafe-destination, redirect-destination, and unresolved-destination. Match on reason, as on create.

JSON • Status: 403 Forbidden
{
    "valid": false,
    "message": "Google Safe Browsing lists this destination for hosting malware, so we cannot shorten it. If you think that is wrong, tell us at /report and we will look at it.",
    "reason": "unsafe-destination",
    "threat": "MALWARE"
}

This check does not create a short link, and it does not count toward the destination-velocity limit — asking does not pause a destination. Safe Browsing fails open: if that lookup is down, or not configured, the URL is not refused for unsafe-destination. Create does the same. A valid: true is not a guarantee the destination is safe when the lookup did not finish. A hostname that does not resolve is refused (unresolved-destination) without being counted as a creation attempt. A block on the caller's IP (blocked-ip on create) is not applied here; it is about the network, not the URL, and create still enforces it.

Anything else wrong with the URL (empty, not a web address, and the other reason codes listed under create) comes back 200 with valid: false, reason, and message.

60 requests per minute per IP. No daily ceiling — this creates nothing.

2. Get Link Statistics

GET /api/stats/{short_code}

Retrieve detailed statistics for a specific short link.

Path Parameters:

Parameter Type Description
short_code string The short code or alias of the link

Headers:

Header Type Description
X-Link-Password string Required when the link is password-protected. The password is read only from this header. A ?password= query string is ignored, so the password is never written to access logs. Links without a password need no header.

Example Request:

cURL
curl -X GET https://zip1.io/api/stats/awesome
cURL — password-protected link
curl -X GET https://zip1.io/api/stats/awesome \
  -H "X-Link-Password: team2024@"

Success Response:

JSON • Status: 200 OK
{
    "message": "Statistics retrieved successfully",
    "data": {
        "short_code": "awesome",
        "total_clicks": 523,
        "unique_clicks": 421,
        "creation_date": "2026-01-15T10:30:45.482913+00:00",
        "last_click": "2026-01-20 15:45:12",
        "click_limit": "1000",
        "expiration_time": null,
        "is_password_protected": false,
        "top_countries": [
            {"country": "United States", "clicks": 234},
            {"country": "United Kingdom", "clicks": 89},
            {"country": "Germany", "clicks": 67}
        ],
        "recent_clicks": []
    }
}

Field Notes:

  • click_limit is a string, not a number (or null when the link has no limit). Cast it before comparing.
  • creation_date is a full ISO 8601 UTC timestamp, whichever way the link was made. It read back as a bare clock time such as "14:22:07" for website-created links until 2026-09-01; that is fixed. Links too old to carry a usable timestamp still report "Unknown".
  • last_click is "YYYY-MM-DD HH:MM:SS" in UTC — a space, not a T, and no offset. It is null before the first click.
  • expiration_time is an ISO 8601 UTC timestamp, or null.
  • top_countries holds at most the three busiest countries, sorted by clicks.
  • recent_clicks is present for forward compatibility and is always an empty array today — per-click detail is not exposed here. Use /export/{short_code}/{format} for the full breakdown.

Error Responses:

A password-protected link returns no data object until the header matches. A missing password and a wrong password each count as a failed guess. The same caps as the password page apply: too many failures answers 429 with Retry-After, including when a later request sends the right password. The message does not say which cap tripped. A successful response for a protected link is sent with Cache-Control: no-store.

JSON • Status: 404 Not Found
{
    "message": "Short link not found"
}
JSON • Status: 401 Unauthorized — password missing
{
    "message": "Password required"
}
JSON • Status: 401 Unauthorized — password wrong
{
    "message": "Invalid password"
}
JSON • Status: 429 Too Many Requests — password guesses
{
    "error": "Too many wrong passwords. Try again in 15 minutes.",
    "retry_after": 900
}

3. Export Statistics

GET /export/{short_code}/{format}

Export detailed statistics for a short link in various formats.

Path Parameters:

Parameter Type Description
short_code string The short code or alias of the link
format string Export format: json, csv, xlsx, or xml

Example Requests:

cURL - JSON Format
curl -X GET https://zip1.io/export/awesome/json
cURL - CSV Format
curl -X GET https://zip1.io/export/awesome/csv \
  -o statistics.zip

Response:

Every format is served as a file download (Content-Disposition: attachment):

  • json — application/json, the full statistics document
  • xml — application/xml, the same data as XML
  • xlsx — an Excel workbook, one sheet per breakdown
  • csv — a ZIP archive (application/zip), not a single CSV file: one CSV per breakdown (general info, countries, browsers, referrers, and so on). Save it as .zip and unpack it.

A password-protected link needs its password: send it as ?password=… or as a form field. Without it the endpoint returns 400.

Errors here do not follow the message convention used by /api/create and /api/stats. A GET returns an HTML error page; the same request as a POST (this endpoint accepts both) returns JSON keyed by error type:

  • 404 unknown short code — {"UrlError": "The requested Url never existed"}
  • 400 unsupported format — {"FormatError": "Invalid format; format must be json, csv, xlsx or xml"}
  • 400 missing or wrong password — {"PasswordError": "Invalid Password"}

Rate Limiting

Our API implements rate limiting to ensure fair usage:

  • Create Short Link: 10 requests per minute per IP
  • Check a URL: 60 requests per minute per IP. Does not create a link and does not count toward destination velocity
  • Get Statistics: 30 requests per minute per IP. Password guesses on a protected link have a separate cap (see the 429 example under Get Link Statistics); that response is not the limiter message below.
  • Export Statistics: No rate limit

When rate limited, you'll receive a 429 status code with the following response:

JSON • Status: 429 Too Many Requests
{
    "error": "ratelimit exceeded 10 per 1 minute"
}

Best Practices

  • Always validate URLs on your end before sending them to our API
  • Handle rate limiting gracefully by implementing exponential backoff
  • Store the short URLs returned by our API for future reference
  • Use descriptive aliases when possible for better link management
  • Implement proper error handling for all possible response codes
  • Consider caching statistics data to reduce API calls

Code Examples

Python:

Python
import requests
import json

# Create a short link
def create_short_link(url, alias=None, max_clicks=None):
    endpoint = "https://zip1.io/api/create"
    payload = {"url": url}
    
    if alias:
        payload["alias"] = alias
    if max_clicks:
        payload["max-clicks"] = max_clicks
    
    response = requests.post(
        endpoint,
        headers={"Content-Type": "application/json"},
        data=json.dumps(payload)
    )
    
    if response.status_code == 200:
        return response.json()
    else:
        raise Exception(f"Error: {response.json()['message']}")

# Get statistics
def get_stats(short_code):
    endpoint = f"https://zip1.io/api/stats/{short_code}"
    response = requests.get(endpoint)
    
    if response.status_code == 200:
        return response.json()
    else:
        raise Exception(f"Error: {response.json()['message']}")

# Example usage
try:
    # Create a short link
    result = create_short_link(
        url="https://github.com/username/project",
        alias="myproject",
        max_clicks=1000
    )
    print(f"Short URL: {result['short_url']}")

    # Get statistics
    stats = get_stats("myproject")
    print(f"Total clicks: {stats['data']['total_clicks']}")
    
except Exception as e:
    print(f"Error: {e}")

JavaScript (Node.js):

JavaScript
const axios = require('axios');

// Create a short link
async function createShortLink(url, options = {}) {
    try {
        const response = await axios.post('https://zip1.io/api/create', {
            url: url,
            ...options
        }, {
            headers: {
                'Content-Type': 'application/json'
            }
        });
        
        return response.data;
    } catch (error) {
        throw new Error(error.response.data.message);
    }
}

// Get statistics
async function getStats(shortCode) {
    try {
        const response = await axios.get(`https://zip1.io/api/stats/${shortCode}`);
        return response.data;
    } catch (error) {
        throw new Error(error.response.data.message);
    }
}

// Example usage
(async () => {
    try {
        // Create a short link
        const result = await createShortLink('https://github.com/username/project', {
            alias: 'myproject',
            'max-clicks': 1000,
            description: '<your internal note, e.g. order 1234>'
        });
        console.log(`Short URL: ${result.short_url}`);
        
        // Get statistics
        const stats = await getStats('myproject');
        console.log(`Total clicks: ${stats.data.total_clicks}`);
        
    } catch (error) {
        console.error(`Error: ${error.message}`);
    }
})();

🤖 AI Integration (Model Context Protocol)

Zip1.io supports the Model Context Protocol (MCP), enabling AI assistants like Claude to interact with the URL shortener directly.

MCP Endpoint:

GET /mcp

Get server information and available tools.

Setup for Claude Code (Recommended):

Claude Code has native HTTP MCP support. Simply run this command:

Terminal
claude mcp add --transport http zip1 https://zip1.io/mcp

Setup for Claude Desktop:

⚠️ Important: Claude Desktop currently only supports stdio-based MCP servers, not HTTP servers. We recommend using Claude Code for zip1.io's MCP integration.

Available MCP Tools:

  • create_short_url - Create shortened URLs with custom aliases, passwords, and max clicks
  • get_url_stats - Retrieve detailed analytics for shortened URLs
  • validate_url - Check if URLs can be shortened
  • generate_short_code - Generate random short code suggestions

Example Usage:

After configuring Claude Code, you can use natural language prompts like:

  • "Shorten https://github.com/my-repo with alias 'repo'"
  • "Get statistics for short code 'docs'"
  • "Create a password-protected link for https://example.com/secret"
📚 Full MCP Documentation: For detailed information about the MCP integration, see our MCP documentation.

Need Help?

If you have any questions or need assistance with our API: