Connect with Geena

Connect with Geena

One button in your app opens a popup on Geena’s origin; the user logs in (email + one-time code — first-time users are provisioned on the spot), sees one consent screen with your manifest’s asks and terms, and the popup closes. Your backend then exchanges a short-lived code for tokens and the connection’s request_id. Returning users with a live consent skip the screen entirely — the popup flashes and closes.

The flow is standard OAuth 2.0 authorization-code with PKCE (S256, mandatory), plus one Geena-specific parameter: manifest_id, which tells the ceremony what you are asking for.

Two ways a connection starts

Every manifest carries an initiation policy — organization-initiated, recipient-initiated, or both — chosen when it is authored:

  • Recipient-initiated (this page). Your button carries manifest_id; the user’s click is the request. This is the lane for self-service signup and most product flows, and it needs a manifest whose policy allows recipient initiation. An organization-only manifest refuses the ceremony before any screen renders — except for a user who already holds an active connection, because a returning user’s ceremony is a login, which the policy never gates.
  • Organization-initiated. Your team creates the request against a chosen person in the Geena dashboard (Manifests → send), and Geena invites them by email; they accept on Geena’s own pages, no integration involved. Your app meets the connection afterwards: run a pure-login ceremony and list GET /partner/v1/requests to discover it — or, if your systems recorded the request id at send time, pass request_id in the authorize URL to resume that exact invitation in-app.

Both lanes end in the same place: an active connection addressed by request_id, indistinguishable on every other route.

1. The authorize URL

Open a popup (synchronously, inside the click handler — popup blockers) to:

GET https://api.test.geena.eu/oauth/authorize
Parameter Value
response_type code
client_id your registered client id
redirect_uri a page on one of your registered origins (exact origin match)
state random value you verify on return (CSRF)
code_challenge BASE64URL(SHA-256(verifier)) — PKCE S256, required
code_challenge_method S256
manifest_id the manifest this button asks consent for
scope optional — omit for a data connection; organization for the back-office sign-in

manifest_id is deployment configuration of your app, next to your client_id: publish the manifest in the Geena dashboard and copy its id. Connections are one per (user, organization, manifest) — the same button on the same user resumes the existing connection instead of duplicating it, so the call is safely repeatable. A different product flow with a different ask is simply a different manifest_id on a different button.

Info

Omitting manifest_id runs a pure login ceremony: the user consents to the identity connection only and no data connection is created. You can also pass request_id instead, to resume a specific invitation your organization sent by email — useful only if you carry Geena request ids in your own records; most integrations should let manifest_id do the work.

2. Popup + PKCE snippet

function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function connectWithGeena({ clientId, manifestId, redirectUri }) {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const digest = await crypto.subtle.digest(
    "SHA-256", new TextEncoder().encode(verifier));
  const challenge = base64url(new Uint8Array(digest));
  const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
  sessionStorage.setItem("geena_connect", JSON.stringify({ verifier, state }));

  const url = new URL("https://api.test.geena.eu/oauth/authorize");
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
    manifest_id: manifestId,
  }).toString();

  const popup = window.open(url, "geena-connect", "popup,width=480,height=720");

  return new Promise((resolve, reject) => {
    window.addEventListener("message", function onMsg(event) {
      if (event.origin !== window.location.origin) return;
      if (!event.data || event.data.type !== "geena:connected") return;
      window.removeEventListener("message", onMsg);
      const saved = JSON.parse(sessionStorage.getItem("geena_connect"));
      if (event.data.error) return reject(new Error(event.data.error));
      if (event.data.state !== saved.state) return reject(new Error("state mismatch"));
      resolve({ code: event.data.code, verifier: saved.verifier });
    });
  });
}

On mobile, fall back to a full-page redirect to the same URL — the flow is identical, only the window management differs.

3. The landing page on your origin

The browser returns to your redirect_uri with ?code=...&state=... (or ?error=access_denied if the user declined). Serve a tiny page there that hands the result to the opener and closes:

<!doctype html>
<script>
  const p = new URLSearchParams(window.location.search);
  if (window.opener) {
    window.opener.postMessage({
      type: "geena:connected",
      code: p.get("code"),
      state: p.get("state"),
      error: p.get("error"),
    }, window.location.origin);
  }
  window.close();
