All articles

Upload a file by API and get a public link (curl, Node, Python)

Sunny 7 min read

#ai#api#upload#file

Upload a file by API, get a link

report.pdf Live
/p/report

To upload a file by API and get a public link, make three HTTP calls. First, POST /api/v1/uploads with the file's name, type and size to get a one-time upload URL. Second, PUT the raw bytes to that URL. Third, POST /api/v1/uploads/complete and you get back a link like https://linkinseconds.com/p/report-x7 that opens in any browser, with no sign-in for the viewer. All you need is an API key from your dashboard.

Below are working examples in curl, Node and Python, followed by the parts that matter in real use: safe retries, view-only links, listing your links, errors and limits.

Before you start: get an API key

Sign in, open API keys and create one. It starts with lis_ and is shown once, so copy it into an environment variable straight away:

Shell
export LIS_API_KEY=lis_YOUR_KEY

Send it on every call as Authorization: Bearer lis_.... The API only accepts this header, never your browser cookies, so a web page cannot quietly call it on your behalf. See Authentication and API keys.

Why three calls instead of one?

Because the file never passes through our API servers. Step 1 checks your key, plan and file details and hands back a signed URL. Step 2 sends the bytes directly to storage. Step 3 asks us to inspect what actually arrived and publish it. This is why the API can take files as large as your plan allows, rather than the few megabytes a single serverless request can carry.

  1. POST /uploads with { name, type, size } returns { path, signedUrl }.
  2. PUT signedUrl with the raw bytes and the same Content-Type. The URL is valid for 10 minutes and needs no API key.
  3. POST /uploads/complete with { path, name, type } returns { slug, title, url }.

Upload with curl

This uses jq to read the JSON. Change FILE and TYPE for your file.

upload.sh
export LIS_API_KEY=lis_YOUR_KEY
FILE=report.pdf; TYPE=application/pdf
SIZE=$(wc -c < "$FILE" | tr -d ' ')

# 1. Ask for an upload URL
START=$(curl -s -X POST https://linkinseconds.com/api/v1/uploads \
  -H "Authorization: Bearer $LIS_API_KEY" -H "Content-Type: application/json" \
  -d "{\"name\":\"$FILE\",\"type\":\"$TYPE\",\"size\":$SIZE}")
URL=$(echo "$START" | jq -r .signedUrl)
UPLOAD_PATH=$(echo "$START" | jq -r .path)

# 2. PUT the bytes straight to storage
curl -s -X PUT -H "Content-Type: $TYPE" --data-binary @"$FILE" "$URL"

# 3. Complete the upload and get the link
curl -s -X POST https://linkinseconds.com/api/v1/uploads/complete \
  -H "Authorization: Bearer $LIS_API_KEY" -H "Content-Type: application/json" \
  -d "{\"path\":\"$UPLOAD_PATH\",\"name\":\"$FILE\",\"type\":\"$TYPE\"}"

The last call prints the link:

200 OK
{
  "slug": "report-x7",
  "title": "report",
  "url": "https://linkinseconds.com/p/report-x7"
}

Upload with Node.js

Node 18 or newer has fetch built in, so there is nothing to install.

publish.mjs
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const API = "https://linkinseconds.com/api/v1";
const headers = {
  Authorization: `Bearer ${process.env.LIS_API_KEY}`,
  "Content-Type": "application/json",
};

async function publish(file, type) {
  const bytes = await readFile(file);
  const idempotencyKey = randomUUID();

  const start = await fetch(`${API}/uploads`, {
    method: "POST",
    headers,
    body: JSON.stringify({ name: file, type, size: bytes.length, idempotencyKey }),
  }).then((r) => r.json());
  if (start.existing) return start.existing;
  if (!start.signedUrl) throw new Error(start.error);

  const put = await fetch(start.signedUrl, { method: "PUT", headers: { "Content-Type": type }, body: bytes });
  if (!put.ok) throw new Error("Storage upload failed");

  const done = await fetch(`${API}/uploads/complete`, {
    method: "POST",
    headers,
    body: JSON.stringify({ path: start.path, name: file, type, idempotencyKey }),
  }).then((r) => r.json());
  if (!done.url) throw new Error(done.error);
  return done;
}

console.log(await publish("report.pdf", "application/pdf"));

