Search these docs, or ask Revo a question — answers link the pages they came from.
Authentication
The Revolution API is reached by plugins. A plugin is registered in a customer’s tenant by
someone with write:tenant-settings, authenticates with a key pair it holds itself, and receives a
token scoped to what that registration was granted.
There are no API keys and no client secrets. Authentication is
private_key_jwt: you sign a short-lived JWT with your
private key, Revolution verifies it against the public JWKS your customer registered, and answers
with an access token.
The two kinds of plugin
Section titled “The two kinds of plugin”Which grant you use is decided by how your plugin was registered, and Revolution refuses the other one.
| User interface | Service | |
|---|---|---|
| What it is | A page launched from Revolution’s sidebar, in an iframe | A background process with no user and no browser |
| Grant | authorization_code + PKCE | client_credentials |
| Reach | Its granted scopes intersected with the capabilities of whoever launched it | Its granted scopes, whole |
| Needs a launch | Yes — the auth code comes from Revolution | No |
The intersect is worth understanding before you design around a scope. A UI plugin granted
schedules:execute still cannot start a schedule for an operator who cannot start one themselves,
so the same plugin legitimately does different things for different people. A service plugin has
nobody to narrow against, which is why an administrator authorises its grant deliberately at
install time.
The token endpoint
Section titled “The token endpoint”One endpoint, both grants:
POST /api/v1/{tenantId}/external-plugin/oauth/tokenContent-Type: application/x-www-form-urlencoded{tenantId} is the customer’s tenant, and the token you get back is valid only on routes for
that tenant.
The client assertion
Section titled “The client assertion”Both grants carry the same proof of identity: a JWT you sign with the private key whose public half your customer registered.
| Claim | Value |
|---|---|
iss | your client_id |
sub | your client_id |
aud | the exact token URL you are POSTing to, tenant substituted: https://<host>/api/v1/<tenantId>/external-plugin/oauth/token |
jti | unique per assertion — Revolution refuses a repeat |
iat | now; no more than 5 minutes old, with 30 seconds of clock tolerance |
exp | short, and no more than 5 minutes after iat |
Signed RS256 — the only algorithm accepted, so none and the symmetric algorithms are
refused outright.
Set the header’s kid to the key you signed with. Revolution will try every key in your
registered JWKS, so a mismatched kid is not by itself a cause of failure while your JWKS holds one
key — but it becomes one the moment you add a second, so treat it as required.
A service plugin: client_credentials
Section titled “A service plugin: client_credentials”Nothing to wait for — a service plugin can acquire a token whenever it likes.
curl -X POST "$REVO/api/v1/$TENANT_ID/external-plugin/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=client_credentials" \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \ --data-urlencode "client_assertion=$ASSERTION"import httpx
response = httpx.post( f"{REVO}/api/v1/{TENANT_ID}/external-plugin/oauth/token", data={ "grant_type": "client_credentials", "client_id": CLIENT_ID, "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", "client_assertion": assertion, },)response.raise_for_status()token = response.json()["access_token"]// What RevoServiceTokenProvider in the SDK does for you, including the cache and the backoff.using var form = new FormUrlEncodedContent(new Dictionary<string, string>{ ["grant_type"] = "client_credentials", ["client_id"] = clientId, ["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ["client_assertion"] = assertion,});
var response = await http.PostAsync(tokenUrl, form);response.EnsureSuccessStatusCode();A UI plugin: authorization_code + PKCE
Section titled “A UI plugin: authorization_code + PKCE”The auth code is not something you request. Revolution mints it when an operator launches your
plugin, and hands it to your page over postMessage. There is no authorization endpoint for you to
redirect to, and no redirect_uri.
-
Revolution loads your page in an iframe, with launch parameters on the URL — including an
opaque_codeidentifying this launch. -
Your page generates a PKCE verifier and its S256 challenge, and posts
plugin:pkce-initto the host with the challenge and the launch’sopaque_code. -
Revolution authorises the launch, and posts
host:auth-codeback withcode,stateandtenantId. -
Your backend — not the browser — exchanges that code, together with the verifier and a client assertion, for a token.
curl -X POST "$REVO/api/v1/$TENANT_ID/external-plugin/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "code=$AUTH_CODE" \ --data-urlencode "code_verifier=$PKCE_VERIFIER" \ --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \ --data-urlencode "client_assertion=$ASSERTION"The code is single-use and short-lived (two minutes). It is spent on the first attempt whether that attempt succeeds or not, so a failed exchange means a fresh launch, not a retry.
The response
Section titled “The response”Both grants answer with the same shape:
{ "access_token": "…", "token_type": "Bearer", "expires_in": 3600, "revo": { "tenant": "t0a1b2c3d", "user": { "userId": "…", "firstname": "…", "lastname": "…" }, "roles": [{ "name": "…", "group": "…" }] }}revo.user is populated for a UI plugin — it is the operator who launched it — and is the identity
you should key any per-operator state on. A service plugin has no user.
There is no refresh token. grant_type=refresh_token is refused. Renewing means acquiring
again with a fresh assertion, which is cheap and needs no stored state; a service plugin should
simply hold the token until shortly before it expires and then ask again.
Calling the API
Section titled “Calling the API”curl "$REVO/api/v1/$TENANT_ID/devices" \ -H "Authorization: Bearer $TOKEN"Two things to know:
- Only routes that opt in accept a plugin token. Every other endpoint refuses it however valid it is. The API Reference lists the ones that do.
- Each of those routes requires a specific scope, of the form
area:read,area:writeorarea:execute. A token carries the scopes the customer granted your registration; a call outside them is refused with403and an empty body.
Responses are raw JSON by default. See the REST API page for the optional
X-Envelope header if you want the wrapped shape.
When it fails
Section titled “When it fails”The token endpoint is anonymous, so its error_description is always the same fixed text and the
real reason is in the customer’s Revolution log, not in your response:
{ "error": "invalid_client", "error_description": "The token request was rejected." }That is deliberate — an anonymous caller must not be able to tell “no such plugin” from “wrong key”
from “disabled”. The error code does narrow it to one of four, and every one of them comes back
with HTTP 400:
error | What it means |
|---|---|
invalid_client | The assertion was refused — signature, iss/sub, aud, freshness, or a replayed jti. Much the most common failure on a first integration |
invalid_grant | Everything else about the request was refused: the plugin is not registered, is disabled, has the wrong type for this grant, or the auth code is spent or expired |
unsupported_grant_type | grant_type was neither authorization_code nor client_credentials |
invalid_request | client_assertion_type was not urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
The causes, roughly in order of likelihood on a first integration:
| Cause | What to check |
|---|---|
| The plugin is not registered, or is disabled | Ask your customer to confirm the client id in Settings → Tenant Settings → External Plugins |
| The registered JWKS does not match your private key | Regenerate the JWKS from the key you are actually signing with |
| Wrong grant for the plugin type | A UI plugin cannot use client_credentials, and vice versa |
The assertion’s aud does not name the route tenant, or still has {tenantId} in it | See the audience note above |
| Clock drift over 30 seconds | Check the clock on the machine signing the assertion |
A replayed jti | Generate a fresh one per assertion |
| The auth code was already used, or is older than two minutes | Relaunch the plugin |
A 403 with an empty body on an API call is a different thing entirely, and always one of three:
the token does not carry that endpoint’s scope; the customer has narrowed your grant since the
token was minted (your registration is re-checked live, so a change takes effect within minutes
without your token changing); or the plugin is restricted to specific devices and the request named
one outside that list.
A 429 means you hit the per-tenant rate limit on the token endpoint. Do not depend on a
Retry-After header — the limiter is a sliding window, which reports no retry-after value of its
own, so treat the header as advisory and possibly absent. Honour it when it is there; otherwise back
off on a schedule of your own — a few seconds, doubling, with jitter — and always add jitter, so a
fleet of your workers restarted together does not come back in lockstep. You should not be anywhere
near this limit in normal operation: a token is good for its whole lifetime, and renewing is one
request.
Handling the token
Section titled “Handling the token”The token is a bearer credential for a tenant, for its lifetime. Keep it server-side. If your plugin has a browser half, give the browser a session token of your own rather than passing Revolution’s through — that is what the templates do, and it is why they have a backend at all.