---
title: Unsplash API — Stock Photos with Attribution
slug: unsplash-api-stock-photos
category: Integrations
summary: Drop-in UnsplashService (plain Net::HTTP, no gem) to search and fetch stock photos, the .env keys it needs, and the mandatory photographer/Unsplash attribution pattern for views.
tags: [unsplash, api, images, env, attribution, service]
status: stable
visibility: public
source_project: llamapress.ai
layers: [controller, view]
related:
  - title: ImageMagick Image Cropping & Transparency
    url: /cookbook/imagemagick-image-cropping
    summary: Cleaning up uploaded images (trim, transparency, square variants) once you have them.
---

# Unsplash API — Stock Photos with Attribution

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

Your app needs real photography — hero images, blog illustrations, card backgrounds —
without a designer or a stock-photo subscription. Unsplash's API is free and this recipe
gives you a complete, copy-paste `UnsplashService` (plain `Net::HTTP` + `JSON`, **no gem
required** — important, since Leo instances can't add gems) plus the attribution markup
Unsplash's terms require on every use.

> **When to use:** you need searchable, high-quality stock photos at render/build time.
> **When not to:** the user is uploading their own images (that's Active Storage), or
> you need guaranteed-forever image URLs with zero external dependency (download nothing
> — Unsplash requires hotlinking, see Gotchas).

---

## The 80/20 in one breath

1. Register a (free) app at `unsplash.com/developers` and copy its **Access Key**.
2. Add `UNSPLASH_ACCESS_KEY` to the `.env`, then recreate the web container so it loads.
3. Copy the `UnsplashService` class below into `app/services/unsplash_service.rb`.
4. `UnsplashService.new.search("mountains", count: 3)` → array of photo hashes with
   `url`, `photographer`, and ready-made attribution links.
5. Render the image with the `<figure>` + attribution `<figcaption>` pattern —
   attribution is **mandatory**, not decorative.

---

## Layer 1 — Environment keys (.env)

```bash
# .env  (project root, next to docker-compose.yml)

# REQUIRED — the "Access Key" from your app at https://unsplash.com/developers
# (Client-ID auth; you do NOT need the Secret Key for search/fetch)
UNSPLASH_ACCESS_KEY=your_access_key_here

# OPTIONAL — your app's name, used in the utm_source attribution params.
# Defaults to "leonardo_rails_app" if unset. Use your registered Unsplash app name.
UNSPLASH_APP_NAME=my_app_name
```

Getting the key: log in at `unsplash.com/developers` → **Your apps** → **New
Application** → accept the terms → copy the **Access Key** from the app page. New apps
start in **Demo** mode: 50 requests/hour, which is plenty for build-time image sourcing.

**After editing `.env`, a plain restart is NOT enough** — Docker only reads `.env` at
container *creation*. Recreate the web service:

```bash
# on the box, from ~/Leonardo
docker compose down llamapress && docker compose up -d llamapress
```

Verify it landed: `docker compose exec llamapress bin/rails runner 'puts ENV["UNSPLASH_ACCESS_KEY"].to_s[0,4]'`
should print the first 4 characters, not a blank line.

## Layer 2 — The service (copy-paste, no gem)

```ruby
# app/services/unsplash_service.rb
require 'net/http'
require 'json'
require 'uri'

class UnsplashService
  UNSPLASH_API_URL = "https://api.unsplash.com"

  def initialize(api_key: ENV["UNSPLASH_ACCESS_KEY"], app_name: ENV["UNSPLASH_APP_NAME"] || "leonardo_rails_app")
    @api_key = api_key
    @app_name = app_name
  end

  # Search for photos
  # Example: UnsplashService.new.search("mountains", count: 1)
  def search(query, count: 3, orientation: nil, size: 'regular')
    params = {
      query: query,
      per_page: count
    }
    params[:orientation] = orientation if orientation

    uri = URI("#{UNSPLASH_API_URL}/search/photos")
    uri.query = URI.encode_www_form(params)

    response = get(uri)
    return response if response[:error]

    data = response[:data]
    (data["results"] || []).map { |photo| map_photo(photo, size) }
  rescue => e
    { error: "Unsplash search failed: #{e.message}" }
  end

  # Get a single photo by ID
  # Example: UnsplashService.new.get_by_id("abc123")
  def get_by_id(photo_id, size: 'regular')
    uri = URI("#{UNSPLASH_API_URL}/photos/#{photo_id}")

    response = get(uri)
    return response if response[:error]

    map_photo(response[:data], size)
  rescue => e
    { error: "Unsplash get_by_id failed: #{e.message}" }
  end

  private

  def get(uri)
    req = Net::HTTP::Get.new(uri)
    req["Authorization"] = "Client-ID #{@api_key}"
    req["Accept-Version"] = "v1"

    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }

    if res.is_a?(Net::HTTPSuccess)
      { data: JSON.parse(res.body) }
    else
      { error: "Unsplash API error: #{res.code} #{res.body}" }
    end
  end

  def map_photo(photo, size)
    photographer = photo.dig("user", "name")
    photographer_url = "#{photo.dig("user", "links", "html")}?utm_source=#{@app_name}&utm_medium=referral"
    unsplash_url = "https://unsplash.com/?utm_source=#{@app_name}&utm_medium=referral"

    {
      url: photo.dig("urls", size),
      alt_description: photo["alt_description"],
      photographer: photographer,
      photographer_url: photographer_url,
      unsplash_link: "#{photo.dig("links", "html")}?utm_source=#{@app_name}&utm_medium=referral",
      html_attribution: "Photo by <a href='#{photographer_url}'>#{photographer}</a> on <a href='#{unsplash_url}'>Unsplash</a>"
    }
  end
end
```

## Layer 3 — Using it

```ruby
# rails console / rails runner / any controller or job

photos = UnsplashService.new.search("golden retriever", count: 3)
# => [
#   {
#     url: "https://images.unsplash.com/photo-...&w=1080",   # hotlink this
#     alt_description: "golden retriever lying on grass",
#     photographer: "Jane Doe",
#     photographer_url: "https://unsplash.com/@janedoe?utm_source=my_app_name&utm_medium=referral",
#     unsplash_link: "https://unsplash.com/photos/abc123?utm_source=my_app_name&utm_medium=referral",
#     html_attribution: "Photo by <a href='...'>Jane Doe</a> on <a href='...'>Unsplash</a>"
#   },
#   ...
# ]

# Options:
UnsplashService.new.search("office", count: 1, orientation: "landscape")  # landscape | portrait | squarish
UnsplashService.new.search("office", size: "small")  # raw | full | regular (~1080w) | small (~400w) | thumb (~200w)
UnsplashService.new.get_by_id("abc123")              # one photo hash (not an array)
```

**Check for errors before iterating.** On failure the service returns a `Hash` with an
`:error` key instead of an `Array` — calling `.each` on it will surprise you:

```ruby
photos = UnsplashService.new.search(query)
if photos.is_a?(Hash) && photos[:error]
  Rails.logger.warn(photos[:error])   # bad key, rate limit (403), network...
  photos = []
end
```

## Layer 4 — The view, with mandatory attribution

Unsplash's API terms require crediting the photographer AND Unsplash, both linked, with
`utm_source` params, **every place a photo appears**. The service pre-builds the URLs.
House pattern:

```erb
<%# app/views/pages/example.html.erb — photo is one hash from UnsplashService#search %>
<figure class="my-10">
  <img src="<%= photo[:url] %>" alt="<%= photo[:alt_description] %>"
       class="rounded-xl shadow-lg w-full h-96 object-cover" loading="lazy">
  <figcaption class="text-sm text-gray-500 mt-3 text-center italic">
    Photo by <a href="<%= photo[:photographer_url] %>" target="_blank" rel="noopener"
                class="hover:text-blue-600 underline decoration-dotted transition-colors"><%= photo[:photographer] %></a>
    on <a href="<%= photo[:unsplash_link] %>" target="_blank" rel="noopener"
          class="hover:text-blue-600 underline decoration-dotted transition-colors">Unsplash</a>
  </figcaption>
</figure>
```

For static marketing pages, a common pattern is to run the search **once** (console or
build step), then hardcode the returned `url` + attribution links into the `.html.erb` —
zero API calls at request time, rate limit never touched.

---

## Gotchas (the hard-won stuff)

- **Attribution is a hard requirement, not a courtesy.** Photographer link + Unsplash
  link, both with `?utm_source=<your_app_name>&utm_medium=referral`. Skipping it
  violates the API terms and blocks any future production-rate-limit approval.
- **Hotlink the `images.unsplash.com` URLs — do not download and re-host.** The API
  guidelines require serving the URLs Unsplash returns (they're CDN-backed and support
  `&w=`/`&q=` resizing params you can append).
- **Error returns a `Hash`, success returns an `Array`** (for `search`). Guard with
  `photos.is_a?(Hash) && photos[:error]` before iterating, or a bad key silently
  becomes a `NoMethodError` in the view.
- **Demo apps are capped at 50 requests/hour.** A 403 with "Rate Limit Exceeded" means
  wait or apply for production. For static pages, fetch once and hardcode the URLs.
- **`.env` edits need a container recreate**, not `docker compose restart` — restart
  reuses the old environment. `down <service> && up -d <service>` (scoped to the one
  service, so you don't take down the rest of the stack).
- **Missing `UNSPLASH_ACCESS_KEY` fails as a 401 at request time**, not at boot. If
  every call errors, verify the key is actually in the container's env (Layer 1's
  `rails runner` check) before debugging the code.
- **`alt_description` can be `nil`.** Fall back to your search query for the `alt`
  attribute so accessibility/SEO don't suffer: `photo[:alt_description] || query`.
- **Production approval requires the download trigger.** If you build a user-facing
  photo picker (not just build-time sourcing) and want the 5,000 req/hour production
  tier, Unsplash also requires hitting each photo's `links.download_location` endpoint
  when a user selects a photo. Build-time page decoration doesn't need this.

---

## Files this pattern touches

```
.env                                     (add UNSPLASH_ACCESS_KEY, optional UNSPLASH_APP_NAME)
app/services/unsplash_service.rb         (new — the class above, verbatim)
app/views/<wherever photos render>.html.erb
```

## How to adapt to your schema

1. **Static pages (most common):** run `UnsplashService.new.search(...)` once in the
   console, paste the `url` + attribution links into the ERB. No runtime dependency.
2. **Dynamic content (e.g. auto-illustrated records):** call the service in a
   controller/job, cache the returned hash (e.g. a `jsonb` column or `Rails.cache`) so
   you don't re-hit the API — and keep the attribution fields with the URL, they travel
   together.
3. **Different image sizes per context:** pass `size:` (`thumb` for cards, `regular`
   for heroes) or append Unsplash's dynamic-resize params to the URL (`&w=600&q=75`).
4. Safe to drop: `get_by_id` if you only ever search; `orientation` if layout doesn't
   care.
