Skip to main content

Getting Started

Base URL

All API requests go to:

https://api.alamy.com/v3

Requests are authenticated with an OAuth 2.0 access token obtained from POST /token using the client credentials grant. The sections below cover getting the key and secret you exchange for that token.

Get your API key and secret

To get up and running with the API you will need to acquire your credentials by logging into Alamy's website and visiting https://www.alamy.com/api-partnerships/

Click on the account icon at the top right of the page

Account icon at the top right of the Alamy website

Log into the website using the credentials provided by the account set up process for your API user.

Once logged in you will see the green button on the page change to show your credentials.

The green credentials button shown on the page once logged in

Click that and it will display your API key and secret. These are the credentials you exchange for an access token at POST /token, using the OAuth 2.0 client credentials grant.

API key and secret displayed after clicking the credentials button

Exchange them for an access token

Send the key and secret to POST /token as HTTP Basic credentials — the key is the username and the secret is the password, in that order — with grant_type=client_credentials. Read them from the environment rather than pasting them into a command, so they stay out of your shell history:

export ALAMY_API_KEY=... # your API key
export ALAMY_API_SECRET=... # your API secret

curl -X POST https://api.alamy.com/v3/token \
-u "$ALAMY_API_KEY:$ALAMY_API_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials'
{
"access_token": "eyJraWQiOi...",
"expires_in": 86400,
"token_type": "Bearer"
}

Sending the key and secret as client_id and client_secret form fields works too, if Basic auth is awkward in your HTTP client.

Scopes

Each endpoint requires a scope. Scopes are full URIs, not short names — search on its own is rejected with invalid_scope:

ScopeGrants access to
https://api.alamy.com/v3/scopes/searchSearch
https://api.alamy.com/v3/scopes/itemItem and bulk items
https://api.alamy.com/v3/scopes/downloadDownload
https://api.alamy.com/v3/scopes/feedFeed
https://api.alamy.com/v3/scopes/ordersOrder history

Which scopes your credentials can obtain depends on your subscription.

Any combination is allowed — separate them with spaces. A read-only token for browsing content would be search plus item; add download only where purchases happen:

curl -X POST https://api.alamy.com/v3/token \
-u "$ALAMY_API_KEY:$ALAMY_API_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=https://api.alamy.com/v3/scopes/search https://api.alamy.com/v3/scopes/item'

Omit scope entirely and the token carries every scope your credentials are granted — the simplest choice for a general-purpose integration.

Use scopes to limit what a token can do

Requesting scopes narrows the token to exactly the combination you asked for; it never adds access you were not already granted. That makes it worth using deliberately:

  • Mint a search + item token for the part of your system that browses content, and it cannot spend money even if the token leaks — /download will return 401.
  • Mint a download-scoped token only in the code path that completes a purchase.
  • Mint a feed-only token for the background worker that ingests change notifications.

The trade-off is that a narrowed token returns 401 Unauthorized on any endpoint outside its scopes, and the body does not say why. If a call fails with 401 on a token you know is current, decode its scope claim first.

Token lifetime

expires_in is 86400 seconds — 24 hours. The token is a JWT, so you can read its exp claim instead of tracking expires_in yourself.

Tokens are reused for their lifetime rather than minted per request. A token obtained without a scope parameter is cached with the scopes you held at the time, so a scope added to your credentials afterwards will not appear until that token expires — up to 24 hours later. It surfaces as a 401 from the new endpoint rather than an error when fetching the token.

To pick up a new scope immediately, request one explicitly. Because an explicit request narrows the token, list every scope you need in that one call, not just the new one:

curl -X POST https://api.alamy.com/v3/token \
-u "$ALAMY_API_KEY:$ALAMY_API_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=https://api.alamy.com/v3/scopes/search https://api.alamy.com/v3/scopes/item https://api.alamy.com/v3/scopes/download https://api.alamy.com/v3/scopes/feed https://api.alamy.com/v3/scopes/orders'

Asking for only the newly granted scope would replace a token missing it with one that has nothing else.

Make your first authenticated call

Put the access_token from the previous step in ALAMY_TOKEN and send it as a bearer token. Search and item metadata calls are free:

curl -H "Authorization: Bearer $ALAMY_TOKEN" \
'https://api.alamy.com/v3/search?q=dog&limit=1'

The response is a search result set whose items are IPTC NinJS 2.0 documents. See Guidance for what the fields mean.

A complete first integration

The whole sequence — token, search, metadata, and where a download would go — in one runnable script. The token is deliberately minted with only the search and item scopes, so nothing here touches the download endpoints.

Search and item metadata are free. A download is different: the GET is logged as a download against your account, and the licensed POST decrements your pack. There is no preview or dry-run mode for either, so the download step below is left commented out — see Download and Testing without spending production budget for how to exercise it against a pre-production account instead.

