← 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

Where its edges are

Weaknesses, stated plainly: judge for yourself whether they matter for your use.

Getting a token

Two ways, both producing a heart_… bearer token:

  1. 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/tokens lists your tokens (never their secrets) and DELETE /api/tokens/{id} revokes one, from the same signed-in tab.
  2. 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.

RouteWhat it does
GET /meWho the token is. The cheap way to test one.
GET /assetsYour photos, newest first, with URLs, variants, pairing, visibility.
POST /assetsMultipart 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}/pairForm 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}/candidatesRe-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}/widthsMultipart, 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}/rawThe 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 /ratingsEverything 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 /generateThe 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 sharpenAn 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 /tagsYour tag vocabulary.
GET /changes?since=NYour 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.