Skip to content

Images

Images are the heart of Yesterdays. Most are old photographs, but they also include drawings, postcards, architectural sketches, and more.

The images endpoint has two different response formats: a list response for browsing, and a richer detail response with full metadata, georeferences, subjects, and comments.

List images

GET /api/v2/images/

Returns a paginated list of images with basic metadata. This response is intentionally lightweight and doesn't include georeferences, subjects, or comments. Use the detail_url field to fetch the full details for any image.

Example request

curl "https://yesterdays.maprva.org/api/v2/images/"
import requests

response = requests.get("https://yesterdays.maprva.org/api/v2/images/")
data = response.json()
library(httr2)

resp <- request("https://yesterdays.maprva.org/api/v2/images/") |>
  req_perform()
data <- resp_body_json(resp)

Example response

{
    "count": 37244,
    "next": "https://yesterdays.maprva.org/api/v2/images/?page=2",
    "previous": null,
    "results": [
        {
            "id": 5934,
            "title": "N. 28th and Q Streets",
            "thumbnail": "https://cdn.maprva.org/7416452b52d0f9fdbe1c_thumb",
            "creator": "Shelton, Edith Keesee--1898-1989",
            "original_date": "9/1956",
            "date_display": "9/1956",
            "from_above": false,
            "duplicate_of": null,
            "collection": {
                "id": 39,
                "name": "Edith K. Shelton Photograph Collection",
                "slug": "edith-k-shelton-photograph-collection",
                "source_name": "The Valentine"
            },
            "georeference_status": "georeferenced",
            "detail_url": "https://yesterdays.maprva.org/api/v2/images/5934/"
        }
    ]
}

List fields

