Skip to content

API authentication

Send a credential in the Authorization header of every call.

Terminal window
curl -H "Authorization: Bearer scp_YOUR_KEY" https://example.com/api/v1/listings

There are five kinds of credential:

Credential Looks like For Made by
Public key scp_… An app or page that only reads. The site owner
Admin key sck_… A script or server of the site owner. The site owner
Access token sca_… An app acting for a signed-in user. Lives 15 minutes. POST /auth/token
Personal key sck_… A user’s own script. The user
Same-site session scs_… page token plus the sign-in cookie Theme JavaScript on the site’s own pages, as the signed-in user. osc_api_session_meta()

The site owner makes public and admin keys: Set up the REST API. Writing as a user (listings, comments, saved searches, the account) needs an access token, a personal key or, from the site’s own pages, a same-site session. Admin keys run the admin endpoints.

Key Prefix Held by May hold
Public key scp_ An app or a web page. Safe to ship in client code. listings:read only
Admin key sck_ A script or server of the site owner. Keep secret. listings:read and the admin:* scopes
Personal key sck_ A user’s script. Keep secret. Some of that user’s scopes, see Personal keys

A key is <prefix><16 characters>.<64 hex digits>. The secret half is stored hashed, so a lost key cannot be recovered.

How a call is accepted:

Rule Detail
Header Authorization: Bearer <credential>. Any other scheme, such as Basic auth, is ignored.
Query string ?api_key=scp_… works for public keys on GET and HEAD only. Any other key or token there is a 401, on every endpoint, public ones too. See the warning below.
Cookies Never used alone. Only a same-site session reads the sign-in cookie, and only with a page token in its header. The API sets no cookie.
No credential 401 on every endpoint except POST /auth/token and POST /users.
GET /openapi.json None needed.

Every refusal of a key answers the same 401 unauthorized, whether the key is unknown, has the wrong secret, is revoked, expired, or its owner is gone. A caller cannot tell them apart.

A key stops working when it is revoked, when it expires, or when its owner is disabled or deleted. Its scopes also shrink to what its owner may hold today: demote an admin and the admin keys they made lose their admin scopes.

A user signs in with their password and gets two tokens. Use the access token for calls. Use the refresh token to get a new one.

Terminal window
curl -X POST $API/auth/token \
-H "Content-Type: application/json" \
-d '{"grant_type":"password","username":"[email protected]","password":"…","label":"My phone"}'
{
"access_token": "sca_…",
"token_type": "Bearer",
"expires_in": 900,
"scope": "listings:read listings:write listings:delete comments:write alerts:read alerts:write account:read account:write",
"refresh_token": "scr_…",
"refresh_expires_in": 2592000
}

The token endpoint follows OAuth 2 (RFC 6749): the members are at the top level, with no data wrapper, and the answer has Cache-Control: no-store. A form works as well as JSON, so a standard OAuth client can sign in:

Terminal window
curl -X POST $API/auth/token \
-d grant_type=password -d [email protected] -d password=…

POST /auth/token needs no credential. It takes a JSON or application/x-www-form-urlencoded body:

Member Used by Meaning
grant_type both password or refresh_token. Anything else is 400 unsupported_grant_type. A site owner can switch password off in Settings → API (“Allow apps to sign in with a password”); it then answers unsupported_grant_type too, and refresh tokens keep working.
username password E-mail address or username.
password password The account’s password.
scope password Scopes to ask for, space separated. Leave it out for every user scope. A list with no scope a user can hold is 400 invalid_scope.
label password A name for this sign-in, up to 100 characters. Shown in the session list.
refresh_token refresh_token The scr_… token.

Who may sign in: active, enabled accounts of a site with user accounts on. Admins use keys. The site must have users on, or the answer is 403 feature_disabled. A banned or suspended account, or one that is not activated yet, gets 400 invalid_grant.

Life 15 minutes. expires_in says.
Sent as Authorization: Bearer sca_…
Scopes The ones granted at sign-in, cut down to what the user may hold today.
Ends early When the user signs out, changes their password, signs out of all devices, or the account is disabled. A ban rule that matches the user or the address refuses it with 403 banned.

When it runs out, calls answer 401 token_expired, not unauthorized:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"

