MIMI

INTEGRATION · OPENID CONNECT

Ask for the claims you need.

MIMI is an OpenID Provider. If you have integrated “Sign in with Google”, you have already done this: discovery, an authorization code with PKCE, then a token and a set of claims. The identity work behind those claims — capture, liveness, the register, and the Data Protection Act duty on the evidence — stays with us.

This page assumes you know OIDC. It does not re-teach the spec — it states what MIMI requires, what it returns, and what it does not do.

01 · QUICKSTART

Discovery and the round-trip

Ordinary authorization code flow with PKCE. The only MIMI-specific part is the standard claims request parameter, which is how you name what you need.

The issuer

Everything starts from one URL. Fetch the discovery document at boot and take every endpoint from it — the paths below are stable, but a client that reads them from discovery survives a change that a client with them hardcoded does not.

GEThttps://mimi.ke/.well-known/openid-configuration
https://mimi.ke/.well-known/openid-configuration

The document carries issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri and end_session_endpoint. Its scopes_supported is exactly openid email profile — there is no fourth scope to discover.

The endpoints

PathEndpointNotes
/.well-known/openid-configurationDiscoveryEvery other endpoint comes from here. Read it at startup; never hardcode the rest.
/authorizeAuthorizationresponse_type=code only. PKCE is required of every client, public or confidential.
/tokenTokenclient_secret_basic. Returns an id_token and an access token; no refresh token is issued.
/userinfoUserInfoBearer access token. Answers from MIMI's claim table — never a passthrough of anything upstream.
/jwksJWKSMIMI's public signing keys. Fetch and cache; the private half never leaves the host.
/session/endEnd sessionRP-initiated logout. See the note under Client registration about post-logout redirects.

What MIMI requires of you

RequirementDetail
response_type=codeAuthorization code only. No implicit, no hybrid.
PKCEMandatory, for every client, confidential ones included. Send code_challenge with code_challenge_method=S256, and the matching code_verifier at the token endpoint. A request without a challenge is rejected with invalid_request and the description “Authorization Server policy requires PKCE to be used for this request”.
Client authenticationclient_secret_basic at /token — the secret in the HTTP Basic header, not the body.
Redirect URIExact string match against what is registered. No wildcards, no prefixes, no trailing-slash forgiveness.
Scopesopenid, email, profile. Anything else is rejected with invalid_scope, and one unknown scope fails the whole request.
Token lifetimesAuthorization code 60s (single use). ID token and access token 10 minutes. No refresh token is issued — see Registration.

What the token endpoint returns

A successful exchange returns access_token, token_type, expires_in, scope and id_token. There is no refresh_token, deliberately — see Registration.

The id token is RS256 with a kid naming the key to verify it against, and carries iss, aud, sub and the nonce you sent. Verify all four plus the signature against /jwks, with a library. An id token that agrees with /userinfo is not thereby validated.

Naming the claims you need

The standard claims request parameter is enabled. It is the request MIMI is built around: you name what you need, MIMI works out what is already held and prompts only for what is missing or expired.

AUTHORIZEclaims request parameter
GET /authorize
  ?client_id=<your-client-id>
  &response_type=code
  &redirect_uri=https%3A%2F%2Fbank.example%2Fauth%2Fmimi%2Fcallback
  &scope=openid%20email%20profile
  &state=<random>
  &nonce=<random>
  &code_challenge=<S256 of your verifier>
  &code_challenge_method=S256
  &claims=%7B%22userinfo%22%3A%7B%22email%22%3A%7B%22essential%22%3Atrue%7D%7D%7D

# the claims parameter, decoded:
{ "userinfo": { "email": { "essential": true } } }

A complete round-trip

Runnable as-is on Node 20 or later with no dependencies. Register http://localhost:8787/callback as a redirect URI first, and pass your client id and secret through the environment.

NODEmimi-quickstart.mjs
// mimi-quickstart.mjs — Node 20+, no dependencies. Run: node mimi-quickstart.mjs
// Placeholders only. Never commit a client secret; read it from the environment.
import crypto from "node:crypto"
import http from "node:http"