Field Type Description
id integer Unique identifier
title string Title of the image
thumbnail string URL to a thumbnail of the image
creator string Creator/photographer name
original_date string Date string as recorded by the source
date_display string Human-friendly date display
from_above boolean Whether this is a from-above (bird's-eye) image
duplicate_of integer or null ID of the image this is a duplicate of, or null if not a duplicate
collection object Collection info (id, name, slug, source_name)
georeference_status string One of "available", "georeferenced", "duplicate", or "will_not_georef"
detail_url string API link to the full detail for this image

Filtering

Parameter Type Description
source integer Filter by source ID
collection integer Filter by collection ID
subject integer Filter by subject ID
creator string Search creator name (case-insensitive, partial match)
year_min number Include images whose date range overlaps with or follows this year
year_max number Include images whose date range overlaps with or precedes this year
georeferenced boolean true for georeferenced images only, false for un-georeferenced
from_above boolean true for from-above images, false for standard images

Example: combining filters

Find all georeferenced images from the Valentine, taken between 1900 and 1920:

curl "https://yesterdays.maprva.org/api/v2/images/?source=2&georeferenced=true&year_min=1900&year_max=1920"
import requests

response = requests.get("https://yesterdays.maprva.org/api/v2/images/", params={
    "source": 2,
    "georeferenced": "true",
    "year_min": 1900,
    "year_max": 1920,
})
data = response.json()
library(httr2)

resp <- request("https://yesterdays.maprva.org/api/v2/images/") |>
  req_url_query(source = 2, georeferenced = "true", year_min = 1900, year_max = 1920) |>
  req_perform()
data <- resp_body_json(resp)

Ordering

Parameter Description
order Sort by collection order
title Sort alphabetically by title
original_date Sort by date
created_at Sort by when the image was added to Yesterdays
last_georeferenced_at Sort by when the image was most recently georeferenced

Prefix with - to sort descending. For example, ordering=original_date gives oldest first, while ordering=-original_date gives newest first.

# Most recently georeferenced images first
curl "https://yesterdays.maprva.org/api/v2/images/?ordering=-last_georeferenced_at"
import requests

# Most recently georeferenced images first
response = requests.get("https://yesterdays.maprva.org/api/v2/images/", params={
    "ordering": "-last_georeferenced_at",
})
data = response.json()
library(httr2)

# Most recently georeferenced images first
resp <- request("https://yesterdays.maprva.org/api/v2/images/") |>
  req_url_query(ordering = "-last_georeferenced_at") |>
  req_perform()
data <- resp_body_json(resp)

Default ordering is -order (newest additions first).

Get a single image

GET /api/v2/images/{id}/

Returns complete details for a single image, including its georeferences, subjects, comments, and license information.

Example request

curl "https://yesterdays.maprva.org/api/v2/images/5934/"
import requests

response = requests.get("https://yesterdays.maprva.org/api/v2/images/5934/")
data = response.json()
library(httr2)

resp <- request("https://yesterdays.maprva.org/api/v2/images/5934/") |>
  req_perform()
data <- resp_body_json(resp)

Example response

{
    "id": 5934,
    "title": "N. 28th and Q Streets",
    "permalink": "https://cdn.maprva.org/7416452b52d0f9fdbe1c",
    "thumbnail": "https://cdn.maprva.org/7416452b52d0f9fdbe1c_thumb",
    "original_url": "https://valentine.rediscoverysoftware.com/MADetailB.aspx?rID=PHC0039/-#V.91.42.2952&db=biblio&dir=VALARCH",
    "description": "35mm color slide of the intersection of N. 28th Street and Q Street in north Church Hill; image shows a horse with small wagon loaded with a bale of hay, stopped at a Yield sign; three children stand on sidewalk near horse; two-story house with siding in background; Richmond, Virginia.",
    "creator": "Shelton, Edith Keesee--1898-1989",
    "license": null,
    "original_date": "9/1956",
    "edtf_date": "1956-09",
    "date_display": "9/1956",
    "collection": {
        "id": 39,
        "name": "Edith K. Shelton Photograph Collection",
        "slug": "edith-k-shelton-photograph-collection",
        "source_name": "The Valentine"
    },
    "subjects": [
        {
            "id": 150,
            "title": "horse",
            "slug": "horse",
            "wikidata": {
                "wikidata_id": "Q726",
                "uri": "https://www.wikidata.org/entity/Q726",
                "title": "horse",
                "description": "domesticated four-footed mammal from the equine family"
            }
        }
    ],
    "from_above": false,
    "duplicate_of": null,
    "scale": null,
    "mirror": "none",
    "rotation": 0,
    "georeference_status": "georeferenced",
    "georeferences": [
        {
            "id": 5701,
            "latitude": 37.536789,
            "longitude": -77.409651,
            "direction": 136.0,
            "confidence": "medium",
            "confidence_notes": "",
            "georeferenced_by": "mhpob",
            "georeferenced_at": "2025-12-16T12:55:01Z",
            "validations": []
        }
    ],
    "from_above_georeferences": [],
    "comments": [
        {
            "id": 57,
            "text": "Other angle of #5933",
            "commented_by": "mhpob",
            "created_at": "2025-12-16T12:55:42Z"
        }
    ],
    "detail_url": "https://yesterdays.maprva.org/api/v2/images/5934/",
    "iiif_url": "https://cdn.maprva.org/images/5934/iiif/"
}

Detail fields

The detail response includes all the list fields, plus:

Field Type Description
permalink string Direct URL to the image file. If a rotation or mirror transform has been applied, this automatically points to the corrected version.
original_url string Link to the image in the original archive
description string Full description of the image
license object or null License info (display_name, permalink), or null if no license is available
edtf_date string Date in EDTF format, when available
subjects array Subjects tagged in this image, with Wikidata metadata
scale string Scale notation for maps and from-above imagery
mirror string Mirror transform applied: "none", "h" (horizontal), or "v" (vertical)
rotation integer Clockwise rotation applied to this image: 0, 90, 180, or 270 (degrees)
georeferences array Point georeferences (see below)
from_above_georeferences array From-above polygon georeferences (see below)
comments array Community comments on this image
iiif_url string or null Base URL for this image's IIIF Image Service (append info.json for metadata), or null if tiles haven't been generated yet

Georeference objects

Each georeference in the georeferences array represents someone's placement of this image on the map:

Field Type Description
id integer Unique identifier
latitude number Latitude of the photographer's location
longitude number Longitude of the photographer's location
direction number Compass direction the camera was facing (degrees, 0 = north)
confidence string "low", "medium", or "high"
confidence_notes string Explanation for the chosen confidence level
georeferenced_by string OpenStreetMap username of the contributor
georeferenced_at string ISO 8601 timestamp
validations array Community validations (see below)

From-above georeference objects

The from_above_georeferences array contains polygon-based georeferences for bird's-eye and from-above images:

Field Type Description
id integer Unique identifier
polygon object GeoJSON Polygon geometry representing the area covered
confidence string "low", "medium", or "high"
confidence_notes string Explanation for the chosen confidence level
georeferenced_by string OpenStreetMap username of the contributor
georeferenced_at string ISO 8601 timestamp
validations array Community validations

Validation objects

Validations appear inside georeference objects. They represent community review of a georeference:

Field Type Description
validation string One of "correct", "uncertain", or "incorrect"
validated_by string OpenStreetMap username of the reviewer
notes string Optional notes from the reviewer
validated_at string ISO 8601 timestamp

Comment objects

Field Type Description
id integer Unique identifier
text string The comment text
commented_by string OpenStreetMap username of the commenter
created_at string ISO 8601 timestamp

Import a new image

POST /api/v2/import/commit/

Adds a new image to a collection. Use this when you have an image that isn't yet in Yesterdays — for higher-resolution scans of existing catalog entries, use Replace an image file instead.

Requires an OAuth2 bearer token with the import scope, on a user with contributor (staff) status. See Authentication.

Importing is a three-step flow:

  1. Request an upload slot from Yesterdays (returns a presigned URL into our object storage).
  2. PUT the file bytes directly to that URL.
  3. POST to the commit endpoint with the slot's ID and the new image's metadata.

Step 1: Request an upload slot

GET /api/v2/import/upload-url/?collection={id}&content_type={mime}

Creates a short-lived import slot and returns a presigned PUT URL into object storage. The slot expires after 15 minutes; the URL must be used within that window. If you decide not to commit, release the slot with Cancel a pending import.

Parameter Type Required Description
collection integer yes Collection ID to import into.
content_type string no MIME type of the file you will upload. Defaults to image/jpeg. Must match exactly when you upload — the presigned URL is signed with this value.

Example response:

{
    "slot_id": "b5e7c9f2-4a1b-49d3-8c7e-1234567890ab",
    "upload_url": "https://r2.example.com/.../imports/b5e7c9f2...?X-Amz-Signature=...",
    "upload_headers": { "Content-Type": "image/tiff" },
    "cdn_url": "https://cdn.maprva.org/imports/b5e7c9f2..."
}

Step 2: Upload the file

PUT the file bytes to upload_url using the exact headers returned in upload_headers. Include a Content-Length header matching the file size.

The upload target is object storage directly — not Yesterdays. Do not send your Yesterdays access token on this request.

Step 3: Commit the import

POST /api/v2/import/commit/

Creates the Image row and moves the uploaded file to its permanent location.

Parameter Type Required Description
slot_id UUID yes The slot_id returned in Step 1.
title string yes Image title (max 500 characters).
original_date string yes The date string exactly as recorded by the source archive (max 50 characters). Preserved alongside edtf_date so the original wording isn't lost — e.g. "ca. 1900-1910", "9/1956".
edtf_date string yes The same date parsed into EDTF format (max 50 characters).
source_url string no URL of the image in the original archive.
description string no Full description.
creator string no Creator or photographer name (max 100 characters).
reference_id string no The source archive's reference ID for this image (max 100 characters).
license_name string no Exact name of a recognized license. Unknown values are rejected.
rotation integer no Display rotation in degrees clockwise: 0, 90, 180, or 270. Default 0.
mirror string no Display mirror: "none", "h", or "v". Default "none".

The image bytes are uploaded as-is — rotation and mirror are stored as display metadata and applied at render time, not baked into the file. If you want the canonical bytes to be physically oriented, bake the transform in before uploading.

Example response (201 Created):

{ "image_id": 5934 }

What happens after a successful commit

  • A new Image row is created with the metadata you provided.
  • The uploaded file is moved from the import slot's temporary location to images/{image_id}/original.{ext}, where {ext} is derived from the slot's content_type.
  • permalink points at the moved file.
  • The import slot is consumed and cannot be committed again.
  • A background task generates the thumbnail, display variant, and IIIF tiles from the source bytes.

Errors

HTTP Error Meaning
400 license_name: License '...' is not recognized. The provided license isn't in this instance's set — fetch Licenses for the allowed values.
400 edtf_date: Invalid EDTF date: ... The provided date doesn't parse as EDTF.
400 title: This field is required. A required field is missing or empty.
401 invalid_token Access token missing, expired, or revoked.
403 insufficient_scope / permission denied Token lacks the import scope, or the user is not a contributor.
404 Import slot not found or does not belong to you. Slot expired, doesn't exist, or was created by a different user.
409 Image has not been uploaded to S3 yet. No object at the slot's storage key — Step 2 hasn't completed.

Cancel a pending import

POST /api/v2/import/cancel/

Releases one or more pending import slots and deletes their temporary uploaded files. Use this when you've requested a slot but decided not to commit, so the temporary upload isn't left orphaned (the server reaps abandoned slots eventually, but cancelling is courteous and immediate).

Parameter Type Required Description
slot_ids array yes 1–100 slot UUIDs to cancel.

Slot IDs that don't exist, belong to a different user, or have already been committed are silently ignored. The response counts only the slots actually deleted.

Example response:

{ "deleted": 3 }

Complete example

INSTANCE="https://yesterdays.maprva.org"
TOKEN="your-access-token"
COLLECTION_ID=39
FILE=/path/to/photo.tif
MIME=image/tiff

# 1. Request an upload slot.
SLOT=$(curl -s \
  -H "Authorization: Bearer $TOKEN" \
  "$INSTANCE/api/v2/import/upload-url/?collection=$COLLECTION_ID&content_type=$MIME")
SLOT_ID=$(echo "$SLOT" | jq -r .slot_id)
UPLOAD_URL=$(echo "$SLOT" | jq -r .upload_url)

# 2. PUT the file bytes directly to object storage.
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: $MIME" \
  --data-binary "@$FILE"

# 3. Commit the import.
curl -X POST "$INSTANCE/api/v2/import/commit/" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "slot_id": "$SLOT_ID",
  "title": "N. 28th and Q Streets",
  "original_date": "9/1956",
  "edtf_date": "1956-09",
  "creator": "Shelton, Edith Keesee",
  "license_name": "CC BY 4.0",
  "source_url": "https://valentine.example/...",
  "description": "35mm color slide...",
  "rotation": 0,
  "mirror": "none"
}
EOF
import requests

