Developers

Connect your app to Unio.

Your users get the Unio button — every app they use, one click away — and arrive in your app already signed in. Three steps, and no new tables. Or hand the AI prompt to your coding agent and let it do them.

Fulfilling or paid from a store product? Unio also sends you signed webhooks for every purchase, renewal, cancellation and refund.

How it works

Unio says who is arriving. Your app keeps its own users.

Unio is an OpenID Connect provider. Your app stays in charge of its own accounts, passwords and two-step codes; Unio only tells it which of its users a person is. You store one id per user — nothing else.

  • Step 1

    Get your keys

    Tell us your hosts and addresses. You get a client id and a secret.

    Details ↓
  • Step 2

    Add the Unio button

    One script tag or an iframe in your signed-in pages. It shows the person’s apps.

    Details ↓
  • Step 3

    Add Sign in with Unio

    Three small pages on your server: connect, launch, callback. Plus two calls back to us.

    Details ↓

Connecting, once per person

  1. Your signed-in user opens the Unio button and clicks Connect Unio. A popup opens your connect page.
  2. Your server asks Unio to sign this person in, and sends the popup to Unio.
  3. Unio asks who they are — “Add YourApp to Ahmad’s Unio account?”, or sign in, or make a new account.
  4. Unio sends the popup back to your callback. Your server checks the answer and saves Unio’s id for this user.
  5. Your server sends the person’s details. The link is now live, and the popup closes itself.

Switching in, every time after

  1. In any Unio app, the person clicks your tile. A new tab opens your launch address.
  2. Your server asks Unio who this is. Unio answers at once, with no screen, for a person with a live link.
  3. Your callback finds the user by that id and signs them in — with your own two-step codes and rules.
Step 1

Get your keys.

Send us:

  • Your app's name and a square logo.
  • Every host your users sign in on (for example app.example.com and example.com). Each becomes an allowed origin, and may frame the panel.
  • The callback on each host, for example https://example.com/unio/callback.
  • Your connect page path, for example /unio/connect.
  • Your launch address, for example https://example.com/unio/launch.
  • A separate set for staging. Staging is its own client.

We set up your client and send back a client id and a client secret. The secret is shown once, so put it straight into your server’s secret settings. Your hosts are allowed to show the Unio panel from then on.

Nothing is live until you are ready. Your client can be switched off at any time, and a new secret can be issued if the old one leaks — the old one stops at once.
Step 2

Add the Unio button.

Put this in the layout of your signed-in pages. It adds a small round Unio button; clicking it opens the panel with the person’s apps.

html
<script
  src="https://uniodental.com/widget.js"
  data-partner="yourapp"
  data-partner-user="<user hash>"
  async
></script>

data-partner-user is a hash of your signed-in user, so on a shared front-desk computer each person keeps their own Unio panel. Make it like this, with a secret key of your own:

pseudocode
pu = HMAC_SHA256(key: UNIO_PANEL_KEY, message: your_user_id).hex()[0, 32]

Show the button only when someone is signed in, their account is active, and no one is impersonating them. Keep it clear of fixed bottom actions, such as a wizard’s Next button.

Or: a native nav-menu item

Load the widget with data-hide-launcher="true" and put data-unio-trigger on the menu item that opens the panel. The panel anchors to whichever trigger was clicked.

html
<!-- Once, anywhere in <body>: the panel and the JS API,
     with no floating button of its own. -->
<script
  src="https://uniodental.com/widget.js"
  data-partner="yourapp"
  data-partner-user="<user hash>"
  data-hide-launcher="true"
  async
></script>

<!-- Your nav's Unio entry. data-unio-trigger anchors the panel to it. -->
<li>
  <button data-unio-trigger onclick="Unio.toggle()">Unio</button>
</li>

Or: your own container

Render the panel yourself — inside your own themed button, for example. The card comes from the /embed page itself and the iframe is transparent.

html
<iframe
  src="https://uniodental.com/embed?partner=yourapp&host=<this page's origin>&pu=<user hash>"
  width="320"
  height="440"
  style="border:none"
  title="Unio"
></iframe>

Programmatic control

js
window.Unio.open()    // show
window.Unio.close()   // hide
window.Unio.toggle()  // flip

Every option is a data attribute

html
<script
  src="https://uniodental.com/widget.js"
  data-partner="yourapp"
  data-partner-user="<user hash>"
  data-position="bottom-right"
  data-color="#0A1620"
  data-label="Unio"
  data-auto-open="false"
  async
></script>
AttributeValuesDefault
data-partnerYour Unio client id. "Connect Unio" then opens your own connect pagenone
data-partner-userThe hash of your signed-in user (A-Z a-z 0-9 _ -, up to 64). One panel sign-in per user on shared computersnone
data-positionbottom-right, bottom-left, top-right, top-leftbottom-right
data-colorAny CSS color for the button#0A1620
data-labelAccessibility label for the buttonOpen Unio
data-auto-opentrue opens the panel on page loadfalse
data-hide-launchertrue skips the floating button (you call Unio.toggle())false

Your own Content-Security-Policy must allow https://uniodental.com under frame-src, and under script-src if you use the widget.

Step 3

Add Sign in with Unio.

Standard OpenID Connect, with pushed requests and PKCE required. Every call to Unio comes from your server, authenticated with HTTP Basic (client id and secret). The browser never sees the secret.