token_expired means the token was genuine. Swap the refresh token for new tokens and retry the call. unauthorized means the token is forged, or went stale: the password changed or the account was disabled. Sign in again. An expired token is not counted as a failed guess.

Terminal window
curl -X POST $API/auth/token \
-H "Content-Type: application/json" \
-d '{"grant_type":"refresh_token","refresh_token":"scr_…"}'

The answer has the same shape as a sign-in, with a new refresh token.

POST /auth/token takes JSON or application/x-www-form-urlencoded. Any other content type is a 415.

Rule Detail
Single use Each refresh token works once. Store the new one and drop the old.
Life 30 days by default (the site’s App sign-in lasts setting), counted from the last use. refresh_expires_in says.
Retry The same token sent again within 30 seconds, before its new token is used, gets that same new token. A client that lost the answer keeps its sign-in.
Reuse Any other second use ends the whole sign-in: 400 invalid_grant, and every token of that sign-in stops. Sign in again.
Password change Ends every sign-in. The one that changed the password gets a new one.
Other refusals An unknown, expired or revoked token, or an account that may not sign in, is 400 invalid_grant.

Two requests with the same refresh token at once both get the same new token. Once a thread uses that new token, a late refresh with the old one ends the sign-in, so a client with several threads should still refresh in one place.

A refused grant is a 400 with the OAuth 2 error and error_description members (RFC 6749 §5.2). The usual problem members are there too: code is the same as error, and detail as error_description:

{
"type": "https://mindstellar.com/docs/developers/api/errors/#invalid_grant",
"title": "The credentials or refresh token are not valid.",
"status": 400,
"detail": "The e-mail, username or password is wrong.",
"code": "invalid_grant",
"error": "invalid_grant",
"error_description": "The e-mail, username or password is wrong.",
"instance": "urn:request:Xq3v9LmT2aBc"
}
Case Status and error
Wrong password, unknown account, bad or reused refresh token 400 invalid_grant
Account not activated yet 400 invalid_grant, the same answer as a wrong password
Banned or suspended account 400 invalid_grant
Missing or unknown members, or a body that cannot be read 400 invalid_request
A scope naming no scope a user can hold 400 invalid_scope
Unknown grant_type 400 unsupported_grant_type
Too many wrong passwords 429 login_blocked, with Retry-After; no error

Wrong passwords share the web sign-in’s counter. An unknown account and a wrong password answer the same, and take as long. So does an account that is not activated yet, so sign-in cannot tell which e-mails sign-up hid as taken; the activation e-mail tells its owner.

login_blocked counts the name as typed. Failures by an account’s username and by its e-mail share one budget; once it is spent, the other name answers invalid_grant like a wrong password, so a locked username does not give away its e-mail.

Terminal window
curl -X POST $API/auth/sign-out -H "Authorization: Bearer $TOKEN"

It answers 204 and ends this sign-in: its refresh token and its access tokens, at once. It needs an access token, not a key. To end every sign-in, use POST /account/sign-out-everywhere below.

Users see their sign-ins at GET /account/sessions and on the API access page of their account, each with its id, name and current. DELETE /account/sessions/{id} ends one. Personal keys are listed and revoked at /account/keys.

Terminal window
curl -X POST $API/account/sign-out-everywhere -H "Authorization: Bearer $TOKEN"

204. It needs an access token with account:write. Every access token, refresh token, page token and personal key of the user stops at once, this access token too, and so does every web sign-in. Sign in again to carry on. An admin key with admin:users does the same for any user with POST /admin/users/{id}/sign-out-everywhere. The account page and the admin Users screen have the same button.

An admin’s own Sign out of all devices (Edit profile) also revokes every admin and public key that admin made. Other admins’ keys keep working.

POST /account/password with current_password and new_password needs an access token with account:write. Like the web form, it signs the user out everywhere: every sign-in and personal key ends. The answer is a new sign-in for this client, with the same label: an access token and a refresh token. Store both. Wrong passwords count toward the same limit as sign-in.

PATCH /account with a new email must also send current_password, checked the same way. A missing one is a 422 with code required on /current_password, a wrong one a 422 on /current_password. A stolen access token alone cannot move the account to another address.