INSTANCE = "https://yesterdays.maprva.org"
TOKEN = "your-access-token"
COLLECTION_ID = 39
FILE = "/path/to/photo.tif"
MIME = "image/tiff"

auth = {"Authorization": f"Bearer {TOKEN}"}

# 1. Request an upload slot.
slot = requests.get(
    f"{INSTANCE}/api/v2/import/upload-url/",
    params={"collection": COLLECTION_ID, "content_type": MIME},
    headers=auth,
).json()

# 2. Stream the file directly to object storage.
with open(FILE, "rb") as f:
    put = requests.put(
        slot["upload_url"],
        data=f,
        headers=slot["upload_headers"],
    )
    put.raise_for_status()

# 3. Commit the import.
resp = requests.post(
    f"{INSTANCE}/api/v2/import/commit/",
    headers=auth,
    json={
        "slot_id": slot["slot_id"],
        "title": "N. 28th and Q Streets",
        "original_date": "9/1956",
        "edtf_date": "1956-09",
        "creator": "Shelton, Edith Keesee",
        "license_name": "CC BY 4.0",
        "source_url": "https://valentine.example/...",
        "description": "35mm color slide...",
        "rotation": 0,
        "mirror": "none",
    },
)
resp.raise_for_status()
print(resp.json())  # {"image_id": 5934}
library(httr2)

