API
API reference
Read your gallery's artworks, artists, exhibitions and more from your own website or another system.
A read-only HTTP API over your gallery’s data. Use it to build your own website, sync an existing one, or feed another system.
This page is written for whoever is doing the wiring — you can send it to a developer, they don’t need an ArtPanel account to read it.
Base URL
https://api.artpanel.app/functions/v1/api/v1
Every response is JSON. Every endpoint is GET; anything else returns 405.
Authentication#
Send the API key as a bearer token.
curl -H "Authorization: Bearer sk_live_your_key_here" \
"https://api.artpanel.app/functions/v1/api/v1/artworks?limit=5"
Keys are created in the panel under Settings → API. The key is shown once, at creation, and cannot be recovered afterwards — only replaced.
Keep the key on a server#
The key can read gallery data. Never put it in a browser or a mobile app — anything shipped to a visitor’s device can be read by that visitor.
The normal arrangement is a small server-side proxy: the browser calls your own backend, your backend adds the key and calls ArtPanel. Every serverless host supports this in a few lines.
Keys expire#
Keys carry an expiry chosen when they’re created — from 7 days to 2 years, or never. An expired key returns 401, the same as an invalid one.
To rotate without downtime, use Rotate in the panel: it issues the replacement and gives the old key a grace period, so both work while you deploy.
Scopes#
Each key carries a set of scopes, and each endpoint requires exactly one. Requesting an endpoint the key lacks returns 403, naming the missing scope.
| Scope | Opens |
|---|---|
artists:read | /artists |
artworks:read | /artworks |
exhibitions:read | /exhibitions |
viewing_rooms:read | /viewing-rooms |
collections:read | /collections |
contacts:read | /contacts |
deals:read | /deals |
invoices:read | /invoices |
payments:read | /payments |
loans:read | /loans |
club:read | /club, /club-tiers |
website:read | /website |
Grant only what’s needed. A public website almost never needs contacts:read, deals:read, invoices:read or payments:read — those cover client records and money.
Rate limits#
600 requests per minute, per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
Over the limit returns 429 with Retry-After in seconds:
{
"error": "Rate limit exceeded",
"detail": "This API key is limited to 600 requests per 60s.",
"retry_after_seconds": 23
}
If you’re near the limit for a public website, cache at your proxy rather than asking for a raise — gallery data changes a few times a day.
Errors#
| Status | Meaning |
|---|---|
401 | Missing, malformed, revoked or expired key |
403 | Valid key, but it lacks the scope for this endpoint |
404 | Unknown resource, or no record with that id |
405 | Method other than GET |
429 | Rate limited |
500 | Server error |
Pagination#
| Parameter | Default | Max |
|---|---|---|
limit | 100 | 500 |
offset | 0 | — |
{
"data": [ ... ],
"pagination": { "limit": 100, "offset": 0, "total": 63 }
}
total is the full count matching the query, not the size of the page — so you can page until offset + limit >= total.
Fetching one record#
Every collection also serves a single record:
GET /<resource>/<id>
The id may be the UUID or the short id — the short one appears in ArtPanel’s own public URLs, so it’s usually the one already to hand.
curl -H "Authorization: Bearer $KEY" ".../artworks/yTaMbsyy"
data is the record itself, not a one-element array. A miss returns 404 with a code you can branch on:
{ "code": "not_found", "error": "No artworks with id 'abc123'" }
Viewing rooms and club tiers have no short id — address those by UUID.
Resources#
GET /artists#
Sorted by display name. Biography, statement, dates, nationality, links, portrait, education, awards, exhibition history.
GET /artworks#
Sorted newest first. Title, medium, materials, year, dimensions, framing, price, currency, status, images, edition details, and an embedded artist.
Filter: ?status=available
GET /exhibitions#
Sorted by start date, newest first.
status is derived from the dates, not from the internal workflow state — always one of upcoming, current, past. Filter on those:
curl -H "Authorization: Bearer $KEY" ".../exhibitions?status=current"
Each exhibition embeds artists and artworks.
Responses also include
exhibition_artistsandexhibition_artworks, wrapped as[{ "artist": {…} }], for consumers written against the older endpoints. Prefer the flat arrays — the wrapped keys will be removed in a future version.
GET /viewing-rooms#
Published rooms only. Drafts are never served, and access tokens are never included in any response.
GET /collections#
Curated groupings, each embedding an artworks array of thin references.
GET /club#
Four sections in one response:
{
"data": [ /* membership tiers */ ],
"program": { /* name, tagline, contact */ },
"events": [ /* member gatherings, by date */ ],
"collection": [ /* works offered to members */ ]
}
If a section fails it returns empty rather than failing the whole request. GET /club-tiers returns just the tiers as a normal paginated collection.
GET /website#
Not a list — one object describing what the gallery chose to show:
{
"data": {
"exhibitions": { "hidden": [], "homeOrder": [], "order": [], "overrides": {} },
"artists": { "hidden": [], "order": [] },
"content": { "about": {}, "contact": {}, "footer": {} }
},
"updated_at": "2026-08-11T20:31:00Z"
}
Fetch the entities from their own endpoints, then apply hidden and order from here. Every key is always present, so you never branch on null.
What it does not decide: current vs past. That’s a fact about dates, and /exhibitions already serves it as status.
GET /contacts, /deals, /invoices, /payments, /loans#
Business records, each behind its own scope. Internal fields are never served — private notes, internal ratings, engagement scores, tax identifiers, dates of birth, credit limits, cost and acquisition prices, owner and consignor identities, storage locations.
Filters: status on invoices and payments, stage on deals.
What is never returned#
The API serves through an allowlist defined in the database, not a list of fields to hide. A column added to a table does not appear here until it’s deliberately added to the public contract.
- Nothing soft-deleted. Delete a record in the panel and it leaves the API immediately, including where it’s embedded inside another record.
- No unpublished viewing rooms, and no access tokens for any room.
- No internal financials — cost, purchase price, minimum price.
- No private notes, internal ratings, or engagement scores.
- No ownership or custody detail — owner, consignor, storage location.
- No credentials, of any kind.
Versioning#
The version is in the path: /v1/.
Within v1 fields, resources and query parameters may be added. Parse defensively and ignore what you don’t recognise. Nothing will be removed or renamed in v1, and no field will change meaning — anything requiring that ships as /v2, with /v1 kept working alongside.
GET /_meta#
Reports what’s deployed right now:
{
"service": "artpanel-public-api",
"version": "v1",
"commit": "938deeed25d463d0f5a128044569cb71cddaa216",
"deployed_at": "2026-08-12T21:45:22Z",
"resources": ["artists", "artworks", "..."]
}
Useful for confirming a change is live, and for discovering which resources the deployment serves.
A worked example#
A website showing current exhibitions, respecting the gallery’s curation:
const KEY = process.env.ARTPANEL_API_KEY; // server-side only
const BASE = "https://api.artpanel.app/functions/v1/api/v1";
const get = async (path) => {
const res = await fetch(`${BASE}/${path}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 5);
await new Promise((r) => setTimeout(r, wait * 1000));
return get(path);
}
if (!res.ok) throw new Error(`ArtPanel ${res.status}: ${await res.text()}`);
return res.json();
};
const [shows, curation] = await Promise.all([
get("exhibitions?status=current&limit=100"),
get("website"),
]);
const { hidden, order } = curation.data.exhibitions;
const visible = shows.data
.filter((e) => !hidden.includes(e.id))
.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
Troubleshooting#
401 on every request. The key is expired or revoked. Check its status in Settings → API — the Expires column shows the date, and an expired key reads “Expired”.
403 on one endpoint. The key is valid but lacks that scope. Scopes are set when the key is created; create a new key with the scope you need.
A record still appears after deleting it. It shouldn’t — deleted records leave the API immediately. If your site still shows it, the cache is between you and us, not us.
Checking whether it’s live at all. GET /_meta needs a valid key and returns the deployed version. If that works and something else doesn’t, the problem is the scope or the resource name, not the key.
Did this answer your question?
Sorry about that — what were you looking for?
Still stuck? Email support@artpanel.app.
Thanks — that helps us keep these guides useful.