{"slug":"unsplash-api-stock-photos","meta":{"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 \u0026 Transparency","url":"/cookbook/imagemagick-image-cropping","summary":"Cleaning up uploaded images (trim, transparency, square variants) once you have them."}]},"body":"# Unsplash API — Stock Photos with Attribution\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nYour app needs real photography — hero images, blog illustrations, card backgrounds —\nwithout a designer or a stock-photo subscription. Unsplash's API is free and this recipe\ngives you a complete, copy-paste `UnsplashService` (plain `Net::HTTP` + `JSON`, **no gem\nrequired** — important, since Leo instances can't add gems) plus the attribution markup\nUnsplash's terms require on every use.\n\n\u003e **When to use:** you need searchable, high-quality stock photos at render/build time.\n\u003e **When not to:** the user is uploading their own images (that's Active Storage), or\n\u003e you need guaranteed-forever image URLs with zero external dependency (download nothing\n\u003e — Unsplash requires hotlinking, see Gotchas).\n\n---\n\n## The 80/20 in one breath\n\n1. Register a (free) app at `unsplash.com/developers` and copy its **Access Key**.\n2. Add `UNSPLASH_ACCESS_KEY` to the `.env`, then recreate the web container so it loads.\n3. Copy the `UnsplashService` class below into `app/services/unsplash_service.rb`.\n4. `UnsplashService.new.search(\"mountains\", count: 3)` → array of photo hashes with\n   `url`, `photographer`, and ready-made attribution links.\n5. Render the image with the `\u003cfigure\u003e` + attribution `\u003cfigcaption\u003e` pattern —\n   attribution is **mandatory**, not decorative.\n\n---\n\n## Layer 1 — Environment keys (.env)\n\n```bash\n# .env  (project root, next to docker-compose.yml)\n\n# REQUIRED — the \"Access Key\" from your app at https://unsplash.com/developers\n# (Client-ID auth; you do NOT need the Secret Key for search/fetch)\nUNSPLASH_ACCESS_KEY=your_access_key_here\n\n# OPTIONAL — your app's name, used in the utm_source attribution params.\n# Defaults to \"leonardo_rails_app\" if unset. Use your registered Unsplash app name.\nUNSPLASH_APP_NAME=my_app_name\n```\n\nGetting the key: log in at `unsplash.com/developers` → **Your apps** → **New\nApplication** → accept the terms → copy the **Access Key** from the app page. New apps\nstart in **Demo** mode: 50 requests/hour, which is plenty for build-time image sourcing.\n\n**After editing `.env`, a plain restart is NOT enough** — Docker only reads `.env` at\ncontainer *creation*. Recreate the web service:\n\n```bash\n# on the box, from ~/Leonardo\ndocker compose down llamapress \u0026\u0026 docker compose up -d llamapress\n```\n\nVerify it landed: `docker compose exec llamapress bin/rails runner 'puts ENV[\"UNSPLASH_ACCESS_KEY\"].to_s[0,4]'`\nshould print the first 4 characters, not a blank line.\n\n## Layer 2 — The service (copy-paste, no gem)\n\n```ruby\n# app/services/unsplash_service.rb\nrequire 'net/http'\nrequire 'json'\nrequire 'uri'\n\nclass UnsplashService\n  UNSPLASH_API_URL = \"https://api.unsplash.com\"\n\n  def initialize(api_key: ENV[\"UNSPLASH_ACCESS_KEY\"], app_name: ENV[\"UNSPLASH_APP_NAME\"] || \"leonardo_rails_app\")\n    @api_key = api_key\n    @app_name = app_name\n  end\n\n  # Search for photos\n  # Example: UnsplashService.new.search(\"mountains\", count: 1)\n  def search(query, count: 3, orientation: nil, size: 'regular')\n    params = {\n      query: query,\n      per_page: count\n    }\n    params[:orientation] = orientation if orientation\n\n    uri = URI(\"#{UNSPLASH_API_URL}/search/photos\")\n    uri.query = URI.encode_www_form(params)\n\n    response = get(uri)\n    return response if response[:error]\n\n    data = response[:data]\n    (data[\"results\"] || []).map { |photo| map_photo(photo, size) }\n  rescue =\u003e e\n    { error: \"Unsplash search failed: #{e.message}\" }\n  end\n\n  # Get a single photo by ID\n  # Example: UnsplashService.new.get_by_id(\"abc123\")\n  def get_by_id(photo_id, size: 'regular')\n    uri = URI(\"#{UNSPLASH_API_URL}/photos/#{photo_id}\")\n\n    response = get(uri)\n    return response if response[:error]\n\n    map_photo(response[:data], size)\n  rescue =\u003e e\n    { error: \"Unsplash get_by_id failed: #{e.message}\" }\n  end\n\n  private\n\n  def get(uri)\n    req = Net::HTTP::Get.new(uri)\n    req[\"Authorization\"] = \"Client-ID #{@api_key}\"\n    req[\"Accept-Version\"] = \"v1\"\n\n    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }\n\n    if res.is_a?(Net::HTTPSuccess)\n      { data: JSON.parse(res.body) }\n    else\n      { error: \"Unsplash API error: #{res.code} #{res.body}\" }\n    end\n  end\n\n  def map_photo(photo, size)\n    photographer = photo.dig(\"user\", \"name\")\n    photographer_url = \"#{photo.dig(\"user\", \"links\", \"html\")}?utm_source=#{@app_name}\u0026utm_medium=referral\"\n    unsplash_url = \"https://unsplash.com/?utm_source=#{@app_name}\u0026utm_medium=referral\"\n\n    {\n      url: photo.dig(\"urls\", size),\n      alt_description: photo[\"alt_description\"],\n      photographer: photographer,\n      photographer_url: photographer_url,\n      unsplash_link: \"#{photo.dig(\"links\", \"html\")}?utm_source=#{@app_name}\u0026utm_medium=referral\",\n      html_attribution: \"Photo by \u003ca href='#{photographer_url}'\u003e#{photographer}\u003c/a\u003e on \u003ca href='#{unsplash_url}'\u003eUnsplash\u003c/a\u003e\"\n    }\n  end\nend\n```\n\n## Layer 3 — Using it\n\n```ruby\n# rails console / rails runner / any controller or job\n\nphotos = UnsplashService.new.search(\"golden retriever\", count: 3)\n# =\u003e [\n#   {\n#     url: \"https://images.unsplash.com/photo-...\u0026w=1080\",   # hotlink this\n#     alt_description: \"golden retriever lying on grass\",\n#     photographer: \"Jane Doe\",\n#     photographer_url: \"https://unsplash.com/@janedoe?utm_source=my_app_name\u0026utm_medium=referral\",\n#     unsplash_link: \"https://unsplash.com/photos/abc123?utm_source=my_app_name\u0026utm_medium=referral\",\n#     html_attribution: \"Photo by \u003ca href='...'\u003eJane Doe\u003c/a\u003e on \u003ca href='...'\u003eUnsplash\u003c/a\u003e\"\n#   },\n#   ...\n# ]\n\n# Options:\nUnsplashService.new.search(\"office\", count: 1, orientation: \"landscape\")  # landscape | portrait | squarish\nUnsplashService.new.search(\"office\", size: \"small\")  # raw | full | regular (~1080w) | small (~400w) | thumb (~200w)\nUnsplashService.new.get_by_id(\"abc123\")              # one photo hash (not an array)\n```\n\n**Check for errors before iterating.** On failure the service returns a `Hash` with an\n`:error` key instead of an `Array` — calling `.each` on it will surprise you:\n\n```ruby\nphotos = UnsplashService.new.search(query)\nif photos.is_a?(Hash) \u0026\u0026 photos[:error]\n  Rails.logger.warn(photos[:error])   # bad key, rate limit (403), network...\n  photos = []\nend\n```\n\n## Layer 4 — The view, with mandatory attribution\n\nUnsplash's API terms require crediting the photographer AND Unsplash, both linked, with\n`utm_source` params, **every place a photo appears**. The service pre-builds the URLs.\nHouse pattern:\n\n```erb\n\u003c%# app/views/pages/example.html.erb — photo is one hash from UnsplashService#search %\u003e\n\u003cfigure class=\"my-10\"\u003e\n  \u003cimg src=\"\u003c%= photo[:url] %\u003e\" alt=\"\u003c%= photo[:alt_description] %\u003e\"\n       class=\"rounded-xl shadow-lg w-full h-96 object-cover\" loading=\"lazy\"\u003e\n  \u003cfigcaption class=\"text-sm text-gray-500 mt-3 text-center italic\"\u003e\n    Photo by \u003ca href=\"\u003c%= photo[:photographer_url] %\u003e\" target=\"_blank\" rel=\"noopener\"\n                class=\"hover:text-blue-600 underline decoration-dotted transition-colors\"\u003e\u003c%= photo[:photographer] %\u003e\u003c/a\u003e\n    on \u003ca href=\"\u003c%= photo[:unsplash_link] %\u003e\" target=\"_blank\" rel=\"noopener\"\n          class=\"hover:text-blue-600 underline decoration-dotted transition-colors\"\u003eUnsplash\u003c/a\u003e\n  \u003c/figcaption\u003e\n\u003c/figure\u003e\n```\n\nFor static marketing pages, a common pattern is to run the search **once** (console or\nbuild step), then hardcode the returned `url` + attribution links into the `.html.erb` —\nzero API calls at request time, rate limit never touched.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Attribution is a hard requirement, not a courtesy.** Photographer link + Unsplash\n  link, both with `?utm_source=\u003cyour_app_name\u003e\u0026utm_medium=referral`. Skipping it\n  violates the API terms and blocks any future production-rate-limit approval.\n- **Hotlink the `images.unsplash.com` URLs — do not download and re-host.** The API\n  guidelines require serving the URLs Unsplash returns (they're CDN-backed and support\n  `\u0026w=`/`\u0026q=` resizing params you can append).\n- **Error returns a `Hash`, success returns an `Array`** (for `search`). Guard with\n  `photos.is_a?(Hash) \u0026\u0026 photos[:error]` before iterating, or a bad key silently\n  becomes a `NoMethodError` in the view.\n- **Demo apps are capped at 50 requests/hour.** A 403 with \"Rate Limit Exceeded\" means\n  wait or apply for production. For static pages, fetch once and hardcode the URLs.\n- **`.env` edits need a container recreate**, not `docker compose restart` — restart\n  reuses the old environment. `down \u003cservice\u003e \u0026\u0026 up -d \u003cservice\u003e` (scoped to the one\n  service, so you don't take down the rest of the stack).\n- **Missing `UNSPLASH_ACCESS_KEY` fails as a 401 at request time**, not at boot. If\n  every call errors, verify the key is actually in the container's env (Layer 1's\n  `rails runner` check) before debugging the code.\n- **`alt_description` can be `nil`.** Fall back to your search query for the `alt`\n  attribute so accessibility/SEO don't suffer: `photo[:alt_description] || query`.\n- **Production approval requires the download trigger.** If you build a user-facing\n  photo picker (not just build-time sourcing) and want the 5,000 req/hour production\n  tier, Unsplash also requires hitting each photo's `links.download_location` endpoint\n  when a user selects a photo. Build-time page decoration doesn't need this.\n\n---\n\n## Files this pattern touches\n\n```\n.env                                     (add UNSPLASH_ACCESS_KEY, optional UNSPLASH_APP_NAME)\napp/services/unsplash_service.rb         (new — the class above, verbatim)\napp/views/\u003cwherever photos render\u003e.html.erb\n```\n\n## How to adapt to your schema\n\n1. **Static pages (most common):** run `UnsplashService.new.search(...)` once in the\n   console, paste the `url` + attribution links into the ERB. No runtime dependency.\n2. **Dynamic content (e.g. auto-illustrated records):** call the service in a\n   controller/job, cache the returned hash (e.g. a `jsonb` column or `Rails.cache`) so\n   you don't re-hit the API — and keep the attribution fields with the URL, they travel\n   together.\n3. **Different image sizes per context:** pass `size:` (`thumb` for cards, `regular`\n   for heroes) or append Unsplash's dynamic-resize params to the URL (`\u0026w=600\u0026q=75`).\n4. Safe to drop: `get_by_id` if you only ever search; `orientation` if layout doesn't\n   care.\n"}