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 }, "cart": { "max_lines": 50, "max_qty": 20 }, "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, spam or unpaid 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. GET /listingsalso takespage(1 or more) for numbered pages, with any sort and not withcursor.meta.pageis the page and, withcount=true,meta.pagesis how many there are. Past the deepest page (10,000 listings) it is a422page_too_deep.meta.totalisnullunless you sendcount=true, which counts all matches on the first page (it costs a query). Later pages of acreated,idorpricesort leave itnull.- On a listing search,
count=truealso sendsmeta.total_capped. When it is true the count stopped at the site’s search count limit (Listings → Settings):totalis that limit, and more listings match. - 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. A site that sells listing slots may answer unpaid, with a warnings code listing_unpaid and a payment member: see Publish a listing.
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.
From PHP, inside the site
Section titled “From PHP, inside the site”A theme or plugin can call any route without HTTP with osc_api_get() and osc_api_call().
They work with the API switched off, need no key, and act as the signed-in visitor (or the
admin on an admin page). Admin routes from a public page answer 403. A signed-in visitor or admin
only reads this way: a write answers 403 wrong_credential, so it must come from the browser
with its page token.
$page = osc_api_get('listings', ['limit' => 12]);if ($page['status'] === 200) { foreach ($page['body']['data'] as $listing) { /* ... */ }}$seo = osc_api_call('GET', 'seo', ['for' => 'listing', 'id' => 12]);Both return ['status' => int, 'body' => array, 'headers' => array]. A problem comes back as its
status and body, never as an exception. The api_request_before hook still fires.
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