INSTANCE      <- "https://yesterdays.maprva.org"
TOKEN         <- "your-access-token"
COLLECTION_ID <- 39
FILE          <- "/path/to/photo.tif"
MIME          <- "image/tiff"

auth <- function(req) req_auth_bearer_token(req, TOKEN)

# 1. Request an upload slot.
slot <- request(paste0(INSTANCE, "/api/v2/import/upload-url/")) |>
  auth() |>
  req_url_query(collection = COLLECTION_ID, content_type = MIME) |>
  req_perform() |>
  resp_body_json()

# 2. PUT the file bytes directly to object storage.
request(slot$upload_url) |>
  req_method("PUT") |>
  req_headers(!!!slot$upload_headers) |>
  req_body_file(FILE) |>
  req_perform()

# 3. Commit the import.
resp <- request(paste0(INSTANCE, "/api/v2/import/commit/")) |>
  auth() |>
  req_body_json(list(
    slot_id       = slot$slot_id,
    title         = "N. 28th and Q Streets",
    original_date = "9/1956",
    edtf_date     = "1956-09",
    creator       = "Shelton, Edith Keesee",
    license_name  = "CC BY 4.0",
    source_url    = "https://valentine.example/...",
    description   = "35mm color slide...",
    rotation      = 0,
    mirror        = "none"
  )) |>
  req_perform() |>
  resp_body_json()