const ISSUER        = process.env.MIMI_ISSUER    ?? "https://mimi.ke"
const CLIENT_ID     = process.env.CLIENT_ID      ?? "<your-client-id>"
const CLIENT_SECRET = process.env.CLIENT_SECRET  ?? "<your-client-secret>"
const REDIRECT_URI  = "http://localhost:8787/callback"   // must be registered EXACTLY

// 1. Discovery. Read every endpoint from here — never hardcode them.
const conf = await fetch(`${ISSUER}/.well-known/openid-configuration`).then((r) => r.json())

// 2. PKCE + anti-forgery values. MIMI requires PKCE of every client.
const b64url    = (b) => b.toString("base64url")
const verifier  = b64url(crypto.randomBytes(32))
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest())
const state     = b64url(crypto.randomBytes(16))
const nonce     = b64url(crypto.randomBytes(16))

const authUrl = new URL(conf.authorization_endpoint)
authUrl.search = new URLSearchParams({
  client_id: CLIENT_ID,
  response_type: "code",
  redirect_uri: REDIRECT_URI,
  scope: "openid email profile",
  state,
  nonce,
  code_challenge: challenge,
  code_challenge_method: "S256",
}).toString()

console.log("\nOpen this in a browser:\n" + authUrl + "\n")

// 3. Catch the redirect back, exchange the code, read the claims.
http
  .createServer(async (req, res) => {
    const url = new URL(req.url, REDIRECT_URI)
    if (url.pathname !== "/callback") return res.writeHead(404).end()

    const err = url.searchParams.get("error")
    if (err) {
      // Errors MIMI can redirect arrive here with the state you sent.
      console.error("authorization failed:", err, url.searchParams.get("error_description") ?? "")
      res.end("Failed — see the console.")
      return process.exit(1)
    }
    // Reject a response that is not the one you started. Non-negotiable.
    if (url.searchParams.get("state") !== state) {
      res.end("state mismatch")
      return process.exit(1)
    }

    const basic = Buffer.from(
      `${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`,
    ).toString("base64")

    const tokens = await fetch(conf.token_endpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${basic}`,          // client_secret_basic
      },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code: url.searchParams.get("code"),
        redirect_uri: REDIRECT_URI,
        code_verifier: verifier,                  // the other half of the PKCE pair
      }),
    }).then((r) => r.json())

    // In production, VALIDATE the id_token — signature against ${ISSUER}/jwks,
    // then iss / aud / exp / nonce. Use a library (openid-client, jose); do not
    // hand-roll it, and do not trust an unvalidated token because /userinfo
    // agreed with it.
    const claims = await fetch(conf.userinfo_endpoint, {
      headers: { Authorization: `Bearer ${tokens.access_token}` },
    }).then((r) => r.json())

    console.log("\nclaims:", claims)
    res.end("Signed in. You can close this tab.")
    process.exit(0)
  })
  .listen(8787)
A five-step integration diagram headed "If a bank can integrate 'Sign in with Google', it can integrate mimi". Step 01 (the bank) redirects to mimi with a GET /authorize request carrying client_id, scope=openid and a claims parameter naming verified_claims. Step 02 (mimi) re-reads the store and runs only what is missing — held skips, missing runs the step, expired runs it again. Step 03 (mimi) asks the customer and only then issues a code, returning a 302 to the bank's callback or error=access_denied. Step 04 (the bank) exchanges the code at POST /token with PKCE. Step 05 (the bank) reads verified_claims from GET /userinfo and opens the account. Two panels below list what the institution no longer builds — document capture, OCR, liveness, face match, register access, evidence retention and the DPA obligations — and how TendaWorld's own apps integrate on identical terms.
BOARD D · 08The whole relying-party integration as ordinary OIDC: authorize, consent, code, token, userinfo — the only mimi-specific part is the claims parameter naming the verified claims required.

02 · CLAIMS

What comes back

MIMI answers from its own claim table. It never passes through a token or an attribute from anything behind it, and it never hands you evidence — the ID photographs and the face capture stay with MIMI, which is most of what you are buying.

The three standard claims

ClaimScopeWhere it comes fromLapses
subopenidThe subject identifier, stable and permanent for a person across every MIMI client. Key your account records on it.Never
emailemailA what-i-have claim, written at login with ceremony assurance.When the person removes it
nameprofileA who-i-am claim, as it reads on the document MIMI verified.When the person removes it
GET/userinfo · 200 OK
GET /userinfo            Authorization: Bearer <access token>

200 OK
{
  "sub":   "a3f1c8b2-4d90-4a11-9f3e-0b2c7d5e9c02",
  "email": "amina@example.com",
  "name":  "Amina W. Kariuki"
}

The claim model

Every fact MIMI holds is one row: the attribute, who verified it, how, when, and when it lapses. The families are how a person reads their own store; they are not three subsystems, and a claim is the same kind of thing in each.

These are the names inside the store, not the wire format. Held is not the same as released: a passkey is a claim like any other and it is what a returning person signs in with, but no client ever receives it. You get what you asked for and the person released, and nothing else.

FamilyWhat it answersClaims
who-i-amThe person is who they say they arename, national_id, liveness, face_match
what-i-haveThe person answering now is the one who set this upemail, passkey, work_email
what-i-ownThe person holds a thingA signing certificate, an active subscription

Assurance: two words, not a scale

MIMI records one of two values against every claim. This is not a numeric ladder and there is no level 1 through 4 — the question is only whether an authenticating party reported a ceremony.

channelceremony
What it isAn email link or an SMS code — a secret sent to an address and returnedA passkey, a brokered sign-in, or a live face match
What it provesSomebody can read that inbox right nowThis person is the one who set the account up
Relayable / phishableYes — it is a bearer tokenNo
Opens the storeNoYes — this is the floor

What this means for you: a channel proof resolves which account is meant, and never admits anyone to it. Every authorization you receive from MIMI was opened by a ceremony proof.

The ke_mimi trust framework

ke_mimi is the named trust framework MIMI issues under — a framework identifier that travels inside a claim payload, not branding, which is why it is lowercase where the product name is not. It names the rules a Kenyan verified claim was produced under: which documents count, which checks were run, and by whom.

Verified attributes

Verified attributes travel as OIDC4IDA verified_claims — the published OpenID standard for “attribute, plus who verified it, how, and when”. Nothing bespoke, and nothing for your vendor to learn beyond the spec.

GET/userinfo · verified claims
GET /userinfo            Authorization: Bearer <access token>

200 OK
{
  "sub": "a3f1c8b2-…-9c02",
  "verified_claims": {
    "verification": {
      "trust_framework": "ke_mimi",
      "time": "2026-08-14T09:12:00Z",
      "evidence": [
        { "type": "document",  "document_type": "ke_national_id" },
        { "type": "biometric", "method": "face_match" }
      ]
    },
    "claims": {
      "given_name":  "Amina",
      "family_name": "Kariuki",
      "birthdate":   "1994-03-08"
    }
  }
}

Expiry, revocation, and why you must not cache

MIMI re-reads every claim on every request and derives state at read time. It holds no completion flag and no progress field, which is what lets an abandoned verification cost nothing and a late register reply upgrade a person silently. Two consequences are yours to handle:

BehaviourWhat you must do
A claim can appear between two of your requestsRe-request rather than assuming a person is stuck at the assurance you first saw.
A person can revoke your access at any timeHandle a later authorization returning less than an earlier one. It is not an error and not a downgrade of the person.
Claims expire on their own clockExpiry is evaluated when a claim is read, never stored. Policy is per family — a liveness check lapses in 90 days, and a work address has a shorter life than a personal one.
A bank screen headed "That's all we needed", subtitled "Confirmed by mimi a moment ago. Nothing to type." Three green-ticked rows show the claims that came back: Legal name — Nelson Kariuki Mwangi; ID number — 32104471; Date of birth — 8 March 1994. Beneath them a shield-marked line reads "Substantial assurance · ID matched to the register, live face match", and the card ends in a primary button, "Open my account".
BOARD E · 03What the relying party actually receives after a successful authorization — the claims it asked for, each already verified, plus the assurance level behind them.
A credential detail page inside MIMI for "National ID number", shown masked as bullet points ending 4471 with a Reveal control. A section headed "HOW THIS WAS VERIFIED — trust framework · ke_mimi" lists four timestamped steps on 12 August: document, extract, liveness and register. A right-hand rail headed "WHAT AN INSTITUTION RECEIVES" prints the OIDC4IDA payload — verified_claims with a verification block (trust_framework ke_mimi, assurance_level substantial, time, and evidence entries of type document, biometric and electronic_record) and a claims block carrying given_name, family_name and birthdate. Beneath it, a control shows the credential is shared with ACME Bank and offers "Revoke access — takes effect immediately".
BOARD D · 03The OIDC4IDA verified_claims payload a client parses, set next to the human evidence trail it summarises: verification provenance and claims arrive in one object.
Two states shown side by side under the heading "LIVING CREDENTIALS — a credential you can't withdraw isn't yours". On the left, STATE · EXPIRED: "Your face match has expired", explaining liveness lapses after 90 days on purpose because it proves you were there at the time. On the right, STATE · REVOKING: "Stop sharing with ACME Bank?", explaining mimi will refuse their next request immediately, with a note about what revocation can and cannot undo. Each panel lists the concrete impact and ends in a confirm action.
BOARD D · 09Why a client must re-read claims on every request rather than cache them: proofs expire on a clock (liveness after 90 days) and the subject can revoke a relying party's access at any moment.

03 · THE BUTTON

Continue with MIMI

One shape, two surfaces. Never redraw the mark and never change the words — a person has to recognise this button on a site they have never used before, and that only works if it is the same button everywhere.

The values

On lightOn dark
Background#0F172A#FFFFFF
Label#FFFFFF#0F172A
Mark stroke#04D56D#0B7A42
BorderNoneNone
Height · radius60px · 999px (full pill)60px · 999px (full pill)
Padding · gap18px all round · 12px18px all round · 12px
Mark24 × 24px, stroke-width 16 on a 256 viewBox. One board draws it at 25px; that is a one-pixel slip, not a second size — use 24.Same geometry, deeper stroke
Label typeSpace Grotesk 17px / 600 / 22px, −0.01emSame

States

StateBehaviour
PressedDarken 8%. No scale, no bounce.
RedirectingThe label becomes “Taking you to MIMI”. The mark spins; the button stays put.
UnavailableHide it. Never show a dead MIMI button — fall through to your own form. This is deliberately not a disabled state: an outage is our problem, and it must never read to a customer as a verdict on them.
FocusNot specified in the design. The CSS below ships a visible focus ring anyway, because a keyboard user must be able to see where they are. Restyle it to your own focus language; do not delete it.

Copy this

HTMLthe markup
<!-- point href at your own route that starts the OIDC redirect -->
<a class="mimi-button" href="#">
  <svg class="mimi-button__mark" viewBox="0 0 256 256" fill="none"
       stroke-width="16" stroke-linecap="round" stroke-linejoin="round"
       aria-hidden="true" focusable="false">
    <polyline points="48 88 48 48 88 48" />
    <polyline points="168 48 208 48 208 88" />
    <polyline points="208 168 208 208 168 208" />
    <polyline points="88 208 48 208 48 168" />
    <circle cx="128" cy="128" r="32" />
  </svg>
  Continue with MIMI
</a>
CSSno framework, no build step
/* "Continue with MIMI" — drop into your own stylesheet.
   Values from the MIMI button spec: 60px tall, full pill, 18px padding,
   12px gap, 24px mark, 17px/600 label. */
.mimi-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  box-sizing: border-box;
  width: 100%;              /* the spec draws it full-width in the host card */
  min-height: 60px;
  padding: 18px;
  border: 0;
  border-radius: 999px;
  background: #0F172A;
  color: #FFFFFF;
  font-family: "Space Grotesk", system-ui, sans-serif;
  font-size: 17px;
  font-weight: 600;
  line-height: 22px;
  letter-spacing: -0.01em;
  text-decoration: none;
  cursor: pointer;
}

.mimi-button__mark { flex: 0 0 auto; width: 24px; height: 24px; display: block; }
.mimi-button__mark path,
.mimi-button__mark circle,
.mimi-button__mark polyline { stroke: #04D56D; }

/* On a dark surface (#07101F and similar): invert the pill, deepen the mark. */
.mimi-button--on-dark { background: #FFFFFF; color: #0F172A; }
.mimi-button--on-dark .mimi-button__mark path,
.mimi-button--on-dark .mimi-button__mark circle,
.mimi-button--on-dark .mimi-button__mark polyline { stroke: #0B7A42; }

/* Pressed: darken 8%. No scale, no bounce. */
.mimi-button:active { filter: brightness(0.92); }

/* NOT in the spec — added because a keyboard user must be able to see focus,
   and no focus treatment is drawn on the board. Restyle it to match your own
   focus language if you have one; do not remove it. */
.mimi-button:focus-visible { outline: 3px solid #04D56D; outline-offset: 3px; }

/* Redirecting: the label becomes "Taking you to MIMI", the mark spins, the
   button stays put. Add the class when you start the redirect. */
.mimi-button.is-redirecting .mimi-button__mark { animation: mimi-spin 1s linear infinite; }
@keyframes mimi-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .mimi-button.is-redirecting .mimi-button__mark { animation: none; }
}

/* Unavailable is NOT a disabled state. If MIMI cannot be reached, remove the
   button and fall through to your own form. Never show a dead MIMI button. */
JSoptional — the redirecting state
// The board's "Redirecting" state: label swap + spinning mark, button in place.
document.querySelector(".mimi-button")?.addEventListener("click", (e) => {
  const el = e.currentTarget
  el.classList.add("is-redirecting")
  el.lastChild.textContent = " Taking you to MIMI "
})

PLACEMENT

MIMI belongs as the primary action, with your own form demoted beneath it. A customer who has a MIMI should not have to find it below fifteen minutes of typing — that is the whole trade you are offering them.

A bank sign-up card headed "Open an account" with the line "If you have a mimi, we already have everything we need." Its primary action is a full-width dark navy pill button showing a green square-bracket scan mark and the words "Continue with mimi". Below an "or" divider sits a quieter secondary option, "Fill in the form instead", footnoted "The form takes about 15 minutes and a branch visit to verify".
BOARD E · 01Where the button belongs on a relying party's sign-up screen: mimi as the primary action, the manual form demoted to the fallback beneath it.
A partner specification sheet headed "FOR PARTNERS — The button", subtitled "One shape, two surfaces. Never redraw the mark, never change the words." Two variants are shown as full-width pills: "On light" is a dark navy pill with a green mark and white label, "On dark" is a white pill with a deep-green mark and dark navy label; both read "Continue with mimi". Underneath, a bordered STATES table lists three rows — Pressed: "Darken 8%. No scale, no bounce."; Redirecting: "Label becomes 'Taking you to mimi'. Mark spins, button stays put."; Unavailable: "Hide it. Never show a dead mimi button — fall through to the form."
BOARD E · 02The embeddable button a partner ships: one pill shape on light and dark surfaces, plus the three behavioural states it must implement. Exact values are in button-spec.md.

04 · REGISTRATION & ERRORS

Getting a client, and reading a failure

Client registration is not open. There is no self-service console and no dynamic registration endpoint — a client is configured by hand, by us, on the server.

How you get credentials

Email mail@tenda.world with your redirect URIs and the claims you need. We issue a client id and secret out of band. You will never be asked to send a secret to us, and we will never put one in an email you did not initiate.

Mechanically a client is three environment variables on the MIMI server — MIMI_CLIENT_<NAME>_ID, _SECRET and _REDIRECTS — found by scanning the environment at boot, so adding one is configuration rather than a code change. What that means for you: turnaround is a redeploy on our side, not a self-service form. Send every redirect URI you will need, for every environment, in one go.

What we configure, and what you get

SettingValue
Grant typesauthorization_code
Response typescode
Token endpoint authclient_secret_basic
PKCERequired. Not negotiable per client.
Redirect URIsA list, matched exactly. Give us every environment you need up front — adding one is a server change, not a form.
Refresh tokensNot issued. A MIMI refresh token would outlive the upstream session it rests on. Re-authorize instead; a person with a live MIMI session will not see a prompt.
Post-logout redirectsRegistered alongside your redirect URIs. /session/end ends the MIMI session and returns the person to the URI you registered. Send us the ones you need in the same message.

Redirect URI rules

RuleWhy
Exact string matchNo wildcard, no prefix, no port fuzzing. A registered URI is compared character for character.
HTTPS in productionPlain HTTP is for localhost during development only.
No fragmentA fragment cannot survive the redirect; a URI carrying one will not match.
Query strings are part of the stringIf your callback carries a fixed query parameter, register the URI including it.
One per environmentStaging and production are different URIs. Register both rather than sharing one client across them.

Errors

Errors MIMI can safely redirect arrive at your callback as error and error_description, carrying the state you sent. Errors it cannot — anything wrong with the client id or the redirect URI itself — are rendered on MIMI’s own page instead, because redirecting them would mean trusting the thing that is wrong.

ErrorWhat happenedWhat to do
temporarily_unavailableA 503 from any endpoint: MIMI is not answering right now. Nothing is wrong with your request.Treat it as the Unavailable button state — hide MIMI and fall through to your own form. Never show it as a customer failure.
invalid_clientThe client id is not registered, or the secret sent to /token is wrong. An unknown client id at /authorize renders MIMI’s own error page and does not redirect — a redirect URI belonging to an unknown client is not one MIMI will send a browser to.If you are debugging a typo you will see a page, not a callback. Check the id against what we issued, and that the secret is in the Basic header rather than the body.
invalid_redirect_uriThe redirect_uri is not an exact match for a registered one. Shown on MIMI’s page, never redirected.Compare character for character, trailing slash included. Send us the URI to register if it is new.
invalid_requestA malformed request — most often a missing code_challenge, because PKCE is required of every client.Read error_description; it names the parameter.
invalid_scopeA scope outside openid email profile. One unknown scope fails the whole request.Send only the three. Use the claims parameter to ask for attributes, not extra scopes.
unauthorized_clientA grant type the client is not configured for — a refresh token exchange, for instance.Only authorization_code is configured. Re-authorize rather than refreshing.
invalid_grantAt /token: the code expired (they live 60 seconds), was already used, or the code_verifier does not match the challenge.Exchange immediately and once. Keep the verifier with the session that started the request.
access_deniedThe person declined, or did not complete what was needed. You receive nothing at all — not even confirmation that they have a MIMI.Offer your own path. This is a legitimate choice, not a failure.
login_requiredYou sent prompt=none and there was no live session to reuse.Retry without prompt=none.

What MIMI never sends you

NeverWhy
Any token from the provider behind MIMIIt would let you act as that person elsewhere. Reads that need it are proxied server-side, inside MIMI.
Evidence — ID images, face captures, biometric templatesThey stay encrypted with MIMI, which is the point: the Data Protection Act 2019 duty on that material stays ours.
An unlogged readEvery read of a person’s claims is written to an access log, including our own. They can see it.
Three failure cards side by side, each with an icon, a machine code, a human headline and a recovery action. ACCESS_DENIED — "You didn't share, and that's fine", explaining the bank received nothing at all, not even that the person has a mimi, with the action "Fill in the form". ASSURANCE TOO LOW — "Almost — one more check", explaining a current account needs Substantial assurance, with the action "Finish in mimi". MIMI UNREACHABLE — "We can't reach mimi right now", stating nothing is wrong with the customer's account and this must never be treated as a failure, with the action "Continue without mimi".
BOARD E · 04The three ways an authorization ends without claims, and what the bank should show for each — note that an outage is a mimi problem, never a verdict on the customer.
A card headed "VERIFICATION RECORD" for Nelson Kariuki Mwangi, referenced "acct-2026-08-15-4471 · relied on 15 Aug 2026, 09:14 EAT". A four-row table lists Assurance — Substantial; Trust framework — ke_mimi; Evidence — Document · biometric · electronic record; Verified by mimi — 12 Aug 2026, 09:14 EAT. A note says this is what you show a regulator and that the ID photographs and the face capture stay with mimi. Two actions close the card: "Export for audit" and "Re-check now".
BOARD E · 05The provenance record a relying party keeps for its regulator: assurance level, trust framework, evidence types and both timestamps — without ever holding the ID images themselves.