WhatAddressUse
DiscoveryGET https://uniodental.com/api/oidc/.well-known/openid-configurationRead once and cache. Its issuer must equal UNIO_ISSUER exactly.
Signing keysGET https://uniodental.com/api/oidc/jwksVerifies id_tokens. Cache; refetch once on an unknown kid.
Pushed requestPOST https://uniodental.com/api/oidc/requestStarts every connect and launch, from your server.
AuthorizeGET https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri>Where you send the browser next.
TokenPOST https://uniodental.com/api/oidc/tokenTrades the code for an id_token, from your server.
ProfilePUT https://uniodental.com/api/partner/v1/profileAfter every successful callback. The first one confirms the link.
DisconnectPOST https://uniodental.com/api/partner/v1/disconnectWhen your user removes Unio, or is deleted.
PanelGET https://uniodental.com/embed?partner=<client id>&host=<page origin>&pu=<user hash>The iframe inside your Unio button.
Popup finishGET https://uniodental.com/embed/doneWhere a connect started from the panel ends: add ?error=<reason> when it failed.

The three pages you build

  • Connect (for example /unio/connect). A GET shows a small page whose form POSTs back with your CSRF protection; it may submit itself only when the visitor came from https://uniodental.com. The POST needs a signed-in, active user with a verified email, then makes the pushed request with unio_intent=connect.
  • Launch (your launch address). Unio opens it with ?iss=https://uniodental.com/api/oidc. Accept GET and POST, and turn a POST into a GET with a 303 before touching the session. Check iss exactly, keep target_link_uri only if it is a path on your own host, then make the pushed request with unio_intent=launch.
  • Callback (for example /unio/callback). Check state and iss, trade the code for an id_token, verify it, and then connect or sign in. Every branch ends in a redirect.

1. Start: the pushed request

Keep state, nonce and the PKCE verifier in your server-side session, single use, for 10 minutes. The unio_* fields are only accepted here, never from a browser address.

http
POST https://uniodental.com/api/oidc/request
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

response_type=code
client_id=<client id>
redirect_uri=<your callback>
scope=openid
state=<random>
nonce=<random>
code_challenge=<S256 of the verifier>
code_challenge_method=S256
unio_intent=connect | launch
unio_partner_user_id=<your user id, as text>
login_hint=<their email>
unio_email_verified=true | false
unio_given_name=<first name>
unio_family_name=<last name>
unio_partner_company_id=<company open on your side, as text>

→ 201 { "request_uri": "urn:ietf:params:oauth:request_uri:…", "expires_in": 60 }

Then send the browser to:
https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri>
FieldValueNotes
response_typecode
client_id<client id>Also the Basic auth user.
redirect_uri<your callback>Exactly one of the addresses you registered, on the host the person is using.
scopeopenidNothing else is needed.
state<random>Kept in your session; checked on the callback.
nonce<random>Kept in your session; must equal the id_token nonce.
code_challenge<S256 of the verifier>PKCE is required.
code_challenge_methodS256
unio_intentconnect | launchConnect attaches your user; launch signs an already-linked one in.
unio_partner_user_id<your user id, as text>Connect only. Who is connecting on your side.
login_hint<their email>Connect only. Shown and pre-filled on Unio's screens.
unio_email_verifiedtrue | falseConnect only. Exactly whether your user proved this address.
unio_given_name<first name>Connect only. Used if they make a new Unio account.
unio_family_name<last name>Connect only.
unio_partner_company_id<company open on your side, as text>Connect only. Leave it out for a personal account. When that company is linked to a Unio practice, the sign-in screen names the practice.

2. The callback

A plain GET redirect back to you — never a form post, so your session cookie arrives.

http
GET https://yourapp.com/unio/callback?code=…&state=…&iss=https%3A%2F%2Funiodental.com%2Fapi%2Foidc

or, when it did not work:
GET https://yourapp.com/unio/callback?error=access_denied&error_description=user_cancelled&state=…&iss=…
error_descriptionMeaningWhat to do
user_cancelledThe person closed or declined.Say nothing went wrong; offer to try again.
unio_not_connectedLaunch, but this Unio person has no confirmed link to your app.Ask them to sign in to your app and connect Unio from the Unio button.
impersonation_not_allowedA Unio admin is viewing as this person.Refuse. Nothing is attached or signed in.
partner_user_linked_elsewhereYour user is already linked to a different Unio person.Tell them this account is connected to another Unio account.
unio_account_linked_elsewhereThis Unio person is already linked to a different user of your app.Tell them to disconnect Unio from the other account first.

3. The token, and the only claim you use

http
POST https://uniodental.com/api/oidc/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
code=<code>
redirect_uri=<the same callback>
code_verifier=<the PKCE verifier>

→ 200 { "id_token": "…", "access_token": "…", "token_type": "Bearer", "expires_in": 600 }

Verify the id_token: RS256 only, a kid that is in the signing keys, iss equal to https://uniodental.com/api/oidc, aud equal to your client id, exp in the future, and nonce equal to the one you stored. Then use only sub: the stable id of this Unio person for your app. Keep no Unio tokens.

