Upload API Reference

Upload images, videos, and audio to lovely.bio programmatically using API keys. Files are stored on Cloudflare R2 and instantly accessible via your profile.

Overview

The lovely.bio upload API lets you upload files from any environment — scripts, apps, or integrations — without going through the dashboard. Authenticate with an API key and follow the 3-step upload flow.

1
Presign — Tell the API what you're uploading. Get back a short-lived signed URL pointing directly to storage.
2
PUT to storage — Upload your file bytes directly to the signed URL. No auth needed for this step.
3
Finalize — Tell the API the upload is done. Get back your public URL and view link.
All API requests must be made over HTTPS to https://lovely.bio. Requests and responses use JSON.

API keys

Generate API keys from your dashboard under Settings → API Keys. Keys start with lbio_ and are only shown once at creation — store them securely.

Pass your key in the Authorization header on every request:

HTTP
Authorization: Bearer lbio_your_api_key_here
Never expose your API key in client-side code or public repos. If a key is compromised, revoke it from the dashboard immediately.

List API keys

GET /api/apikeys Requires dashboard login (JWT)

Returns all API keys for the authenticated user. Key secrets are never returned — only the prefix and metadata.

200 OK
{
  "keys": [
    {
      "id":           "550e8400-e29b-41d4-a716-446655440000",
      "name":         "My script",
      "key_prefix":   "lbio_a1b2c3",
      "created_at":   "2026-01-15T12:00:00Z",
      "last_used_at": "2026-05-01T08:22:10Z",
      "revoked":      false
    }
  ]
}

Create an API key

POST /api/apikeys Requires dashboard login (JWT)
FieldTypeDescription
namestringrequiredA label for this key. Max 100 chars.
201 Created
{
  "id":          "550e8400-e29b-41d4-a716-446655440000",
  "name":        "My script",
  "key_prefix":  "lbio_a1b2c3",
  "created_at":  "2026-05-08T10:00:00Z",
  "key":         "lbio_a1b2c3d4e5f6..."
}

Revoke an API key

DELETE /api/apikeys?id={key_id} Requires dashboard login (JWT)

Immediately revokes the key. Any in-flight requests using it will fail.

200 OK
{ "ok": true }

How uploads work

lovely.bio uses a presigned URL flow. Your file goes directly from your machine to Cloudflare R2 storage — it never passes through the lovely.bio server. This keeps uploads fast regardless of file size.

Step 1 — Presign

POST /api/uploads/presign Accepts API key or JWT
FieldTypeDescription
mime_typestringrequiredMIME type of the file (e.g. image/jpeg)
size_bytesnumberrequiredExact byte size of the file
titlestringoptionalDisplay name. Defaults to filename if omitted. Max 200 chars.
200 OK
{
  "upload_id":  "aBc1234XyZ",
  "kind":       "image",
  "put_url":    "https://...",
  "public_url": "https://...",
  "expires_in": 300
}

Step 2 — PUT to storage

Upload your file directly to the put_url using an HTTP PUT request. Set Content-Type to your file's MIME type. Do not include your API key — the URL is already signed.

HTTP
PUT {put_url}
Content-Type: image/jpeg
Content-Length: 102400

<raw file bytes>
The signed URL expires in 5 minutes. Complete the upload before then.

Step 3 — Finalize

POST /api/uploads/finalize Accepts API key or JWT
FieldTypeDescription
upload_idstringrequiredThe ID returned by /presign
content_hashstringoptionalSHA-256 hex digest of the file. Used for content moderation checks.
200 OK
{
  "ok":         true,
  "upload_id":  "aBc1234XyZ",
  "public_url": "https://r2.lovely.bio/uploads/.../aBc1234XyZ.jpg",
  "view_url":   "https://lovely.bio/yourusername/v/aBc1234XyZ"
}

Python example

Install requests then drop in lovely_bio.py from the SDK. One function call does everything.

Python
from lovely_bio import upload

result = upload("photo.jpg",  api_key="lbio_...")
result = upload("clip.mp4",   api_key="lbio_...")
result = upload("song.mp3",   api_key="lbio_...")
result = upload("photo.jpg",  api_key="lbio_...", title="Summer 2026")

print(result["view_url"])
print(result["public_url"])

If you'd rather not use the SDK, here's the raw implementation:

Python — raw
import hashlib, requests

API_KEY = "lbio_your_key_here"
BASE    = "https://lovely.bio"
headers = {"Authorization": f"Bearer {API_KEY}"}