A personal key is for a user’s own script: a long-lived credential that needs no sign-in dance. The site owner must switch on Let users make personal API keys, or /account/keys answers 403 feature_disabled. While it is off, existing personal keys answer 403 feature_disabled too; they work again when it is switched back on.

Terminal window
curl -X POST $API/account/keys \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Stock script","scopes":["listings:read","listings:write"],"expires_at":"2027-03-01","current_password":"…"}'
{
"data": {
"id": 7,
"name": "Stock script",
"prefix": "sck_Ab3dEf5gHi7jKl9M",
"scopes": ["listings:read", "listings:write"],
"status": "active",
"created_at": "2026-10-03T09:12:00Z",
"last_used_at": null,
"expires_at": "2027-03-01T23:59:59Z",
"token": "sck_Ab3dEf5gHi7jKl9M.…"
}
}

token is in this answer only. It is not stored, so copy it now.

Rule Detail
Who calls An access token with account:write. A key cannot make or list keys. The new key cannot hold a scope the token lacks: 403 with the scopes named.
Password current_password is checked again, and counts toward the sign-in limit. A wrong one is a 422 on /current_password.
name 1 to 100 characters.
scopes At least one of: listings:read, listings:write, listings:delete, comments:write, alerts:read, alerts:write, account:read, plus any plugin scope users may hold. Never account:write.
expires_at Required. A day such as 2027-03-01 (it works to the end of that day), an RFC 3339 date-time, or a number of days such as 90d. In the future and within a year (366 days).
Ends on sign-out The key is revoked when the user changes their password or signs out of all devices. Make a new one.
Ends also When revoked, expired, or the user is disabled or deleted.
Manage GET /account/keys, GET /account/keys/{id}, DELETE /account/keys/{id}, or the account’s API access page.

Calls with a personal key count in the user’s api_user bucket, shared with their access tokens, so more keys do not raise the limit.

A theme’s own JavaScript can call the API as the visitor who is signed in on the site: save a search, post a comment, edit a listing, without a key and without asking for the password again. The browser sends the sign-in cookie. The page adds a short-lived page token in a header. A visitor who is not signed in gets a guest page token, which reads public data only.

Use When
Same-site session JavaScript on this site’s own pages: the signed-in visitor, or a guest reading public data.
Public key A page or app that only reads public data, on this site or another.
Access token An app, or a page on another site, acting for a user who signs in to it.
Personal key or admin key A script or server. Never in a page.

Every public page carries it in <head>: osc_head() prints it, and a theme without osc_head() gets it from the header hook. A page prints it once, so calling osc_api_session_meta() as well adds nothing.

<meta name="shopclass-api" content="{&quot;url&quot;:&quot;https://example.com/api/v1&quot;,&quot;token&quot;:&quot;scs_…&quot;,&quot;header&quot;:&quot;X-Shopclass-Token&quot;,&quot;expires_at&quot;:&quot;2026-10-04T14:00:00Z&quot;}">

Its content is JSON: url (the API address), token (a guest token when nobody is signed in), header and expires_at. osc_api_session_token() returns the token alone.

Admin panel pages print the same tag with osc_api_admin_session_meta(). Its token calls admin endpoints as the signed-in admin, with that admin’s scopes. It needs the admin panel’s own session in the same browser: a token for another admin, or one made before that admin signed out of all devices, is 401 session_required. It never acts as a user, and GET /auth/session does not renew it.

Page tokens work while the site owner has the API switched off. Then only page-token calls are answered: every key and token, and POST /auth/token and POST /users, get 403 api_disabled.

const api = JSON.parse(document.querySelector('meta[name="shopclass-api"]').content);
const res = await fetch(api.url + '/account/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', [api.header]: api.token },
body: JSON.stringify({ q: 'bike', category: [12] }),
});

fetch() sends the cookie on a same-origin call by default. Do not send credentials: 'include' to another site.

A page token lives 2 hours. A page left open longer can get a new one before it runs out:

const fresh = await fetch(api.url + '/auth/session', { headers: { [api.header]: api.token } });
api.token = (await fresh.json()).data.token;

GET /auth/session answers token, header and expires_at. It works only for a signed-in session call, with a page token that still works. A guest reloads the page instead.

Every rule must pass, or the call is refused. A call with a page token is never run as anonymous:

