REST API
The API speaks JSON over HTTPS. Reading needs a key, writing needs a signed-in user or an admin key, and signed webhooks tell your server when something changes.
| Base URL | https://example.com/api/v1 |
| Without friendly URLs | https://example.com/index.php?page=api&path=v1/… |
| Auth | Authorization: Bearer <key or token>, see Authentication |
| Errors | application/problem+json, see Errors |
| Every endpoint | Reference, the API explorer, openapi.json, or the site’s own /api/v1/openapi.json |
| Client code | openapi.json is OpenAPI 3.1: use a generator that reads 3.1, such as openapi-generator 7 or later. Webhooks are described there but not generated. |
The site owner turns the API on and makes your key:
Set up the REST API. The API is off by default, and once on it needs a
key or token for every call except sign-in, sign-up and GET /openapi.json. Writing needs a user account, not a public key.
Quick start
Section titled “Quick start”Set two variables. A public key (scp_…) is enough for everything below.
API=https://example.com/api/v1KEY=scp_YOUR_KEYThe site
Section titled “The site”curl -H "Authorization: Bearer $KEY" $API/{ "data": { "name": "Shopclass Dev", "description": "Shopclass is a free classified site.", "url": "https://example.com/", "api_version": "v1", "default_locale": "en_US", "locales": [{ "code": "en_US", "name": "English (US)", "direction": "ltr" }], "currency": "USD", "timezone": "Europe/Madrid", "friendly_urls": true, "features": { "users": true, "registration": true, "comments": true }, "api": { "registration": false, "personal_keys": true, "photo_urls": false }, "links": { "listings": "https://example.com/api/v1/listings", "categories": "https://example.com/api/v1/categories", "countries": "https://example.com/api/v1/countries", "currencies": "https://example.com/api/v1/currencies", "custom_fields": "https://example.com/api/v1/custom-fields", "openapi": "https://example.com/api/v1/openapi.json" } }}The api member tells an app what the site allows, so it can hide a button before the
call fails:
| Member | Meaning |
|---|---|
registration |
POST /users works: the site allows sign-up through the API and has registration on. |
personal_keys |
Users may make personal keys. |
photo_urls |
A new listing may name photos by web address (photo_urls). |
Categories
Section titled “Categories”curl -H "Authorization: Bearer $KEY" "$API/categories?tree=1&fields=id,slug,name,children"Flat by default, in display order, each with parent_id. tree=1 nests them under children.
GET /categories/{id-or-slug} returns one category with its custom fields, under custom_fields.
Search listings
Section titled “Search listings”curl -H "Authorization: Bearer $KEY" \ "$API/listings?category=for-sale&price_max=500&with_photos=1&sort=price&order=asc&limit=1&fields=id,title,price,category,seller"{ "data": [ { "id": 159, "title": "Vintage Hardcover Classics Collection (20 Books)", "category": { "id": 12, "slug": "books-magazines", "name": "Books - Magazines", "path": [{ "id": 1, "slug": "for-sale", "name": "For sale" }] }, "price": { "amount": "90.00", "currency": "USD", "formatted": "$ 90" }, "seller": { "id": 23, "name": "Michael Reed", "username": "michaelreed", "url": "https://example.com/user/profile/michaelreed" } } ], "meta": { "total": null, "limit": 1 }, "links": { "self": "https://example.com/api/v1/listings?category=for-sale&…", "next": "https://example.com/api/v1/listings?category=for-sale&…&cursor=eyJ2…" }}Filters for GET /listings:
| Parameter | Meaning |
|---|---|
q |
Search words (up to 200 characters). With q, the default sort is relevance. |
category |
Category ids or slugs. A slug path such as vehicles/cars names its last slug. Subcategories are included. An unknown one is a 422. |
country |
A two-letter code (DE) or a name. |
region, city, city_area |
An id or a name. |
user |
Seller ids. |
locale |
Search and return text in this locale, such as en_US. |
price_min, price_max |
Whole units of the site’s currency. |
with_photos, premium |
1 or true to keep only those. |
custom_field[<id>] |
A custom field value, such as custom_field[4]=Blue. Ids come from GET /custom-fields. Needs a category filter, and the field must be searchable in that category (a parent category’s fields count). An unknown id, or a field the category does not have, is a 422; the web search page ignores them. |
lat, lng, radius |
Distance search: a point, and optionally a radius in km (1 to 1000). A 422 while the site has distance search off. |
sort |
created (default), id, price, relevance or distance (nearest first; needs lat and lng). |
order |
desc (default) or asc. |
limit |
Page size, 1 up to the site’s maximum results per page. Above that is a 422. |
cursor |
Where the last page ended. Take it from links.next. |
count |
true to fill meta.total; otherwise it is null. |
fields |
Keep only these members: fields=id,title,price. id is always kept. |
include |
Switch on costly members: custom_fields (custom field values) and translations. |
A parameter the endpoint does not know is a 422, so a typo does not silently return everything.
Category, place and user filters take one value, a comma list (city=1187,3218) or the
parameter repeated.
One listing
Section titled “One listing”curl -H "Authorization: Bearer $KEY" "$API/listings/159?include=custom_fields,translations"Related: GET /listings/{id}/photos, GET /listings/{id}/photos/{photo},
GET /listings/{id}/comments (approved comments, oldest first, when comments are on),
GET /comments/{id} and GET /users/{id}/listings.
A pending, disabled or spam listing is a 404 except to its owner and keys with the admin view.
An expired listing answers with status: "expired", as its page shows it; search leaves it out.
Paging
Section titled “Paging”Follow links.next until it is null. Do not build cursors yourself; each one works for a week.
Every list has meta and links.self and links.next, including short ones answered whole, such
as categories or your keys: there meta.total and meta.limit are the item count and next is
null today. A later version may page them.
URL="$API/listings?limit=50"while [ "$URL" != "null" ]; do PAGE=$(curl -s -H "Authorization: Bearer $KEY" "$URL") echo "$PAGE" | jq -r '.data[].title' URL=$(echo "$PAGE" | jq -r '.links.next')done- A cursor is signed and only works with the filters, sort and order it was made for. Any
other use is a
400invalid_cursor. meta.totalisnullunless you sendcount=true, which counts all matches on the first page (it costs a query). Later pages of acreated,idorpricesort leave itnull.- A cursor is good for a week and its expiry is rounded up to the hour, so a page’s
links.nextandETagstay the same within the hour. limitdefaults to the site’s results per page. Locations default to 500, max 1000, and sendtotal: null.created,idandpricepage by position, so a new listing never shifts a page. Listings without a price come last when highest first, and first when lowest first.relevancepages by offset, since its score is not stored, and stops after 10,000 results. The last page then hasmeta.truncated: trueand nolinks.next.- A listing search answered by a search plugin may also carry
meta.facets.category(category id => count),meta.suggestion(a corrected query) andmeta.relaxed: true(a fuzzy match).
Saving bandwidth with ETags
Section titled “Saving bandwidth with ETags”Every successful GET carries an ETag. Send it back in If-None-Match. If nothing
changed you get 304 Not Modified and no body.
curl -i -H "Authorization: Bearer $KEY" $API/# ETag: "9a04037fc4f6b5e68d56cca62a681b9b"
curl -i -H "Authorization: Bearer $KEY" -H 'If-None-Match: "9a04037fc4f6b5e68d56cca62a681b9b"' $API/# HTTP/1.1 304 Not ModifiedAnswers for public keys also carry
Cache-Control: public, max-age=60, stale-while-revalidate=60, so a CDN may keep them.
The site owner sets the number of seconds. Reads with any other key, and openapi.json, are private, no-cache.
Reads of account*, admin/* and */sessions are private, no-store, and so is every write.
To avoid overwriting a change made meanwhile, send the ETag of your last GET in If-Match on a
PUT, PATCH or DELETE: see Writes.
Quick start: writing
Section titled “Quick start: writing”Writes act for a user. This walkthrough signs in, uploads a photo, posts a listing, edits it
and deletes it. jq picks values out of the answers.
API=https://example.com/api/v11. Sign in
Section titled “1. Sign in”TOKEN=$(curl -s -X POST $API/auth/token -H "Content-Type: application/json" \ | jq -r '.access_token')The answer also has a refresh_token. The access token lives 15 minutes. When calls answer
401 token_expired, swap the refresh token for new ones:
Authentication.
2. Upload a photo
Section titled “2. Upload a photo” | jq -r '.data.token')The answer is 200, with no Location. The token lasts two hours and works for this user only.
3. Post the listing
Section titled “3. Post the listing”curl -i -X POST $API/listings \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d "{\"category_id\":12,\"title\":\"Vintage books\",\"description\":\"20 hardcovers.\", \"price\":\"90.00\",\"currency\":\"USD\",\"photo_tokens\":[\"$PHOTO\"]}"HTTP/1.1 201 CreatedLocation: https://example.com/api/v1/listings/412The body is {"data": {…the listing…}}. If the site checks new listings first, data.status is
pending and a warnings list says listing_pending.
4. Edit it
Section titled “4. Edit it”Send only what changes, as a JSON merge patch. An empty body ({}) changes nothing and answers
200 with the current resource (not on PATCH /admin/areas/{id}, where name is required).
curl -X PATCH $API/listings/412 \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/merge-patch+json" \ -d '{"price":"75.00"}'5. Delete it
Section titled “5. Delete it”curl -i -X DELETE $API/listings/412 -H "Authorization: Bearer $TOKEN" # 204 No ContentRetrying a write
Section titled “Retrying a write”Send an Idempotency-Key on a POST and retry the same call with the same key:
see Writes.
More: comments, saved searches, photo uploads, limits and warnings are in Writes.
Response shape
Section titled “Response shape”One thing:
{ "data": { "id": 27, "name": "James Foster" } }A list:
{ "data": [ ], "meta": { "total": null, "limit": 12 }, "links": { "self": "https://…", "next": null }}Rules that hold everywhere:
| Rule | Detail |
|---|---|
| Names | snake_case. |
| Ids | Integers. Countries and currencies use their code, such as "DE", "EUR"; webhook endpoints a string such as "ep_3f…". |
| Times | RFC 3339 in UTC: 2026-07-05T01:47:36Z. A date you send is a day, 2026-07-05, or an RFC 3339 date-time. |
| Money | {"amount": "90.00", "currency": "USD", "formatted": "$ 90"}. The amount is a decimal string. price is null when the listing or its category has no price. |
| Missing values | null, never "". |
| URLs | Absolute. |
| Booleans | true and false. |
| Text | Plain text: Tom & Jerry, not Tom & Jerry. Escape it before you put it in HTML. A listing’s description is the one exception: an HTML fragment the site has already cleaned. |
| Unknown members | Ignore them. New ones can appear in v1. |
A listing has: id, url, status, title, description, locale, category
(id, slug, name, path), price, location (country, region, city,
city_area, address, zip, lat, lng), contact (name, email, phone), seller
(id, name, username, url) or null, photos (id, thumbnail, preview,
normal, original), premium, views, published_at, updated_at, expires_at.
custom_fields and translations appear with include=. original is null unless the site keeps
original photos.
Who sees which members is in Authentication.
Plugins can add data under an ext member: Plugin endpoints.
The API does not count views.
Versioning
Section titled “Versioning”The version is in the path: /api/v1. Inside v1 changes only add things: new members, new
endpoints, new optional parameters. Removing or retyping a member, or changing a status
or error code, would be a new version, served beside v1 for at least 12 months. Endpoints
that do not change answer under both. How old endpoints are retired is in the
API changelog.
- Authentication: keys, sign-in, scopes, limits
- Writes: listings, photos, comments, saved searches
- Admin endpoints: moderate and manage a site with an admin key
- Webhooks: get a signed POST when something changes
- Errors
- Reference
- Plugin endpoints: add your own