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.
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:
Authorization: Bearer lbio_your_api_key_here
List API keys
Returns all API keys for the authenticated user. Key secrets are never returned — only the prefix and metadata.
{
"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
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | A label for this key. Max 100 chars. |
{
"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
Immediately revokes the key. Any in-flight requests using it will fail.
{ "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
| Field | Type | Description | |
|---|---|---|---|
| mime_type | string | required | MIME type of the file (e.g. image/jpeg) |
| size_bytes | number | required | Exact byte size of the file |
| title | string | optional | Display name. Defaults to filename if omitted. Max 200 chars. |
{
"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.
PUT {put_url}
Content-Type: image/jpeg
Content-Length: 102400
<raw file bytes>
Step 3 — Finalize
| Field | Type | Description | |
|---|---|---|---|
| upload_id | string | required | The ID returned by /presign |
| content_hash | string | optional | SHA-256 hex digest of the file. Used for content moderation checks. |
{
"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.
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:
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.
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.
<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
mime_type when presigning. Uploading a file with a mismatched MIME type will cause the finalize step to fail.
Rate limits
| Limit | Value | Notes |
|---|---|---|
| Uploads per hour | 20 | Per user account. Resets on a rolling 1-hour window. |
| Active API keys | 1 | Per account. Revoke your existing key to generate a new one. |
| Presign URL TTL | 5 min | Complete the PUT within 5 minutes of presigning. |
| Key name length | 100 chars | — |
| Title length | 200 chars | Truncated 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": "Description of what went wrong" }
| Status | When it happens |
|---|---|
| 400 | Missing or invalid fields — check mime_type, size_bytes, or upload_id |
| 401 | Missing or invalid API key / JWT token |
| 403 | Upload blocked (content policy). Check for "code": "blocked-hash" in the response. |
| 404 | Upload ID not found or doesn't belong to your account |
| 429 | Rate limit exceeded — max 20 uploads per hour |
| 500 | Internal server error — try again or contact support |