Rule Detail
Page token Only in the X-Shopclass-Token header. Never read from the query string or the body. It names the user and their sign-out stamp, so another user’s token, or one made before a password change or a sign-out of all devices, is refused: 401 session_required. An expired one is 401 token_expired.
Sign-in cookie The site’s signed sign-in cookie for that same user. With no valid cookie: 401 session_required. A cookie with no page token is no credential: 401. A guest token needs no cookie.
Same origin Origin must be the site’s own scheme, host and port: https://example.com for a site at https://example.com/shop/. A GET with no Origin must show Sec-Fetch-Site: same-origin, or a Referer from the site. Anything else is 403 cross_origin. http and https, other ports and subdomains are other origins.
User only An active, enabled user who is not banned. Suspended or unconfirmed: 401 session_required. Banned: 403 banned. An admin uses an admin page token instead (above).
Scopes Every user scope except account:write: no profile, password or e-mail change, no keys, no ending sign-ins. No admin scope. A guest token holds listings:read only, and a banned address gets 403 banned.
No CORS An answer never carries Access-Control-Allow-Origin, even when the site allows *. X-Shopclass-Token is not an allowed CORS header, so a page on another site cannot send it.
Never stored Answers are Cache-Control: private, no-store and vary on Cookie. Guest answers are private.

A call with Authorization: Bearer or ?api_key= is a key or token call: the page token is ignored. Calls count in the user’s api_user bucket, shared with their access tokens. Guest calls count per address, at the no-key limit. Idempotency-Key values are kept per user for session calls.

The sign-in cookie is SameSite=Lax and HttpOnly, so other sites’ pages cannot send it with a fetch(), and page scripts cannot read it.

Two installs on the same origin, such as example.com/a/ and example.com/b/, share one origin, so they can read each other’s pages. Give each its own host.

Call Public key Admin key Access token Personal key Same-site session
Read listings, categories, locations, public profiles yes yes yes yes, with listings:read yes
Admin view and non-live listings no with admin:* no no no
Post, edit, delete own listings and photos no no yes with listings:write / listings:delete yes
Comment, delete own comment no no yes with comments:write yes
Saved searches (/account/alerts) no no yes with alerts:read / alerts:write yes
Read the account, own listings in any status, and sessions no no yes with account:read yes
Edit the account, change password, manage keys no no yes no no
POST /auth/token, POST /users no credential needed
POST /auth/sign-out no no yes no no
POST /account/sign-out-everywhere no no yes no no
GET /auth/session no no no no yes

Writes to a user’s own data need an access token or a personal key. An admin key is not a user, so it gets 403 wrong_credential there.

A scope is resource:verb. A call needs the scope its endpoint names. Every read endpoint needs listings:read, and any admin:* scope also grants it. listings:write also grants listings:read, and account:write also grants account:read.

Scope Held by What it does
listings:read Everyone Read listings, categories, fields, locations, currencies and public profiles.
listings:write Users Post and edit own listings, upload photos.
listings:delete Users Delete own listings.
comments:write Users Post comments, delete own comments.
alerts:read Users List own saved searches. alerts:write includes it.
alerts:write Users Make and stop own saved searches.
account:read Users Read the own account, the own listings in any status, and the list of sign-ins and keys.
account:write Access tokens only Edit the account, change the password, manage personal keys (listing them too, so a key never sees other keys), end sessions.
admin:listings Admin keys, moderators’ keys The admin view of listings, reading listings that are not live, and /admin/listings.
admin:comments Admin keys, moderators’ keys Moderate comments: /admin/comments.
admin:users Admin keys The admin view of users, reading disabled users, and /admin/users.
admin:taxonomy Admin keys The admin view of categories, and /admin/categories, /admin/currencies, /admin/custom-fields and the location endpoints.
admin:settings Admin keys /admin/settings and /admin/jobs.
admin:keys Admin keys /admin/keys.
admin:webhooks Admin keys /admin/webhooks and /admin/webhook-events.

A moderator’s key may hold only listings:read, admin:listings and admin:comments. Every admin endpoint is listed in Admin endpoints.

Plugins can add scopes named ext:<plugin-slug>:<verb>. Each declares who may hold it. See Plugin endpoints.

A call missing a scope gets 403 insufficient_scope and a header that names it:

WWW-Authenticate: Bearer error="insufficient_scope", scope="admin:users"