Notes

  • Bake transforms in, then upload. The image bytes are stored as-is. Rotation and mirror are display metadata, not pixel transforms.
  • Processing is asynchronous. The commit returns as soon as the DB row exists and the file is in its permanent location. Thumbnail, display variant, and IIIF tile generation run in the background — expect a brief window where thumbnail and iiif_url are null.
  • Atomicity. Each import is atomic per slot: either an Image row is created and the file is in place, or nothing happened. A failure mid-flow leaves the slot consumable for at most the 15-minute window before reaping.
  • Validate license names client-side. Fetch GET /api/v2/licenses/ once at the start of a batch and check every license_name against it. Catching unknown licenses before requesting the upload slot saves a round-trip and a wasted slot.

Replace an image file

POST /api/v2/images/{id}/replace/

Replaces the canonical file for an existing image. All of the image's metadata — title, description, date, collection, license, favorites, tags, subjects, georeferences, and comments — remain attached to the same image ID. Only the underlying file bytes change.

This is the endpoint to use when you have a higher-resolution scan of an image that's already in the catalog.

Requires an OAuth2 bearer token with the import scope, on a user with contributor (staff) status. See Authentication.

Replacement is a three-step flow:

  1. Request an upload slot from Yesterdays (returns a presigned URL into our object storage).
  2. PUT the file bytes directly to that URL.
  3. POST to the replace endpoint with the slot's ID to finalize.

Step 1: Request an upload slot

GET /api/v2/import/upload-url/?collection={id}&content_type={mime}

Creates a short-lived import slot and returns a presigned PUT URL into object storage. The slot expires after 15 minutes; the URL must be used within that window.

Parameter Type Required Description
collection integer yes Collection ID the target image belongs to.
content_type string no MIME type of the file you will upload. Defaults to image/jpeg. Must match exactly when you upload — the presigned URL is signed with this value.

Example response:

{
    "slot_id": "b5e7c9f2-4a1b-49d3-8c7e-1234567890ab",
    "upload_url": "https://r2.example.com/.../imports/b5e7c9f2...?X-Amz-Signature=...",
    "upload_headers": { "Content-Type": "image/tiff" },
    "cdn_url": "https://cdn.maprva.org/imports/b5e7c9f2..."
}

Step 2: Upload the file

PUT the file bytes to upload_url using the exact headers returned in upload_headers. Include a Content-Length header matching the file size.

The upload target is object storage directly — not Yesterdays. Do not send your Yesterdays access token on this request.

Step 3: Commit the replacement

POST /api/v2/images/{id}/replace/
Parameter Type Required Description
slot_id UUID yes The slot_id returned in Step 1.

Example response (200 OK):

{ "image_id": 5934 }

What happens after a successful commit

  • permalink is updated to point at the newly uploaded file.
  • rotation and mirror reset to 0 and "none" — the uploaded file is treated as correctly oriented. Apply any rotation or mirroring before uploading.
  • thumbnail and any derived assets are cleared.
  • A background task regenerates the thumbnail, display variant, and IIIF tiles from the new source, and cleans up prior versions.
  • The previous images/{id}/original.* file is deleted when the new file has a different extension.
  • The import slot is consumed (cannot be committed again).

