If you've ever scripted stock photo sourcing — pulling images for a content pipeline, a CMS seed script, a batch of location pages, whatever — you've probably hit the same trap I kept hitting: a big chunk of what shows up in a normal Unsplash search is actually Unsplash+, a paid tier, and nothing about the URL or the search result makes that obvious until you're looking at the actual photo page.
Grab enough images automatically and you will download a premium photo you don't have a license for, without realizing it, unless you check for it explicitly.
The problem in practice
A typical workflow looks like: search for something, pick a promising result, construct an images.unsplash.com URL, download it. That last step almost always "succeeds" — you get a valid image file back — regardless of whether the photo is free or Unsplash+. The CDN doesn't refuse the request. Nothing errors. You just end up with a file you don't actually have rights to use, sitting in your project looking exactly like every other downloaded image.
The only reliable tell is on the photo's own page: free photos serve from images.unsplash.com, Unsplash+ photos serve from plus.unsplash.com. If you're constructing download URLs from a search result without visiting the actual page, you can miss this entirely.
Checking programmatically instead of by eye
Unsplash's own site uses an internal API you can query directly for search results, and — usefully — each result includes a plus boolean:
curl -s "https://unsplash.com/napi/search/photos?query=warehouse+logistics&per_page=10" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for r in data['results']:
print(r['id'], '|', r['alt_description'], '| plus:', r['plus'])
"
That gives you a clean list to eyeball — plus: False is safe, plus: True isn't — before you commit to downloading anything. You can filter it in the same script instead of eyeballing it:
free_results = [r for r in data['results'] if not r['plus']]
For a single candidate photo you already have the ID for, you can check it directly the same way:
curl -s "https://unsplash.com/napi/photos/PHOTO_ID" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d['plus'], d['urls']['raw'])"
This also hands you the actual photo description and location metadata, which is worth checking too — I've had search results come back for a photo whose alt text vaguely matched what I was looking for, but which turned out (per its own location data) to be a completely different city than intended.
Downloading and actually looking at what you got
Once you've confirmed a photo is free, constructing a sized download URL is straightforward:
https://images.unsplash.com/photo-XXXXXXXX?w=1600&auto=format&fit=crop&q=80
The step people skip here is the same one that matters most: open the downloaded file and actually look at it before using it. Alt text and descriptions are written by photographers and occasionally wrong, misleading, or missing context — I've pulled a photo tagged for one city that turned out, on inspection, to have an odd framing issue (an out-of-focus object blocking half the shot) that no amount of metadata would have warned me about. A quick visual check catches that in seconds; skipping it means finding out after it's already live somewhere.
The full loop
- Search via the API, filter for
plus: false - For your shortlisted candidates, fetch the individual photo record and confirm
plusagain (belt and suspenders — I've seen search results and individual lookups disagree) - Construct the sized download URL and pull the file
- Actually open and look at the image before using it
- Only then wire it into whatever you're building
It's a few extra steps over "grab the first result that looks right," but it's the difference between a defensible content-sourcing process and quietly shipping an unlicensed image because the download URL didn't complain.
Built this into a content pipeline for DevFixel. If Unsplash's public API behavior changes or there's a more official way to filter by license, I'd genuinely like to know — this was reverse-engineered from what the site itself calls, not from documented API behavior.
Top comments (0)