Authentication
One personal op_ token opens API v2, API v1 and the dashboard. How it is sent, issued and rotated, how the network site is chosen and what rights it carries.
Every request to API v2 carries a personal OnPress token: op_ followed by 64 hex characters. There is one token per user and it has no rights of its own — it inherits its owner’s rights in full, the way an account password does. The same token works in API v1 and in the dashboard.
Sending the token
Either as a Bearer token or in a separate header — the two are equivalent:
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/frames?blog_id=235"
curl -s -H "X-OnPress-Key: $OP_TOKEN" "$BASE/frames?blog_id=235"
Only the SHA-256 of the token is stored. A lost token cannot be recovered or shown again; the only way forward is to issue a new one.
Issuing and rotating
A token is issued in the WordPress profile (wp-admin/profile.php, section “API Token”) or by API v1, which accepts an Application Password for exactly this purpose — so the first token needs neither a browser nor WP-CLI:
# what is issued now: prefix and dates, never the token itself
curl -s -u "$LOGIN:$APP_PASSWORD" "$SITE/wp-json/onpress/v1/api-token"
# issue or re-issue: the token text is shown this once
curl -s -X POST -u "$LOGIN:$APP_PASSWORD" "$SITE/wp-json/onpress/v1/api-token"
{ "token": "op_9f3c1a7e…", "prefix": "op_9f3c1a7e", "created_at": "2026-09-12 20:27:45", "last_used_at": null }
POSTrotates, it does not read. Issuing is the only way to learn a token’s text, and it necessarily replaces the previous token, which stops working at once — for every agent and every dashboard session that used it. There is no separate revoke: one row per user. Check withGET; callPOSTonly when you intend to rotate.
The result of a token check is cached in the service’s memory for a minute. A freshly issued token works immediately; a rotated-away one may still pass for up to a minute. That is not a reason to rotate again — wait, or restart the service if a revocation must be instant. last_used_at is updated at most once a minute per token.
Choosing the network site
OnPress is a network of sites on one database, so every request says which site it is about. The site is chosen by the authentication layer, not by the endpoint — otherwise a token with access to one site could reach another by a parameter. The order is fixed:
blog_idin the JSON body;blog_idin the query string;- the
X-OnPress-Blog-Idheader; domain— in the body, in the query or as theX-OnPress-Domainheader; the scheme and a leadingwww.are dropped;- nothing given — blog 1, the main site of the network.
| Parameter | Where | Type | Meaning |
|---|---|---|---|
blog_id | body, query, X-OnPress-Blog-Id | int | network site id; this documentation is 233 |
domain | body, query, X-OnPress-Domain | string | the site’s domain instead of its id |
The fallback to blog 1 is the most expensive mistake available. The staging environment shares its database with production, so a forgotten
blog_idis not an error and not an empty result — it is a successful write to the main live site. Ablog_idthat is not a positive integer falls back to blog 1 as well. Name the site in every request, reads included.
A domain that is not in the network is 404 site_not_found.
Rights
Access to the chosen site is checked by membership: the token’s owner must be a user of that blog. Network super admins reach every site. No membership is 403 forbidden_site.
Membership is enough for pages, frames, data, styles, menus, the sidebar and media. Two groups of endpoints ask for more. Site settings — GET and PATCH /site/settings, PUT /site/front-page, POST /site/cache/flush — require the site’s administrator (or a super admin), otherwise 403 forbidden. Creating a site, POST /sites, is for network super admins only. See Sites and settings.
Rate limit
120 requests a minute per user, counted in a sliding window. Every authenticated response carries the counters:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | the limit per minute |
X-RateLimit-Remaining | requests left in the current window |
X-RateLimit-Reset | Unix time when the window frees up |
Retry-After | only on 429: seconds to wait |
Over the limit is 429 too_many_requests. The counter is per user, not per token, so rotating the token does not reset it; it lives in the service’s memory and resets when the service restarts.
Authentication errors
These come back in the standard WordPress error shape {code, message, data.status}, not in the v2 shape with success and error, because the request never reached an endpoint. Their messages are in Russian — branch on code, never on the text. Real responses:
{"code":"missing_token","message":"Нужен API-токен: заголовок Authorization: Bearer <token> или X-OnPress-Key","data":{"status":401}}
{"code":"invalid_token","message":"Неверный или отозванный API-токен","data":{"status":401}}
| Code | HTTP | When |
|---|---|---|
missing_token | 401 | neither Authorization: Bearer nor X-OnPress-Key was sent |
invalid_token | 401 | unknown token, or rotated away more than a minute ago |
forbidden_site | 403 | the user is not a member of the chosen site |
site_not_found | 404 | domain names no site of the network |
too_many_requests | 429 | over 120 requests a minute |
auth_error | 500 | the token could not be checked; details are in the service log |
The full list of codes is in Errors.
The token in API v1 and in the dashboard
API v1 accepts the same op_ token through the adapter in the onpress-api-keys mu-plugin: Authorization: Bearer op_… or X-OnPress-Key on any /wp-json/ request. It lets in a network super admin or a member of the site the request is addressed to, and answers with the same codes — 401 invalid_token, 403 forbidden_site. The token logs in only API requests: it does not make the visitor logged in on the site’s pages. An Application Password keeps working for v1 and remains the way to issue a token without a browser.
The dashboard signs in with the same token and uses it for both APIs. Rotating the token signs every dashboard session out.
The one endpoint without a token
curl -s "$BASE/health"
{"ok":true,"db":true}
db is a real ping of the database; when it fails the endpoint answers 503 with "db": false.