Unsplash
Category: Stock Images
Integration type: Platform-level API key
External API: Unsplash API v1
Purpose
Unsplash provides high-quality professional photography free for commercial use under the Unsplash License. It complements Pixabay with a more curated, higher-resolution image library — better suited for blog featured images, hero sections, and premium client work.
Unsplash is a platform-level integration. The free API tier allows 50 requests/hour; production apps can apply for higher limits.
Config Structure
Platform config (env vars)
UNSPLASH_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # From unsplash.com/oauth/applications
UNSPLASH_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # For OAuth (not needed for search)
UNSPLASH_API_BASE_URL=https://api.unsplash.comUnsplash uses the Authorization: Client-ID <access_key> pattern for API calls.
Integration Pattern
Tool layer (packages/tools/src/unsplash.ts)
import axios from 'axios';
class UnsplashTool {
constructor(
private accessKey: string,
private baseUrl: string = 'https://api.unsplash.com',
) {}
private headers() {
return { Authorization: `Client-ID ${this.accessKey}` };
}
async searchPhotos(options: {
query: string;
page?: number; // Default: 1
perPage?: number; // 1–30, Default: 10
orderBy?: 'relevant' | 'latest';
orientation?: 'landscape' | 'portrait' | 'squarish';
color?: string; // e.g. 'black_and_white', 'blue', 'green'
contentFilter?: 'low' | 'high'; // Default: 'low' (SFW)
}): Promise<UnsplashSearchResult> {
const response = await axios.get(`${this.baseUrl}/search/photos`, {
headers: this.headers(),
params: {
query: options.query,
page: options.page ?? 1,
per_page: options.perPage ?? 10,
order_by: options.orderBy ?? 'relevant',
orientation: options.orientation,
color: options.color,
content_filter: options.contentFilter ?? 'low',
},
});
return {
total: response.data.total,
totalPages: response.data.total_pages,
photos: response.data.results.map((p: any) => this.mapPhoto(p)),
};
}
async getPhoto(photoId: string): Promise<UnsplashPhoto> {
const response = await axios.get(`${this.baseUrl}/photos/${photoId}`, {
headers: this.headers(),
});
return this.mapPhoto(response.data);
}
async getRandomPhoto(options: {
query?: string;
orientation?: 'landscape' | 'portrait' | 'squarish';
count?: number; // 1–30
} = {}): Promise<UnsplashPhoto[]> {
const response = await axios.get(`${this.baseUrl}/photos/random`, {
headers: this.headers(),
params: {
query: options.query,
orientation: options.orientation,
count: options.count ?? 1,
},
});
const data = Array.isArray(response.data) ? response.data : [response.data];
return data.map(p => this.mapPhoto(p));
}
// Trigger a download event — required by Unsplash API guidelines when an image is actually used
async triggerDownload(downloadUrl: string): Promise<void> {
await axios.get(downloadUrl, { headers: this.headers() });
}
private mapPhoto(p: any): UnsplashPhoto {
return {
id: p.id,
description: p.description ?? p.alt_description,
width: p.width,
height: p.height,
color: p.color, // Dominant colour as hex
urls: {
raw: p.urls.raw, // Original, unprocessed
full: p.urls.full, // Full resolution JPEG
regular: p.urls.regular, // ~1080px wide
small: p.urls.small, // ~400px wide
thumb: p.urls.thumb, // ~200px wide
},
photographer: {
name: p.user.name,
username: p.user.username,
profileUrl: p.user.links.html,
},
downloadUrl: p.links.download_location, // For triggering download event
pageUrl: p.links.html,
};
}
async verify(): Promise<void> {
const response = await axios.get(`${this.baseUrl}/me`, {
headers: this.headers(),
});
// /me returns 401 for Client-ID auth — that's expected; check rate limit headers instead
// A 200 on /photos proves the key works
const testResponse = await axios.get(`${this.baseUrl}/photos`, {
headers: this.headers(),
params: { per_page: 1 },
});
if (!Array.isArray(testResponse.data)) {
throw new Error('Unsplash API verification failed');
}
}
}Required: trigger download on use
Unsplash API guidelines require that when a photo is actually used (not just searched), you call the download_location URL. This lets Unsplash track photo usage and credit photographers:
// When a photo is selected and about to be embedded
const photo = await unsplash.getPhoto(selectedPhotoId);
await unsplash.triggerDownload(photo.downloadUrl);
// Then download and store
const imageBuffer = await axios.get(photo.urls.regular, { responseType: 'arraybuffer' });
const s3Key = `media/${tenantId}/unsplash/${activityId}/${photo.id}.jpg`;
await s3.upload({ key: s3Key, body: Buffer.from(imageBuffer.data), contentType: 'image/jpeg' });Attribution Requirements
The Unsplash License is free for commercial use but requires photo credit in a reasonable place:
Photo by Photographer Name on Unsplash
For blog posts, this should be added as a caption or footer credit. The photographer object in UnsplashPhoto contains all attribution fields.
Pixabay vs Unsplash — Selection Guide
| Use case | Preferred source |
|---|---|
| Blog featured image (premium look) | Unsplash |
| Social post background (quick) | Pixabay |
| Illustrations / vectors | Pixabay only |
| Very specific niche subject | Pixabay (larger database) |
| High-resolution (print quality) | Unsplash |
| Zero attribution obligation | Pixabay (stricter commercial freedom) |
The agent can be instructed to prefer one over the other per content type. The default search order is: Unsplash first for blog posts; Pixabay first for social and GBP posts.
Test Cases
Unit tests (packages/tools/src/unsplash.test.ts)
| Test | Approach |
|---|---|
searchPhotos() sends Client-ID auth header | Mock axios.get; assert Authorization: Client-ID ... |
searchPhotos() maps all URL variants | Mock urls object; assert regular, full, thumb |
searchPhotos() maps alt_description when description is null | Mock { description: null, alt_description: 'A dog' } |
triggerDownload() GETs the download location URL | Assert called with downloadUrl |
getRandomPhoto() wraps single photo in array | Mock non-array response; assert [photo] returned |
verify() calls /photos endpoint | Assert GET to /photos with per_page: 1 |
| Throws on 403 (invalid access key) | Mock 403; assert error |
Rate Limits
| Plan | Rate limit |
|---|---|
| Demo | 50 requests/hour |
| Production (approved) | 5000 requests/hour |
Apply for production access via the Unsplash developer portal after launching. Demo mode watermarks images in the API response; production mode does not.
Related
- Pixabay Provider — complementary free image source
- Google Gemini Provider — AI-generated images when stock photos don’t fit
- DALL-E Provider — OpenAI image generation
- AWS S3 Provider — image storage after download