4. Connect or sign in

  • Connect: the signed-in user must be the one who started. Refuse a sub that is linked to another of your users, and a user who is linked to another sub. Save the link, then send the profile. If that fails, delete the link you just made.
  • Launch: find the user linked to this sub. Refuse inactive users, admins and impersonation. If someone else is signed in, ask “Switch to Ahmad?” or “Stay as Sara”. Otherwise reset the session and sign them in — or hand them to your two-step code step, exactly where a correct password would.

5. Send the person’s details

After every successful callback. The first one makes the link live in Unio. Every key is optional: one you leave out keeps its value, and null clears it.

http
PUT https://uniodental.com/api/partner/v1/profile
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json

{
  "sub": "<id_token sub>",
  "partner_user_id": "<your user id, as text>",
  "profile": {
    "first_name": "Ahmad",
    "last_name": "Khan",
    "email": "dr.ahmad@example.com",
    "email_verified": true,
    "phone": "+1 555 0100",
    "job_title": "Owner dentist",
    "company_name": "Bright Smiles Dental",
    "company_type": "seller",
    "address": {
      "street": "1 Main St",
      "city": "Naples",
      "state": "FL",
      "zip": "34102",
      "country": "US",
      "formatted": "1 Main St, Naples, FL 34102"
    },
    "companies": [
      {
        "id": "<your company id>",
        "name": "Bright Smiles Dental",
        "leader": true
      }
    ]
  }
}

→ 204   ·   404 {"error":"unknown_subject"}   ·   409 {"error":"partner_user_mismatch"}

6. Disconnect

When your user removes Unio in your settings, or is deleted.

http
POST https://uniodental.com/api/partner/v1/disconnect
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json

{ "sub": "<id_token sub>" }

→ 204 (always; safe to repeat)

7. Close the popup

A connect started from the panel ends by sending the popup to https://uniodental.com/embed/done, which hands the panel its sign-in and closes. When it did not work, add ?error=<reason> and the popup says why. It knows:

partner_user_linked_elsewhere, unio_account_linked_elsewhere, impersonation_not_allowed, user_cancelled, linked_elsewhere, already_connected, impersonating, inactive, not_allowed, switched_off, email_unverified, expired, session_changed, no_workspace, host_not_allowed, rate_limited, profile_failed, unavailable

No new tables

One row per connected user, in a table you already have.

Store the link in your existing sign-in identities table (the one that holds Google or other logins): provider unio, uid = sub. Two unique indexes keep it one Unio person to one of your users, both ways:

sql
-- The only database change. Limited to Unio rows, so your other providers are untouched.
CREATE UNIQUE INDEX CONCURRENTLY index_identities_on_unio_uid
  ON identities (provider, uid) WHERE provider = 'unio';
CREATE UNIQUE INDEX CONCURRENTLY index_identities_on_unio_owner
  ON identities (user_id) WHERE provider = 'unio';
Rules

What keeps your users’ accounts safe.

Each one is a way we have seen an integration go wrong.

  • Only the id_token sub identifies a Unio person. Never find, link or sign in anyone by matching an email: that hands an account to whoever registered the address first.
  • Connect needs a signed-in, active user whose email is verified. An unverified account may have been set up by someone else with the owner's address.
  • Never connect or switch while someone is impersonating a user. An admin viewing as someone must not act as them.
  • Admins, and anyone with admin permissions, sign in the normal way. A second way into a powerful account is a second thing to protect.
  • Keep your two-step login. Unio replaces the password, never the code. Send no profile until the code is entered.
  • Never silently replace whoever is signed in. On a shared front-desk computer, ask "Switch to Ahmad?" or "Stay as Sara".
  • One Unio person to one of your users, both ways. Hold it with two unique indexes, not only with checks.
  • State, nonce and code verifier stay server side, single use, 10 minutes. They are what stops a forged or replayed callback.
  • Every callback branch redirects. Never render a page while the code is in the address bar, and never log the code.
  • Check iss on the launch and on the callback. It must equal UNIO_ISSUER exactly; a target_link_uri must be a path on your own host.
  • Keep no Unio tokens. You need the sub, nothing else. The access token is unused.
  • The client secret never reaches a browser. Every call to Unio is made by your server.
  • Rate limit the two start actions. Each makes a server call to Unio from your address, and Unio limits per address, so one script could stop it for all your users.
  • Do not route this through your social-login library. Those callbacks usually create users and link by email. Write dedicated handlers.
  • Send the whole companies list, and send it again when it changes. Unio adds people to the practice a company is linked to. Resend the profile when someone joins or leaves a company or their role changes, and mark your own staff as not a leader.
  • A target is a path on your own host, checked after parsing. Refuse any control character, space or backslash in it (raw or percent-encoded), then parse it against your own origin: the result must still be your origin, and you redirect to its re-serialized path and query, never the raw string. Anything else is dropped and the person goes home: an open redirect hands a signed-in session to whoever wrote the link.
  • Your /unio pages cannot be framed. Send frame-ancestors 'none' (or X-Frame-Options: DENY) on the connect, launch and "Switch to?" pages, so no other site can overlay them and trick a click.
  • A changed email is unproven again. Until the user confirms the new address, refuse connect (email_unverified) and send unio_email_verified=false and email_verified false; resend the profile with true once they confirm it.
  • On a launch, a 409 from the profile call signs the person out again. partner_user_mismatch means your stored link and Unio disagree about who this is. Sign out, delete your stored link without calling disconnect, and ask them to connect Unio again. Any other profile failure is logged, not shown.
  • Allow at most 60 seconds of clock skew. On exp and iat, and no more: a wider window keeps a stolen id_token useful.
  • The secret can be replaced without a deploy. Read it from your settings. When Unio rotates it, the old one stops at once, so put the new one in straight away.
