OAuth apps
How first-party integrations connect to fastmon; self-registration, PKCE, short-lived access tokens, and rotating refresh tokens instead of an API key.
A first-party integration (a shop plugin, an assistant) does not hold an API key. It holds a connection: an OAuth grant approved by a person, plus a pair of tokens under it:
- A short-lived access token (
fmt_…), valid for 15 minutes. This is what goes inAuthorization: Bearer. - A refresh token (
fmr_…), which rotates on every use and can do nothing except fetch the next pair.
The grant is the consent itself and outlives every token issued under it. It is what "disconnect" deletes. An API key remains the right answer where nothing can renew a token (a cron job, a CI pipeline); an installed app has a running process, so it refreshes.
The OAuth endpoints are not part of the generated endpoint reference. This page and the discovery document are the contract.
Who owns the connection
The integration declares credential_owner at registration, and it decides two different lifetimes. Leaving it out declares either:
| Value | What it means | Who lists and ends it |
|---|---|---|
organization | The connection belongs to the tenant. Its scopes are frozen at consent, and it keeps working after the approver's account is gone. | Organization settings → Access, or /organizations/{org_id}/connections (needs org_key:manage) |
user | The connection belongs to the approver. Its authority is re-derived from their current role on every request, and it ends with their membership or account. | Account → Access, or /account/connections (session only) |
either (default) | The integration works both ways; the consent screen asks. With no choice made, it resolves to user, the narrower answer. | whichever was chosen |
A shop plugin is organization: the shop has to keep reporting after an employee leaves. A personal assistant is user: it reads on one person's behalf, so demoting them narrows it and removing them ends it. Declare the one you mean; the default is the narrow answer, because the integration that says nothing is usually a stock client that has never heard of this field.
An MCP client is user and nothing else. Declaring organization and then naming an MCP endpoint is refused at consent.
Approving an org-owned connection requires org_key:manage (members and owners, not viewers), because it produces a credential that outlives every person: the same authority as issuing an organization key.
The app is a public client
An installed app ships as readable code on someone else's server. There is no secret it could keep, and no fixed redirect URI, because every installation has its own domain. Two consequences:
- PKCE (S256) is mandatory and is the only thing authenticating the code exchange. An intercepted authorization code is worthless without the verifier.
- Each installation registers itself and gets its own
client_idwith its own exact redirect URIs. Everything self-registered shows as unverified; the signal the approver actually judges is the redirect domain, which for a shop plugin is their own shop.
Discovery
Everything starts here, so nothing below needs to be hard-coded:
GET https://api.fastmon.eu/.well-known/oauth-authorization-serverReturns the issuer, the authorize, token, and registration endpoints, the scope catalog, and confirmation that none client auth and S256 are what we accept.
The flow
1. Register the installation (once)
{
"client_name": "Fastmon for Shopware",
"redirect_uris": ["https://shop.example.com/fastmon/callback"],
"scope": "app:write site:write app:read site:read analytics:read",
"credential_owner": "organization"
}The response echoes everything back plus your client_id. There is no client secret. Redirect URIs must be absolute https without wildcards or fragments (plain http only on loopback). Ask for the scopes setup and display need and nothing more: this list is the ceiling for everyone who can trigger the connect flow, and the approver may narrow it further.
Read the scope you get back. A registration that names none declares analytics:read alone, which is the narrowest thing worth having and rarely what a setup flow needs.
2. Send the person to fastmon
Generate a PKCE verifier, keep it, and send only its hash:
GET /auth/app/authorize
?response_type=code
&client_id=dyn_…
&redirect_uri=https://shop.example.com/fastmon/callback
&state=<random, bound to their session in your app>
&code_challenge=<base64url(sha256(verifier))>
&code_challenge_method=S256
&scope=<space-separated, subset of what you registered>
&resource=<the endpoint this token is for, if it is not the dashboard API>scope is optional and asks for less than you declared, which is what a client should do when one flow needs less than the full installation. Names we do not implement (openid, offline_access) are dropped rather than refused; a request where nothing survives comes back as invalid_scope. Omitting it asks for your whole declaration.
resource (RFC 8707) names the endpoint the token is for, and only an MCP client needs it today. The value is bound to the connection at consent, so the token request may present the same value again and nothing else: naming an audience there for the first time is refused with invalid_target.
They log in if needed, then land on the consent screen: it names the account approving, offers the organizations they are a member of, and lists the requested permissions, which they may narrow. Approval redirects back to your callback with code and state; declining returns error=access_denied.
3. Exchange the code
POST /auth/app/token
grant_type=authorization_code&code=…&client_id=dyn_…
&redirect_uri=…&code_verifier=<the verifier from step 2>{
"access_token": "fmt_…",
"token_type": "bearer",
"expires_in": 900,
"refresh_token": "fmr_…",
"scope": "analytics:read site:read",
"organization": { "id": "…", "name": "…" }
}Read scope. It is what was actually granted, which is regularly less than you declared: the approver may tick fewer scopes, and their role cuts the list again. The code is single-use and short-lived; store the organization id and the refresh token.
4. Refresh before the access token expires
POST /auth/app/token
grant_type=refresh_token&client_id=dyn_…&refresh_token=fmr_…Same shape back, with a new refresh token. Each refresh token works exactly once; store the replacement before using the new access token.
Presenting an already-spent refresh token disconnects the app: two parties holding the same token means one of them stole it. Never retry a refresh with the same token after a failed or timed-out request; if you did not durably store the response, treat the connection as lost and send the person through consent again.
Errors the app has to handle
/register, /token, and /revoke answer in the OAuth wire format, not the fastmon error envelope: {"error": "invalid_grant", "error_description": "…"}. Branch on error; never parse error_description.
| Response | Meaning | What to do |
|---|---|---|
400 invalid_grant at the token endpoint | Code or refresh token spent, expired, or revoked; PKCE proof failed; or a refresh that named an audience the approval did not | Send the person through consent again |
401 on a normal API call | The access token expired (it is meant to) | Refresh, then retry once; invisible to the person |
403 permission_denied on an API call | The connection's scopes do not cover it; details.permission names it | A configuration problem on the integration side |
403 organization_not_approved | New organizations start under review | Tell the person the account is being reviewed; the connection stays valid |
Connecting again
Approving the same integration for the same organization a second time updates the existing connection instead of adding another one: the grant keeps its id, its scopes are replaced by whatever was just approved (which can narrow as well as widen), and every refresh token from before is superseded. Reconnecting is therefore a real answer to a leaked token. A connection that was disconnected is never revived; approving again after that is a new connection.
Disconnecting
From the app (when it is uninstalled): POST /auth/app/revoke with the token in the body. Handing back a refresh token ends the connection; handing back an access token revokes only that token. The endpoint always answers 200.
From the dashboard: Organization settings → Access lists the connected apps with the addresses their codes were sent to; disconnecting revokes every refresh token under the grant at once, so the app cannot rotate its way back in. Access tokens already issued expire on their own within minutes. A personal connection is listed under Account → Access instead. Removing the person who approved an organization-owned connection does not disconnect it; that is the point of the organization owning it.
Related
- MCP server: the read-only analytics surface for the customer's own AI client, and the one place
resourceis required. - Authentication: API keys, the credential for everything that cannot refresh.
- Organizations → Roles: the permissions scopes are drawn from.
- Errors: the envelope everything outside the OAuth endpoints uses.