The same resource looks different to different callers. There are three views.

View Who gets it
public Everyone: public keys, other users, and admin keys without the resource’s admin scope.
owner The user a resource belongs to, calling with their own key or token.
admin An admin key holding the resource’s admin scope.
Resource Scope that gives the admin view
Listing admin:listings
User admin:users
Category admin:taxonomy

What each view adds:

Resource public owner and admin add
Listing contact.email only when the seller chose to show it. contact.phone unless the site hides it. Neither on an expired listing, nor for a caller who is not a signed-in user when only users may contact sellers. contact.email and contact.phone always, plus show_email.
Listing, admin only ip, stats (report counters). Listings that are not live can be read.
User id, name, username, url, avatar, is_company, website, location, listings_count, registered_at. Only active users. email, phone_land, phone_mobile, address, zip, lat, lng, confirmed, blocked, last_access_at, last_access_ip.

The listing’s edit secret is never sent in any view. A user’s access token or personal key gets the owner view of their own listings and their own account.

Plugin fields in ext are sent in the views their plugin declared. An undeclared field is sent to the admin view only.

Limits count requests in a fixed 60-second window. The site owner sets the numbers.

Bucket Counted per Default
api_anon IP address, no key 60 a minute
api_key admin key 120 a minute
api_key public key and IP address together 120 a minute
api_user user (access token, personal key or same-site session) 120 a minute
api_write an extra bucket for POST, PUT, PATCH, DELETE 30 a minute

Posting has its own hourly limits on top: see Writes.

The per-minute read buckets count in the site’s object cache. With memcached or Redis all web servers share one count. With APCu each web server counts its own, so a site behind several servers allows that many per server. With no object cache they count in the database, in samples. api_write and the hourly caps count exactly on every request: in Redis or Valkey when the site has it, otherwise in the database. With Redis or Valkey every bucket counts there.

IPv6 addresses count by their /64. An IPv4-mapped IPv6 address counts as that IPv4 client.

Answers to keys and tokens carry the limit headers. Answers that a CDN may cache (public-key reads) leave them out, so one caller’s counter is never cached for another. A 429 always carries them.

Header Example Meaning
RateLimit-Policy "api_key";q=120;w=60 Limit q per window of w seconds.
RateLimit "api_key";r=119;t=57 r requests left, t seconds to the reset.
X-RateLimit-Limit 120 Same as q.
X-RateLimit-Remaining 119 Same as r.
X-RateLimit-Reset 1791058440 Unix time of the reset.
Retry-After 57 On 429 only: seconds to wait.

When a call counts against two buckets, RateLimit and X-RateLimit-* describe the one closest to its limit.

RateLimit and RateLimit-Policy follow the IETF draft. The X-RateLimit-* set says the same for clients and proxies that only know the older names; read whichever your client knows.

{
"type": "https://mindstellar.com/docs/developers/api/errors/#rate_limited",
"title": "Too many requests.",
"status": 429,
"detail": "Too many requests. Try again shortly.",
"code": "rate_limited",
"instance": "urn:request:Xq3v9LmT2aBc"
}

Wait for Retry-After, then retry. The counter fails open: if the site cannot reach it, requests are allowed.

Wrong keys are counted so nobody can guess one.

Failures in 15 minutes Result
20 for one key id from one address That key id is refused from that address, even with the right secret.
500 from one address, with no known key id Every wrong token from that address gets 429 instead of 401. A valid token still works, so a shared address (an office, carrier NAT) is not locked out.

Both answer 429 too_many_failures with Retry-After: 900 until the window passes. One address’s failures never lock out another address.

Fix the key before retrying. A client that retries a 401 in a loop locks itself out. An expired access token (token_expired) is not counted. Wrong passwords at POST /auth/token have their own limit: 429 login_blocked.

  • Put a public key in an app or page. Never put an admin key or a personal key there.
  • In a mobile app, keep the refresh token in the platform’s secure storage.
  • Keep an admin key in a server environment variable, not in source control.
  • Give each app its own key, so one leak is one revoke.
  • Give an admin key only the scopes it needs, and an expiry if the job is temporary.
  • In a browser, the site owner must list your site under Allowed origins.
  • In the site’s own theme, use a same-site session instead of a key.