API
Upload a file from your own code and get a URL back. One key, three calls, no SDK to install.
Authentication
Every request carries your key as a bearer token. Create one on your dashboard.
Authorization: Bearer ptu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys are shown once, when you create them. Send them in the header and never in a query string — a key in a URL ends up in access logs, browser history and referrer headers.
Uploading a file
Three steps: ask for a URL, send the bytes straight to storage, then publish. The bytes never pass through our API, which is why file size is limited by your plan rather than by a request body limit.
# 1. Ask for a URL to upload to.
curl -X POST https://phototourl.org/api/v1/uploads \
-H "Authorization: Bearer $PHOTOTOURL_KEY" \
-H "Content-Type: application/json" \
-d '{"contentType":"image/png","size":48120,"fileName":"chart.png"}'
# -> {"id":"7f3c…","key":"pending/2026-09-17-7f3c….png",
# "uploadUrl":"https://…","headers":{…},"completeUrl":"/api/v1/uploads/7f3c…/complete"}
# 2. PUT the bytes straight to storage, with the headers you were given.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/png" \
-H "Cache-Control: $CACHE_CONTROL" \
--data-binary @chart.png
# 3. Publish it. This is where the file is checked and the link is minted.
curl -X POST https://phototourl.org/api/v1/uploads/7f3c…/complete \
-H "Authorization: Bearer $PHOTOTOURL_KEY" \
-H "Content-Type: application/json" \
-d '{"key":"pending/2026-09-17-7f3c….png","contentType":"image/png","fileName":"chart.png"}'
# -> {"upload":{"id":"7f3c…","url":"https://…","sizeBytes":48120, …}}async function upload(file, key) {
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
// 1. Authorize.
const start = await fetch("https://phototourl.org/api/v1/uploads", {
method: "POST",
headers,
body: JSON.stringify({ contentType: file.type, size: file.size, fileName: file.name }),
});
if (!start.ok) throw new Error((await start.json()).error);
const { id, key: objectKey, uploadUrl, headers: putHeaders } = await start.json();
// 2. Send the bytes to storage. They never pass through the API, which is
// what lets this handle files far larger than a normal request body.
const put = await fetch(uploadUrl, { method: "PUT", headers: putHeaders, body: file });
if (!put.ok) throw new Error("Upload failed");
// 3. Publish.
const done = await fetch(`https://phototourl.org/api/v1/uploads/${id}/complete`, {
method: "POST",
headers,
body: JSON.stringify({ key: objectKey, contentType: file.type, fileName: file.name }),
});
if (!done.ok) throw new Error((await done.json()).error);
return (await done.json()).upload.url;
}Naming, expiring and locking a link
The same controls the dashboard offers. Fields you leave out are untouched; null clears one. Your plan decides which are available.
curl -X PATCH https://phototourl.org/api/v1/uploads/7f3c… \
-H "Authorization: Bearer $PHOTOTOURL_KEY" \
-H "Content-Type: application/json" \
-d '{"slug":"q3-revenue","password":"hunter2","expiresAt":"2027-01-01T00:00:00Z"}'Endpoints
| GET | /api/v1/me | Your plan, limits and usage. |
| GET | /api/v1/uploads | Your uploads, newest first. |
| POST | /api/v1/uploads | Authorize an upload and get a URL to PUT to. |
| POST | /api/v1/uploads/{id}/complete | Validate what landed and publish it. |
| GET | /api/v1/uploads/{id} | One upload. |
| PATCH | /api/v1/uploads/{id} | Set a slug, expiry, password or protection. |
| DELETE | /api/v1/uploads/{id} | Delete the file and its link. |
| POST | /api/v1/uploads/{id}/file | Authorize a replacement for an existing link. |
| PUT | /api/v1/uploads/{id}/file | Swap the bytes, keeping the URL. |
Limits
Uploads are counted against your account, not against each key — issuing more keys does not raise the allowance. Replacing a file costs nothing.
| Plan | Uploads | Max size | API |
|---|---|---|---|
| plus · £4 | 500 / month | 50MB | Not included |
| pro · £12 | 2,000 / month | 100MB | Not included |
| business · £25 | 10,000 / month | 250MB | Included |
Errors
Every failure is JSON with an `error` field, plus whatever context is useful.
{ "error": "Monthly upload limit reached (2000 per month).", "limit": 2000, "window": "month" }401— The key is missing, wrong or revoked.403— Your plan does not include this.413— The file is larger than your plan allows.429— You have used this period's uploads.