Errors

HTTP Error message Meaning
400 Slot belongs to a different collection than the target image. The collection used to create the slot doesn't match the image's collection.
401 invalid_token Access token missing, expired, or revoked.
403 insufficient_scope / permission denied Token lacks the import scope, or the user is not a contributor.
404 Image not found. No image with the given ID.
404 Import slot not found or does not belong to you. Slot expired, doesn't exist, or was created by a different user.
409 Image has not been uploaded to S3 yet. No object at the slot's storage key — Step 2 hasn't completed.

Complete example

INSTANCE="https://yesterdays.maprva.org"
TOKEN="your-access-token"
IMAGE_ID=5934
COLLECTION_ID=39
FILE=/path/to/replacement.tif
MIME=image/tiff

# 1. Request an upload slot.
SLOT=$(curl -s \
  -H "Authorization: Bearer $TOKEN" \
  "$INSTANCE/api/v2/import/upload-url/?collection=$COLLECTION_ID&content_type=$MIME")
SLOT_ID=$(echo "$SLOT" | jq -r .slot_id)
UPLOAD_URL=$(echo "$SLOT" | jq -r .upload_url)

# 2. PUT the file bytes directly to object storage.
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: $MIME" \
  --data-binary "@$FILE"

# 3. Commit the replacement.
curl -X POST "$INSTANCE/api/v2/images/$IMAGE_ID/replace/" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"slot_id\": \"$SLOT_ID\"}"
import requests

INSTANCE = "https://yesterdays.maprva.org"
TOKEN = "your-access-token"
IMAGE_ID = 5934
COLLECTION_ID = 39
FILE = "/path/to/replacement.tif"
MIME = "image/tiff"

auth = {"Authorization": f"Bearer {TOKEN}"}

# 1. Request an upload slot.
slot = requests.get(
    f"{INSTANCE}/api/v2/import/upload-url/",
    params={"collection": COLLECTION_ID, "content_type": MIME},
    headers=auth,
).json()

# 2. Stream the file directly to object storage.
with open(FILE, "rb") as f:
    put = requests.put(
        slot["upload_url"],
        data=f,
        headers=slot["upload_headers"],
    )
    put.raise_for_status()

# 3. Commit the replacement.
resp = requests.post(
    f"{INSTANCE}/api/v2/images/{IMAGE_ID}/replace/",
    json={"slot_id": slot["slot_id"]},
    headers=auth,
)
resp.raise_for_status()
print(resp.json())
library(httr2)

INSTANCE      <- "https://yesterdays.maprva.org"
TOKEN         <- "your-access-token"
IMAGE_ID      <- 5934
COLLECTION_ID <- 39
FILE          <- "/path/to/replacement.tif"
MIME          <- "image/tiff"

auth <- function(req) req_auth_bearer_token(req, TOKEN)

# 1. Request an upload slot.
slot <- request(paste0(INSTANCE, "/api/v2/import/upload-url/")) |>
  auth() |>
  req_url_query(collection = COLLECTION_ID, content_type = MIME) |>
  req_perform() |>
  resp_body_json()

# 2. PUT the file bytes directly to object storage.
request(slot$upload_url) |>
  req_method("PUT") |>
  req_headers(!!!slot$upload_headers) |>
  req_body_file(FILE) |>
  req_perform()

# 3. Commit the replacement.
resp <- request(paste0(INSTANCE, "/api/v2/images/", IMAGE_ID, "/replace/")) |>
  auth() |>
  req_body_json(list(slot_id = slot$slot_id)) |>
  req_perform() |>
  resp_body_json()

Notes

  • Orientation. The replacement file is stored as-is. Rotation and mirror metadata are reset on commit. If your file needs reorientation, bake it into the bytes before uploading.
  • Processing is asynchronous. The replace call returns as soon as the DB is updated; thumbnail, display variant, and IIIF tile regeneration run in the background. Expect a brief window where thumbnail and iiif_url are null while derivatives rebuild.
  • Atomicity. Each replace is atomic per image: the replacement either fully takes effect (DB updated, old file deleted, derivatives queued) or the image is left untouched. There is no partial state.