OAuth: organizer connects your integration¶
The flow that issues an installation token — server-to-server credentials your integration uses to read event data on behalf of a specific event.
This is the OAuth flow you'll implement first, regardless of what your integration does. Even a participant-facing app (which also implements Login with Revento) can only be reached by participants once an organizer has connected it to their event using this flow.
When to use this flow¶
- You need to read event data, participants, applications, or program.
- Your integration acts on behalf of "this specific event," not on behalf of an individual user.
- Token requests come from your servers, not from a browser.
If your integration only signs participants in (and never reads bulk event data), you might get away with Login with Revento alone. In practice, almost everyone needs both.
Sequence¶
sequenceDiagram
autonumber
actor O as Organizer
participant B as Organizer's browser
participant Y as Your backend
participant R as Revento
O->>B: Click "Connect {your integration}"<br/>in the integrations panel
B->>Y: GET /connect (your route)
Y->>Y: Generate PKCE verifier + challenge<br/>Generate CSRF state token
Y->>B: 302 → Revento /oauth/authorize<br/>(client_id, redirect_uri, scope, state, code_challenge, event_id)
B->>R: GET /oauth/authorize?...
R->>O: Render consent screen<br/>(scopes, organization name, event name)
O->>R: Click "Authorize"
R->>B: 302 → your redirect_uri<br/>?code=AUTH_CODE&state=...
B->>Y: GET /callback?code=...
Y->>Y: Verify state matches what was sent
Y->>R: POST /oauth/token<br/>(grant_type=authorization_code, code, code_verifier, client_secret)
R-->>Y: 200 OK<br/>{access_token, refresh_token, event_id, organization_id, scope}
Y->>Y: Store tokens, bound to (event_id, organization_id)
Y-->>B: Show "Connected!" UI
Authorization request¶
Redirect the organizer's browser to:
GET https://auth.revento.example/oauth/authorize?
response_type=code
&client_id={your client_id}
&redirect_uri={your registered redirect_uri}
&scope={space-separated scopes}
&event_id={the event being connected}
&state={CSRF token}
&code_challenge={PKCE challenge}
&code_challenge_method=S256
Parameters¶
| Parameter | Required | Description |
|---|---|---|
response_type |
yes | Always code. We don't support implicit or other grant types. |
client_id |
yes | Your integration's client_id, issued at registration. |
redirect_uri |
yes | Must exact-match one of the redirect URIs you registered. No wildcards, no path-prefix matching. |
scope |
yes | Space-separated list of scopes you're requesting. Must be a subset of the scopes declared in your integration manifest. |
event_id |
yes | The event the organizer is connecting your integration to. Revento uses this to scope the consent screen and bind the resulting token. |
state |
recommended | CSRF token. Returned untouched on the redirect. Verify it matches before exchanging the code. |
code_challenge |
yes | PKCE challenge: base64url-encoded SHA-256 of your code_verifier. |
code_challenge_method |
yes | Always S256. We don't support plain. |
prompt |
optional | Only consent is supported. Forces the consent screen even if the organizer has previously consented. Other values (login, none, select_account) return invalid_request. Default behavior already re-shows the consent screen for fresh connections, so this is rarely needed. |
Validation rules¶
- Redirect URI must exact-match.
https://example.com/cbandhttps://example.com/cb/are different URIs. Register every variant you actually use. - Scopes must be a subset of your manifest. Requesting
participants.readwhen your manifest only declaresevent.readreturnsinvalid_scope. - PKCE is required. Both for public and confidential clients. No exceptions.
- The organizer must have permission to connect. If the organizer hitting
/oauth/authorizelacksintegration.manageorevent.owneron the event, they see an error page rather than the consent screen.
Possible error redirects¶
If the authorization request can't proceed, Revento either renders an error page (for user-facing errors) or redirects back to your redirect_uri with error parameters (for protocol-level errors).
error |
When |
|---|---|
invalid_request |
Malformed request, missing required parameter, bad PKCE format. |
unauthorized_client |
client_id is unknown or your integration has been suspended. |
invalid_scope |
A requested scope isn't declared in your manifest. |
access_denied |
The organizer clicked "Cancel" on the consent screen. |
server_error |
Internal error during authorization. Surface a "try again later" message; the failure is logged on our side and the request_id will help if you escalate. |
What the organizer sees¶
The consent screen renders in the organizer's locale (Polish or English; copy below is the English version — the Polish locale shows equivalent translations) with this structure:
- Headline: "{your integration name} is requesting access to {event name} data"
- Publisher line: "Publisher: {your publisher name}"
- Scope list: each scope as a row with its description, marked "required" or with an opt-in checkbox (optional)
- Data scope clause: "Only within event {event name}. No data modification."
- Responsibility note: "Your organization {organization name} is responsible for data shared with the integration."
- Actions: "Authorize" (primary) and "Cancel" (secondary)
The organizer can decline optional scopes by unchecking them. Required scopes are all-or-nothing — granted or the connection doesn't happen.
Token exchange¶
After the organizer approves, Revento redirects to your redirect_uri with:
You verify state matches what you sent, then exchange the code:
POST /oauth/token HTTP/1.1
Host: auth.revento.example
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code={the code from the redirect}
&redirect_uri={the same redirect_uri you used in /authorize}
&client_id={your client_id}
&client_secret={your client_secret}
&code_verifier={the PKCE verifier matching the challenge}
Successful response:
{
"access_token": "rev_install_8h3k2m...",
"refresh_token": "rev_refresh_a92cf1...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "event.read participants.read program.read",
"event_id": "evt_abc123",
"organization_id": "org_xyz789",
"integration_id": "int_yourapp"
}
Response fields¶
| Field | Description |
|---|---|
access_token |
The installation token. Use as Authorization: Bearer ... on every API call. Lifetime ~1 hour. |
refresh_token |
One-time-use refresh token. Lifetime: 90-day sliding window, 1-year hard cap. See Refresh tokens. |
token_type |
Always Bearer. |
expires_in |
Seconds until the access token expires. Refresh before this hits 0. |
refresh_expires_in |
Seconds until the refresh token expires. Refreshing extends this — see refresh tokens. |
scope |
The scopes the organizer actually granted. May be a subset of what you requested if optional scopes were declined. |
event_id |
The event this token is bound to. |
organization_id |
The organization this token is bound to. |
integration_id |
Your integration's stable id (matches your client_id context). |
Token-exchange errors¶
error |
When |
|---|---|
invalid_grant |
Code is unknown, expired (codes live ~10 minutes), already used, or doesn't match the redirect_uri / code_verifier you originally sent. |
invalid_client |
client_id / client_secret mismatch, or client_secret was rotated and you're using the old one. |
invalid_request |
Missing required parameter, malformed body. |
Authorization codes are one-time use. If you exchange a code successfully, then exchange the same code again, the second attempt returns invalid_grant AND invalidates the tokens issued by the first exchange. Don't retry token-exchange requests on transient network errors without first checking whether the first attempt actually succeeded.
Storing the installation token¶
Treat both access_token and refresh_token as you would a password.
- Encrypted at rest (your DB-level encryption, KMS-backed envelope encryption, or a secrets manager).
- Never in env vars committed to git.
- Never in client-side code or anywhere a customer can inspect.
- Logged at most as
last 4 chars + lengthfor debugging. - Per (event, organization, integration) tuple — store
event_idandorganization_idalongside the token so you know what it can read.
A typical schema:
CREATE TABLE revento_installations (
id SERIAL PRIMARY KEY,
event_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
access_token BYTEA NOT NULL, -- encrypted
refresh_token BYTEA NOT NULL, -- encrypted
access_expires_at TIMESTAMPTZ NOT NULL,
refresh_expires_at TIMESTAMPTZ NOT NULL,
scopes TEXT[] NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active|revoked|suspended
connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, organization_id)
);
Revocation¶
A connection can end any of these ways. Your integration sees all of them as "the token stops working."
| Initiator | What happens |
|---|---|
| Organizer revokes from the integrations panel | Both access and refresh tokens are invalidated immediately. A data.deletion_required webhook fires (if subscribed). |
| Organization loses formal status (rare) | All connections across all of that org's events invalidated. |
| Revento suspends your integration | All your tokens invalidated; we email you and the organizer. |
| You unpublish your integration | All your tokens invalidate within one dispatch cycle. data.deletion_required fires per (event, organization). |
In all three cases, your next API call returns 401 token_revoked. Your integration should:
- Catch the 401, mark the installation as
revokedin your storage. - Stop polling, stop scheduling work for this connection.
- Within 30 days of receiving
data.deletion_required, delete cached event data on your side. - Surface the disconnect to your operators if the integration's UX assumes a live connection.
Re-authorization (new scopes)¶
If you ship a new version of your integration that declares a scope your existing connections don't have, every existing connection enters a "re-consent required" state.
- The organizer sees a banner in their integrations panel: "This integration requires additional permissions: {new scope}."
- Tapping "Accept new permissions" runs them through this same flow again, with the new scope set in the consent screen.
- The previous token continues to work for the previous scope set until the new token is issued.
- There is no silent / banner-only path that adds scopes without consent.
See Scopes — versioning for the rules.
Common pitfalls¶
- State validation skipped. Always compare the
stateparameter on the redirect against what you sent. Without this check, a hostile actor can complete the OAuth flow on a victim's behalf and bind your integration to an attacker-controlled session. - Token persisted in plaintext. Easy mistake during prototyping. Wire up encryption before you ship.
- Catching 401 only on access tokens. Refresh tokens can also be revoked (e.g. organizer disconnect, family-revoke). A
400 invalid_granton a refresh attempt is not a transient error — it's a permanent revocation. Don't retry; mark the installation revoked. - Hard-coded redirect URI. Make it config-driven so dev / staging / prod can each register their own URI without code changes.
- Treating
scopein the response as identical to your request. If the organizer declined an optional scope, the response has fewer scopes. Read it back and adapt.