Settings & testing

Five settings, and all five or nothing.

SettingExampleNotes
UNIO_ISSUERhttps://uniodental.com/api/oidcCompared byte for byte with iss.
UNIO_ORIGINhttps://uniodental.comWhere the partner API and the panel live.
UNIO_CLIENT_IDyourappFrom Unio. Also your ?partner= value.
UNIO_CLIENT_SECRET<shown once>Secret. Server side only.
UNIO_PANEL_KEY<any long random string>Yours alone: keys the user hash (pu). Never sent to Unio.

With any of them missing, turn the feature off: no button, and every Unio page returns 404.

Testing

  • In automated tests, fake Unio at the HTTP layer and sign test id_tokens with your own RSA key.
  • Staging gets its own client and secret from us. Test connect, switching in, and disconnect there before production.
  • Against a local Unio: set UNIO_ISSUER=http://localhost:3000/api/oidc and UNIO_ORIGIN=http://localhost:3000, and serve your app over http:// too (Safari refuses Secure cookies over plain HTTP).
AI prompt

Let your coding agent do the integration.

Copy this into Claude Code, Cursor or any coding agent, in your app’s repository. It works for any stack: the agent reads your codebase first, builds steps 2 and 3 the way your app already does things, writes the tests, and tells you what to send us.

prompt
You are adding "Sign in with Unio" to this application.

