# Enrol your customers for signing

Enrolment begins at an authorization URL and ends with an authorization code returned to the application's callback. The backend exchanges that code for tokens. The MIMI service at `mimi.ke` can include payment, document and live face checks, security questions, passkey creation, a drawn signature and certificate issuance, according to the configured journey.

SecurySign also hosts an enrolment flow at `securysign.com`. Open it with the RP client ID and callback to create a passkey, drawn signature and certificate. With `signa-kyc`, verified identity is resolved first. See [Enrol through SecurySign directly](#/docs/enrolment#enrol-through-securysign-directly) for the link and code exchange.

## What you need

Choose the enrolment service and obtain its credentials before starting:

For MIMI enrolment, you'll need:

- An approved relying party (RP) with the `signa-enrolment` scope, used to sign the customer in. Add `signa-entitlement-required` if the customer must hold an active subscription.
- A MIMI client. Request one with your return URLs, the steps your customers must complete and your price. Save its `client_secret` when you receive it, because you see it only once.

## What the customer sees

The configured MIMI journey consists of the following steps. When a customer returns, completed steps are reused:

1. **Sign in** with Google through SecurySign. This step is silent if you signed the customer in first.
2. **Pay** for a 365-day subscription by M-Pesa STK push or card. Payment comes first, because a certificate is issued only to a customer with an active subscription.
3. **Submit an ID or passport:** the front and back of the ID, or the passport photo page.
4. **Complete a live face check**, matched to the document portrait.
5. **Answer three security questions.** The answers are stored hashed.
6. **Create a passkey and draw a signature**, in one step.
7. **Receive a certificate**, issued automatically.

The finish screen offers a return to the application. Existing certificates are retained; customers whose required steps are already complete can proceed to the callback.

## Start the journey

Use the endpoints from `https://mimi.ke/.well-known/openid-configuration`. Generate `state`, `nonce` and a PKCE `code_verifier` on the backend and save them in the customer's session. Set `code_challenge` to the verifier's base64url-encoded SHA-256 digest, then redirect the browser to `/authorize`:

```text
GET https://mimi.ke/authorize
  ?client_id=clientX
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fmimi%2Fcallback
  &response_type=code
  &scope=openid
  &state=<random>
  &nonce=<random>
  &code_challenge=<S256-challenge>
  &code_challenge_method=S256
  &claims=%7B%22id_token%22%3A%7B%22sub%22%3A%7B%22value%22%3A%22<sub>%22%7D%7D%7D
```

| Parameter | Required | Value |
|---|---|---|
| `client_id` | Yes | Your MIMI client ID. |
| `redirect_uri` | Yes | A return URL registered with your MIMI client, URL-encoded. |
| `response_type` | Yes | `code` |
| `scope` | Yes | `openid` |
| `state` | Yes | A random value you store in the customer's session. Carry in it the page your customer lands on afterwards; MIMI takes no return-URL parameter. |
| `nonce` | Yes | A random value you store in the customer's session and check in the ID token. |
| `code_challenge` | Yes | The base64url-encoded SHA-256 of your `code_verifier`. |
| `code_challenge_method` | Yes | `S256` |
| `claims` | Recommended | `{ "id_token": { "sub": { "value": "<sub>" } } }`, URL-encoded, where `<sub>` is the customer's `sub` from [single sign-on](#/docs/sso). It pins the journey to that account: if the signed-in account differs, MIMI asks your customer to switch, and if they decline, you get `error=access_denied&error_description=account_mismatch`. |

At the callback, compare `state` with the saved session value. Within 60 seconds, exchange the code on the backend using the saved verifier and MIMI client credentials:

```bash
curl -X POST https://mimi.ke/token \
  -u "$MIMI_CLIENT_ID:$MIMI_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/mimi/callback" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
```

Validate the ID token through an OIDC library against the issuer `https://mimi.ke`, the client ID as audience, the saved `nonce` and the discovery signing keys. The returned `sub` is the same SecurySign identifier used for the customer in the application.

The customer has up to 60 minutes to complete the interaction. An authorization code is issued after the required enrolment steps are finished.

## Handle the outcomes

| What happened | What you do |
|---|---|
| The customer abandons midway | When your customer chooses to continue, you open `/authorize` again; the journey resumes at the first unfinished step. |
| `error=access_denied` with `account_mismatch` | Show your own screen and offer sign-out and retry |
| "That link has already been used" on MIMI | Your customer reused a callback, usually through the back button. Send them through `/authorize` again. |
| Any other `error` | Keep the customer in your app and offer a retry they start themselves. |
| A second code exchange fails | You continue with the tokens from the successful exchange; for a fresh login, you start a new authorization request. |
| `503` from `/authorize` | MIMI is unavailable. Retry later, and contact support if it persists. |

## Enrol through SecurySign directly

The SecurySign-hosted flow uses the RP's enrolment configuration. Its screens take the customer through passkey creation, the signature image and final review, then return a code for the PKCE exchange.

### Set up your RP

The hosted flow requires:

- An approved relying party (RP) with the `signa-enrolment` scope, and its `client_id`.
- Your callback URL registered as a redirect URI on the RP. It must match exactly.
- The `signa-kyc` scope when your RP requires SecurySign to verify your customer's identity before it issues the certificate. See [Verify identity during enrolment](#/docs/enrolment#verify-identity-during-enrolment).

### Send your customer to SecurySign

Generate `state` and `code_verifier` for each attempt and save them in the customer session. Include the verifier's base64url-encoded SHA-256 digest as `code_challenge` in the URL:

```text
https://securysign.com/
  ?client_id=signa-rp-42
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fsecurysign%2Fcallback
  &response_type=code
  &scope=signa-enrolment
  &state=<random>
  &code_challenge=<S256-challenge>
  &code_challenge_method=S256
  &idp_hint=<your-identity-provider-alias>
  #/enrol
```

Place the query string before `#/enrol` so the hosted app reads the parameters when the enrolment route loads.

| Parameter | Required | Value |
|---|---|---|
| `client_id` | Yes | Your client ID. |
| `redirect_uri` | Yes | Your callback URL, exactly as registered on your RP, URL-encoded. |
| `response_type` | Yes | `code` |
| `scope` | Yes | `signa-enrolment` |
| `state` | Yes | A random value you store in the customer's session. |
| `code_challenge` | Yes | The base64url-encoded SHA-256 of your `code_verifier`. |
| `code_challenge_method` | Yes | `S256` |
| `idp_hint` | No | The alias of the identity provider registered for your RP. Your customer signs in there without a provider choice. |
| `prompt` | No | `login` to make your customer sign in again, for example before a sensitive action. |
| `request_uri` | No | Reuses an identity verification you ran yourself; see [Reuse a verification you ran yourself](#/docs/enrolment#reuse-a-verification-you-ran-yourself). |

The same entry link works for new and returning customers. Already-enrolled customers reach the finish screen with a return button.

### Verify identity during enrolment

With `signa-kyc`, the certificate uses the name from the verified identity document. The flow resolves identity in this order:

1. With your `request_uri`, SecurySign links your verification of that customer to the person who opens the link, and they skip the identity step; see [Reuse a verification you ran yourself](#/docs/enrolment#reuse-a-verification-you-ran-yourself).
2. When your customer already has a verified identity on that account, SecurySign uses that verification, and they skip the identity step.
3. When capture is required, Your customer sees a **Verify your identity** step before the passkey step: they scan their ID or passport, then take a live face check, on the computer or on their phone through a QR code.

If capture is needed, the screens are **Verify your identity**, **Create passkey**, **Add signature** and **Review and finish**. Identity verification provides document and live face capture, plus a QR-code option for customers who want to continue on their phone.

### Reuse a verification you ran yourself

A verification that already returned `verified` through [KYC](#/docs/kyc) can be reused. Request a `request_uri` for it on the backend and include that reference in the hosted enrolment URL.

Call `POST https://securysign.com/api/enrolment/request` with HTTP Basic authentication using the RP's `client_id` and OIDC `client_secret`. The approved RP must have the `signa-kyc` scope.

| Field | Type | Required | Description |
|---|---|---|---|
| `customer_id` | string | Yes | The customer you verified, as sent when you started the verification. |
| `rp_urn` | string or integer | No | The `rp_urn` you set for that customer: the plain ID or the full URN. It must belong to this `customer_id`. |

```bash
curl -X POST https://securysign.com/api/enrolment/request \
  -u "signa-rp-42:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678" }'
```

With a recorded `rp_urn`, include both identifiers:

```bash
curl -X POST https://securysign.com/api/enrolment/request \
  -u "signa-rp-42:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }'
```

```json
{ "request_uri": "urn:securysign:request:Xk3fQ9vT2mL8", "expires_in": 600 }
```

The response contains `request_uri` and `expires_in`. URL-encode the reference and add it before `#/enrol`. The customer must open the link within its 600-second lifetime:

```text
https://securysign.com/
  ?client_id=signa-rp-42
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fsecurysign%2Fcallback
  &response_type=code
  &scope=signa-enrolment
  &state=<random>
  &code_challenge=<S256-challenge>
  &code_challenge_method=S256
  &request_uri=urn%3Asecurysign%3Arequest%3AXk3fQ9vT2mL8
  #/enrol
```

The first account to open the link is assigned the latest `verified` result for that customer and has one hour to finish. The verification must have been created by the same `client_id` and be available to that account. An unusable reference returns an [outcome error](#/docs/enrolment#outcomes-and-errors) to the callback.

| Response | Cause | Resolution |
|---|---|---|
| `401 invalid_client` | Your `client_id` or `client_secret` is wrong | Send your RP credentials |
| `403 This relying party is not enabled for Identity Verification` | Your RP lacks the `signa-kyc` scope | Request the scope from the RP dashboard |
| `403 rp_urn belongs to another relying party: send an rp_urn your RP set` | The URN carries another RP's `client_id` | Send an `rp_urn` your RP set |
| `409 rp_urn urn:securysign:signa-rp-42:user-42 does not belong to this customer_id: send the rp_urn recorded for this customer, or omit rp_urn` | `rp_urn` belongs to a different customer, or to none | Send the `rp_urn` you set for this `customer_id`, or omit it |
| `422 customer_id is required` | The request has no `customer_id` | Send `customer_id`, with or without `rp_urn` |
| `429` | You sent more than 60 requests in a minute from one IP address | Wait a minute, then try again |

Use `request_uri` for passing an existing verification into a new enrolment integration.

### Exchange the code

Check `state` at the callback, then exchange the code on the backend within 120 seconds. Send the saved `code_verifier` and exactly the same redirect URI. Each code can be used once:

```bash
curl -X POST https://securysign.com/api/enrolment/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/securysign/callback" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
```

```json
{
  "access_token": "eyJhbGciOi…",
  "token_type": "Bearer",
  "expires_in": 280,
  "credential_id": "a1b2c30000000000000000000000000000000000000000000000000000000000",
  "certificate_status": "active"
}
```

The response includes the customer's SecurySign `access_token`, its remaining lifetime in `expires_in`, `credential_id` and `certificate_status`. Use that token for [certificate requests](#/docs/enrolment#manage-the-certificate).

### Outcomes and errors

A successful callback contains `code` and `state`. An unsuccessful callback contains `error` and `error_description`; use the reported reason to decide how to retry:

| What you get | Why | What you do |
|---|---|---|
| `code` and `state` | Enrolment finished | Exchange the code |
| `error=access_denied` with `request_uri is unknown or has expired: start enrolment again with a new request_uri` | The link was opened more than 600 seconds after you requested it, enrolment took longer than one hour, or the `request_uri` belongs to another `client_id` | Request a new `request_uri` and send a new link |
| `error=access_denied` with `request_uri was already used by another person: start enrolment again with a new request_uri` | Another person opened this link first | Request a new `request_uri` for this person |
| `error=access_denied` with `kyc_customer_id must be a customer this client_id has verified, not yet enrolled by another person` | You have no `verified` verification of this customer with this `client_id`, or another person enrolled with it | Verify the customer first, or leave `request_uri` out and let your customer verify during enrolment |
| `error=access_denied` with `Identity verification required before enrolment` | SecurySign found no verified identity for your customer when enrolment finished | Send them through the enrolment link again; they verify their identity there |
| `error=access_denied` with `subscription_required` | Your RP needs an active subscription | Renew the subscription, then send your customer again |
| `error=access_denied` alone | Your customer cancelled passkey creation | Offer a retry |
| `error=invalid_scope` | Your RP lacks the `signa-enrolment` scope | Request the scope from the RP dashboard |
| `invalid_grant` from `/enrolment/token` | The code expired, was used, or the `code_verifier` or `redirect_uri` differs | Send your customer through enrolment again |

## Enrol from a mobile app

Mobile MIMI enrolment opens in the system browser and returns through the app's registered link. For native capture or passkey creation, configure the capture SDK and association files for the passkey's RP ID, `mimi.ke`.

### Choose an approach

| Approach | What your app does | Files needed for the passkey |
|---|---|---|
| **Web journey** (recommended to start) | Opens `/authorize` in a system-browser tab. MIMI runs every step as web pages, the passkey included. | None, apart from the redirect link |
| **Native** | Captures the ID and face with the native SDK, and creates the passkey with platform APIs under `mimi.ke` | Yes, see [Bind the passkey](#/docs/enrolment#bind-the-passkey-to-mimike) |

Native capture can also be combined with passkey creation in the browser.

### Sign in through the system browser

Use `ASWebAuthenticationSession` on iOS or a Custom Tab or Auth Tab on Android. The backend exchanges the code using PKCE (`S256`) and `client_secret_basic`. For an app that exchanges codes directly, request a public MIMI client from SecurySign.

Use the parameters from the [authorization request](#/docs/enrolment#start-the-journey), including the account-binding `claims`.

### Return with an App Link or Universal Link

The mobile `redirect_uri` must be an HTTPS callback associated with the app and registered with MIMI. Use the session's `state` to match that callback to its intended in-app destination.

### Bind the passkey to `mimi.ke`

Native passkey creation requires the app to be registered for `mimi.ke`. Provide SecurySign with the Android package and every released signing fingerprint, or the Apple Team ID and bundle ID. Once those identifiers appear in the domain's association files, the app can create and use `mimi.ke` passkeys.

Android association is published at `https://mimi.ke/.well-known/assetlinks.json`, including the package and signing fingerprints:

```json
[{
  "relation": [
    "delegate_permission/common.get_login_creds",
    "delegate_permission/common.handle_all_urls"
  ],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.clientx",
    "sha256_cert_fingerprints": ["AB:CD:EF:…"]
  }
}]
```

Use the Play App Signing fingerprint for the release entry. Credential Manager's `createCredential` and `getCredential` calls use `rpId: "mimi.ke"`.

```callout info
**Android origin.** A native Android assertion reports `android:apk-key-hash:<hash>` as its origin. SecurySign accepts it for signing when the hash matches a fingerprint registered for your app, so register the Play App Signing fingerprint as well as any key you sign test builds with.
```

On iOS, `https://mimi.ke/.well-known/apple-app-site-association` includes the Team ID, bundle ID and callback paths. The file is served over HTTPS as `application/json`:

```json
{
  "webcredentials": { "apps": ["ABCDE12345.com.example.clientx"] },
  "applinks": { "details": [{ "appIDs": ["ABCDE12345.com.example.clientx"], "components": [{ "/": "/mimi/callback" }] }] }
}
```

Add `webcredentials:mimi.ke` and `applinks:mimi.ke` to Associated Domains. Create the passkey provider with `ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "mimi.ke")` and origin `https://mimi.ke`.

These passkeys also work on the related signing origin `securysign.com`. The association is published at `https://mimi.ke/.well-known/webauthn`.

### Capture the ID and face

Document and face capture can use the native SDK, the web component in a WebView, a browser tab or the hosted capture page. See the [mobile SDK reference](#/docs/sdk-reference#mobile-capture-sdk) for setup and result submission.

### Take payment

For payment, collect the phone number for an M-Pesa STK push or open the card-payment page in a browser tab. Check the app-store payment requirements applicable to the release.

## Manage the certificate

Enrolment issues an X.509 signing certificate. The requests below require the certificate ID and its owner's access token. See the [Verification API](#/docs/api-certificates) for retrieval, chain and status checks.

### Read the signer's certificate and drawn signature

With `signa-certificate` and `signa-visible-signature`, token claims contain `signa_certificate_url` and `visible_signature_url`. Download both on the backend using the same user's access token to obtain the PEM certificate and signed PNG signature image. [Certificates](#/docs/api-certificates) and [Visible signature](#/docs/api-visible-signature) contain the response schemas.

The drawn signature's QR code opens `https://securysign.com/api/signature/visible/public/<token>`. This public page lets the customer or recipient check the image, signer and issuing CA.

### Renew a certificate

The certificate lifecycle job checks expiry windows of 90, 30 and 7 days. It requests renewal for certificates with automatic renewal enabled and fewer than three previous renewals, retaining the same signing key. To request an earlier renewal, submit the certificate ID:

```bash
curl -X POST https://securysign.com/api/pki/certificates/123/renew \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "idempotencyKey": "renew-123-2026-09-26" }'
```

Here, `123` is the `certificateId` returned by `GET /pki/certificates/me`. Generate an `idempotencyKey` for the renewal attempt and reuse it when retrying that request. Failed renewals are retried after 1, 2, 4 and 8 hours, up to five attempts in total.

### Revoke a certificate

Revocation requires the certificate owner's access token and an assertion from one of their registered passkeys. For an RP integration, the token needs `signa-certificate`. Your backend requests a challenge; your page asks the owner's authenticator to sign it; your backend submits the assertion to revoke the certificate.

1. Request a revocation challenge. The returned challenge is valid once, for this certificate and owner, for five minutes:

   ```bash
   curl -X POST https://securysign.com/api/pki/certificates/123/revoke/challenge \
     -H "Authorization: Bearer $USER_ACCESS_TOKEN"
   ```

   ```json
   { "challenge": "q3Zt9u…", "expiresIn": 300 }
   ```

2. Pass `challenge` to your browser page. Obtain `credentialId` from `GET /auth/credentials` with the owner's token. Set `rpId` to the domain used when that passkey was registered, from your application's passkey configuration. Run this code on a page whose domain can use that RP ID:

   ```javascript
   function decodeBase64Url(value) {
     const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
     return Uint8Array.from(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")), c => c.charCodeAt(0));
   }

   function encodeBase64Url(value) {
     return btoa(String.fromCharCode(...new Uint8Array(value)))
       .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
   }

   async function approveRevocation(challenge, credentialId, rpId) {
     const credential = await navigator.credentials.get({
       publicKey: {
         challenge: decodeBase64Url(challenge),
         rpId,
         allowCredentials: [{ type: "public-key", id: decodeBase64Url(credentialId) }],
         userVerification: "required",
         timeout: 300000
       }
     });
     if (!credential) throw new Error("Passkey approval was cancelled");
     const response = credential.response;
     return { assertion: {
       id: credential.id,
       type: credential.type,
       rawId: encodeBase64Url(credential.rawId),
       response: {
         authenticatorData: encodeBase64Url(response.authenticatorData),
         clientDataJSON: encodeBase64Url(response.clientDataJSON),
         signature: encodeBase64Url(response.signature),
         userHandle: response.userHandle ? encodeBase64Url(response.userHandle) : null
       }
     } };
   }
   ```

   Send the object returned by `approveRevocation` to your backend and save it as `revocation-approval.json`. If the customer cancels the browser prompt, offer another attempt with a fresh challenge.

3. Send the assertion from your backend:

   This Python 3 and curl example builds the revoke request from the saved approval:

   ```bash
   python3 - <<'JSON' > revocation-request.json
   import json
   from pathlib import Path
   event = json.loads(Path("revocation-approval.json").read_text())
   print(json.dumps({
       "credentialId": event["assertion"]["id"],
       "reason": "keyCompromise", "assertion": event["assertion"]
   }))
   JSON
   curl --fail-with-body -X POST https://securysign.com/api/pki/certificates/123/revoke \
     -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
     -H "Content-Type: application/json" \
     --data-binary @revocation-request.json
   ```

   Set `credentialId` to `assertion.id` and send the complete assertion object returned by `approveRevocation` as `assertion`. Use one of the following values for `reason`:

   | `reason` | When to use it |
   |---|---|
   | `keyCompromise` | The device is lost or compromised |
   | `cessationOfOperation` | The user no longer needs the certificate |
   | `superseded` | A newer certificate replaces it |

```json
{ "success": true, "certificateId": 123, "status": "revoked", "revokedAt": "2026-10-10 12:00:00" }
```

A successful request immediately marks the certificate as `revoked` and returns `revokedAt`. Read the current certificate status through `GET /pki/certificate/123` or the public certificate-status endpoint, `GET /pki/ocsp?certId=123`. The certificate revocation list (CRL) is available at `GET /pki/crl`.

For an expired or used challenge, request a fresh challenge and collect another approval. A `409 Certificate already revoked` means the revocation is complete.

```text
issued ──► active ──► renewed (when eligible) ──► ...
              └──► revoked (immediate; CRL + status)
              └──► expired
```

## Before you go live

- [ ] A new customer completes every step and lands on your callback.
- [ ] A fully enrolled customer passes straight through.
- [ ] An abandoned journey resumes where it stopped.
- [ ] `account_mismatch` shows your own screen.
- [ ] A deep destination in `state` lands the customer on it.
- [ ] On mobile, the App Link or Universal Link opens the app on a real device, not the browser.
- [ ] On mobile, sign-in runs in the system browser and no secret ships in the binary.
- [ ] Passkey creation and assertion both work on a real device, and on Android every signing fingerprint you ship with is registered.

For payment testing, request a staging MIMI client with a nominal price from SecurySign. The [Enrolment reference](#/docs/api-enrolment-oidc) and [Certificates reference](#/docs/api-certificates) contain the protocol fields.