curl

Needs jq to read the JSON responses:

#!/usr/bin/env bash
set -euo pipefail

BASE=https://api.alamy.com/v3

# 1. Token. Valid for 24 hours - cache it rather than minting one per request.
TOKEN=$(curl -sS -X POST "$BASE/token" \
-u "$ALAMY_API_KEY:$ALAMY_API_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=https://api.alamy.com/v3/scopes/search https://api.alamy.com/v3/scopes/item' \
| jq -r .access_token)

# 2. Search. Free. Take the seq id of the first result.
ID=$(curl -sS -H "Authorization: Bearer $TOKEN" \
"$BASE/search?q=golden+retriever&limit=1" \
| jq -r '.items[0].altids[] | select(.role == "seq") | .value')

# 3. Complete metadata for that item. Free.
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/item/$ID" \
| jq '{uri, by, usageterms}'

# 4. Download. Logged against your account, so it is left commented out - and
# the token above has no download scope, so it would return 401 anyway.
# curl -sS -H "Authorization: Bearer $DOWNLOAD_TOKEN" "$BASE/download/$ID"

JavaScript

Node 18 or later, no dependencies. Save it as .mjs, or the top-level await will not parse:

const BASE = 'https://api.alamy.com/v3';
const SCOPES = [
'https://api.alamy.com/v3/scopes/search',
'https://api.alamy.com/v3/scopes/item',
].join(' ');

// 1. Token. Valid for 24 hours - cache it rather than minting one per request.
const basic = Buffer.from(
`${process.env.ALAMY_API_KEY}:${process.env.ALAMY_API_SECRET}`,
).toString('base64');

const tokenResponse = await fetch(`${BASE}/token`, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'client_credentials', scope: SCOPES }),
});
// A wrong key or secret fails here, as 400 invalid_client - not later as a 401.
if (!tokenResponse.ok) throw new Error(`token: ${await tokenResponse.text()}`);
const { access_token: token } = await tokenResponse.json();

async function get(path) {
const response = await fetch(`${BASE}${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
// Branch on the status, not the message text: see the Errors page.
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return response.json();
}

// 2. Search. Free. Take the seq id of the first result.
const results = await get('/search?q=golden+retriever&limit=1');
const id = results.items[0].altids.find((altid) => altid.role === 'seq').value;

// 3. Complete metadata for that item. Free.
const item = await get(`/item/${id}`);
console.log(item.uri, item.by, item.usageterms ?? '(no usage restrictions)');

// 4. Download. Logged against your account, so it is left commented out - and
// the token above has no download scope, so it would return 401 anyway.
// const { url, expires_in_seconds } = await get(`/download/${id}`);

Python

Uses requests (pip install requests):

import os

import requests

BASE = "https://api.alamy.com/v3"
SCOPES = " ".join(
[
"https://api.alamy.com/v3/scopes/search",
"https://api.alamy.com/v3/scopes/item",
]
)

# 1. Token. Valid for 24 hours - cache it rather than minting one per request.
token_response = requests.post(
f"{BASE}/token",
auth=(os.environ["ALAMY_API_KEY"], os.environ["ALAMY_API_SECRET"]),
data={"grant_type": "client_credentials", "scope": SCOPES},
timeout=30,
)
# A wrong key or secret fails here, as 400 invalid_client - not later as a 401.
token_response.raise_for_status()
token = token_response.json()["access_token"]

session = requests.Session()
session.headers["Authorization"] = f"Bearer {token}"


def get(path):
response = session.get(f"{BASE}{path}", timeout=30)
# Branch on the status, not the message text: see the Errors page.
response.raise_for_status()
return response.json()


# 2. Search. Free. Take the seq id of the first result.
results = get("/search?q=golden+retriever&limit=1")
item_id = next(a["value"] for a in results["items"][0]["altids"] if a["role"] == "seq")

# 3. Complete metadata for that item. Free.
item = get(f"/item/{item_id}")
print(item["uri"], item["by"], item.get("usageterms", "(no usage restrictions)"))

# 4. Download. Logged against your account, so it is left commented out - and
# the token above has no download scope, so it would return 401 anyway.
# signed = get(f"/download/{item_id}")

To complete step 4 for real, mint a second token that includes https://api.alamy.com/v3/scopes/download in the one code path that downloads, and run it against pre-production credentials while you are still building. The response is a short-lived signed url plus expires_in_seconds; follow it rather than storing it, and see Download for what to store instead.

Where to go next

  • Guidance — how each endpoint behaves, request limits, and how to read the metadata and rights in responses.
  • Errors — every status code the API returns, and which are worth retrying.
  • AI coding tools — the machine-readable contract, a read-only setup, and testing a download path safely.
  • API Reference — the full endpoint reference, generated from the OpenAPI description.