with open("photo.jpg", "rb") as f:
    data = f.read()

presign = requests.post(f"{BASE}/api/uploads/presign", headers=headers, json={
    "mime_type":  "image/jpeg",
    "size_bytes": len(data),
    "title":      "My photo",
}).json()

requests.put(presign["put_url"], data=data, headers={
    "Content-Type":   "image/jpeg",
    "Content-Length": str(len(data)),
})

result = requests.post(f"{BASE}/api/uploads/finalize", headers=headers, json={
    "upload_id":    presign["upload_id"],
    "content_hash": hashlib.sha256(data).hexdigest(),
}).json()

print(result["view_url"])

JavaScript (Node.js)

Works with Node 18+ using the built-in fetch. No dependencies needed.

JavaScript — Node.js
import { readFileSync } from 'fs'
import { createHash } from 'crypto'
import { extname, basename } from 'path'

const API_KEY = 'lbio_your_key_here'
const BASE    = 'https://lovely.bio'

const MIME_TYPES = {
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
  '.png': 'image/png',  '.webp': 'image/webp', '.gif': 'image/gif',
  '.mp4': 'video/mp4',  '.webm': 'video/webm', '.mov': 'video/quicktime',
  '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',  '.wav': 'audio/wav',
}

async function upload(filePath, title) {
  const data = readFileSync(filePath)
  const mime = MIME_TYPES[extname(filePath).toLowerCase()]
  if (!mime) throw new Error(`Unsupported: ${extname(filePath)}`)

  const headers = { Authorization: `Bearer ${API_KEY}` }

  const presign = await fetch(`${BASE}/api/uploads/presign`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      mime_type:  mime,
      size_bytes: data.length,
      title:      title ?? basename(filePath),
    }),
  }).then(r => r.json())

  await fetch(presign.put_url, {
    method: 'PUT',
    headers: { 'Content-Type': mime },
    body: data,
  })

  const hash = createHash('sha256').update(data).digest('hex')
  return fetch(`${BASE}/api/uploads/finalize`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({ upload_id: presign.upload_id, content_hash: hash }),
  }).then(r => r.json())
}

const result = await upload('photo.jpg')
console.log(result.view_url)

JavaScript (Browser)

Read from a file input, then follow the same 3-step flow. Don't hardcode your API key in frontend code — proxy the presign/finalize calls through your own server.

HTML + JavaScript
<input type="file" id="picker" accept="image/*,video/*,audio/*" />

<script>
const BASE = 'https://lovely.bio'

document.getElementById('picker').addEventListener('change', async e => {
  const file = e.target.files[0]
  if (!file) return

  const headers = { Authorization: 'Bearer lbio_...' }

  const presign = await fetch(`${BASE}/api/uploads/presign`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({ mime_type: file.type, size_bytes: file.size, title: file.name }),
  }).then(r => r.json())

  await fetch(presign.put_url, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file,
  })

  const buf  = await file.arrayBuffer()
  const hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', buf))]
    .map(b => b.toString(16).padStart(2, '0')).join('')

  const result = await fetch(`${BASE}/api/uploads/finalize`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({ upload_id: presign.upload_id, content_hash: hash }),
  }).then(r => r.json())

  console.log(result.view_url)
})
</script>

Supported file formats

Images
.jpg .jpeg .png .webp .gif
Max 10 MB
Video
.mp4 .webm .mov
Max 100 MB
Audio
.mp3 .m4a .wav .webm
Max 25 MB
Pass the correct mime_type when presigning. Uploading a file with a mismatched MIME type will cause the finalize step to fail.

Rate limits

LimitValueNotes
Uploads per hour20Per user account. Resets on a rolling 1-hour window.
Active API keys1Per account. Revoke your existing key to generate a new one.
Presign URL TTL5 minComplete the PUT within 5 minutes of presigning.
Key name length100 chars
Title length200 charsTruncated automatically if exceeded.

When you exceed the upload rate limit you'll receive a 429 Too Many Requests response. Wait until the rolling window resets before retrying.

Error reference

All errors return JSON with an error string and the appropriate HTTP status code.

Error response shape
{ "error": "Description of what went wrong" }
StatusWhen it happens
400Missing or invalid fields — check mime_type, size_bytes, or upload_id
401Missing or invalid API key / JWT token
403Upload blocked (content policy). Check for "code": "blocked-hash" in the response.
404Upload ID not found or doesn't belong to your account
429Rate limit exceeded — max 20 uploads per hour
500Internal server error — try again or contact support