Upload with Python

This uses the requests library (pip install requests).

publish.py
import os, uuid, requests

API = "https://linkinseconds.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['LIS_API_KEY']}"}

def publish(path, content_type):
    data = open(path, "rb").read()
    key = str(uuid.uuid4())

    start = requests.post(f"{API}/uploads", headers=HEADERS, json={
        "name": path, "type": content_type, "size": len(data), "idempotencyKey": key,
    }).json()
    if "existing" in start:
        return start["existing"]
    if "signedUrl" not in start:
        raise RuntimeError(start.get("error"))

    requests.put(start["signedUrl"], data=data,
                 headers={"Content-Type": content_type}).raise_for_status()

    done = requests.post(f"{API}/uploads/complete", headers=HEADERS, json={
        "path": start["path"], "name": path, "type": content_type, "idempotencyKey": key,
    }).json()
    if "url" not in done:
        raise RuntimeError(done.get("error"))
    return done

print(publish("report.pdf", "application/pdf"))

Make retries safe with an idempotency key

Networks drop responses, especially on big files. If your script retries a step without care, you can end up with two links for the same file. The Node and Python examples avoid that by sending an idempotencyKey (any UUID) on step 1 and step 3. If that key already produced a link, step 1 answers with { "existing": { slug, title, url } } and you can skip the rest. A retried step 3 returns the same link instead of a duplicate, and it never uses a second free slot.

Useful options

  • linkName on step 3 sets the title shown on the link page. It defaults to the file name.
  • allowDownload: false on step 3 makes the link view-only, so the download button is hidden. Handy for a deck or a draft. Read more in sharing a PDF people can view but not download.
  • slug on both steps sets a custom link name, such as /p/q3-report. This is a Pro feature.

Every field is listed in Upload a file with the API, and the machine-readable version lives at /openapi.json.

GET /api/v1/links returns your links, newest first. Each one has a status: live, review (held by the safety scan), processing (a video still being scanned) or disabled (turned off by you).

Shell
curl -s "https://linkinseconds.com/api/v1/links?limit=5" \
  -H "Authorization: Bearer $LIS_API_KEY"

There is also GET /api/v1/status, a health check that needs no key. Details are in List your links.

Errors you will actually see

Errors come back as JSON with a plain error message you can show to a person.

StatusWhat it usually meansWhat to do
401Missing, wrong or deleted API keyCheck the Authorization header
403A plan limit (live links, storage or a Pro-only option), or an account that can't uploadRead the error and its code
400Bad JSON, a blocked file type, a file over your size limit, or a taken link nameFix the request
409That upload path was already usedStart again from step 1
429Too many uploads this hourWait, then retry
503A temporary problem finishing the uploadRetry step 3 with the same idempotencyKey

The full list is in Errors and limits.

When a file is held for review

Every file is checked: we read the real size and first bytes from storage, make sure the file is what its name says, and run a safety scan. If the scan flags it, step 3 still returns a link but adds "review": true. The link goes live once a person clears it, so tell your users it will start working shortly.

Limits

The API uses the same plan limits as the website. A few worth knowing up front:

  • Per file: 100 MB on Free, 200 MB on Pro, 250 MB on Lifetime.
  • Free accounts keep up to 20 live links at a time. Deleting one frees a slot.
  • Free accounts using an API key can make 30 uploads per hour. Paid plans keep the normal hourly cap.
  • Executables and scripts are always refused. Albums and static websites are created from the website.

Everything else is in Plans and limits.

Working inside Claude, Cursor or VS Code? You do not need to write this code at all. Connect the MCP server and the assistant calls the same upload flow for you. See how to let Claude publish files as links.

Common questions

Do the people I send the link to need an account?

No. The link opens in any browser. Only the uploader needs an account and a key.

Can I upload from a browser app?

Technically yes, but do not ship your key in front-end code. Anyone could read it. Call the API from your server and pass the link to the browser.

What file types can I upload?

PDFs, images, video, audio, documents, HTML, ZIP and more. The full list is in Supported file types. For the bigger picture, start at the developer overview or browse more developer guides.

Turn any file into a link in seconds

Upload a PDF, image, video, or ZIP and get a clean, trackable link with a QR code, free.

Try Link in Seconds β†’