Skip to content

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.

Set two variables. A public key (scp_…) is enough for everything below.

Terminal window
API=https://example.com/api/v1
KEY=scp_YOUR_KEY
Terminal window
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).
Terminal window
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.

Terminal window
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.

Terminal window
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.

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.

Terminal window
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 400 invalid_cursor.
  • meta.total is null unless you send count=true, which counts all matches on the first page (it costs a query). Later pages of a created, id or price sort leave it null.
  • A cursor is good for a week and its expiry is rounded up to the hour, so a page’s links.next and ETag stay the same within the hour.
  • limit defaults to the site’s results per page. Locations default to 500, max 1000, and send total: null.
  • created, id and price page by position, so a new listing never shifts a page. Listings without a price come last when highest first, and first when lowest first.
  • relevance pages by offset, since its score is not stored, and stops after 10,000 results. The last page then has meta.truncated: true and no links.next.
  • A listing search answered by a search plugin may also carry meta.facets.category (category id => count), meta.suggestion (a corrected query) and meta.relaxed: true (a fuzzy match).

Every successful GET carries an ETag. Send it back in If-None-Match. If nothing changed you get 304 Not Modified and no body.

Terminal window
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 Modified

Answers 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.

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.

Terminal window
API=https://example.com/api/v1
Terminal window
TOKEN=$(curl -s -X POST $API/auth/token -H "Content-Type: application/json" \
-d '{"grant_type":"password","username":"[email protected]","password":"…","label":"curl"}' \
| 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.

Terminal window
PHOTO=$(curl -s -X POST $API/photos -H "Authorization: Bearer $TOKEN" -F [email protected] \
| jq -r '.data.token')

The answer is 200, with no Location. The token lasts two hours and works for this user only.

Terminal window
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 Created
Location: https://example.com/api/v1/listings/412

The body is {"data": {…the listing…}}. If the site checks new listings first, data.status is pending and a warnings list says listing_pending.

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).

Terminal window
curl -X PATCH $API/listings/412 \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/merge-patch+json" \
-d '{"price":"75.00"}'
Terminal window
curl -i -X DELETE $API/listings/412 -H "Authorization: Bearer $TOKEN" # 204 No Content

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.

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 &amp; 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.

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.