Unio (https://uniodental.com) is an OpenID Connect provider for dental software. When you are done:
- a signed-in user of this app can connect their account to Unio from a floating Unio button, and
- a person signed in to Unio can open this app from Unio's app switcher and be signed in with one click.
This app keeps its own users and its own login. Unio only tells it WHICH of its users is arriving.

Work in the order below. Do not skip step 1, and do not change any name or value in step 2.

=== STEP 1. READ THE CODEBASE FIRST (write nothing yet) ===

Find and write down:
- Language, framework and version; how routes, handlers, background jobs, migrations and tests are written here.
- The authentication system: how a user is signed in, signed out, and how the session is reset.
- The user model: the id type; how "active / disabled" is stored; how admins and any admin permissions or roles are stored; how "email verified" is stored and what sets it.
- Two-step login (2FA / OTP), if any: the exact point where a correct password hands over to the code step.
- Impersonation ("log in as user"), if any, and how to tell it is happening.
- An existing table for external sign-in identities (for example connected_accounts, identities, oauth_accounts, social_accounts). Reuse it. Only if none exists, add the smallest table possible: owner, provider, uid, timestamps.
- The layouts used by signed-in pages, and the app's Content-Security-Policy.
- The HTTP client and the JWT / JWKS library already in the dependency tree. Prefer them over adding new ones.
- Cookies that outlive a session (last workspace, pending invites) and where sign-out clears them.

Report what you found before changing anything. If the app cannot tell whether a user's email is verified, or cannot tell admins apart, stop and ask.

=== STEP 2. THE CONTRACT ===

Every server-to-server call uses HTTP Basic auth with client id and client secret, over HTTPS.
Issuer: https://uniodental.com/api/oidc

Endpoints:
- Discovery: GET https://uniodental.com/api/oidc/.well-known/openid-configuration (auth: none). Read once and cache. Its issuer must equal UNIO_ISSUER exactly.
- Signing keys: GET https://uniodental.com/api/oidc/jwks (auth: none). Verifies id_tokens. Cache; refetch once on an unknown kid.
- Pushed request: POST https://uniodental.com/api/oidc/request (auth: Basic). Starts every connect and launch, from your server.
- Authorize: GET https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri> (auth: none). Where you send the browser next.
- Token: POST https://uniodental.com/api/oidc/token (auth: Basic). Trades the code for an id_token, from your server.
- Profile: PUT https://uniodental.com/api/partner/v1/profile (auth: Basic). After every successful callback. The first one confirms the link.
- Disconnect: POST https://uniodental.com/api/partner/v1/disconnect (auth: Basic). When your user removes Unio, or is deleted.
- Panel: GET https://uniodental.com/embed?partner=<client id>&host=<page origin>&pu=<user hash> (auth: none). The iframe inside your Unio button.
- Popup finish: GET https://uniodental.com/embed/done (auth: none). Where a connect started from the panel ends: add ?error=<reason> when it failed.

Pushed request fields (form-encoded POST to https://uniodental.com/api/oidc/request; the reply is 201 with request_uri and expires_in):
- response_type = code
- client_id = <client id>. Also the Basic auth user.
- redirect_uri = <your callback>. Exactly one of the addresses you registered, on the host the person is using.
- scope = openid. Nothing else is needed.
- state = <random>. Kept in your session; checked on the callback.
- nonce = <random>. Kept in your session; must equal the id_token nonce.
- code_challenge = <S256 of the verifier>. PKCE is required.
- code_challenge_method = S256
- unio_intent = connect | launch. Connect attaches your user; launch signs an already-linked one in.
- unio_partner_user_id = <your user id, as text> (connect only). Who is connecting on your side.
- login_hint = <their email> (connect only). Shown and pre-filled on Unio's screens.
- unio_email_verified = true | false (connect only). Exactly whether your user proved this address.
- unio_given_name = <first name> (connect only). Used if they make a new Unio account.
- unio_family_name = <last name> (connect only)
- unio_partner_company_id = <company open on your side, as text> (connect only). Leave it out for a personal account. When that company is linked to a Unio practice, the sign-in screen names the practice.
A request that is not pushed is refused. The unio_* fields are only accepted in the pushed request, never in a browser URL.

After the pushed request, send the browser to https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri>.

Callback: Unio sends the browser back with a plain GET to your redirect_uri, with code, state and iss. Never form_post.
On failure it sends error=access_denied&error_description=<reason>&state=<state>&iss=<issuer>. The reasons:
- user_cancelled: The person closed or declined. Say nothing went wrong; offer to try again.
- unio_not_connected: Launch, but this Unio person has no confirmed link to your app. Ask them to sign in to your app and connect Unio from the Unio button.
- impersonation_not_allowed: A Unio admin is viewing as this person. Refuse. Nothing is attached or signed in.
- partner_user_linked_elsewhere: Your user is already linked to a different Unio person. Tell them this account is connected to another Unio account.
- unio_account_linked_elsewhere: This Unio person is already linked to a different user of your app. Tell them to disconnect Unio from the other account first.

Token: POST https://uniodental.com/api/oidc/token with grant_type=authorization_code, code, redirect_uri, code_verifier. The reply has an id_token.
Verify the id_token: RS256 only (refuse "none" and every other algorithm), kid required, signature from the signing keys, iss equals the issuer, aud equals your client id (if aud has several values, azp must equal your client id), exp in the future and iat not in the future (at most 60 seconds of clock skew either way), nonce equals the one you stored. Then use ONLY its sub. sub is the stable id of this Unio person for your app. Ignore every other claim.

Profile: PUT https://uniodental.com/api/partner/v1/profile with a JSON body like this (every profile key is optional; a key left out keeps its value; null clears it; company_type is buyer, seller or broker):
{
  "sub": "<id_token sub>",
  "partner_user_id": "<your user id, as text>",
  "profile": {
    "first_name": "Ahmad",
    "last_name": "Khan",
    "email": "dr.ahmad@example.com",
    "email_verified": true,
    "phone": "+1 555 0100",
    "job_title": "Owner dentist",
    "company_name": "Bright Smiles Dental",
    "company_type": "seller",
    "address": {
      "street": "1 Main St",
      "city": "Naples",
      "state": "FL",
      "zip": "34102",
      "country": "US",
      "formatted": "1 Main St, Naples, FL 34102"
    },
    "companies": [
      {
        "id": "<your company id>",
        "name": "Bright Smiles Dental",
        "leader": true
      }
    ]
  }
}
Replies: 204 saved; 404 {"error":"unknown_subject"}; 409 {"error":"partner_user_mismatch"}. The FIRST successful profile call is what makes the link live in Unio.

Disconnect: POST https://uniodental.com/api/partner/v1/disconnect with {"sub": "<sub>"}. Always 204, safe to repeat.

Launch: Unio opens <your launch address>?iss=<issuer> in a new tab (target_link_uri is optional). Accept GET and POST.

Panel: the Unio button is an iframe at https://uniodental.com/embed?partner=<client id>&host=<the page's origin>&pu=<user hash>.
pu is the first 32 hex characters of HMAC-SHA256(UNIO_PANEL_KEY, your user id). Allowed characters: A-Z a-z 0-9 _ - (up to 64). Never the raw id or an email.
The panel's "Connect Unio" opens a popup at <host><your connect path>?from=embed. A connect started there must END by sending the popup to https://uniodental.com/embed/done (success) or https://uniodental.com/embed/done?error=<reason> (failure). Reasons the popup understands: partner_user_linked_elsewhere, unio_account_linked_elsewhere, impersonation_not_allowed, user_cancelled, linked_elsewhere, already_connected, impersonating, inactive, not_allowed, switched_off, email_unverified, expired, session_changed, no_workspace, host_not_allowed, rate_limited, profile_failed, unavailable. Any other reason shows a general message.

=== STEP 3. WHAT TO BUILD ===

1. Settings, read from the environment or the app's secret store, never committed:
- UNIO_ISSUER (e.g. https://uniodental.com/api/oidc): Compared byte for byte with iss.
- UNIO_ORIGIN (e.g. https://uniodental.com): Where the partner API and the panel live.
- UNIO_CLIENT_ID (e.g. yourapp): From Unio. Also your ?partner= value.
- UNIO_CLIENT_SECRET (e.g. <shown once>): Secret. Server side only.
- UNIO_PANEL_KEY (e.g. <any long random string>): Yours alone: keys the user hash (pu). Never sent to Unio.
When any of them is missing, the feature is off: every /unio route returns 404 and no button renders.

2. A Unio client module (server side). Discovery cached about an hour: its issuer must equal UNIO_ISSUER exactly and every endpoint must be on the issuer's origin, so the secret can never be sent elsewhere. Signing keys cached; refetch once on an unknown kid, and only while verifying a token that came from the token endpoint. Calls: pushed request, token exchange, id_token verification, profile, disconnect. Timeouts of about 5 seconds, never follow redirects, never log response bodies, the secret or the code.

3. The pending sign-in: store state, nonce, code_verifier, mode (connect or launch), redirect_uri, whether it came from the panel, the user id (connect) and the target (launch) in the server-side session, bound to this browser, single use, expiring after 10 minutes.

4. Routes (keep these paths unless the app has a strong convention):
- GET /unio/connect: a small page whose form POSTs to /unio/connect with the app's CSRF protection. It may submit itself only when the Referer's origin is https://uniodental.com; otherwise it shows a Continue button.
- POST /unio/connect: requires a signed-in, active user with a verified email, not impersonated, not an admin. Store the pending sign-in, make the pushed request with unio_intent=connect and the connect-only fields, redirect the browser to the authorize address. Rate limited per IP (for example 10 per 3 minutes).
- GET and POST /unio/launch: a POST is redirected with 303 to the GET with the same query BEFORE anything touches the session or flash (a cross-site POST arrives without your session cookie, and a new session cookie would sign the current user out). The GET checks iss equals UNIO_ISSUER exactly, keeps target_link_uri only if it is a path on this host (it starts with exactly one "/", not "//" or "/\"; it holds no control character, space or backslash, raw or percent-encoded; and once parsed against this origin it is still on this origin, in which case you keep the re-serialized path and query, never the raw string; otherwise it is dropped), and shows a small page whose CSRF-protected form POSTs to /unio/launch/start (same Referer rule for submitting itself).
- POST /unio/launch/start: refuse if impersonating; store the pending sign-in; pushed request with unio_intent=launch and no connect fields; redirect to the authorize address. Rate limited per IP.
- GET /unio/callback: take the pending sign-in out of the session (missing, expired or state mismatch: fail); iss must equal UNIO_ISSUER; if error is present, map error_description; else exchange the code and verify the id_token. Every branch ends in a redirect. Then:
  Connect: the signed-in user must still be the one who started (else fail with session_changed). If the sub is already linked to a different user: fail with linked_elsewhere. If this user is linked to a different sub: fail with already_connected. Otherwise save the link (provider "unio", uid = sub) in the identity table. Then send the profile. If the profile call fails, DELETE the row you just created without calling disconnect, and fail with profile_failed. On success: from the panel, redirect the popup to https://uniodental.com/embed/done; otherwise back to the settings page with a notice.
  Launch: find the user linked to this sub (none: explain they must connect first). Refuse inactive users, admins and anyone with admin permissions, and impersonation. If a DIFFERENT user is signed in in this browser, store a 5-minute pending switch and ask "Switch to <name>?" or "Stay as <name>"; on "switch", re-check everything before acting. If the same user is already signed in, go to the target. Otherwise sign out, reset the session, clear the cookies that outlive a session, then: with two-step login on, hand over to the existing code step exactly where a correct password would (do not sign in, do not send the profile yet); without it, sign in, send the profile (a 409 partner_user_mismatch signs them out again, deletes your stored link without calling disconnect and asks them to connect Unio again; any other failure is logged, not shown), and go to the target or home.
- A connect started from the panel reports every failure by redirecting the popup to https://uniodental.com/embed/done?error=<reason>, never by stranding it on your page.
- Every /unio page you render (connect, launch, the "Switch to?" question) sends Content-Security-Policy frame-ancestors 'none' (or X-Frame-Options: DENY), so no other site can frame it.

5. Linking storage: in the identity table, provider "unio" and uid = sub. Add two unique indexes limited to provider "unio": one on (provider, uid) and one on the owner. Build them without locking the table if the database supports it. Add no other table or column. Catch the unique-violation error and treat it as linked_elsewhere.

6. Disconnect: when a user removes the Unio link in your settings, or the user is deleted, POST disconnect with the sub from a background job with retries.
   Email change: when a linked user changes their email, mark it unverified until they confirm it (connect is refused meanwhile with email_unverified), resend the profile with email_verified false from a background job, and resend it with true once they confirm.

7. The Unio button: a small floating button (bottom-right) that opens a panel containing the iframe from step 2, inside a container that survives your page navigation (for example data-turbo-permanent). Show it only when signed in, active, not impersonating and configured. Make sure it never covers a fixed bottom action such as a wizard's Next button. Alternatively load https://uniodental.com/widget.js with data-partner="<client id>" and data-partner-user="<user hash>". Allow https://uniodental.com in your Content-Security-Policy under frame-src (and script-src if you use widget.js).

8. Tests, with Unio faked at the HTTP layer (discovery, signing keys, pushed request, token, profile, disconnect; sign test id_tokens with your own RSA key):
- connect happy path; the pushed request carries every field above; no token is stored.
- tampered, reused, expired and other-browser state are refused before any code exchange.
- wrong nonce, wrong iss, wrong aud, wrong algorithm, expired token are refused.
- sub linked to another user; user linked to another sub; failed profile call removes the new link and sends no disconnect.
- launch: linked active user signed in and sent to the target; unknown sub; inactive; admin; admin permission; impersonating; two-step user sent to the code with no profile sent; someone else signed in is asked; a target on another host is ignored; wrong iss refused; a POSTed launch is bounced to a GET without a new session cookie.
- unverified email cannot connect; the button shows only when it should; disconnect job calls Unio.
- a launch target of //evil.test, /\evil.test, /%09/evil.test, a path with a raw tab or a backslash, https://evil.test or javascript:alert(1) is ignored.
- a launch whose profile call answers 409 partner_user_mismatch signs the person out and deletes the stored link, with no disconnect call.
- an id_token whose exp passed more than 60 seconds ago, or whose iat is more than 60 seconds ahead, is refused.
- the connect, launch and switch pages send frame-ancestors 'none' (or X-Frame-Options: DENY).
- an email change marks the email unverified and resends the profile with email_verified false.
- every route 404s when unconfigured.

=== RULES ===
- Only the id_token sub identifies a Unio person. Never find, link or sign in anyone by matching an email: that hands an account to whoever registered the address first.
- Connect needs a signed-in, active user whose email is verified. An unverified account may have been set up by someone else with the owner's address.
- Never connect or switch while someone is impersonating a user. An admin viewing as someone must not act as them.
- Admins, and anyone with admin permissions, sign in the normal way. A second way into a powerful account is a second thing to protect.
- Keep your two-step login. Unio replaces the password, never the code. Send no profile until the code is entered.
- Never silently replace whoever is signed in. On a shared front-desk computer, ask "Switch to Ahmad?" or "Stay as Sara".
- One Unio person to one of your users, both ways. Hold it with two unique indexes, not only with checks.
- State, nonce and code verifier stay server side, single use, 10 minutes. They are what stops a forged or replayed callback.
- Every callback branch redirects. Never render a page while the code is in the address bar, and never log the code.
- Check iss on the launch and on the callback. It must equal UNIO_ISSUER exactly; a target_link_uri must be a path on your own host.
- Keep no Unio tokens. You need the sub, nothing else. The access token is unused.
- The client secret never reaches a browser. Every call to Unio is made by your server.
- Rate limit the two start actions. Each makes a server call to Unio from your address, and Unio limits per address, so one script could stop it for all your users.
- Do not route this through your social-login library. Those callbacks usually create users and link by email. Write dedicated handlers.
- Send the whole companies list, and send it again when it changes. Unio adds people to the practice a company is linked to. Resend the profile when someone joins or leaves a company or their role changes, and mark your own staff as not a leader.
- A target is a path on your own host, checked after parsing. Refuse any control character, space or backslash in it (raw or percent-encoded), then parse it against your own origin: the result must still be your origin, and you redirect to its re-serialized path and query, never the raw string. Anything else is dropped and the person goes home: an open redirect hands a signed-in session to whoever wrote the link.
- Your /unio pages cannot be framed. Send frame-ancestors 'none' (or X-Frame-Options: DENY) on the connect, launch and "Switch to?" pages, so no other site can overlay them and trick a click.
- A changed email is unproven again. Until the user confirms the new address, refuse connect (email_unverified) and send unio_email_verified=false and email_verified false; resend the profile with true once they confirm it.
- On a launch, a 409 from the profile call signs the person out again. partner_user_mismatch means your stored link and Unio disagree about who this is. Sign out, delete your stored link without calling disconnect, and ask them to connect Unio again. Any other profile failure is logged, not shown.
- Allow at most 60 seconds of clock skew. On exp and iat, and no more: a wider window keeps a stolen id_token useful.
- The secret can be replaced without a deploy. Read it from your settings. When Unio rotates it, the old one stops at once, so put the new one in straight away.

=== BEFORE PRODUCTION: WHAT UNIO CHECKS ===
Unio turns your production client on only after it has run these against your STAGING. Make each one an automated test where you can, and walk the rest by hand before you report:
1. Settings empty. Expected: Every /unio route answers 404 and no Unio button shows.
2. Connect from the Unio button, then open your app from a Unio tile, then disconnect in your settings. Expected: Each works; after disconnect the tile is gone from Unio.
3. The same callback address opened a second time, or in another browser. Expected: Refused, nobody signed in.
4. A launch with iss set to another address. Expected: Refused.
5. A launch whose target is //evil.test, /\evil.test, /%09/evil.test, a path with a raw tab, https://evil.test or javascript:alert(1). Expected: The target is ignored and the person lands on your home page.
6. A launch POSTed from another site while someone is signed in. Expected: Bounced to a GET; the signed-in person is not signed out.
7. A launch for an admin, an inactive user, and while impersonating. Expected: Refused each time; nobody is signed in.
8. A launch for a user with two-step login. Expected: The code step, not signed in until the code is entered.
9. A launch while someone else is signed in in that browser. Expected: Asked "Switch to <name>?" or "Stay as <name>"; nothing changes before the answer.
10. Connect with an unverified email, and while impersonating. Expected: Refused (email_unverified, impersonating).
11. A second user of your app connects the same Unio person. Expected: Refused (linked_elsewhere).
12. Your connect, launch and "Switch to?" pages loaded inside another site's iframe. Expected: Blocked by the browser.
13. The panel iframe address and your page source. Expected: pu is a hash, never a raw id or an email; the client secret appears nowhere in a browser.
14. Connect starts, and launch starts, from one address beyond your rate limit. Expected: The later ones are refused, for both.
15. A user changes their email. Expected: Connect is refused until they confirm it, and the next profile Unio receives says email_verified false.
16. A callback with iss missing, or set to another address. Expected: Refused before any code exchange.
17. Your tests: id_tokens with alg none, HS256 signed with Unio's public key, the wrong aud or azp, expired, a missing or wrong nonce, and an unknown kid. Expected: All refused; the unknown kid refetches the signing keys once, no more.
18. A connect started as one user, finished after signing in as another. Expected: Refused (session_changed).
19. Connect by an admin, or anyone with admin permissions. Expected: Refused (not_allowed).
20. A launch whose profile call Unio answers 409 partner_user_mismatch. Expected: The person is signed out again and asked to connect Unio again.
21. A token exchange forced to fail. Expected: Nothing logged or sent to error tracking carries the Authorization header, the secret or the code.
22. Two connect starts. Expected: Each sends a fresh code_challenge (S256) from a new random verifier, a new state and a new nonce.

=== STEP 4. WHEN YOU ARE DONE, REPORT ===
- Every file you added or changed, and the migration.
- The settings to fill in, per environment.
- What to send Unio to get a client:
- Your app's name and a square logo.
- Every host your users sign in on (for example app.example.com and example.com). Each becomes an allowed origin, and may frame the panel.
- The callback on each host, for example https://example.com/unio/callback.
- Your connect page path, for example /unio/connect.
- Your launch address, for example https://example.com/unio/launch.
- A separate set for staging. Staging is its own client.
- Each check under BEFORE PRODUCTION: the test that covers it, or how you checked it by hand.
- Anything from step 1 that did not fit this plan, and what you did about it.

Review what it builds as you would any change to your sign-in code.

Go live

Before you switch it on.

Unio turns your production client on only after running these checks against your staging. Pass them first: each one is a way an integration goes wrong.

We tryYou must
Settings empty.Every /unio route answers 404 and no Unio button shows.
Connect from the Unio button, then open your app from a Unio tile, then disconnect in your settings.Each works; after disconnect the tile is gone from Unio.
The same callback address opened a second time, or in another browser.Refused, nobody signed in.
A launch with iss set to another address.Refused.
A launch whose target is //evil.test, /\evil.test, /%09/evil.test, a path with a raw tab, https://evil.test or javascript:alert(1).The target is ignored and the person lands on your home page.
A launch POSTed from another site while someone is signed in.Bounced to a GET; the signed-in person is not signed out.
A launch for an admin, an inactive user, and while impersonating.Refused each time; nobody is signed in.
A launch for a user with two-step login.The code step, not signed in until the code is entered.
A launch while someone else is signed in in that browser.Asked "Switch to <name>?" or "Stay as <name>"; nothing changes before the answer.
Connect with an unverified email, and while impersonating.Refused (email_unverified, impersonating).
A second user of your app connects the same Unio person.Refused (linked_elsewhere).
Your connect, launch and "Switch to?" pages loaded inside another site's iframe.Blocked by the browser.
The panel iframe address and your page source.pu is a hash, never a raw id or an email; the client secret appears nowhere in a browser.
Connect starts, and launch starts, from one address beyond your rate limit.The later ones are refused, for both.
A user changes their email.Connect is refused until they confirm it, and the next profile Unio receives says email_verified false.
A callback with iss missing, or set to another address.Refused before any code exchange.
Your tests: id_tokens with alg none, HS256 signed with Unio's public key, the wrong aud or azp, expired, a missing or wrong nonce, and an unknown kid.All refused; the unknown kid refetches the signing keys once, no more.
A connect started as one user, finished after signing in as another.Refused (session_changed).
Connect by an admin, or anyone with admin permissions.Refused (not_allowed).
A launch whose profile call Unio answers 409 partner_user_mismatch.The person is signed out again and asked to connect Unio again.
A token exchange forced to fail.Nothing logged or sent to error tracking carries the Authorization header, the secret or the code.
Two connect starts.Each sends a fresh code_challenge (S256) from a new random verifier, a new state and a new nonce.
  • Production has its own client, the secret is in production’s settings, and every host is registered.
  • Your Content-Security-Policy allows https://uniodental.com.
Cookies & browsers

The panel does not depend on a third-party cookie.

The popup is a top-level window on uniodental.com, and the panel is an iframe on uniodental.com inside your page. Browsers keep those two cookie jars apart, and with third-party cookies blocked the iframe’s cookie is dropped anyway.

So the popup hands over the sign-in explicitly: it postMessages a short-lived token to the panel that opened it, targeted at our exact origin, and the panel sends it as an Authorization header. With data-partner-user the token is kept per person, so the next person at the same computer never sees the last one’s apps. It works the same in Chrome, Edge, Safari and Firefox, with nothing for you to configure.

Brand assets

Stable URLs for the Unio mark.

The wordmark, white, on the brand navy square. Hotlink these directly — they will not move, and they are never behind an auth or pre-launch redirect.

  • https://uniodental.com/icon.svg — the canonical app icon. Square and full-bleed, with no corner rounding, because most surfaces round the tile themselves. Also at /icon.png (512×512), /apple-touch-icon.png (180×180) and /favicon.ico.
  • https://uniodental.com/icon-rounded.svg — the same mark with the rounding applied, for surfaces that drop it in as-is. Use it only if you are not setting your own border-radius. Also at /icon-rounded.png.
  • https://uniodental.com/logo.svg — the wordmark on its own, transparent, with fill="currentColor" so it inherits your text color.

Ready to connect your app?

Send us your hosts and addresses from step 1, and we’ll set up your client.

Talk to us

Building an app for the store instead? Browse the directory →