</script>

4. Exchange the code on your backend

Send the code and PKCE verifier to your own backend; the exchange requires your client_secret and must never happen in the browser. The code is single-use and expires after 5 minutes; redirect_uri must repeat the value used at authorize.

curl -s https://api.test.geena.eu/oauth/token \
  -d grant_type=authorization_code \
  -d client_id=your-app \
  -d client_secret=$GEENA_CLIENT_SECRET \
  -d code=$CODE \
  -d code_verifier=$VERIFIER \
  -d redirect_uri=https://app.example.com/geena/callback
{
  "access_token": "eyJhbGciOiJFZERTQSIs…",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "0f3c…",
  "scope": "offline_access profile",
  "request_id": "6f9b2c9e-6a4e-4a44-9d5e-2f0f6f2a9b11"
}

request_id is the connection this ceremony minted or resumed — store it with your user record; every /partner/v1 route is addressed by it. (It is absent on pure-login ceremonies; you can always re-discover connections via GET /partner/v1/requests.) scope is what the user actually consented to — read it rather than assuming what you asked for; it is how an organization-access ceremony tells you whether the person turned out to be a member.

5. Token lifecycle

  • Access tokens live 15 minutes. Refresh silently with the refresh_token grant; the partner session rolls forward on each refresh, so an actively used integration does not expire.
  • Refresh tokens rotate. Every refresh response carries a new refresh_token; always persist the latest. Presenting a superseded or revoked refresh token is treated as theft evidence: the whole token family and its session are revoked, and you must reconnect through the ceremony.
curl -s https://api.test.geena.eu/oauth/token \
  -d grant_type=refresh_token \
  -d client_id=your-app \
  -d client_secret=$GEENA_CLIENT_SECRET \
  -d refresh_token=$REFRESH_TOKEN
  • interaction_required on any token call means the user’s consent was revoked (they can do this from their Geena dashboard at any time) — open the ceremony popup again; nothing else will mint tokens.
  • Disconnect cleanly with RFC 7009 revocation — present any of your refresh tokens and the whole grant is torn down (consent, sessions, token families):
curl -s https://api.test.geena.eu/oauth/revoke \
  -d client_id=your-app \
  -d client_secret=$GEENA_CLIENT_SECRET \
  -d token=$REFRESH_TOKEN
Tip

The token endpoints are per-IP rate limited. Exchange and refresh from your backend, cache access tokens for their full 15 minutes, and never poll /oauth/token.

6. Organization access (back-office sign-in)

The ceremony has a second use: letting a member of your own organization sign in to your back office and act under that membership — reading the copies your organization kept, deciding update proposals, attaching files — through the Organization API. It is the same popup, the same code exchange and the same token lifecycle, with three differences:

  • scope=organization (alongside the identity scopes: scope=profile offline_access organization) and no manifest_id or request_id. Organization access is a pure login — it never rides on a data ask, and combining the two is refused with invalid_request before any screen renders. The app needs Organization access switched on (Organization → Apps in the dashboard; for a client the Geena team registered, ask them). Asking for a scope outside the app’s ceiling is refused, never silently narrowed.
  • The scope means “if member”. A person who holds an active membership of the organization that owns your client sees an Organization access consent — “App wants to act under your membership of Organization”, the three things the app may then do as them (read the organization’s requests and copies, keep and decide on its behalf, never touch their own vault), and a revocation note — and the button reads Accept and sign in. Anyone else gets the plain login binding instead: no error, no visible difference on the screen. The token response’s scope is how your backend learns which it got — organization present means the token opens /org/v1; absent means this person is not a member, so route them to your ordinary user flow.
  • Membership is re-checked on every call, and so is the member’s role: reading needs request_read, keeping and deciding need request_manage, attaching files needs the file permissions — exactly what the dashboard requires of the same person. Removing the member from your organization ends their access on the next call; the token itself does not need to be revoked.

A member whose consent already covers the scope skips the screen on return, so “sign in to the back office” is one round-trip for everyone but first-timers.