← Heart Photos · Heart Pay · Error codes
Photos: the photo library
One library of photographs, used by he.art (where photos are items you edit), avif.he.art (an AVIF converter that files what it converts), and this gallery. Upload an image through any of them (or through the API or MCP below) and every one of them sees it. This page is the honest account: what it does, how to drive it, and where its edges are.
What it does
- One Asset ID. Every photo has one id, its Asset ID: seven characters,
lowercase letters and digits (
wxogst2). It is the same id everywhere the photo is:heart.photos/photo/<id>,he.art/photo/<id>,i.heart.photos/<id>, and every{id}in the API and MCP below. - Converts and keeps. An upload (JPEG, PNG, WebP, AVIF, HEIC or JPEG XL, and TIFF, BMP or the first frame of a GIF, up to 25 MB and 64 megapixels) becomes an AVIF. The API converts on the server; the Heart Photos and he.art uploaders usually encode in your browser and send the original along with the result. Either way the untouched source is kept, so quality choices are never final and the original can always be downloaded back. The library may keep it smaller as JPEG XL: a JPEG comes back as the very same file, byte for byte, and a PNG or lossless WebP as a PNG of the very same pixels, with its color profile.
- Resolutions. Each photo can carry
original,thumb,mobile,1080pand4krenditions and widths fromw320tow3840, made only when they would actually be smaller. Public URLs live oni.heart.photos/<id>[/<kind>]: the bare URL is the full-size image,/autopicks a size for the requesting device,/cardis a 1200 px JPEG for link previews,/originalis the upload (see above for one kept as JPEG XL),/jxlis the JPEG XL the library keeps it as (only for one kept that way; the asset'surls.jxl), and a size not made yet is answered with the next larger one. - Deduplicates. Bytes are content-addressed by SHA-256. Uploading an image you already hold returns the existing photo instead of a copy, and identical bytes are stored once even when several accounts hold them. The Heart Photos uploader asks before it converts anything, so an image you already have is neither converted nor sent again. The work is shared too: an image another account already holds, rendered at the same settings, is not converted again, and a background another account already removed from the same image is yours at once, with no credits spent. Each account's photo stays its own (its name, words and visibility).
- Reference-counted deletion. Deleting removes your photo. The bytes are removed only when no reference from anyone remains, so two accounts holding the same image can each delete freely without breaking the other. Even then they wait 30 days in the store's trash before they are gone for good.
- Visibility.
private(owner only),unlisted(anyone with the link, never listed),public(also on this gallery's wall). Enforced where the bytes are served. - Galleries. Photos can be gathered into galleries at
heart.photos/gallery/<id>, each private, unlisted or public in its own right. A gallery opened by its link shows its public and unlisted photos to anyone who has the link, and its private ones only to their owner. Galleries are made and arranged on this site; the API does not manage them yet. - Pairs, series, tags, ratings, titles. A wide photo and its tall rendition can be joined as one image, and a run of photos grouped as a series; photos carry tags shared with he.art's vocabulary and a rating; He.art can write an AI title and description for gallery photos.
- Identity that cannot rot. Accounts are keyed by the Google
sub(a permanent account number that never changes and is never reused), not by email, which can change hands. Sign in with the same Google account anywhere and it is the same library.
Where its edges are
Weaknesses, stated plainly: judge for yourself whether they matter for your use.
- Bytes live in Cloudflare R2. One bucket, served to the public through
i.heart.photosbehind Cloudflare's cache, so an image nobody has asked for lately takes a moment longer the first time. The storage layer is swappable, and has already moved more than once. - Making a photo private is not instant everywhere. Cloudflare's copies are cleared the moment a photo is made private, put in the Trash or rendered again, but a browser that already downloaded it may keep its own copy for up to an hour.
- One region, one machine, one maintainer. Served from one Fly.io machine at a time in
iad, kept running rather than woken by the first request. A release starts its replacement and switches over once that answers. No SLA. Not the place for anything irreplaceable without your own backup. - Conversions queue. The server converts a few images at a time; when it is busy an
upload is answered
503withRetry-After: 5, and should simply be sent again then. - AVIF only. Renditions are AVIF; if a consumer cannot decode AVIF, use
/sourcefor the original format. - The changes feed is at-least-once. Consumers must treat events idempotently.
- Tokens are bearer secrets. Shown once when made, and revocable (see below). An ordinary
token can read, upload, edit and delete the account's photos, so treat it like a
password, and give anything that only files images a
"no-delete"token. - No browser CORS on the API yet. The API is for servers, scripts and agents; browser apps
on other origins cannot call it directly today. (The public wall's
/gallery.jsonand the images oni.heart.photoscan be read from any origin.)
Getting a token
Two ways, both producing a heart_… bearer token:
- By hand: sign in at he.art and, in that tab's developer
console, ask for one. There is no page for this yet. The secret is in the answer exactly once.
fetch('/api/tokens', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'my-script', scopes: ['no-delete'] }) }) .then((r) => r.json()).then(console.log) → {"id":"Ab3xY9kQ","name":"my-script","token":"heart_…","scopes":["no-delete"],…}GET /api/tokenslists your tokens (never their secrets) andDELETE /api/tokens/{id}revokes one, from the same signed-in tab. - By exchange (for products): verify your user with Google Sign-In, then post the ID
token here. The library verifies it itself and answers with a token for that account.
One token per (account, product): exchanging again replaces it.
curl -s https://heart.photos/api/v1/token \ -d '{"google_id_token":"eyJ…","product":"my-app","scopes":["no-delete"]}' → {"token":"heart_…","sub":"1044…","email":"…"}
The "no-delete" scope
scopes is optional either way. A token made with ["no-delete"] can read,
upload, rename, describe, tag, change visibility and make sizes, and is refused with a JSON
403 on deleting, rotating, pairing, and making, removing or choosing a rendering. The
restriction belongs to the token for its whole life and is enforced by the server on every call,
through the API and the MCP alike.
The API (v1)
All under https://heart.photos/api/v1, JSON in and out,
Authorization: Bearer heart_…. The v1 prefix is a promise: these paths
and shapes stay.
| Route | What it does |
|---|---|
GET /me | Who the token is. The cheap way to test one. |
GET /assets | Your photos, newest first, with URLs, variants, pairing, visibility. |
POST /assets | Multipart upload: file, optional name, quality (1–100, default 60). Duplicate bytes return the existing photo with duplicate_of. |
GET /assets/{id} | One photo. |
PATCH /assets/{id} | Any of name (up to 120 characters), description (up to 2,000), rotation, visibility, credit, license, alt, own_work (you made it and hold every right to it), source_url. A no-delete token may not rotate. public needs an open license (CC0, public domain, CC BY or CC BY-SA) and either own_work or a source_url (the page it came from, which with its credit is the attribution an open license asks for). |
DELETE /assets/{id} | Drop your reference; bytes go only when nothing else points at them. |
POST /assets/{id}/pair | Form field with=<id> joins wide+tall; empty splits. |
POST /assets/{id}/tags | {"tags":[…]}, up to 50; the photo carries exactly these words afterwards. |
GET /blobs/{sha256} | Do you already hold these bytes? 200 with the photo, or 404: the way to skip a 25 MB upload. A photo whose bytes were lost answers 404 as well, so the upload goes ahead and puts them back. Scoped to you: it never reveals other accounts' holdings. |
POST /assets/{id}/candidates | Re-render at another quality/speed; up to 8 candidates are kept until one is chosen. |
POST /assets/{id}/choose/{candidate} | Serve this rendering from now on. |
POST /assets/{id}/widths | Multipart, one AVIF per width: parts named w320, w640, w1280, w1920, w2560, w3840, each with a w1280_size field such as 1280x720. A part larger than the one already held is dropped. Served at i.heart.photos/{id}/w1280; a width not made yet falls back to the next wider one. |
POST /assets/{id}/variants/{kind} | Make mobile, 1080p or 4k. |
GET /assets/{id}/raw | The served AVIF bytes, owner-authenticated, so it works for private photos. ?variant=source for the untouched original, ?candidate=<id> for one named rendering rather than whichever is being served, which is what lets two renderings of the same photograph be compared against each other. A named rendering is immutable, so it is cached outright. |
POST /ratings | {"asset_id","rating"}: your rating of a photo, -4 (the very worst) to 4 (the very best), 0 for no opinion, null to take it back. Private unless you say "public": true or have made that your default. A site's account rates its own photos for its signed-in readers by naming them as rater. Answers the photo's counts. |
GET /ratings/counts?ids= | For up to 200 photos you may see: how many people rated each, liked it, disliked it and said each level, the names of those who chose to be named, and your own rating. Never who else. |
GET /liked?owner= | One owner's images that people like, most liked first: owner (a handle, or an account id such as site:viki), optional gallery (a gallery id) and tag, min (the fewest Likes, default 1), sort=likes, limit (default 100, at most 500) and cursor (the next of the page before). Each item: id, likes (how many people rated it Like or better, the number Heart Photos shows as Likes; never who), liked_at (the latest like), title, width, height, vis, created_at, urls and variants. Ties go to the latest like, then the newest image. No token is needed: anybody sees the public images, and the owner's own token sees their unlisted ones too; private images and the Trash never appear. Answered from memory, with an ETag (send If-None-Match for a 304) and a version; an answer without a token may be kept for 60 seconds. |
GET /ratings | Everything you rated, newest first, with whether each is public. Also at heart.photos/rated. |
PATCH /ratings | {"asset_ids":[…]} or {"all":true}, with "public": show or hide your name on them. |
PUT /ratings/default | {"public":true|false}: what your new ratings start as (private unless you change it). |
GET /generate | The styles this library can draw with, and the shapes they are offered in. Each says whether it can be shown a photograph (reference), what that costs (ref_seconds), and which one is offered first for it (ref_default). |
POST /generate | {"style","prompt","size","seed","name"}, and rater for a site, which draws for one of its people so the image is theirs: ask for an image to be made rather than taken. The library decides the model, the steps and the sampler; a worker draws it on its graphics card. Answers a job; the image lands in your library, private, carrying the model, the words and the seed. For original art and decoration, never as an image of a real thing. |
POST /generate with ref | {"ref":"<asset id>","prompt","style"}: draw FROM a photograph, so the words say what to change about it rather than what to draw. The photograph must be your own or one that is public, the style must be one that says reference, and the image comes back the same shape as the photograph, exactly, at about a megapixel, whatever size says. The shape is never stretched: the size chosen is the largest one the model can be asked for that holds the photograph's proportions, and for every ordinary shape it holds them exactly. It carries made_from, so what it was drawn from is on the record beside it. |
POST /generate with regions | {"ref","prompt","regions":[{"kind":"rect","box":[x1,y1,x2,y2]},{"kind":"ellipse","box":[...]},{"kind":"path","points":[[x,y],...]}]}: the parts of the photograph the edit may change, every number a share of the photograph from 0 to 1, the file's own way up. Up to 20 regions and 400 points an outline. The image comes back changed inside them and nowhere else, at full size, and carries them as made_regions. |
POST /generate with sharpen | An edit of a photograph comes back at the photograph's own size, and what it redraws is enlarged from the model's drawing. Left out, it comes back BOTH ways, as two images from the one drawing: "… - Sharpen On", with the redrawn parts sharpened by RealESRGAN x4plus (its model reads "… + RealESRGAN x4plus"), and "… - Sharpen Off", as the model drew it. "sharpen":true or false asks for that one alone. A face the edit redraws can look painted when sharpened. |
GET /generate/{job} | How it is going, and image_id once it is drawn; for an edit made both ways, image_id is the sharpened one and plain_id the other. |
GET /tags | Your tag vocabulary. |
GET /changes?since=N | Your events after cursor N, oldest first: created deleted renamed described rotated visibility paired unpaired tagged galleried rated moved (put in or taken out of a gallery, given a rating, moved to a new place in the wall's order). Keep next and hand it back. |
Upload example
curl -s https://heart.photos/api/v1/assets \ -H "Authorization: Bearer $TOKEN" \ -F [email protected] -F name="Sunset at the pier" -F quality=60
The MCP
The library speaks the Model Context Protocol, so
an AI agent handed a token can work with your photographs like any other product. Streamable
HTTP, stateless, at https://heart.photos/mcp; every request carries the same
Authorization header.
Tools: whoami, list_photos, get_photo,
upload_photo (fetches a public http(s) image URL server-side),
delete_photo, set_visibility, rename_photo,
describe_photo, pair_photos, set_tags,
check_hash, liked_photos (GET /liked). They are the API above wearing names an agent can pick from a list; the
two cannot drift apart, because the tools call the API. Hand an agent a
"no-delete" token and it cannot lose a photo, whatever it is asked to do.
Connecting (Claude Code / any MCP client)
{
"mcpServers": {
"heart-photos": {
"type": "http",
"url": "https://heart.photos/mcp",
"headers": { "Authorization": "Bearer heart_…" }
}
}
}
Or: claude mcp add --transport http heart-photos https://heart.photos/mcp
--header "Authorization: Bearer heart_…"
For product builders
The intended shape: your product signs users in with Google, exchanges the ID token for a
library token once per sign-in, and then talks v1 on the user's behalf. Identity is verified by
the library itself on every exchange: your product is never trusted about who someone is. Ask for
"no-delete" unless your product really deletes, use GET /blobs/{sha256}
before uploading, consume /changes instead of re-listing, and never key anything on
email.
To sell monthly or yearly subscriptions on your own site, with Heart Photos running the payments, see Heart Pay.