API admin endpoints
The admin endpoints do what the admin screens do, with an admin key. They live under
/api/v1/admin/ and use the same code as the screens, so the same checks, e-mails, caches
and plugin hooks run.
API=https://example.com/api/v1KEY=sck_YOUR_ADMIN_KEYThe site owner makes the key and picks its scopes:
Set up the REST API.
Public keys, personal keys and access tokens get 403 here.
Scopes
Section titled “Scopes”Each endpoint needs one scope. A key holds only the scopes it was made with.
| Scope | Endpoints |
|---|---|
admin:listings |
/admin/listings |
admin:comments |
/admin/comments |
admin:conversations |
/admin/conversations, /admin/messages |
admin:users |
/admin/users |
admin:taxonomy |
/admin/categories, /admin/currencies, /admin/custom-fields, /admin/regions, /admin/cities, /admin/areas |
admin:settings |
/admin/settings, /admin/jobs |
admin:keys |
/admin/keys |
admin:webhooks |
/admin/webhooks, /admin/webhook-events |
A missing scope is 403 insufficient_scope. Any admin:* scope also lets the key read
public data.
Moderator keys
Section titled “Moderator keys”A key made for a moderator account can hold only admin:listings, admin:comments and
admin:conversations, and only while moderators may open those pages in the admin. Make one with
php oc-cli.php api:key:create --admin=<moderator> --scopes=admin:listings,admin:comments,admin:conversations.
The settings screen is for full admins.
Who is acting
Section titled “Who is acting”On an admin endpoint the request runs as the admin who owns the key. The activity log
records that admin. Listing actions are logged as API key #<id>.
On every other endpoint an admin key runs as nobody, so a public call never picks up an admin’s rights.
Rules for every endpoint
Section titled “Rules for every endpoint”- Answers and errors are as in the rest of the API:
{"data": …}, lists withmetaandlinks, errors as problem documents. - Writes take JSON.
PATCHis a merge patch: send only what changes. An unknown member is a422. - Creates answer
201with aLocation. Deletes answer204. Idempotency-Keyworks on writes. It is ignored on the four that hand out a secret: make a key, rotate a key, add a webhook endpoint, rotate its secret.- Writes count in the key’s write rate limit.
- Text is cleaned as the admin screens clean it.
Listings
Section titled “Listings”admin:listings. These show every listing, whatever its status, in the admin view.
A PATCH on a listing, comment or user saves its edit and its status changes together: when one
is refused, nothing is saved.
| Endpoint | Does |
|---|---|
GET /admin/listings |
Search. Filters: status (active, pending, disabled, expired, spam, unpaid; a comma list or repeated), user (ids) and category (ids or slugs, subcategories included; an id may name a category that is off), each a comma list or repeated as in search, q (title contains), include, locale, fields, limit, cursor, count. |
GET /admin/listings/{id} |
One listing. |
PATCH /admin/listings/{id} |
Edit any member of a listing, plus owner_id (null for none), contact_name, contact_email and expires_at (a day or an RFC 3339 date-time, which names its day in UTC; null for never). approved approves a listing that waits for moderation, or sends it back. blocked, spam and premium (no end date) set or clear each one. product sells it; see Selling a listing. |
DELETE /admin/listings/{id} |
Delete. |
POST /admin/listings/{id}/bump |
Move to the top of “newest first”. 409 for a listing that waits for payment. |
POST /admin/listings/{id}/publish |
Publish a listing that waits for payment, without charging the seller. 409 when it does not wait for payment. |
The admin view reads approved, blocked, spam and premium back under the same names;
status sums them up. A flag already as asked changes nothing. An unblock runs first and a
block last, whatever order the members come in. A status change answers 409 when the listing refuses it, such as
approving a blocked one; send blocked: false in the same call. A listing that waits for payment
(status: "unpaid") refuses approved: true and blocked: false with 409: publish it instead. The e-mails the screens send
go out after the change is saved.
curl -X PATCH $API/admin/listings/412 -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"approved": true}'
curl "$API/admin/listings?status=pending&limit=50" -H "Authorization: Bearer $KEY"Selling a listing
Section titled “Selling a listing”product on PATCH /admin/listings/{id} makes a listing buyable. An object sets its default
variant with the members sent: sku (up to 64 characters), price (a decimal such as "12.50";
null sells at the listing price), stock (null does not track it), weight in grams and
shipping_class (up to 40 characters). A member left out keeps its value. product: null stops
selling and removes the row.
Only an admin writes product. A user’s POST or PATCH that sends it is a 422. The admin view
reads the stock count back as product.variants[].stock; everyone else sees only in_stock.
A sale takes stock in one statement that refuses to go below zero, so two buyers cannot both take
the last unit.
curl -X PATCH $API/admin/listings/412 -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"product": {"sku": "MUG-1", "price": "9.50", "stock": 4}}'For options such as Colour and Size, send product.variants: one row per combination, each with
options (name => value) and the same members as above plus photo_id (one of the listing’s
photos). The rows replace every row the listing has; a row whose options match a stored one keeps
its id. Limits: 3 option names of up to 30 characters, 30 values of up to 40 characters per name,
100 rows, every row names every option, and no combination twice. Sending the default members
without variants turns the listing back into one default row.
curl -X PATCH $API/admin/listings/412 -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"product": {"variants": [ {"options": {"Colour": "Sunflower", "Size": "M"}, "price": "25", "stock": 3}, {"options": {"Colour": "Cherry", "Size": "M"}, "price": "25", "stock": 0}]}}'A kept row whose stock or selling price changes fires variant_stock_changed or
variant_price_changed with the variant id, listing id, old and new value.
Comments
Section titled “Comments”admin:comments.
| Endpoint | Does |
|---|---|
GET /admin/comments |
List. Filters: status (active, pending, disabled, spam), listing and user (ids), each a comma list or repeated, limit, cursor, count. |
GET /admin/comments/{id} |
One comment, with the author’s e-mail, approved and blocked. |
PATCH /admin/comments/{id} |
Edit title, body, author_name, author_email. approved: true approves it and tells the author; false holds it back. blocked: true blocks it; false unblocks it. |
DELETE /admin/comments/{id} |
Delete. |
As for listings, a flag already as asked changes nothing, and an unblock runs before an approval.
Conversations
Section titled “Conversations”admin:conversations. The same review as Listings → Messages, and it works while the site
switch for messaging is off. Moderators need the Messages page.
| Endpoint | Does |
|---|---|
GET /admin/conversations |
List, newest first. Filters: status (open, held, closed, reported), user (buyer or seller) and listing (ids), each a comma list or repeated, limit, cursor. |
GET /admin/conversations/{id} |
One conversation with every message, held ones included. |
POST /admin/conversations/{id}/close |
Close a reported conversation. 409 when it is not reported. |
POST /admin/conversations/{id}/clear-report |
Drop the report and open it again. 409 when it is not reported. |
GET /admin/messages |
Messages waiting for review, newest first. status takes held. |
GET /admin/messages/{id} |
One message, with its sender and state (sent, held, removed). |
POST /admin/messages/{id}/release |
Deliver a held message. 409 when it is not held. |
POST /admin/messages/{id}/remove |
Drop a held message. 409 when it is not held. |
There is no admin reply or edit. On a demo site the writes answer 403 demo_site.
admin:users.
| Endpoint | Does |
|---|---|
GET /admin/users |
List. Filters: q (e-mail, username or name starting with it), confirmed, blocked, locale, fields, limit, cursor, count. |
GET /admin/users/{id} |
One user, every member. |
PATCH /admin/users/{id} |
Edit the profile, email, username or password (at least 5 characters). A new password ends every sign-in and key of that user. confirmed marks the account confirmed, or not. blocked: true blocks the user, and their sign-ins and keys stop working. Both read back under the same names, and status (active, pending or disabled) sums them up. A flag already as asked changes nothing; an unblock runs before a confirm. |
DELETE /admin/users/{id} |
Delete the user with their listings, comments and saved searches. |
GET /admin/users/{id}/sessions |
The user’s live sign-ins. Their keys are at /admin/keys. |
DELETE /admin/users/{id}/sessions/{session} |
End one sign-in. |
curl -X PATCH $API/admin/users/23 -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"blocked": true}'curl -X DELETE $API/admin/users/23/sessions/9 -H "Authorization: Bearer $KEY"Categories, currencies, fields and locations
Section titled “Categories, currencies, fields and locations”admin:taxonomy.
| Endpoint | Does |
|---|---|
GET /admin/categories, GET /admin/categories/{id} |
Every category, enabled or not, with each language’s name, slug and description under translations. |
POST /admin/categories |
Add one. Needs translations. The slug is made from the name. Optional: parent_id, expiration_days, price_enabled, enabled. |
PATCH /admin/categories/{id} |
Edit. A new slug keeps the old one redirecting. apply_to_subcategories copies the expiry and price setting down. enabled: false on a top category takes its subcategories and their listings with it. |
DELETE /admin/categories/{id} |
Delete with its subcategories and their listings. A large one answers 202 with no body and no Location: it is hidden now and emptied in the background. Read GET /admin/categories/{id} until it answers 404. |
GET, PATCH, DELETE /admin/currencies/{code}, POST /admin/currencies |
Add, rename or change the symbol of, or delete a currency. A currency a listing uses, or the site default, cannot be deleted (409). |
GET, PATCH, DELETE /admin/custom-fields/{id}, POST /admin/custom-fields |
Custom fields: name, type, slug, required, searchable, options, categories. Deleting a field deletes its values. |
GET, PATCH, DELETE /admin/regions/{id}, POST /admin/regions |
Regions. POST takes country and name. A country that does not exist is a 422 on /country. |
GET, PATCH, DELETE /admin/cities/{id}, POST /admin/cities |
Cities. POST takes region_id and name. A region_id that does not exist is a 422 on /region_id. |
GET, PATCH, DELETE /admin/areas/{id}, POST /admin/areas |
City areas. POST takes city_id and name. A city_id that does not exist is a 422 on /city_id. |
Deleting a region, city or area deletes the places under it and the listings in them.
Each POST answers with a Location for the new item. Lists of categories, fields, countries and currencies for apps stay on the public routes.
curl -X POST $API/admin/categories -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"parent_id": 1, "translations": {"en_US": {"name": "Bikes"}}}'Settings and jobs
Section titled “Settings and jobs”admin:settings.
| Endpoint | Does |
|---|---|
GET /admin/settings |
The settings the API can change. |
PATCH /admin/settings |
Change some of them. All are saved, or none. Answers with the new values. |
GET /admin/jobs |
The background job queue: counts per status, and dead_letters, the newest jobs that gave up (limit, up to 200; 50 by default). |
A change goes through the settings screen’s own form, so its checks and save hooks run. A
refusal is a 422 with the screen’s message.
The settings you can read and change:
| Group | Members |
|---|---|
| Site | site_title, site_description, contact_email, language, currency, timezone, week_start, date_format, time_format, rss_items, latest_listings_at_home, results_per_page, selectable_parent_categories, contact_attachments |
| Comments | comments_enabled, comments_need_account, comments_captcha, comments_per_page, comments_notify_admin, comments_notify_seller |
| API | api_cors_origins, api_rate_limit_default, api_rate_limit_anon, api_rate_limit_write, api_listing_rate, api_cache_max_age, api_hide_phone, api_photo_urls, api_user_keys, api_registration |
Never exposed: keys, passwords, webhook secrets, the switch that turns the API off, and the private-network switch for webhooks. Those stay on the screen.
curl -X PATCH $API/admin/settings -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{"comments_enabled": true, "results_per_page": 24}'admin:keys. The same keys as Settings → API.
| Endpoint | Does |
|---|---|
GET /admin/keys, GET /admin/keys/{id} |
Keys, newest first. Never the secret. kind is admin, public or user. |
POST /admin/keys |
Make a key. Body: name, optional kind (admin by default, or public), scopes, expires_at (a day such as 2027-03-01, an RFC 3339 date-time, or a number of days such as 90d). A key that expires cannot make a key that outlives it: with no expires_at, the new key gets the same expiry. The answer has the token, once. |
POST /admin/keys/{id}/rotate |
Make a new key with the same kind, scopes and expiry, but never past the calling key’s expiry. The old one keeps working until you revoke it. Only for your own keys and public keys; a rotated public key becomes yours. Another admin’s key answers 403 not_owner. |
DELETE /admin/keys/{id} |
Revoke. Not another admin’s own key: that answers 403 not_owner. |
A key made here belongs to the admin who owns the calling key. It cannot hold a scope the
calling key lacks: 403 with the scopes named. A scope the site does not know is a 422 on /scopes/<n>.
Unlike the screen, these calls do not ask for a password. The key is the proof.
curl -X POST $API/admin/keys -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Stock sync", "scopes": ["admin:listings"], "expires_at": "90d"}'Webhooks
Section titled “Webhooks”admin:webhooks. Endpoints are the same as on Settings → API → Webhooks. What the site
sends is in Webhooks.
| Endpoint | Does |
|---|---|
GET /admin/webhook-events |
Every event an endpoint can subscribe to, plugin events too, but not ping (sent only by the test call): type, description, schema. |
GET /admin/webhooks, GET /admin/webhooks/{id} |
Endpoints, newest first. Never the secret. |
POST /admin/webhooks |
Add one. Body: url, events (at least one), optional description and enabled (true by default). The answer has the secret, once. |
PATCH /admin/webhooks/{id} |
Change url, events, description or enabled. enabled: true clears a pause and the failure count. |
DELETE /admin/webhooks/{id} |
Delete, and drop the deliveries still waiting. |
POST /admin/webhooks/{id}/rotate-secret |
A new secret, shown once. The old one also signs for 24 hours. |
POST /admin/webhooks/{id}/test |
Queue a ping. 202 with its message_id. |
GET /admin/webhooks/{id}/deliveries |
The endpoint and its deliveries still on the job queue, newest first (limit, up to 100). Delivered ones leave the queue. |
An endpoint has: id (ep_…), url, description, events, enabled, status
(active, paused or disabled), failures, last_status, last_attempt_at,
last_success_at, last_failure_at, paused_at, paused_reason, previous_secret_until,
created_by, created_at, updated_at. last_status is HTTP <code>, Timed out,
Could not connect or why the address was refused; it never quotes the network error.
A delivery has: job_id, message_id, type, status (pending, running or error, which
means it gave up), attempts, last_error, test, created_at, next_run_at.
A bad address, an unknown event or a site with 50 endpoints is a 422.
curl -X POST $API/admin/webhooks -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/shopclass", "events": ["listing.created", "listing.deleted"]}'{ "data": { "id": "ep_3f9c1a07b2d84e55", "url": "https://hooks.example.com/shopclass", "events": ["listing.created", "listing.deleted"], "enabled": true, "status": "active", "failures": 0, "secret": "whsec_Kq2c1n0b8mJf3VtY7wQz5uXr9sLd4HaE" }}Keep the secret. The site does not show it again.