Delegated access with OAuth 2.1
Your application runs at other customers’ sites: register, have users consent with PKCE, exchange the oat_ token, renew, revoke.
7 minute readAPIUpdated: 10/25/2026
On this page
Before you start
- For
- Anyone who builds software that runs on other people’s machines and cannot hide a secret: a command-line tool, a desktop application, an AI assistant.
- Prerequisite
- the base URL of your environment in
SZENERA_APIand a browser in which the consenting person is signed in - Where
- command line and browser
By the end of this guide, your application lets a person decide instead of asking for a key: It registers itself, sends the person to give consent, exchanges the code with PKCE for an oat_… access token, and renews or revokes it. It never sees the person’s password.
When this approach is the right one
| Situation | Choice |
|---|---|
| Your own server talks to Szenera | an API key ak_…, which belongs to the organization and is attached to no account |
| Your application runs at other customers’ sites and should see their inventory | this approach: A person there consents, and your application gets a token |
| An AI assistant should work on your behalf | this approach, usually through the MCP service |
Delegated access always has two sides: the application that acts and the account on whose behalf it acts. At runtime, the intersection of the two applies. If the person loses a permission, your application loses it at the same moment.
The addresses
Your application has to hard-code exactly one address: the metadata document. Everything else is stated in it, as absolute URLs. Issuer and endpoints will in future be named by the metadata of auth.<your-domain>, if your environment has this host. So do not enter any endpoint by hand, read it from the document you fetch there. The addresses at the API base URL remain reachable until further notice.
| Route | Purpose |
|---|---|
GET /.well-known/oauth-authorization-server |
metadata document of the authorization server (RFC 8414) |
GET /.well-known/oauth-protected-resource |
metadata document of the protected resource (RFC 9728) |
POST /oauth/register |
registration of a public client (RFC 7591) |
GET /oauth/authorize |
validates the request and leads to the consent page |
POST /oauth/token |
exchange a code or refresh token for a new token |
POST /oauth/revoke |
revoke a token (RFC 7009) |
GET /v1/me/connected-apps |
the connected applications of your own account |
DELETE /v1/me/connected-apps/{clientId} |
take away from an application everything this account gave it |
The first six addresses carry no /v1: The standards prescribe their paths literally, and a third-party client looks there and nowhere else. What lies behind the consent lives under /v1 as usual, see Base URL, /v1, and stability.
Steps
-
Fetch the metadata document. It names the endpoints, the supported methods, and under
scopes_supportedevery permission that delegated access can carry.curl -sS "$SZENERA_API/.well-known/oauth-authorization-server" | jq '{issuer, authorization_endpoint, token_endpoint}' -
Register your client, once, without authentication and without a secret. Only public clients exist:
token_endpoint_auth_methodis alwaysnone.curl -sS -X POST "$SZENERA_API/oauth/register" \ -H "Content-Type: application/json" \ -d '{ "client_name": "Warenwirtschaft-Brücke", "redirect_uris": ["http://127.0.0.1:8765/callback"] }'{ "client_id": "oac_01KQRWJRC0OAUTHCLIENT00001", "token_endpoint_auth_method": "none", "redirect_uris": ["http://127.0.0.1:8765/callback"] }Registration is limited to 10 calls per hour and sender address. Store the identifier and do not register anew on every start.
-
Generate a code verifier and its code challenge. The method is exclusively
S256, and there is noplain.VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=') -
Send the person to the browser. They land on the consent page of the portal and see the name of your application, the redirect target, and the requested permissions, bundled into areas with one sentence and one toggle each.
open "$SZENERA_API/oauth/authorize?response_type=code&client_id=oac_01KQRWJRC0OAUTHCLIENT00001&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=scene.read%20playlist.read&state=$(openssl rand -hex 16)&code_challenge=$CHALLENGE&code_challenge_method=S256"When the person returns, check
stateagainst the value you sent before you redeem the code. -
Exchange the code. The body is form-encoded, not JSON.
curl -sS -X POST "$SZENERA_API/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d code=ocd_01KQRWJRC0OAUTHCODE0000001 \ -d redirect_uri=http://127.0.0.1:8765/callback \ -d client_id=oac_01KQRWJRC0OAUTHCLIENT00001 \ -d code_verifier=$VERIFIER{ "access_token": "oat_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "ort_…", "scope": "playlist.read scene.read" } -
Work with it like with any other Bearer token:
curl -sS "$SZENERA_API/v1/me" -H "Authorization: Bearer oat_…" -
Renew in time. The refresh token rotates: After the exchange, the old one is used up, and the response carries a new one.
curl -sS -X POST "$SZENERA_API/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=refresh_token -d refresh_token=ort_… -d client_id=oac_01KQRWJRC0OAUTHCLIENT00001 -
Give back what you no longer need. Revocation always answers
200, even for an unknown value.curl -sS -X POST "$SZENERA_API/oauth/revoke" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d token=ort_… -d client_id=oac_01KQRWJRC0OAUTHCLIENT00001
Options
Lifetimes
| Item | Valid |
|---|---|
Access token oat_… |
one hour |
Refresh token ort_… |
thirty days, rotates on every exchange |
Authorization code ocd_… |
two minutes, exactly one redemption |
| open consent request | ten minutes |
Permissions
There is no catalog of its own: A scope is a permission key, the same one that an API key may carry as well: scene.read, playlist.write, and so on. The complete list appears as scopes_supported in both metadata documents. GET /v1/permission-catalog also tells you, through the field appliesTo, which permissions are released for machines.
Two cuts apply at consent: A permission that machines never carry cannot be granted, and neither can a permission that the consenting person does not hold themselves. Both are listed with their reason in their area. Ask only for what you need: A request that is too broad makes the decision harder and is rejected instead of being silently trimmed.
Areas and partial consent
The consent page does not show the permissions as a list but bundled by the category of the permission catalog: “Scenes”, “Playlists & schedules”, “Media”, “Displays & locations”, “Reports”. Each area carries one sentence about what your application can do in it, one toggle, and the individual permissions in an expandable list. All requested areas are switched on at the start. The same bundling appears later in the Connected applications card, collapsed into a tally (“5 areas, 18 permissions”).
Expect a subset. The person may switch individual areas off, and POST /v1/oauth/authorizations/{id}/approve accepts any subset of what was requested. The access token then carries only the granted permissions, and the token response names them under scope:
{ "access_token": "oat_…", "scope": "scene.read", "expires_in": 3600 }
Compare scope with what you asked for instead of assuming it. The platform answers what is missing with 403 at call time, not at the exchange. If you depend on an area, tell the person in plain language and send them through the round trip a second time. There is deliberately no way to extend an existing consent afterward.
Limits
GET /oauth/authorize, POST /oauth/token, and POST /oauth/revoke are limited to 60 calls per minute and sender address, the two metadata documents to 120, and registration to 10 per hour. The realtime channel stays closed to delegated access, and you fetch events through Webhooks.
The platform operator can block a client. Then no new round trip begins, and a running renewal fails. Existing access tokens run out instead of being revoked. Likewise, an organization can switch delegated access off entirely: The consent then fails with OAuthAuthorization.DelegationDisabled, at the point where the person decides rather than at the exchange.
Check the result
GET /v1/mewith the token answers200and names the account and the organization on whose behalf you act.- The token response names under
scopethe permissions that were actually granted. They can be fewer than those requested. - The person sees your application in the portal under Settings › My account › Security in the Connected applications card, expandable by the same areas, and can revoke it there with one click.
- After a revocation, the same call answers
401.
If something goes wrong
The endpoints of the standard answer in the format of the standard ({ "error": …, "error_description": … }) and not in the error format of this API. The routes under /v1 answer as usual.
| Symptom | Meaning | Solution |
|---|---|---|
400 invalid_grant on the exchange |
The code verifier does not match the challenge, or the code has expired. | Start the round trip again. The verifier and the challenge must come from the same run. |
400 invalid_grant, and all tokens are gone |
A code or a refresh token was redeemed a second time. The whole token family expires: Two holders of the same secret cannot be told apart. | Discard the old token, have the person consent again, and never run two processes on the same token. |
400 without a redirect |
redirect_uri is not in the list from the registration. This very address is what could not be verified. |
Enter the address at registration and send it word for word at the exchange. |
410 OAuthAuthorization.Expired when opening the consent page |
The request is older than ten minutes. Explicitly not “not found”. | Send the person there again. |
422 naming permission keys |
A scope was confirmed that the person cannot hold or that is not intended for applications. | Remove the named key from the request. |
401 during operation |
The access token is older than one hour, or the person has revoked it. | Renew once. If that fails too, have the person consent again. |
The OAuthClient.* and OAuthAuthorization.* codes are listed in full, with meaning, trigger, and status, under Error codes: accounts and sessions. The two credential codes Auth.OAuthTokenRevoked and Auth.OAuthClientBlocked are listed under Error codes: possible on any route.