Skip to content

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.

Which grant you use is decided by how your plugin was registered, and Revolution refuses the other one.

User interfaceService
What it isA page launched from Revolution’s sidebar, in an iframeA background process with no user and no browser
Grantauthorization_code + PKCEclient_credentials
ReachIts granted scopes intersected with the capabilities of whoever launched itIts granted scopes, whole
Needs a launchYes — the auth code comes from RevolutionNo

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.

One endpoint, both grants:

POST /api/v1/{tenantId}/external-plugin/oauth/token
Content-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.

Both grants carry the same proof of identity: a JWT you sign with the private key whose public half your customer registered.

ClaimValue
issyour client_id
subyour client_id
audthe exact token URL you are POSTing to, tenant substituted: https://<host>/api/v1/<tenantId>/external-plugin/oauth/token
jtiunique per assertion — Revolution refuses a repeat
iatnow; no more than 5 minutes old, with 30 seconds of clock tolerance
expshort, 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.

Nothing to wait for — a service plugin can acquire a token whenever it likes.

Terminal window
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"

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.

  1. Revolution loads your page in an iframe, with launch parameters on the URL — including an opaque_code identifying this launch.

  2. Your page generates a PKCE verifier and its S256 challenge, and posts plugin:pkce-init to the host with the challenge and the launch’s opaque_code.

  3. Revolution authorises the launch, and posts host:auth-code back with code, state and tenantId.

  4. Your backend — not the browser — exchanges that code, together with the verifier and a client assertion, for a token.

Terminal window
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.

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.

Terminal window
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:write or area:execute. A token carries the scopes the customer granted your registration; a call outside them is refused with 403 and 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.

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:

errorWhat it means
invalid_clientThe assertion was refused — signature, iss/sub, aud, freshness, or a replayed jti. Much the most common failure on a first integration
invalid_grantEverything 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_typegrant_type was neither authorization_code nor client_credentials
invalid_requestclient_assertion_type was not urn:ietf:params:oauth:client-assertion-type:jwt-bearer

The causes, roughly in order of likelihood on a first integration:

CauseWhat to check
The plugin is not registered, or is disabledAsk your customer to confirm the client id in Settings → Tenant Settings → External Plugins
The registered JWKS does not match your private keyRegenerate the JWKS from the key you are actually signing with
Wrong grant for the plugin typeA UI plugin cannot use client_credentials, and vice versa
The assertion’s aud does not name the route tenant, or still has {tenantId} in itSee the audience note above
Clock drift over 30 secondsCheck the clock on the machine signing the assertion
A replayed jtiGenerate a fresh one per assertion
The auth code was already used, or is older than two minutesRelaunch 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.

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.