# SecurySign > SecurySign provides APIs for identity verification, digital signatures, enrolment, OpenID Connect (OIDC) sign-in and document encryption. The guides cover integration, request examples and the web app. Production API: https://securysign.com/api. Sandbox API: https://signa.dev.securysign.com/api. Importable OpenAPI 3.1 request and response schemas: https://securysign.com/docs/openapi.json Combined guides: https://securysign.com/llms-full.txt ## Getting started - [Introduction](https://securysign.com/docs/overview.md): services and integration options - [Quick start](https://securysign.com/docs/quickstart.md): register, request a signing token, embed the iframe, receive a signature - [Demos](https://securysign.com/docs/demo.md): live LOA-4 signing demo, Android demo apps (download), web SDK identity-verification demo, and the SecurySign app walkthroughs ## Integration - [Core concepts](https://securysign.com/docs/core-concepts.md): environments, the four credentials, RP ownership by contact email, scopes and the claims they add, levels of assurance, terms - [Iframe](https://securysign.com/docs/iframe.md): signing iframe guide and reference: URL parameters, attributes, postMessage events, drop-in component - [SDKs](https://securysign.com/docs/sdks.md): which SDK to use for which part of your stack - [RP integration guide](https://securysign.com/docs/rp-integration-guide.md): register your application, credentials and the SSC secret, signing allowance, signing origins, redirect URIs, LOA-4, self-service API - [IdP integration guide](https://securysign.com/docs/idp-integration-guide.md): connect an enterprise OIDC or SAML 2.0 identity provider ## Reference - [KYC](https://securysign.com/docs/kyc.md): verify an ID document, liveness and face match; selfie of a verified customer (return_selfie); phone handoff; face re-confirmation - [Enrolment](https://securysign.com/docs/enrolment.md): hosted enrolment from payment to certificate, mobile apps, certificate renewal and revocation - [Single sign-on](https://securysign.com/docs/sso.md): OIDC sign-in against SecurySign, claims, user access tokens - [SDK reference](https://securysign.com/docs/sdk-reference.md): server libraries (Node.js, PHP, Python, Java), web capture SDK (, pass-through, CSP), mobile capture SDK (native, WebView, Android browser tab, hosted page) - [API reference](https://securysign.com/docs/api-reference.md): base URLs and conventions for the OpenAPI reference (signing tokens, hash signing, PAdES, webhooks, KYC, relying parties, identity providers, certificates, encryption, visible signature, plans, OIDC) - [Hash signing API](https://securysign.com/docs/hash-signing-api.md): create-and-approve signing requests for document hashes from your backend, passkey verification, batches, daily allowance, signed webhooks; with the endpoint reference - [PAdES signing API](https://securysign.com/docs/pades-signing-api.md): send a PDF, have the user approve its hash with a passkey, receive the signed PDF; with the endpoint reference - [Verification API](https://securysign.com/docs/verification-api.md): check signatures at receipt, validate against the CA, CRL and certificate status endpoint, read the signer's certificate; with the Certificates endpoint reference - [Encryption API](https://securysign.com/docs/encryption-api.md): AES-256-GCM on the device with RSA-OAEP key wrapping to the user's HSM key; with the endpoint reference ## Workflows - [Verification](https://securysign.com/docs/verification.md): verify a signature against the original document in the SecurySign web app - [Encryption and decryption](https://securysign.com/docs/encryption.md): encrypt and decrypt files in the SecurySign Vault (PRF hardware key, AES secret, public key modes) - [Hash signing](https://securysign.com/docs/hash-signing.md): sign a document's hash with your passkey in the SecurySign web app and download the signature package - [PAdES signing](https://securysign.com/docs/pades-signing.md): sign a PDF with your passkey in the SecurySign web app and download it with the embedded signature ## Resources - [Security and compliance](https://securysign.com/docs/security.md) - [Errors and rate limits](https://securysign.com/docs/errors.md) - [Pricing and limits](https://securysign.com/docs/limits.md): plans and the per-day signing allowance - [Troubleshooting](https://securysign.com/docs/troubleshooting.md) - [Changelog](https://securysign.com/docs/changelog.md) ## Legal - [Terms of Service](https://securysign.com/docs/terms.md) - [Privacy Policy](https://securysign.com/docs/privacy.md) - [Cookie Policy](https://securysign.com/docs/cookies.md) --- Source: https://securysign.com/docs/overview.md # SecurySign developer documentation SecurySign provides APIs for identity verification, digital signatures, single sign-on and document encryption. Identity verification reads the submitted document’s details and portrait, checks the face capture for liveness, and compares the captured face with that portrait. API signing starts with a document hash or a PDF and asks the signer to approve with a passkey. Once that approval has been verified, SecurySign signs with a key held in a hardware security module (HSM) and returns the signer’s X.509 certificate for verification. ## What you can build | Your task | How you integrate it | Guide | |---|---|---| | Sign users in | Redirect through OpenID Connect (OIDC), then exchange the authorization code for tokens. | [Single sign-on](#/docs/sso) | | Verify a customer's identity | Submit document images and the transaction ID from a live face capture; read the verdict and extracted fields. | [KYC](#/docs/kyc) | | Enrol a signer | Open the hosted enrolment link and exchange the code returned to your callback. | [Enrolment](#/docs/enrolment) | | Collect a signature on your page | Request a signing token on your backend and pass it to the signing iframe. | [Iframe](#/docs/iframe) | | Sign from your backend | Create a hash-signing request, or prepare a PDF; collect the customer's passkey approval in the signing frame. | [Hash signing API](#/docs/api-hash-signing), [PAdES signing API](#/docs/api-pades-signing) | | Verify a signature | Check the signed bytes, the signer's certificate and its revocation status. | [Verification API](#/docs/api-certificates) | | Encrypt a document | Encrypt on the device, encrypt the file key with the user's public key and store the encrypted result. | [Encryption API](#/docs/api-encryption) | Start by [registering your application](#/docs/rp-integration-guide) as a relying party (RP) to obtain its credentials and approved configuration. The [Quick start](#/docs/quickstart) then walks through embedding the signing iframe, from requesting a token to receiving a signature. ## Get the OpenAPI spec The [OpenAPI specification](/docs/openapi.json) contains the authentication requirements, request schemas and responses for each operation. It can be imported into Postman or used to generate a client. --- Source: https://securysign.com/docs/quickstart.md # Sign your first document The signing iframe lets a customer approve a document without leaving your page. The backend computes the document hash and requests a signing token; the page opens the iframe and receives the signature after passkey approval. These examples use production. To use the sandbox, register the application there separately and use `https://signa.dev.securysign.com` for both the API and the frame. ## 1. Register your application Register the application as a relying party (RP), with its name, HTTPS origin, callback URL and contact email: ```bash curl --fail-with-body -X POST https://securysign.com/api/rp/register \ -H "Content-Type: application/json" \ --data '{ "name": "clientX", "origin": "https://app.example.com", "redirect_uris": ["https://app.example.com/auth/callback"], "contact_email": "dev@example.com", "requested_scopes": ["signa:sign"] }' ``` Registration returns `{"rpId": 42, "status": "pending"}`. Once approved, the registration provides: - your client ID, such as `signa-rp-42`, assigned from the registration ID; - your **Secure Signature Confirmation (SSC) secret**, available on the [RP dashboard](https://cloud.securysign.com/#/rp/dashboard); - approval for your registered origin to embed the signing frame. See the [RP integration guide](#/docs/rp-integration-guide) for the full registration request and approval errors. ## 2. Request a signing token Set `RP_CLIENT_ID` and `SSC_SECRET` in the backend environment using the values from the approved registration. The example below requires Python 3, curl and `sha256sum`, with `Contract.pdf` in the working directory. It hashes the file and builds a JSON body containing the credentials. ```bash export DOCUMENT_HASH="$(sha256sum Contract.pdf | cut -d ' ' -f 1)" python3 - <<'JSON' > signing-token-request.json import json, os print(json.dumps({ "clientId": os.environ["RP_CLIENT_ID"], "clientSecret": os.environ["SSC_SECRET"], "documentHash": os.environ["DOCUMENT_HASH"], "loa": "LOA-2" })) JSON curl --fail-with-body -X POST https://securysign.com/api/ssc/token \ -H "Content-Type: application/json" \ --data-binary @signing-token-request.json -o signing-token-response.json rm signing-token-request.json ``` A successful request returns the following fields. The token and expiry shown here are illustrative: ```json { "token": "eyJhbGciOi…", "expiresAt": 1790000300, "loa": "LOA-2", "credentialBound": false } ``` Pass `token` and `documentHash` from the backend to the page. The token is valid for five minutes and only for the submitted hash. A `403` response identifies the reason it was rejected: a pending registration, an incorrect SSC secret or an assurance level above the RP's approved maximum. See [Iframe troubleshooting](#/docs/iframe#troubleshooting) for the corresponding fixes. After a `429`, wait for the signing allowance to reset. ## 3. Embed the iframe Call `openSigning` with the hash and token returned by the backend. `URLSearchParams` handles encoding for the document name and the page's origin: ```html ``` Serve the page from the RP's approved origin. The `/#/sign-frame` route opens the signing screen; the `allow` attribute permits a passkey prompt inside the cross-origin frame. ## 4. Listen for the result Check both the sender's origin and its window before handling a message. The functions `saveSignature`, `showError` and `cancel` below stand for the application's own handlers: ```js window.addEventListener("message", (event) => { if (event.origin !== "https://securysign.com" || event.source !== frame.contentWindow) return; const msg = event.data; if (msg.type === "SSC_SIGN_COMPLETE") saveSignature(msg); if (msg.type === "SSC_SIGN_ERROR") showError(msg.error); if (msg.type === "SSC_CLOSE_FRAME") cancel(); }); ``` ## 5. Sign a document Selecting **Sign** opens the customer's passkey prompt. After approval, the frame sends `SSC_SIGN_COMPLETE`, including `signatureBase64`, `documentHash` and `levelOfAssurance`. Check that the returned hash matches the saved document hash, then store the complete result with the document. An `RP origin not authorized` error means the embedding origin needs approval. Submit it through [Signing origins](#/docs/rp-integration-guide#3-authorise-additional-signing-origins). ## Next steps For resizing, batch signing, all frame events and level of assurance 4 (LOA-4), see the [Iframe guide](#/docs/iframe). The [Hash signing API](#/docs/api-hash-signing) covers requests created on the backend, including approval and completion. [KYC](#/docs/kyc) covers identity verification before signing, and the [OpenAPI specification](#/docs/api-reference#get-the-openapi-spec) contains the complete request and response schemas. --- Source: https://securysign.com/docs/demo.md # Demos The demos show signing and identity capture against the live SecurySign service. Identity-verification demos process the document and face images entered during the session. ## LOA-4 signing demo The signing demo uses level of assurance 4 (LOA-4). Select **Register Passkey** to sign in with Google and register a passkey on the device. The signing screen then shows the document hash, merchant details and requested assurance level before asking for approval with that passkey. ## Mobile app demos Both Android demos require Android 9 or later and a camera, and connect to `https://securysign.com/kyc-demo`. One runs the live face capture inside a WebView; the other opens a Chrome Custom Tab. | Demo app | Where you capture your face | Download | |---|---|---| | WebView | In the app's WebView using ``. | [WebView APK](https://securysign.com/kyc-demo/web/downloads/securysign-kyc-demo-webview.apk) | | Custom Tab | In a Chrome Custom Tab, then back to the app through a link. | [Custom Tab APK](https://securysign.com/kyc-demo/web/downloads/securysign-kyc-demo-customtab.apk) | Install the Android application package (APK), choose **Phone number** or **Email** for `customer_id`, and enter `phone_number`. Using the same details resumes the existing verification. The document screen captures both sides of a national identity card or the passport photo page, followed by live face capture and the verdict. After a `verified` result, **Live Check** compares a new photo with the stored identity portrait. For implementation examples, see [Web component in a WebView](#/docs/sdk-reference#web-component-in-a-webview) and [Browser tab on Android](#/docs/sdk-reference#browser-tab-on-android). ## Web SDK demo The [web capture demo](https://securysign.com/kyc-demo/web/) starts with a phone number or email, then takes the customer through document capture, the live face check and the verdict. **Details** displays the API steps. The same page can be opened on a phone to use its camera. ## Try it in the SecurySign app The web application also supports the flows below with a Google account. Signing prompts for passkey creation when one is first needed. | Screen | What you do there | Guide | |---|---|---| | [Home](https://securysign.com/) | Add a document, approve its signature and download the signature package. | [Hash signing](#/docs/hash-signing) | | [PAdES](https://securysign.com/#/pades) | Upload a PDF and download it with an embedded signature. | [PAdES signing](#/docs/pades-signing) | | [Verify](https://securysign.com/#/verify) | Compare a saved signature with the original file. | [Verification](#/docs/verification) | | [Vault](https://securysign.com/#/vault) | Encrypt, store and decrypt a file. | [Encryption and decryption](#/docs/encryption) | | [Visible signature](https://securysign.com/#/visible-signature) | Draw the signature image used on your signed documents. | [Enrolment](#/docs/enrolment) | --- Source: https://securysign.com/docs/core-concepts.md # Core concepts A relying party (RP) is an application registered with SecurySign. Its registration defines the application's credentials, permitted origins, redirect URLs, scopes and signing allowance. API operations use either those application credentials or an individual user's access token. ## Environments Sandbox and production have separate registrations, credentials and tokens. | Resource | Production | Sandbox | |---|---|---| | API | `https://securysign.com/api` | `https://signa.dev.securysign.com/api` | | OpenID Connect (OIDC) issuer | `https://securysign.com/auth/realms/signa` | `https://idp.dev.securysign.com/realms/signa` | | Signing iframe | `https://securysign.com/#/sign-frame` | `https://signa.dev.securysign.com/#/sign-frame` | | MIMI enrolment | `https://mimi.ke` | SecurySign supplies a staging client on request. | ## Credentials Application credentials belong on the backend. Requests that act on a signed-in user instead use the access token obtained by exchanging that user's authorization code. | Credential | How you send it | Where you obtain it | Where you use it | |---|---|---|---| | RP client ID and OIDC client secret | `Authorization: Basic base64(client_id:client_secret)` | Your approved RP registration. | Identity verification and enrolment-reference requests. | | Secure Signature Confirmation (SSC) secret | `clientSecret` in JSON. | The [RP dashboard](https://cloud.securysign.com/#/rp/dashboard), or `ssc_secret` from `GET /rp/me`. | `POST /ssc/token`. | | User access token | `Authorization: Bearer ` | [Single sign-on](#/docs/sso). | Hash signing, PAdES signing, certificates, encryption, webhooks and RP self-service. | | MIMI client credentials | HTTP Basic authentication at `https://mimi.ke/token`. | Your separate MIMI client registration. | The MIMI enrolment code exchange. | For identity capture, the backend requests a liveness session or a phone handoff from SecurySign. A liveness token authorizes the capture component for one verification and expires after 600 seconds. A handoff link lets the customer complete that verification on their phone and expires after 900 seconds. ### Credential rules Hash signing, PDF Advanced Electronic Signature (PAdES) signing and webhook requests require a signed-in user's bearer token. RP administration and webhook subscriptions belong to the contact account identified by `contact_email`; use that account for dashboard changes and RP self-service requests. The SSC secret and OIDC client secret are distinct. Rotating credentials on the dashboard replaces the OIDC client secret. Contact SecurySign support to rotate the SSC secret. The permanent account identifier in an OIDC token is `sub`. The `email` claim contains the address, and `email_verified` indicates whether the identity provider has verified it. Authorization requests must use scopes assigned to the client; requesting an unassigned scope produces `invalid_scope`. ## Scopes Scopes determine which capabilities and claims an application receives. Request them during registration or through the RP dashboard; they take effect after approval. | Scope | What you use it for | |---|---| | `signa:sign` | Identify your RP as a signing integration; the granted value appears in the user's token `scope` claim. | | `signa:integrator` | Register an enterprise identity provider through the Integrations panel or API. | | `signa-kyc` | Submit identity-verification requests. | | `signa-enrolment` | Start hosted enrolment. | | `signa-entitlement-required` | Require an active subscription during enrolment. | | `signa-visible-signature` | Receive `visible_signature_url`, `visible_signature_id` and `visible_signature_sha256` claims. | | `signa-certificate` | Receive `signa_certificate_url`, `signa_certificate_serial` and `signa_certificate_subject` claims. | ## Levels of assurance Each signing token specifies a level of assurance (LOA), up to the RP's approved maximum. Registered signing defaults to `LOA-2`. | Level | How the signing request is authorised | |---|---| | `LOA-0` | You use the anonymous demonstration flow. | | `LOA-1` | You identify an authorised embedding origin in the anonymous flow. | | `LOA-2` | Your backend supplies a signing token; the customer approves with a registered passkey. | | `LOA-4` | Your token names the signer and is bound to the passkey selected for that signer. | A `LOA-4` token request must include the signer's `email`. SecurySign binds the token to that account's most recently registered passkey. See [LOA-4 setup](#/docs/rp-integration-guide#5-enable-loa-4) for the RP verification and approval requirements. ## Terms | Term | Meaning in your integration | |---|---| | RP | Your application registration, or the WebAuthn domain when discussing a passkey's RP ID. | | SSC | Secure Signature Confirmation: the signing frame and its token-based approval flow. | | Passkey | The WebAuthn credential your customer uses to approve an operation. | | `sub` | The permanent OIDC account identifier you receive in tokens. | | MIMI | The enrolment service at `mimi.ke`, reached through its OIDC authorization endpoint. | | HSM | Hardware security module: the platform component that holds and uses the server-side cryptographic keys. | --- Source: https://securysign.com/docs/iframe.md # Sign a document in an iframe on your page Embed the SecurySign signing iframe to collect passkey approval on your page. The backend hashes the document and requests a token for that hash. The page opens the frame with the token, hash and document name. After the customer reviews the request and approves it, the frame returns the signature through `postMessage`. ## What you need Before embedding the frame, make sure the following are in place: - An approved relying party (RP) and its Secure Signature Confirmation (SSC) secret, from the [RP dashboard](https://cloud.securysign.com/#/rp/dashboard). See the [RP integration guide](#/docs/rp-integration-guide). - Every page that embeds the iframe served from an [authorised signing origin](#/docs/rp-integration-guide#3-authorise-additional-signing-origins). ## 1. Request a signing token Compute the SHA-256 hash of the exact document bytes and encode it as a 64-character hexadecimal string. Request the token just before opening the frame so the customer has the full five-minute approval window. The example below uses `RP_CLIENT_ID` and `SSC_SECRET` from the dashboard, Python 3, curl and `sha256sum`, with `Contract.pdf` in the working directory: ```bash export DOCUMENT_HASH="$(sha256sum Contract.pdf | cut -d ' ' -f 1)" python3 - <<'JSON' > signing-token-request.json import json, os print(json.dumps({ "clientId": os.environ["RP_CLIENT_ID"], "clientSecret": os.environ["SSC_SECRET"], "documentHash": os.environ["DOCUMENT_HASH"], "loa": "LOA-2" })) JSON curl --fail-with-body -X POST https://securysign.com/api/ssc/token \ -H "Content-Type: application/json" \ --data-binary @signing-token-request.json -o signing-token-response.json rm signing-token-request.json ``` | Field | Type | Required | Description | |---|---|---|---| | `clientId` | string | Yes | Your client ID, for example `signa-rp-42`. | | `clientSecret` | string | Yes | Your SSC secret from the RP dashboard. It is a different value from your OpenID Connect (OIDC) client secret. | | `documentHash` | string | Yes | The document's SHA-256 hash, in hex. | | `loa` | string | No | The level of assurance (LOA): `LOA-0`, `LOA-1`, `LOA-2` (default) or `LOA-4`. With `LOA-2`, your user approves with any of their passkeys; with `LOA-4`, only the bound passkey works. | | `email` | string | For `LOA-4` | The signer's email address. The token binds the signer's most recently registered passkey. | ```json { "token": "eyJhbGciOiJIUzI1NiIs…", "expiresAt": 1790000300, "loa": "LOA-2", "credentialBound": false } ``` Pass `token` to the browser with the submitted hash. `expiresAt` is the expiry time in Unix seconds, five minutes after issuance. For `LOA-4`, `credentialBound: true` confirms that the token is bound to one passkey. Issuing a token counts against the RP's [signing allowance](#/docs/limits). ## 2. Embed the iframe ```html ``` Use `URLSearchParams` to encode `rpOrigin` and `documentName`. The `/#/sign-frame` route loads the signing screen, and `allow="publickey-credentials-get *"` enables its cross-origin WebAuthn prompt. The [URL parameters](#/docs/iframe#url-parameters) and [iframe attributes](#/docs/iframe#iframe-attributes) are listed below. ## 3. Handle the result The frame sends signing and resize events to its parent page. Check `event.origin` and `event.source` before handling them, so only messages from the embedded SecurySign frame are accepted: ```js const frame = document.getElementById("signa-frame"); window.addEventListener("message", (event) => { if (event.origin !== "https://securysign.com" || event.source !== frame.contentWindow) return; const msg = event.data; switch (msg.type) { case "SSC_SIGN_COMPLETE": // Send to your backend and store it against the document. fetch("/api/signatures", { method: "POST", body: JSON.stringify(msg) }); break; case "SSC_SIGN_ERROR": showError(msg.error); break; case "SSC_CLOSE_FRAME": cancelSigning(); // user dismissed the frame break; case "SSC_RESIZE": frame.style.height = msg.height + "px"; // no dead space, no inner scrollbar break; } }); ``` Store `operationId`, `signatureBase64`, `documentHash`, `userVerified`, `timestamp` and `levelOfAssurance` with the document. Check the returned `documentHash` against the saved hash to confirm that the signature belongs to the file presented for approval. ## 4. Test the integration Load the page on an authorized origin and select **Sign**. The passkey prompt should open, and approval should produce `SSC_SIGN_COMPLETE` with a non-empty `signatureBase64`. Check that `documentHash` and `levelOfAssurance` match the request. ## Drop-in component The component below takes the document hash, name and token from the backend. It builds the iframe URL, adjusts the height on `SSC_RESIZE` and invokes a callback when signing completes, fails or is cancelled. #### React ```tsx import { useEffect, useRef, useState } from "react"; const SIGNA = "https://securysign.com"; type SignResult = | { type: "SSC_SIGN_COMPLETE"; operationId: string; signatureBase64: string; documentHash: string; levelOfAssurance: string } | { type: "SSC_SIGN_ERROR"; error: string } | { type: "SSC_CLOSE_FRAME" }; export function SigningFrame({ token, documentHash, documentName, onResult }: { token: string; documentHash: string; documentName: string; onResult: (result: SignResult) => void; }) { const frame = useRef(null); const [height, setHeight] = useState(420); useEffect(() => { const onMessage = (event: MessageEvent) => { if (event.origin !== SIGNA || event.source !== frame.current?.contentWindow) return; if (event.data?.type === "SSC_RESIZE") return setHeight(event.data.height); if (["SSC_SIGN_COMPLETE", "SSC_SIGN_ERROR", "SSC_CLOSE_FRAME"].includes(event.data?.type)) onResult(event.data); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [onResult]); const params = new URLSearchParams({ documentHash, documentName, mode: "registered", rpOrigin: window.location.origin, token }); return ( ``` ## URL parameters ```text https://securysign.com/#/sign-frame?documentHash=…&documentName=…&mode=registered&rpOrigin=…&token=… ``` The frame is served at `https://securysign.com/#/sign-frame` in production and `https://signa.dev.securysign.com/#/sign-frame` in the sandbox. | Parameter | Required | What you send | |---|---|---| | `documentHash` | Yes | SHA-256 of the document, hex. Must match the token. | | `documentName` | Yes | Shown to the signer. URL-encode it. | | `mode` | Yes | `registered` with a token, or `anonymous` for level of assurance (LOA) 0 and 1 without a token | | `rpOrigin` | Yes | Your page's origin, URL-encoded. It must be an authorised signing origin, and it is bound into the passkey challenge the signer approves. | | `token` | In `registered` mode | The token from `POST /ssc/token` | ## Iframe attributes | Attribute | Why you need it | |---|---| | `allow="publickey-credentials-get *"` | Enables WebAuthn in a cross-origin frame. Without it, your user sees no passkey prompt. | | `allow-scripts` | The signing UI runs JavaScript | | `allow-same-origin` | WebAuthn checks the frame's origin | | `allow-forms` | The signing UI uses form controls | | `allow-popups` | The frame may open dialogs | ## Events Accept incoming messages only from `https://securysign.com` and the embedded frame's `contentWindow`. Use `https://securysign.com` as the target origin for messages sent to the frame. | Event | Direction | When you get or send it | |---|---|---| | `SSC_SIGN_REQUEST` | Page to frame | Send it to start signing without URL parameters | | `SSC_SIGN_COMPLETE` | Frame to page | The document is signed | | `SSC_SIGN_ERROR` | Frame to page | Signing failed | | `SSC_CLOSE_FRAME` | Frame to page | The user closed the frame. Treat it as a cancel. | | `SSC_RESIZE` | Frame to page | The frame's rendered size changed | ### SSC_SIGN_REQUEST When reusing a frame for several documents, send each new document through `SSC_SIGN_REQUEST`. In registered mode, include the token issued by the backend for that document hash. ```js frame.contentWindow.postMessage({ type: "SSC_SIGN_REQUEST", documentName: "Contract.pdf", documentHash: "a3f7c2d8e9b10000000000000000000000000000000000000000000000000000", mode: "registered", token: "eyJhbGci…", }, "https://securysign.com"); ``` ### SSC_SIGN_COMPLETE ```json { "type": "SSC_SIGN_COMPLETE", "operationId": "ssc_sig_42", "signatureBase64": "MEUCIQDxY…", "documentHash": "a3f7c2d8e9b10000000000000000000000000000000000000000000000000000", "userVerified": true, "timestamp": 1790000200, "mode": "registered", "levelOfAssurance": "LOA-2" } ``` ### SSC_SIGN_ERROR ```json { "type": "SSC_SIGN_ERROR", "error": "User verification required" } ``` ### SSC_RESIZE ```json { "type": "SSC_RESIZE", "width": 512, "height": 511 } ``` The first resize event contains `width`; subsequent events update `height`. Apply the reported `height` to the iframe to fit the signing controls. ## Sign several documents with one approval The batch signing frame presents a list of documents for a single passkey approval and returns an outcome for each document. SecurySign identifies the signer from the passkey assertion. As with single-document signing, the page must run on an [authorized signing origin](#/docs/rp-integration-guide#3-authorise-additional-signing-origins). 1. Embed the frame with your page's origin in `rpOrigin`: ```html ``` 2. Wait for `SSC_BATCH_READY`, then send your documents. Give each one an `id` you recognise, the document's SHA-256 hash in hex, and optionally a `name` your user will see: ```js const frame = document.getElementById("securysign-batch"); window.addEventListener("message", (event) => { if (event.origin !== "https://securysign.com") return; const msg = event.data; if (msg.type === "SSC_BATCH_READY") { frame.contentWindow.postMessage({ type: "SSC_BATCH_REQUEST", documents: [ { id: "doc-1", hash: "a3f7c2d800000000000000000000000000000000000000000000000000000000", name: "Contract.pdf" }, { id: "doc-2", hash: "9b2e41f000000000000000000000000000000000000000000000000000000000", name: "Annex A.pdf" }, ], }, "https://securysign.com"); } if (msg.type === "SSC_BATCH_COMPLETE") saveSignatures(msg.batchId, msg.results); if (msg.type === "SSC_SIGN_ERROR") showError(msg.error); }); ``` Alternatively, put the same JSON array in the URL's `documents` parameter, URL-encoded. 3. Your user selects **Sign N documents with one passkey**, where N is the number of documents, and approves. You receive: ```json { "type": "SSC_BATCH_COMPLETE", "batchId": "3f9c1a2e-5b7d-4e8a-9c1f-0a2b3c4d5e6f", "status": "completed", "documentCount": 2, "results": [ { "documentId": "doc-1", "status": "signed", "signature": "MEUCIQDxY…" }, { "documentId": "doc-2", "status": "signed", "signature": "MEQCIF3k…" } ] } ``` Match each entry in `results` to its document. The overall `status` is `completed` when every document succeeds; partially completed batches also arrive through `SSC_BATCH_COMPLETE`. Send `SSC_BATCH_REQUEST` from the origin specified in `rpOrigin`. ## Approve a request your backend created For requests created through the [Hash signing API](#/docs/api-hash-signing), use `requestId` or `batchId` instead of a token: `https://securysign.com/#/sign-frame?requestId=&rpOrigin=`. Approval produces `SSC_SIGN_COMPLETE` or `SSC_BATCH_COMPLETE`. See [Show the request to your user](#/docs/api-hash-signing#2-show-the-request-to-your-user). For a PDF prepared through the [PAdES signing API](#/docs/api-pades-signing), use `padesOperation=`. The frame sends `SSC_PADES_APPROVED` with the passkey assertion, which the backend submits to finalize the PDF. See [Get your user's approval in the signing frame](#/docs/api-pades-signing#2-get-your-users-approval-in-the-signing-frame). ## Browser support Test the iframe in Chrome, Edge, Safari and Firefox with the `allow` attribute above. The available passkeys and approval methods depend on the customer's browser and device; mobile devices use their biometric or screen-lock prompt. ## Troubleshooting | Symptom | Fix | |---|---| | `RP origin not authorized: …` | Authorise the origin on the RP dashboard and wait for approval | | Blank frame | Check the `#/sign-frame` route and that the token has not expired | | No passkey prompt | Add `allow="publickey-credentials-get *"` and serve the page over HTTPS. The user may not have a passkey yet. | | `Invalid client credentials` | You sent the OpenID Connect (OIDC) client secret. Use the SSC secret. | | `LOA-4 requires an email hint` | Add `email` to the token request | | `No credential found for user` | Your LOA-4 signer has no passkey yet. Send them through [enrolment](#/docs/enrolment). | | `Daily signing limit exceeded (…)` | Your RP used its signing allowance for the day; it resets at 00:00 UTC. See [Pricing and limits](#/docs/limits). | | `SSC_SIGN_ERROR` with `Passkey signature verification failed` | The approval did not come from your signer's registered passkey. Ask the signer to try again with the same passkey. | The [Signing tokens reference](#/docs/api-signing-tokens) contains the token, challenge and finalization schemas. ## How it works ```text Your backend Your page SecurySign iframe 1 POST /ssc/token ──► 2 iframe src + token ───► 3 passkey prompt 4 SSC_SIGN_COMPLETE ◄─── signature ``` --- Source: https://securysign.com/docs/sdks.md # SDKs SecurySign integrations use server libraries for authenticated API requests and capture SDKs for document photos and live face capture. Server libraries run on the backend, where application credentials are stored. Capture SDKs run in the browser or mobile app and send requests through that backend. | Component | Where you use it | How you obtain it | |---|---|---| | Server library | Your Node.js, PHP, Python or Java backend, for signing tokens, OpenID Connect (OIDC) login and webhook verification. | Download the source file for your language. | | Web capture SDK | Your HTTPS page, for document photos and the live face capture. | Install `@securysign/identity-capture@0.2.0`. | | Mobile capture SDK | Your iOS, Android, Flutter, React Native or .NET MAUI app. | Request the native package for your application, or embed the web component. | | Signing iframe | Your web page, for document review and passkey approval. | Embed the SecurySign frame URL. | ## Choose your SDK For signing on a web page, request a token on the backend and open the [signing iframe](#/docs/iframe). The token request can use a server library or a direct HTTP call. The [Hash signing API](#/docs/api-hash-signing) and [PAdES signing API](#/docs/api-pades-signing) cover signing requests created on the backend and completed through the frame. For identity verification, the web SDK captures document photos and runs a liveness session obtained through the backend. Mobile integrations can use native capture, a WebView or a browser tab. A hosted capture page is also available: open its link in the app and poll the result from the backend. See the [SDK reference](#/docs/sdk-reference) for installation, backend routes, capture examples, component events, Content Security Policy settings and mobile permissions. --- Source: https://securysign.com/docs/rp-integration-guide.md # Register your application as a relying party Registering an application as a relying party (RP) gives it a client ID, credentials, redirect URLs and signing permissions. After registration, use the developer portal to view the approved configuration and request changes. Keep the OpenID Connect (OIDC) client secret and the separate Secure Signature Confirmation (SSC) secret on the backend. ## 1. Register your application On the [registration page](https://cloud.securysign.com/#/rp/register), the signed-in email becomes the RP's contact account. Enter the application name, HTTPS origin, callbacks and requested scopes. Registration is also available as a JSON request: ```bash curl -X POST https://securysign.com/api/rp/register \ -H "Content-Type: application/json" \ -d '{ "name": "clientX", "origin": "https://app.example.com", "redirect_uris": ["https://app.example.com/auth/callback"], "contact_email": "dev@example.com", "requested_scopes": ["signa:sign", "signa-kyc"] }' ``` | Field | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Your application's name, shown to your users. | | `origin` | string | Yes | The HTTPS origin of your application. Approval authorises it as a signing origin. | | `redirect_uris` | array of strings | Yes | Every OIDC callback URL your application uses, written exactly. | | `contact_email` | string | Yes | The email address that owns the RP. | | `contact_phone` | string | No | A phone number for SecurySign to reach you. | | `logo_url` | string | No | An HTTPS URL of your logo. | | `business_registration_number` | string | No | Your company's registration number. | | `rate_limit_per_minute` | integer | No | You request a per-minute allowance; its registration default is `60`. | | `requested_scopes` | array of strings | No | The scopes you need, from the [scope table](#/docs/core-concepts#scopes). You select names from the supported scope table; the granted set is confirmed after approval. | Save the returned `rpId`, such as `42`, and `status: "pending"`. The contact email identifies the account authorized to manage the RP and register webhooks. Sign in with that account on the [RP dashboard](https://cloud.securysign.com/#/rp/dashboard). See the [registration schema](#/docs/api-relying-parties#register-rp) for the complete inputs and response. ## 2. Wait for approval SecurySign reviews pending registrations. After approval, the dashboard displays the assigned client ID, secrets and granted scopes. The granted scopes may differ from those requested. | Item | Example | What you use it for | |---|---|---| | Client ID | `signa-rp-42` | Every workflow | | OIDC client secret | Save it at approval, because you see it once. Rotate it on the RP dashboard. | Single sign-on token exchange, identity verification | | SSC secret | On the RP dashboard, or in `ssc_secret` from `GET /rp/me` | `POST /ssc/token` only | | Maximum LOA | `LOA-2` | The highest level of assurance you can request in a signing token | | Signing allowance | 60 requests per minute, 100 signatures per day, 10 documents per batch | Raise it with a [plan](#/docs/limits) | ## 3. Authorise additional signing origins Approval authorizes the application's registered origin for iframe signing. Pass that embedding origin as `rpOrigin` in the frame URL. To add another origin, submit its exact HTTPS origin under **Authorized signing origins** on the dashboard or through `POST /rp/request-iframe-whitelist`. It becomes usable after approval; pending or unregistered origins receive `RP origin not authorized: https://other.example.com`. ## 4. Add redirect URIs OIDC callbacks must match a registered redirect URI exactly. Choose **Request Redirect URI Change**, or call `POST /rp/request-redirect-uri-change`, with the complete replacement list. Include both new callbacks and existing callbacks that should remain active. ## 5. Enable LOA-4 Level of assurance 4 (LOA-4) binds a token to a particular signer's passkey and requires RP KYC verification and approval. Submit **Request LOA Change** for `LOA-4`, or use `POST /rp/request-loa-change`. The dashboard shows the approved maximum. Before approval, token requests may return `LOA-4 requires KYC-verified RP` or `Requested LOA LOA-4 exceeds RP maximum LOA-2`. ## 6. Manage the RP from your code The [Relying parties API](#/docs/api-relying-parties) supports the same administration from the backend using the contact account's access token: listing RPs, reading SSC secrets, rotating the OIDC secret, requesting changes to scopes and URLs, raising the LOA, and reading allowance and usage. ## 7. Choose your workflows Use the approved scopes and credentials for the integration needed by the application: | You want to | Workflow | Scope | |---|---|---| | Sign users in with Google or your corporate directory | [Single sign-on](#/docs/sso) | `openid` for the OIDC request | | Verify a customer's ID and face | [KYC](#/docs/kyc) | `signa-kyc` | | Take a customer from sign-up to a signing certificate | [Enrolment](#/docs/enrolment) | `signa-enrolment` | | Let a user sign on your page | [Iframe](#/docs/iframe) | `signa:sign` | | Sign document hashes from your backend | [Hash signing API](#/docs/api-hash-signing) | A user access token | | Return a signed PDF | [PAdES signing API](#/docs/api-pades-signing) | A user access token | | Check a signature | [Verification API](#/docs/api-certificates) | Public certificate endpoints. | | Encrypt documents to a user | [Encryption API](#/docs/api-encryption) | A user access token | ## Troubleshooting | Error | What to do | |---|---| | `Unknown RP client_id` | Copy the client ID again from the RP dashboard; the registration may have been reset | | `RP registration not approved. Status: pending` | Wait for approval | | `Invalid client credentials` | Send the SSC secret, not the OIDC client secret | | `RP origin not authorized: …` | Authorise the origin (step 3) | | `Requested LOA LOA-4 exceeds RP maximum LOA-2` | Enable LOA-4 (step 5) | | `Not authorized for this RP` | Sign in with the RP's `contact_email` | | `Only approved RPs can be modified` | Wait for approval, then make the change | --- Source: https://securysign.com/docs/idp-integration-guide.md # Connect your corporate identity provider SecurySign can broker authentication to a corporate identity provider (IdP) while the application continues to use OpenID Connect (OIDC). Register the provider, configure its broker callback and pass its approved alias as `kc_idp_hint` in authorization requests. The provider can use either of these protocols: | Protocol | Typical providers | |---|---| | OpenID Connect | Any OIDC provider with a discovery document | | SAML 2.0 | Entra ID, Okta, ADFS, Google Workspace SAML | ## What you need To register a provider, you'll need: - An approved relying party (RP) with the `signa:integrator` scope. You need it to see the **Integrations** panel on the [RP dashboard](https://cloud.securysign.com/#/rp/dashboard). - Admin access to your IdP, to create a client or an application for SecurySign. ## Connect an OIDC provider Create a client for SecurySign in the IdP's administration console and save its client ID and secret. The discovery document must expose `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint` and `jwks_uri`. Configure the client as confidential and provide its secret when registering the IdP with SecurySign. On the RP dashboard, choose **Integrations → Add provider → Enterprise OAuth2 / OIDC**. Enter the issuer URL and select **Discover endpoints**, then enter the IdP-issued client ID, secret and supported scopes, typically `openid email`. After approval, the provider page displays its alias and broker callback. Register that callback as the redirect URI in the IdP client and add it to the RP's callback list. Then set `kc_idp_hint=` in the application's authorization URL. A new provider registration receives a new alias and broker callback. Update both the IdP callback and the application's hint when replacing a registration. ## Connect a SAML 2.0 provider For Security Assertion Markup Language (SAML) 2.0, obtain the IdP's metadata URL or its entity ID, single sign-on URL and signing certificate. Enter those values under **Integrations → Add provider → Enterprise SAML 2.0** and require signed assertions. After approval, configure SecurySign as the service provider (SP) in the IdP with the following values: | Setting | Value | |---|---| | SP entity ID | `https://securysign.com/auth/realms/signa` | | ACS or reply URL | The broker callback, `https://securysign.com/auth/realms/signa/broker//endpoint` | | Signing | RSA-SHA256 or stronger | Configure the IdP to release each attribute under one of the accepted names below. The assertion-consumer-service (ACS) callback receives the signed response. | Field | Attribute names | |---|---| | Email | `email`, `mail`, `urn:oid:0.9.2342.19200300.100.1.3`, `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | | First name | `firstName`, `givenName`, `urn:oid:2.5.4.42`, `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | | Last name | `lastName`, `sn`, `urn:oid:2.5.4.4`, `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | Providers that use claim-URI attribute names can use those entries directly in the mapping. Set `kc_idp_hint=` in the application's SecurySign authorization URL to select the approved SAML provider. ## Troubleshooting | Symptom | Fix | |---|---| | No **Integrations** panel | Your RP needs the `signa:integrator` scope | | `invalid_scope` | Request only scopes your provider advertises | | Login never reaches your IdP | Wait for approval, and send the current alias in `kc_idp_hint` | | Login fails at the callback | Add the broker callback to your IdP client and to your RP's redirect URIs | | `Invalid signature in response from identity provider` | Copy the IdP's current signing certificate again, and sign with RSA-SHA256, not SHA-1 | | Blank email after a SAML login | Map an email attribute from the table above | The [Identity providers API](#/docs/api-identity-providers) exposes the same configuration using the contact account's access token. `GET /auth/login-config` returns current aliases and login endpoints; see [Login configuration](#/docs/api-relying-parties#get-login-config). --- Source: https://securysign.com/docs/kyc.md # Verify a customer's identity SecurySign reads the details and portrait from the submitted identity document. It checks the customer’s face capture for liveness and compares the captured face with the document portrait. Start a verification with `POST /api/kyc/verifications`, using the customer's phone number or email address. Capture the document in the frontend and upload the photos through the backend as base64 strings. After the document is accepted, request a face-capture session and pass it to the frontend component. When capture finishes, submit its transaction ID through the backend. The result's `status` determines whether to continue, retry capture or review the case. Customers who prefer their phone can use a handoff link. Request it after starting the verification and display the returned URL as a QR code. The customer completes capture on SecurySign's mobile page while the backend polls the result. ## Connect your frontend and backend Before starting, obtain the following credentials and customer details: - An approved **relying party (RP)** with the `signa-kyc` scope enabled by SecurySign. - The `client_id` and `client_secret` SecurySign assigned to your RP for **OpenID Connect (OIDC)**, stored in your backend’s configuration. - The customer’s registered phone number (`phone_number`) and identifier (`customer_id`, a phone number or email address) from your application’s customer record. - An HTTPS page for browser camera access, with `@securysign/identity-capture@0.2.0` installed in the frontend project. The browser sends document photos and the face-capture transaction ID to application routes such as `/kyc/document` and `/kyc/face`. These routes authenticate with the RP credentials, submit the corresponding SecurySign request and return its result. Identify the customer from the server-side session and load `customer_id`, `phone_number` and any recorded `rp_urn` from their record. Capture data comes from the frontend's JSON body. Implement the following application routes to forward each step to the corresponding SecurySign endpoint: | Route on your backend | Request your backend makes to SecurySign | |---|---| | `POST /kyc/start` | Send the customer identifiers to `POST /api/kyc/verifications`. | | `POST /kyc/document` | Add the frontend’s `images` array to the customer identifier and send it to `POST /api/kyc/customers/document`. | | `POST /kyc/liveness` | Send the customer identifier to `POST /api/kyc/customers/liveness-session` and relay the session JSON to your frontend. | | `POST /kyc/face` | Map the frontend’s `transactionId` to `livenessTransactionId`, add the customer identifier and send it to `POST /api/kyc/customers/face`. | | `POST /kyc/status` | Send the customer identifier to `POST /api/kyc/customers/verification` and relay the result. | | `POST /kyc/handoff` | Send the customer identifier to `POST /api/kyc/customers/handoff` and relay the link JSON. | | `POST /kyc/compare` | Add the frontend’s `image` and your application’s `checks` to the customer identifier and send them to `POST /api/kyc/customers/compare`. | Authenticate with HTTP Basic authentication: `Authorization: Basic ` followed by the base64 encoding of `client_id:client_secret`. Send inputs as JSON with `Content-Type: application/json`, and relay SecurySign's HTTP status and JSON response to the frontend. A rejected request and a completed check with an unsuccessful verdict need different handling. This Node.js helper uses `fetch` and reads the RP credentials from the backend's `CLIENT_ID` and `CLIENT_SECRET` environment variables. Pass the SecurySign endpoint as `path` and its JSON payload as `input`: ```javascript export async function postKyc(path, input) { const credentials = `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`; const response = await fetch(`https://securysign.com${path}`, { method: "POST", headers: { Authorization: `Basic ${Buffer.from(credentials).toString("base64")}`, "Content-Type": "application/json", }, body: JSON.stringify(input), }); return { httpStatus: response.status, body: await response.json() }; } ``` The following functions implement the application routes using that helper. The `customer` argument is the authenticated customer's record: `customer.customer_id` is their phone number or email address, and `customer.phone_number` is their registered number. If `rp_urn` is used, it starts as the application's own reference and is replaced with the full reference returned by the start request. Examples use `user-42` as the reference and `clientX` as the RP's `client_id`. Without `rp_urn`: ```javascript export function startVerification(customer) { return postKyc("/api/kyc/verifications", { customer_id: customer.customer_id, phone_number: customer.phone_number, }); } export function submitDocument(customer, images) { return postKyc("/api/kyc/customers/document", { customer_id: customer.customer_id, images, }); } export function openLivenessSession(customer) { return postKyc("/api/kyc/customers/liveness-session", { customer_id: customer.customer_id, }); } export function submitFace(customer, transactionId) { return postKyc("/api/kyc/customers/face", { customer_id: customer.customer_id, livenessTransactionId: transactionId, }); } export function readVerification(customer) { return postKyc("/api/kyc/customers/verification", { customer_id: customer.customer_id, }); } export function createPhoneHandoff(customer) { return postKyc("/api/kyc/customers/handoff", { customer_id: customer.customer_id, }); } export function compareIdentity(customer, { image, checks } = {}) { return postKyc("/api/kyc/customers/compare", { customer_id: customer.customer_id, image, checks, }); } export function requestEnrolment(customer) { return postKyc("/api/enrolment/request", { customer_id: customer.customer_id, }); } ``` With `rp_urn` from the customer record: ```javascript export function startVerification(customer) { return postKyc("/api/kyc/verifications", { customer_id: customer.customer_id, phone_number: customer.phone_number, rp_urn: customer.rp_urn, }); } export function submitDocument(customer, images) { return postKyc("/api/kyc/customers/document", { customer_id: customer.customer_id, rp_urn: customer.rp_urn, images, }); } export function openLivenessSession(customer) { return postKyc("/api/kyc/customers/liveness-session", { customer_id: customer.customer_id, rp_urn: customer.rp_urn, }); } export function submitFace(customer, transactionId) { return postKyc("/api/kyc/customers/face", { customer_id: customer.customer_id, rp_urn: customer.rp_urn, livenessTransactionId: transactionId, }); } export function readVerification(customer) { return postKyc("/api/kyc/customers/verification", { customer_id: customer.customer_id, rp_urn: customer.rp_urn, }); } export function createPhoneHandoff(customer) { return postKyc("/api/kyc/customers/handoff", { customer_id: customer.customer_id, rp_urn: customer.rp_urn, }); } export function compareIdentity(customer, { image, checks } = {}) { return postKyc("/api/kyc/customers/compare", { customer_id: customer.customer_id, rp_urn: customer.rp_urn, image, checks, }); } export function requestEnrolment(customer) { return postKyc("/api/enrolment/request", { customer_id: customer.customer_id, rp_urn: customer.rp_urn, }); } ``` In `/kyc/document`, call `submitDocument(customer, payload.images)`. In `/kyc/face`, call `submitFace(customer, payload.transactionId)`. The remaining routes call their matching functions. Each helper returns `httpStatus` and `body`; use these for the route's HTTP status and JSON response. Customer identifiers come from the server-side record, while capture data comes from the frontend. Browser face capture also requires a pass-through route at `/api/kyc/faceapi/*` on the application's origin. Forward `GET`, `POST` and `PUT` to the same path and query string at `https://securysign.com`, preserving the raw body and the `authorization`, `content-type`, `accept` and `x-client-key` headers. Relay the upstream status, body, `content-type` and `cache-control`. Allow capture bodies of at least 25 MB, with a 120-second timeout for `/liveness/video` and 30 seconds for other requests. Mount the route before the JSON body parser to preserve the capture body. See the [pass-through implementation](#/docs/sdk-reference#add-the-pass-through) for framework examples. To run the curl examples, set `CLIENT_ID` and `CLIENT_SECRET` to the RP credentials in the backend environment. Use the customer's actual phone number and, for examples with `rp_urn`, the recorded reference in place of `user-42`. Image requests use `jq` to build the JSON body. ### Set the customer identifiers Use the customer's phone number or email address as `customer_id`. The start request also requires `phone_number`. For example, an email-based identifier uses `customer_id: "jane@example.com"` with `phone_number: "+254712345678"`; a phone-based identifier uses `+254712345678` in both fields. Phone numbers must include a country code and match that country's numbering plan. They may start with `+` or `00` and contain spaces, dashes, dots or brackets. Valid numbers are returned in E.164 format, so `+254 712 345 678` becomes `+254712345678`; email addresses are returned in lower case. Save the normalized identifier for later calls. To resume a verification, use the registered phone number. Each number is assigned to one customer. The optional `rp_urn` field stores the application's own customer reference. It accepts a string or integer represented by 1–128 printable ASCII characters without spaces. For `client_id: "clientX"` and `rp_urn: "user-42"`, the response contains `rp_urn: "urn:securysign:clientX:user-42"`. The URN format, `urn:securysign::`, places the reference in the RP's namespace. After `startVerification(customer)` succeeds, save `result.body.rp_urn` in `customer.rp_urn`. Later requests still require `customer_id` and can include either the plain reference (`user-42`) or full URN (`urn:securysign:clientX:user-42`). References are case-sensitive: `user-42` and `User-42` are distinct. Within an RP, each reference belongs to one customer and each customer has one reference. JSON results include the assigned reference even when a later request omits it. ## 1. Start a verification When the customer opens verification, call the application's `/kyc/start` route. The backend loads their record and sends `phone_number`, `customer_id` and any optional `rp_urn` to `POST /api/kyc/verifications`. | Input | How you supply it | |---|---| | `phone_number` | Send the customer’s registered phone number as a string with its country code. | | `customer_id` | Send the phone number or email address you use to identify this customer. If omitted, it defaults to `phone_number`; supply it explicitly with `rp_urn`. | | `rp_urn` | Optionally send your system’s customer reference as a string or integer, using the format above. | Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/verifications \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "phone_number": "+254712345678" }' ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/verifications \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42", "phone_number": "+254712345678" }' ``` Response for a customer without an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "phone_number": "+254712345678", "status": "pending", "created": true, "enrolled": false } ``` Response for a customer with an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "rp_urn": "urn:securysign:clientX:user-42", "phone_number": "+254712345678", "status": "pending", "created": true, "enrolled": false } ``` Save the returned `customer_id` and optional `rp_urn` in the customer record and relay the result to the frontend. For `pending`, open document capture; for `document_verified`, open face capture; for `verified`, continue the customer's task. Handle `needs_review` and `failed` according to the [verdict guidance](#/docs/kyc#act-on-the-verdict). `created: true` identifies a new verification; `created: false` means an existing verification was resumed. Repeating a start request for the same customer and RP returns that verification's current status. When identity comparison is enabled, `enrolled: true` means a verified identity is already on file, possibly from another RP. Complete the document and face steps to compare the new submission with that identity. For invalid inputs or conflicting identifiers, use [Customer and reference errors](#/docs/kyc#customer-and-reference-errors) to correct the values before repeating the start request. ## 2. Capture, encode and submit the document At `pending`, display document capture controls. Open the rear camera when the customer selects “Open camera”. Capture the front and then the back of a national identity document, or the photo page of a passport. The whole document should fit in the frame with some background, in good light and without glare. Draw each captured frame onto a canvas with a long edge of about 1600 pixels and encode it as JPEG at quality `0.85`. Remove the `data:image/jpeg;base64,` prefix before uploading. The frontend sends the strings as `images` to `/kyc/document`; the backend adds the customer identifiers and forwards the array to `POST /api/kyc/customers/document`. For a single image, `image` can hold one base64 string instead. ### Capture in your web page The example below provides a video preview, document-type selector and camera controls after the start request. “Open camera” selects the rear camera, and each capture click saves the displayed frame. Upload one image for a passport or two for a national ID. ```html

``` A `document_verified` result proceeds to face capture in step 4. For `failed` with `document_not_authentic`, offer a document retake. Clear the previous images before collecting the required sides again, then submit the new array with the same customer identifier. Resubmitting the document resets the face step. For native applications, [Capture the ID on the device](#/docs/sdk-reference#capture-the-id-on-the-device) produces the same base64 input. ### Submit files from your backend For image files, base64-encode their bytes before building the JSON body. Here, `id-front.jpg` and `id-back.jpg` are the captured files; `jq` inserts their encoded contents into `images` in front-to-back order. Without `rp_urn`: ```bash base64 < id-front.jpg | tr -d '\n' > id-front.b64 base64 < id-back.jpg | tr -d '\n' > id-back.b64 jq -n --rawfile front id-front.b64 --rawfile back id-back.b64 \ '{ customer_id: "+254712345678", images: [$front, $back] }' | curl -X POST https://securysign.com/api/kyc/customers/document \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ --data-binary @- ``` With the recorded `rp_urn`: ```bash base64 < id-front.jpg | tr -d '\n' > id-front.b64 base64 < id-back.jpg | tr -d '\n' > id-back.b64 jq -n --rawfile front id-front.b64 --rawfile back id-back.b64 \ '{ customer_id: "+254712345678", rp_urn: "user-42", images: [$front, $back] }' | curl -X POST https://securysign.com/api/kyc/customers/document \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ --data-binary @- ``` The [verification result](#/docs/kyc#5-submit-the-result-and-read-the-verdict) contains the extracted document details. For `422 At least one document image is required`, send at least one non-empty base64 image. For `409 This verification is already verified`, retrieve the existing result using the status request in step 6. ### Interpret the document assessment Document processing extracts the printed details and portrait. SecurySign accepts the document when the machine-readable zone (MRZ) check passes. If that check is absent or was not performed, acceptance requires a passing overall processing result. A failed MRZ check rejects the document. Read `status` and `reasons`: an accepted document returns `document_verified`; rejection returns `failed` with `document_not_authentic`. When additional document checks are enabled, processing requests checks of MRZ and printed-text layout, visible security patterns, barcode format, portrait embedding, screen recapture, black-and-white copies, geometry and barcode background. These checks do not change the acceptance rule above. A `document_verified` result permits the face step; the final `verified` verdict also requires the liveness and face-match checks to pass. ## 3. Request a session for the face capture When the customer starts face capture, call `/kyc/liveness`. The backend sends `customer_id` and any matching `rp_urn` to `POST /api/kyc/customers/liveness-session`. Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/liveness-session \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678" }' ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/liveness-session \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }' ``` Response for a customer without an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "url": "/api/kyc/faceapi", "token": "", "expiresIn": 600 } ``` Response for a customer with an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "rp_urn": "urn:securysign:clientX:user-42", "url": "/api/kyc/faceapi", "token": "", "expiresIn": 600 } ``` Relay the response to the frontend and pass its `url` and `token` to the component's `start` method in step 4. With the component's API base left at the page's origin, the relative `/api/kyc/faceapi` URL reaches the backend pass-through. The session expires after 600 seconds, reported in `expiresIn`. Request a fresh session through `/kyc/liveness` for each attempt; its token replaces the previous one. For `401 Invalid or expired liveness session`, restart capture with a new session's `url` and `token`. ## 4. Start capture and handle its event Import the capture package to register ``, then mount the component and call `start(session)` with the session from step 3. The component handles the camera prompts. The following controls belong in a frontend project that resolves `@securysign/identity-capture`. Show them after document verification succeeds. The click handler requests a new session and starts capture. A `securysign-liveness` event with `confirmed` supplies the `transactionId` to send to `/kyc/face`. For `not-live` or `stopped`, enable the button so the customer can try again. ```html

``` Use the result from step 5 to select the next screen. A document failure returns to document capture. A face retry requests a fresh session when the customer starts another attempt. A `verified` result completes the verification step. The Content Security Policy must allow the application's origin and `https://securysign.com` in `connect-src`, `'wasm-unsafe-eval'` in `script-src` for WebAssembly, and `blob:` in `worker-src`. Keep the rest of the application's policy. See the [web capture SDK reference](#/docs/sdk-reference#web-capture-sdk) for the component API and customization, or the [mobile capture SDK reference](#/docs/sdk-reference#mobile-capture-sdk) for native SDKs and WebViews. ## 5. Submit the result and read the verdict In the `/kyc/face` route, copy the capture event's `transactionId` into `livenessTransactionId`. Add `customer_id` and any matching `rp_urn` from the customer record, then send the payload to `POST /api/kyc/customers/face`. Include `return_selfie: true` to request the capture image with the result. Replace the sample `livenessTransactionId` with the ID from the capture event. SecurySign uses it to retrieve the liveness result and compare the captured face with the portrait extracted from the submitted document. Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/face \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "livenessTransactionId": "497f6eca-6276-4993-bfeb-53cbbbba6f08" }' ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/face \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42", "livenessTransactionId": "497f6eca-6276-4993-bfeb-53cbbbba6f08" }' ``` Response for a customer without an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "status": "verified", "verified_name": "JANE WANJIRU DOE", "doc_type": "National ID", "doc_number": "123456789", "personal_number": "12345678", "doc_expiry": "2031-05-14", "nationality": "KEN", "surname": "DOE", "given_names": "JANE WANJIRU", "date_of_birth": "1990-01-31", "sex": "F", "issuing_state": "KEN", "date_of_issue": "2021-05-14", "face_match_score": 0.91, "liveness_score": 1.0, "reasons": [] } ``` Response for a customer with an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "rp_urn": "urn:securysign:clientX:user-42", "status": "verified", "verified_name": "JANE WANJIRU DOE", "doc_type": "National ID", "doc_number": "123456789", "personal_number": "12345678", "doc_expiry": "2031-05-14", "nationality": "KEN", "surname": "DOE", "given_names": "JANE WANJIRU", "date_of_birth": "1990-01-31", "sex": "F", "issuing_state": "KEN", "date_of_issue": "2021-05-14", "face_match_score": 0.91, "liveness_score": 1.0, "reasons": [] } ``` Save the result in the customer record and return it to the frontend. Use `status` and `reasons` to choose the next step; the remaining fields contain the identity details. Document submissions and status requests return the same result format. Dates and country values retain the document processor’s extracted representation; parse them according to that representation rather than assuming a normalized output format. Unavailable details are `null`, as are scores for steps that have not run. | Result fields | How you use them | |---|---| | `customer_id`, optional `rp_urn` | Associate the result with your customer record. | | `status`, `reasons` | Decide which step to take next using the verdict table below. You may receive several reasons. | | `verified_name`, `surname`, `given_names` | Read the holder’s full name and its extracted parts. | | `doc_type`, `doc_number` | Read the document class and the document’s own number. On a Kenyan ID, `doc_number` is the card serial number and changes when the card is replaced. | | `personal_number` | Read the person’s national ID number. For a passport, you receive a personal number when one was extracted, or `null`. | | `doc_expiry`, `date_of_issue` | Read the document’s expiry and issue dates. Use the expiry date to apply your document-validity policy. | | `date_of_birth`, `sex` | Read the holder’s date of birth and sex. | | `nationality`, `issuing_state` | Read the holder’s nationality and the document’s issuing country. | | `face_match_score`, `liveness_score` | Read the face similarity and liveness scores, each on a scale from `0` to `1`. | | Optional `enrolled`, `matches` | Read the comparison with an existing identity when identity comparison is enabled, as described below. | | Optional `selfie` | Read the capture image when you requested it; see [When you get the selfie](#/docs/kyc#when-you-get-the-selfie). | ### Act on the verdict More than one reason may appear in a result. If any reason identifies a document failure, the retry must begin with a new document submission before another face capture. | Status | Reason | How you continue | |---|---|---| | `pending` | None | Show the document camera controls, capture the required sides and submit `images` through `/kyc/document`. | | `document_verified` | None | Enable the face-capture control; its click requests `/kyc/liveness` and starts the component with the returned session. | | `verified` | None | Save the verified result and let the customer continue the task that required verification. | | `needs_review` | `face_match_borderline` | When your customer retries, request a fresh liveness session for their new capture. Alternatively, send the result to your manual review process. | | `failed` | `document_not_authentic` | Clear the previous document images, capture new ones and submit them with the same `customer_id`. | | `failed` | `liveness_failed` | Ask the customer to face the camera in even light, request a new session and submit the next confirmed capture’s transaction ID. | | `failed` | `face_mismatch` | Repeat face capture with a new session. If the mismatch persists, establish whether the document belongs to the person presenting it. | | `failed` | `identity_mismatch` | Check the `customer_id` against your customer record and confirm it identifies the person presenting the document before retrying. | Use the verdict for the decision and the scores to interpret it. With the default thresholds, face similarity of `0.75` or higher passes. If the document is accepted and confirmed liveness scores at least `0.50`, face similarity from `0.65` up to, but not including, `0.75` produces `needs_review`; lower similarity produces `failed`. Confirm the deployment's configured thresholds with SecurySign. | Face-submission error | Your next action | |---|---| | `422 A liveness transaction id is required` | Supply the completed capture’s `transactionId` as `livenessTransactionId`. | | `409 Submit the document before the liveness step` | Submit the document, then capture the face after `document_verified`. | | `409 Resubmit the document before the liveness step` | Submit a new document capture before retrying the face step. | | `409 No document portrait on file for this session` | Submit a clear document photo with its portrait visible, then retry face capture. | | `409 This verification is already verified` | Read the existing verification using the request in step 6. | ### Compare the submission with an identity on file When identity comparison is enabled, `enrolled` and `matches` describe how the submission compares with an identity already on file. For `enrolled: true`, check the field comparisons after document processing and `matches.face` after face capture. ```json { "enrolled": true, "matches": { "face": true, "full_name": true, "surname": true, "given_names": true, "document_number": false, "personal_number": true, "document_type": true, "nationality": true, "issuing_state": true, "date_of_birth": true, "date_of_expiry": false, "date_of_issue": false, "sex": true } } ``` In `matches`, `true` means a match, `false` means a difference, and `null` means a value is unavailable or the comparison is unfinished. A renewed or replacement document can change `document_number`, `date_of_expiry` and `date_of_issue`; review those differences together. The submitted values appear as `doc_number`, `doc_expiry` and `date_of_issue` in the same result. If an existing identity portrait is available, the captured face is also compared with it. For `failed` with `identity_mismatch`, check that `customer_id` belongs to the person being captured before retrying. ### When you get the selfie Include `return_selfie: true` in a face submission or verification-status request to obtain the capture image. For a verified customer with an available capture, `selfie.content_type` is `image/png` and `selfie.data` contains the base64-encoded image bytes. Other statuses and unavailable captures return `selfie: null`. To download retained evidence, send `customer_id` and any matching `rp_urn` to `POST /api/kyc/customers/selfie` or `POST /api/kyc/customers/video`. Successful responses contain binary PNG or `video/mp4` data. The examples below save the files as `selfie.png` and `liveness.mp4`. Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/selfie \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678" }' \ -o selfie.png ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/selfie \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }' \ -o selfie.png ``` Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/video \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678" }' \ -o liveness.mp4 ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/video \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }' \ -o liveness.mp4 ``` For `403 The selfie is available only once the verification is verified.`, complete the document and face steps before requesting it again. For `404 Evidence not found`, check capture availability and retention with SecurySign; an unavailable evidence file does not replace the verification verdict. Document images, portraits and captures are encrypted at rest. Agree the capture-retention period with SecurySign for the deployment. ## 6. (Optional) Continue on a phone When the customer chooses to capture on their phone, call the application's `/kyc/handoff` route. The backend requests a link from `POST /api/kyc/customers/handoff`, including `customer_id` and any matching `rp_urn` for the started verification. Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/handoff \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678" }' ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/handoff \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }' ``` Response for a customer without an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "url": "https://securysign.com/kyc-mobile.html#t=", "expiresIn": 900 } ``` Response for a customer with an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "rp_urn": "urn:securysign:clientX:user-42", "url": "https://securysign.com/kyc-mobile.html#t=", "expiresIn": 900 } ``` Display the returned `url` as a QR code. The customer scans it, opens the link on their phone and completes document and live face capture on SecurySign's hosted page. Track expiry using `expiresIn`; the link lasts 900 seconds. Poll `/kyc/status` every few seconds while capture is in progress. The backend calls `POST /api/kyc/customers/verification` with `customer_id`, adding the matching `rp_urn` and `return_selfie: true` if those options are used. Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/verification \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678" }' ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/verification \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }' ``` Interpret the result as described in step 5. Keep polling while `status` is `pending` or `document_verified`. Stop at `verified`, `needs_review` or `failed` and handle the verdict. The same request can check progress after a timeout or when the customer returns, before repeating any submission. For `401 Invalid or expired handoff session`, request another link through `/kyc/handoff` and replace the QR code with the new `url`. Issuing a new link invalidates the previous token. A failed face submission also retires its token, so a phone retry needs a new handoff link. ## 7. (Optional) Run a Live Check `POST /api/kyc/customers/compare` checks a photo or identity details against a verified identity. Use `customer_id` and any matching `rp_urn` from the customer record. A face comparison takes a fresh customer photo; detail comparisons take the values held by the application. Send the photo in `image` as base64 without a `data:` prefix. Send details in a `checks` object using the attribute names below. A request can contain a photo, details or both. To establish a new verified identity, use the document and live face flow first. Comparison uses the verified identity on file. With cross-RP comparison enabled, the identity may come from another RP. Otherwise, it uses the latest verified identity at the requesting RP. ### Submit a photo for comparison Base64-encode the captured photo and insert it in `image`. This example reads the customer's photo from `customer-photo.jpg`. Without `rp_urn`: ```bash base64 < customer-photo.jpg | tr -d '\n' > customer-photo.b64 jq -n --rawfile photo customer-photo.b64 \ '{ customer_id: "+254712345678", image: $photo }' | curl -X POST https://securysign.com/api/kyc/customers/compare \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ --data-binary @- ``` With the recorded `rp_urn`: ```bash base64 < customer-photo.jpg | tr -d '\n' > customer-photo.b64 jq -n --rawfile photo customer-photo.b64 \ '{ customer_id: "+254712345678", rp_urn: "user-42", image: $photo }' | curl -X POST https://securysign.com/api/kyc/customers/compare \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ --data-binary @- ``` The face result is in `face.match`, with the score in `face.similarity` and the configured pass threshold in `face.threshold`. For example, `{"match": true, "similarity": 0.90, "threshold": 0.75}` indicates a match with the identity's document portrait. A details-only request returns `face: null`. ### Submit details for comparison Use the attribute names below in `checks` with non-empty string values; numeric inputs are read as strings. To compare fields from a previous result, map `doc_number` to `checks.document_number`, `doc_type` to `checks.document_type`, `doc_expiry` to `checks.date_of_expiry`, and `verified_name` to `checks.full_name`. | Attributes | Accepted values and comparison rules | |---|---| | `full_name`, `surname`, `given_names` | Names in any word order or case, with punctuation: `Doe, Jane` matches `JANE DOE`. | | `document_number`, `personal_number` | Document or personal numbers; spaces, dots, dashes and slashes are ignored during comparison. | | `document_type` | The document class you want to compare. | | `nationality`, `issuing_state` | International Organization for Standardization (ISO) 3166 alpha-3 country codes, such as `KEN`, in either case. | | `date_of_birth`, `date_of_expiry`, `date_of_issue` | Dates as `YYYY-MM-DD`, `DD.MM.YYYY`, `DD/MM/YYYY`, `DD-MM-YYYY`, `YYYYMMDD` or `YYYY/MM/DD`. | | `sex` | `M`, `F`, `MALE` or `FEMALE`. | Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/compare \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "checks": { "personal_number": "12345678", "date_of_birth": "31/01/1990", "full_name": "Doe, Jane Wanjiru" } }' ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/kyc/customers/compare \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42", "checks": { "personal_number": "12345678", "date_of_birth": "31/01/1990", "full_name": "Doe, Jane Wanjiru" } }' ``` Response for a customer without an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "enrolled": true, "matches": { "customer_id": true, "personal_number": true, "date_of_birth": true, "full_name": true }, "face": null } ``` Response for a customer with an assigned `rp_urn`: ```json { "customer_id": "+254712345678", "rp_urn": "urn:securysign:clientX:user-42", "enrolled": true, "matches": { "customer_id": true, "personal_number": true, "date_of_birth": true, "full_name": true }, "face": null } ``` `matches.customer_id` indicates whether a verified identity is available. Each submitted attribute appears in `matches` as `true`, `false` or `null`. The response also contains `customer_id`, any recorded `rp_urn`, `enrolled` and `face`. With `enrolled: false`, the response has `matches.customer_id: false`, `null` for each submitted check and `face: null`. Start a verification and complete document and live face capture before comparing again. | Live Check error | Your next action | |---|---| | `422 checks must be an object of attribute names and values, e.g. {"personal_number": "12345678"}` | Supply an object using the attribute names above. | | `422 checks contains "…", which SecurySign does not check` | Replace the named attribute with one from the table; the error also lists accepted names. | | `422 checks. must be a non-empty string` | Supply a non-empty string or numeric value for that attribute. | | `422 image must be a base64-encoded photo` | Supply the base64 image as a string, or omit `image` to compare details. | | `409 No document portrait is on file for this customer: send the checks only, or verify the customer again` | Send `checks` without `image`, or run a new full verification. | ## 8. (Optional) Carry the verification into enrolment Once verification returns `status: "verified"`, request an enrolment reference through `POST /api/enrolment/request`. Authenticate with the same RP credentials and send `customer_id` with any matching `rp_urn`. Without `rp_urn`: ```bash curl -X POST https://securysign.com/api/enrolment/request \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678" }' ``` With the recorded `rp_urn`: ```bash curl -X POST https://securysign.com/api/enrolment/request \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }' ``` ```json { "request_uri": "urn:securysign:request:", "expires_in": 600 } ``` URL-encode the returned `request_uri` and add it to the [hosted enrolment link](#/docs/enrolment#enrol-through-securysign-directly), in the query string before `#/enrol`. The customer must open the link within `expires_in` seconds. The flow reuses the verification instead of repeating identity capture, and the certificate uses `verified_name` from the document. See [Reuse a verification you ran yourself](#/docs/enrolment#reuse-a-verification-you-ran-yourself) for the authorization parameters and callbacks. If a `request_uri` has expired or been claimed by another person, obtain a new reference and build a new link. This endpoint allows 60 requests per minute per client IP address; after `429`, wait for the window to reset. Other identifier and authentication failures use the fixes below. ## Resolve request errors A failed request is identified by its HTTP status and JSON `error`, while a completed check returns a verification verdict. For example, omitting `customer_id` from a later request returns HTTP `422` with `{"error": "customer_id is required"}`. Relay both the status and JSON to the frontend so it can handle the specific failure. ### Customer and reference errors Correct invalid identifiers in the customer record before retrying. `customer_id` must be a string of at most 255 characters without control characters. Email identifiers have a maximum of 254 characters overall and 64 before `@`. | Example input | HTTP status and error | Your next action | |---|---|---| | Omitted `customer_id` in a later request | `422 customer_id is required` | Include your customer’s phone number or email address. | | `rp_urn: "user-42"` without `customer_id` in the start request | `422 customer_id is required when you send rp_urn` | Include `customer_id` explicitly in the start request. | | Omitted `phone_number` in the start request | `422 phone_number is required` | Include your customer’s phone number in the start request. | | `jane@example.com` as `phone_number` | `422 phone_number must be a phone number in international format, e.g. +254712345678` | Supply a phone number with its country code. | | `user-4711` as `customer_id` | `422 customer_id must be a phone number in international format, e.g. +254712345678, or an email address` | Supply a phone number or email address; use `rp_urn` for your system’s identifier. | | `0712345678` as `customer_id` | `422 customer_id looks like a phone number without a country code: send it in international format, e.g. +254712345678` | Add the country code to the number. | | `+2547123456789` or `+254 300 000 000` as `customer_id` | `422` naming the country’s number length or assigned ranges | Correct the number using that country’s full country and area codes, number length and assigned number range. | | `jane@localhost` as `customer_id` | `422` naming an email’s local part or domain | Supply a complete email address with a local part and a domain with a top-level domain, such as `jane@example.com`. | | `user 42` as `rp_urn` | `422 rp_urn must be printable ASCII without spaces, e.g. 42, a UUID or urn:yourapp:user:42` | Supply a reference using printable ASCII characters without spaces, such as `user-42` or a universally unique identifier (UUID). | | A value longer than 128 characters as `rp_urn` | `422 rp_urn is limited to 128 characters` | Shorten your reference to at most 128 characters. | | Another RP’s full URN as `rp_urn` | `403 rp_urn belongs to another relying party: send an rp_urn your RP set` | Supply a reference assigned under your own RP’s credentials. | | A reference assigned to a different customer as `rp_urn` in a later request | `409 rp_urn … does not belong to this customer_id: send the rp_urn recorded for this customer, or omit rp_urn` | Supply the customer’s recorded reference, or omit `rp_urn`. | | A number assigned to another customer as `phone_number` | `409 phone_number … already belongs to another customer: send this customer's own phone number` | Supply the phone number assigned to this customer. | | A number different from the customer’s registered number as `phone_number` | `409 This customer_id is on file with a different phone_number: send the phone number registered for this customer` | Supply the customer’s registered phone number. | | A reference already assigned to another customer as `rp_urn` in the start request | `409 rp_urn … is already assigned to another customer: send that customer's customer_id with it, or use a different rp_urn for this customer` | Use the matching customer identifier, or assign a different reference to this customer. | | A different reference as `rp_urn` for a customer with a recorded reference | `409 This customer_id already has rp_urn …: send that rp_urn, or omit rp_urn` | Supply the reference in the error, or omit `rp_urn`. | | A reference assigned concurrently to another customer as `rp_urn` | `409 rp_urn … was just assigned to another customer: start again with a different rp_urn` | Repeat the start request with a different reference. | ### Authentication, payloads and retries | HTTP status and error | Your next action | |---|---| | `400 Invalid JSON body` | Supply a valid JSON object. | | `400 Unable to read request body` | Repeat the request with a complete JSON body. | | `403 Call this endpoint with your client credentials (HTTP Basic)` | Authenticate your Live Check request with your RP’s `client_id` and `client_secret`. | | `401 invalid_client` | Use the `client_id` and current `client_secret` assigned to your RP. | | `403 This relying party is not enabled for Identity Verification` | Ask SecurySign to enable identity verification for your approved RP. | | `404 No verification for this customer: start one with POST /kyc/verifications` | Start a verification with the same customer identifier and RP credentials. | | `413 Request body exceeds 50MB limit` | Reduce the complete JSON body below 50 megabytes (MB), including base64 image data. | | `415 Content-Type must be application/json` | Set `Content-Type: application/json`. | | `422 return_selfie must be true or false (1 or 0 also work).` | Supply a JSON boolean, `1` or `0`, or the strings `"true"`, `"false"`, `"1"` or `"0"`. String values for `return_selfie` are case-insensitive. | | `408` or a request timeout | Read the current verification, then retry the unfinished step. | | `429` | Wait for the 60-second rate-limit window to reset, then retry. | | `5xx` | Read the current verification and try again. | After a timeout or `5xx`, check `POST /api/kyc/customers/verification` with the same `customer_id` before resubmitting. Continue from the recorded status if the step completed; repeat its request if it remains unfinished. Limits apply per client IP address in 60-second windows: 20 requests per endpoint group for start, document, liveness-session, face, compare and handoff creation; 60 for evidence retrieval; and 120 for capture requests and phone handoff requests. After `429`, wait for the window to reset before retrying. Before deploying, test a document retake and confirm that the new images reach `/api/kyc/customers/document` with the same `customer_id`. Check that a face retry obtains a new session and submits its transaction ID. Complete a phone handoff and check that polling stops at the verdict. Confirm that the pass-through forwards `x-client-key` and that `needs_review` enters the application's review process. --- Source: https://securysign.com/docs/enrolment.md # 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= &nonce= &code_challenge= &code_challenge_method=S256 &claims=%7B%22id_token%22%3A%7B%22sub%22%3A%7B%22value%22%3A%22%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": "" } } }`, URL-encoded, where `` 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= &code_challenge= &code_challenge_method=S256 &idp_hint= #/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= &code_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:` 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/`. 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. --- Source: https://securysign.com/docs/sso.md # Sign users in with SecurySign SecurySign sign-in uses OpenID Connect (OIDC). Redirect the browser to the authorization endpoint; after sign-in, the callback receives `code` and `state`. The backend exchanges the code for tokens. Customers can sign in with Google or an enterprise provider configured through the [IdP guide](#/docs/idp-integration-guide). The resulting access token authenticates [hash signing](#/docs/api-hash-signing), [PAdES signing](#/docs/api-pades-signing), [webhooks](#/docs/api-webhooks), [certificates](#/docs/api-certificates) and [encryption](#/docs/api-encryption) requests on that user's behalf. ## What you need Before starting, you'll need: - An approved relying party (RP) with its client ID and OIDC client secret. See the [RP integration guide](#/docs/rp-integration-guide). - Your callback URL registered as a redirect URI on the RP. OIDC libraries can discover the authorization, token and signing-key endpoints at `https://securysign.com/auth/realms/signa/.well-known/openid-configuration`. ## 1. Send the user to authorize ```text https://securysign.com/auth/realms/signa/protocol/openid-connect/auth ?client_id=signa-rp-42 &redirect_uri=https://app.example.com/auth/callback &response_type=code &scope=openid &state= &kc_idp_hint=google ``` | Parameter | Required | Value | |---|---|---| | `client_id` | Yes | Your client ID, for example `signa-rp-42`. | | `redirect_uri` | Yes | Your callback URL, exactly as registered on your RP, URL-encoded. | | `response_type` | Yes | `code` | | `scope` | Yes | `openid`, followed by any other scopes assigned to your client, separated by spaces. | | `state` | Yes | A random value you generate for this sign-in and store in the user's session. | | `kc_idp_hint` | No | `google` to take your user straight to Google, or the alias of your enterprise provider. Without it, your user sees the SecurySign sign-in page. | ## 2. Exchange the code At the callback, compare `state` with the random value saved in the user's session. Exchange `code` on the backend using the same redirect URI as the authorization request: ```bash curl -X POST https://securysign.com/auth/realms/signa/protocol/openid-connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ --data-urlencode "client_id=signa-rp-42" \ --data-urlencode "client_secret=$OIDC_CLIENT_SECRET" \ --data-urlencode "code=$CODE" \ --data-urlencode "redirect_uri=https://app.example.com/auth/callback" ``` Set `$OIDC_CLIENT_SECRET` to the secret issued at RP approval and `$CODE` to the callback's query parameter. Each authorization code can be used once. The response's expiry fields determine when the tokens need refreshing. ```json { "access_token": "eyJhbGciOiJSUzI1NiIs…", "expires_in": 300, "refresh_token": "eyJhbGciOiJIUzI1NiIs…", "refresh_expires_in": 1800, "token_type": "Bearer", "id_token": "eyJhbGciOiJSUzI1NiIs…", "scope": "openid email profile" } ``` Store `access_token` on the backend and send it as `Authorization: Bearer ` on requests for that user. `expires_in` is the remaining token lifetime in seconds. | Response | Cause | Resolution | |---|---|---| | `400 {"error": "invalid_grant", "error_description": "Code not valid"}` | The code expired or was already used | Send the user through sign-in again | | `400 {"error": "invalid_grant", "error_description": "Incorrect redirect_uri"}` | `redirect_uri` differs from the one in the authorization request | Send the same `redirect_uri` in both requests | | `401 {"error": "unauthorized_client", "error_description": "Invalid client or Invalid client credentials"}` | Your client ID or OIDC client secret is wrong | Send the OIDC client secret from your RP | ## 3. Read the claims | Claim | What you get | |---|---| | `sub` | The user's permanent identifier. Key your users on it. | | `email`, `email_verified` | The user's email address, and whether their identity provider verified it. Rely on the address only when `email_verified` is `true`. | | `preferred_username` | The user's username at SecurySign | | `name`, `given_name`, `family_name`, `picture` | Profile data, when the user's identity provider sends it | | `signa_certificate_url`, `signa_certificate_serial`, `signa_certificate_subject` | The user's signing certificate, when you have the `signa-certificate` scope; see the [Verification API](#/docs/api-certificates#3-read-the-signers-certificate). | | `visible_signature_url`, `visible_signature_id`, `visible_signature_sha256` | The user's drawn signature image, when you have the `signa-visible-signature` scope. | Certificate and signature-image downloads require the access token of the same customer whose URLs appear in the claims. ## Sign in from a native app Native apps open sign-in in the system browser and receive the callback through an app link. Registering a native redirect URI creates a public client named `-native`, for example `signa-rp-42-native`. It uses Proof Key for Code Exchange (PKCE) with `S256`: the app sends a code challenge during authorization and the matching verifier during exchange. Native sign-in also requires: - An approved RP, and your domain (for example `example.com`) as one of its [authorised signing origins](#/docs/rp-integration-guide#3-authorise-additional-signing-origins), as `https://example.com`. - Your domain allowed as a WebAuthn relying party ID at SecurySign; ask support if the files below answer `Unknown domain`. - Your Android package name and signing certificate SHA-256 fingerprints, or your Apple Team ID and bundle identifier. ### Register your app Register the app under **Mobile apps** on the [RP dashboard](https://cloud.securysign.com/#/rp/dashboard), providing the package or bundle identifier, WebAuthn domain, signing identity and callbacks. The same registration can be submitted through [`POST /rp/mobile-apps`](#/docs/api-relying-parties): | Field | Android | iOS | |---|---|---| | WebAuthn RP ID | Your domain, `example.com` | Your domain, `example.com` | | Package name / bundle identifier | `com.example.clientx` | `com.example.clientx` | | Fingerprints / Team ID | Every SHA-256 fingerprint that signs a build you ship: the Play App Signing key for store builds, your debug key for testing | Your 10-character Team ID | | Sign-in redirect URIs | Up to five, matched exactly: `https://example.com/app/callback` (an App Link) or your app ID as the scheme, `com.example.clientx:/oauth2redirect` | The same, with the https form as a Universal Link | | Universal Link paths | — | Other paths your app opens, such as `/sign/*` (optional) | Use a dedicated HTTPS path for the native callback, such as `/app/callback`, separate from the web callback. A small page at that path can handle visits on devices without the app installed. The first native redirect URI creates `signa-rp-42-native`. Later mobile-app changes update its callbacks in the same request. ### Serve the association files from your domain Association files connect the app to its HTTPS callbacks and WebAuthn credentials. SecurySign generates them from the registration. Proxy them through the app's domain at the following paths, returning `200` with `Content-Type: application/json`: ```nginx location = /.well-known/assetlinks.json { proxy_pass https://securysign.com/api/well-known/example.com/assetlinks.json; proxy_ssl_server_name on; } location = /.well-known/apple-app-site-association { proxy_pass https://securysign.com/api/well-known/example.com/apple-app-site-association; proxy_ssl_server_name on; } ``` Validate the Android statement list and Apple App Site Association file after publishing them. Android checks App Links during installation, so reinstall the test build to refresh the result. `adb shell pm get-app-links com.example.clientx` reports the domain as `verified` when association succeeds. On iOS, include `applinks:example.com` and `webcredentials:example.com` in Associated Domains. On Android, declare the callback's HTTPS App Link with `android:autoVerify="true"`. ### Send the user to authorize Use a Custom Tab on Android or `ASWebAuthenticationSession` on iOS; AppAuth can manage the flow on either platform. Generate a random `code_verifier` for each attempt and send its base64url-encoded SHA-256 digest as `code_challenge`: ```text https://securysign.com/auth/realms/signa/protocol/openid-connect/auth ?client_id=signa-rp-42-native &redirect_uri=https://example.com/app/callback &response_type=code &scope=openid &state= &code_challenge= &code_challenge_method=S256 &kc_idp_hint=google ``` ### Exchange the code in the app At the app callback, verify `state` against the saved value. Exchange the returned `code` with the saved `code_verifier` and the same redirect URI: ```bash curl -X POST https://securysign.com/auth/realms/signa/protocol/openid-connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ --data-urlencode "client_id=signa-rp-42-native" \ --data-urlencode "code=$CODE" \ --data-urlencode "code_verifier=$CODE_VERIFIER" \ --data-urlencode "redirect_uri=https://example.com/app/callback" ``` Native-client tokens contain `azp: "signa-rp-42-native"`. SecurySign maps that client to the RP's granted scopes and allowances. If the backend validates `azp`, include the registered client IDs for both web and native sign-in. ## Troubleshooting | Symptom | Fix | |---|---| | `invalid_scope` | Request only scopes assigned to your client. One unassigned scope fails the whole login. | | `invalid_redirect_uri` | Register the exact callback URL as a redirect URI on your RP | | Blank `email` after login | Your user's identity provider shared no email. For your own SAML or OIDC provider, map an email attribute; see the [IdP integration guide](#/docs/idp-integration-guide#connect-a-saml-20-provider) | | `Token missing subject claim` from a SecurySign API | Your client's access tokens carry no `sub`. Contact support to add the `basic` scope to your client. | | Registering an app fails with `… needs https://example.com as one of this RP's authorized signing origins` | Request `https://example.com` under **Authorized signing origins** on the RP dashboard, then register the app again | | `Native app sign-in is not enabled on this deployment yet` | Register the app without redirect URIs, and ask support to enable native sign-in | | `Missing parameter: code_challenge_method` | Send `code_challenge` and `code_challenge_method=S256`; the native client requires PKCE | | `invalid_redirect_uri` from the native client | Send a redirect URI registered on one of your apps, character for character | | The redirect opens the browser instead of your app | Your domain is not serving the association files, or the app was installed before they were live. Check both validators, then reinstall | See the [Sign-in reference](#/docs/api-sign-in-oidc) for authorization parameters and token-exchange fields. --- Source: https://securysign.com/docs/sdk-reference.md # Add SecurySign to your backend, web page or app SecurySign's server libraries handle signing-token requests, sign-in and webhook verification. The capture SDKs handle document photos and live face capture in a browser or mobile app, with API requests sent through the application's backend. For browser signing, see the [Iframe guide](#/docs/iframe). - [Server libraries](#/docs/sdk-reference#server-libraries): Node.js, PHP, Python and Java clients for your backend - [Web capture SDK](#/docs/sdk-reference#web-capture-sdk): ID capture and the live face check in a web page - [Mobile capture SDK](#/docs/sdk-reference#mobile-capture-sdk): ID capture and the live face check in iOS, Android, Flutter, React Native and .NET MAUI apps ## Server libraries Download the source file for the backend's language and import it into the project. The client provides functions for signing tokens, OpenID Connect (OIDC) login and webhook-signature verification. Set `SSC_SECRET` in the server environment to the Secure Signature Confirmation secret from the RP dashboard. The [API reference](#/docs/api-reference) contains the underlying HTTP requests. | Language | File | |---|---| | Node.js | [`signa-node.js`](/docs/sdk-examples/signa-node.js) | | PHP | [`signa-php.php`](/docs/sdk-examples/signa-php.php) | | Python | [`signa-python.py`](/docs/sdk-examples/signa-python.py) | | Java | [`signa-java.java`](/docs/sdk-examples/signa-java.java) | ### Install a server library These downloads use the production host. Install the source file and the dependencies listed for the chosen language. #### Node ```bash curl -fsSLo signa-node.cjs https://securysign.com/docs/sdk-examples/signa-node.js npm install axios node --check signa-node.cjs ``` #### PHP ```bash curl -fsSLo signa-php.php https://securysign.com/docs/sdk-examples/signa-php.php # Enable the PHP curl extension, then check the file: php -m | grep -i '^curl$' php -l signa-php.php ``` #### Python ```bash curl -fsSLo signa_python.py https://securysign.com/docs/sdk-examples/signa-python.py python -m pip install requests python -m py_compile signa_python.py ``` #### Java ```bash curl -fsSLo SecurySignClient.java https://securysign.com/docs/sdk-examples/signa-java.java # Add jackson-databind to your build; see the Maven dependency below. ``` The Node file is saved as `.cjs` to work with both CommonJS and ESM projects. The Python filename uses an underscore because Python module names cannot contain a hyphen. Java requires Java 11 or later and Jackson; put the source in the caller's package or add the appropriate package declaration. ```xml com.fasterxml.jackson.core jackson-databind 2.17.0 ``` ### Request a signing token #### Node ```javascript const { SecurySignClient } = require("./signa-node.cjs"); const client = new SecurySignClient({ appUrl: "https://securysign.com", keycloakUrl: "https://securysign.com/auth", clientId: "signa-rp-42", sscSecret: process.env.SSC_SECRET, redirectUri: "https://app.example.com/auth/callback", }); async function signingTokenFor(documentHash) { const { token } = await client.requestSigningToken({ documentHash, loa: "LOA-2" }); return token; } ``` #### PHP ```php require_once __DIR__ . '/signa-php.php'; $client = new SecurySignClient([ 'appUrl' => 'https://securysign.com', 'keycloakUrl' => 'https://securysign.com/auth', 'clientId' => 'signa-rp-42', 'sscSecret' => getenv('SSC_SECRET'), 'redirectUri' => 'https://app.example.com/auth/callback', ]); $token = $client->requestSigningToken($documentHash, 'LOA-2'); ``` #### Python ```python import os from signa_python import SecurySignClient client = SecurySignClient( app_url="https://securysign.com", keycloak_url="https://securysign.com/auth", client_id="signa-rp-42", ssc_secret=os.environ["SSC_SECRET"], redirect_uri="https://app.example.com/auth/callback", ) token = client.request_signing_token(document_hash=document_hash, loa="LOA-2") ``` #### Java ```java SecurySignClient client = new SecurySignClient( "https://securysign.com", "https://securysign.com/auth", "signa-rp-42", System.getenv("SSC_SECRET"), "https://app.example.com/auth/callback" ); Map token = client.requestSigningToken(documentHash, "LOA-2"); ``` Pass the document's SHA-256 hash as a 64-character hexadecimal `documentHash`. Send the returned `token` from the backend to the page that opens the [signing iframe](#/docs/iframe). In PHP, Python and Java, the return value is a response object or map containing that `token` field. ### Verify a webhook signature #### Node ```javascript const valid = SecurySignClient.verifyWebhookSignature(rawBody, req.headers["x-webhook-signature"], webhookSecret); ``` #### Python ```python import hashlib, hmac def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(header or "", expected) ``` #### PHP ```php function verifyWebhook(string $rawBody, string $header, string $secret): bool { return hash_equals('sha256=' . hash_hmac('sha256', $rawBody, $secret), $header); } ``` Webhook verification takes the raw request body, `X-Webhook-Signature` header and subscription secret, and checks whether the body matches the signed delivery. For a per-request `callbackUrl`, confirm completion through an authenticated API result; see [completion events](#/docs/api-hash-signing#receive-completion-events). Contact [support@tenda.world](mailto:support@tenda.world) for other languages. ## Web capture SDK The web integration uses the browser camera for document photos and `` from [`@securysign/identity-capture`](https://www.npmjs.com/package/@securysign/identity-capture) for face capture. The backend authenticates with RP credentials and submits the photos and capture transaction ID to SecurySign. Before adding capture to a page, obtain: - an approved RP with `signa-kyc` and its OIDC `client_id` and `client_secret`; - an HTTPS capture page; - a signed-in customer record containing their email or international phone number and their `phone_number`. See [KYC](#/docs/kyc) for the verification sequence and verdict fields. ### Add the backend routes Implement four application routes for starting verification, uploading the document, requesting a liveness session and submitting the face result. Authentication middleware supplies `req.user` or `current_user`. Read `customer_id` from that record's email or international phone number, and `phone_number` from its registered number. The `/kyc/` paths below are application routes that forward requests to SecurySign. Routes without `rp_urn`: #### Node ```javascript import express from "express"; const API = "https://securysign.com/api/kyc"; const AUTH = "Basic " + Buffer.from(`${process.env.SECURYSIGN_CLIENT_ID}:${process.env.SECURYSIGN_CLIENT_SECRET}`).toString("base64"); async function kyc(path, body = {}) { const res = await fetch(API + path, { method: "POST", headers: { Authorization: AUTH, "Content-Type": "application/json" }, body: JSON.stringify(body), }); return [res.status, await res.json()]; } const app = express(); app.use("/kyc", express.json({ limit: "15mb" })); // requireUser: your auth middleware. Every call identifies the signed-in customer by customer_id. app.post("/kyc/start", requireUser, async (req, res) => { const [status, body] = await kyc("/verifications", { customer_id: req.user.email, phone_number: req.user.phone }); res.status(status).json(body); // { customer_id, phone_number, status, created } }); app.post("/kyc/document", requireUser, async (req, res) => { const [status, body] = await kyc("/customers/document", { customer_id: req.user.email, images: req.body.images }); res.status(status).json(body); }); app.post("/kyc/liveness", requireUser, async (req, res) => { const [status, body] = await kyc("/customers/liveness-session", { customer_id: req.user.email }); if (status !== 200) return res.status(status).json(body); res.status(status).json({ url: body.url, token: body.token }); // only these reach the browser }); app.post("/kyc/face", requireUser, async (req, res) => { // The capture reports "done" a moment before its upload reaches SecurySign, // which answers 404 until it lands. Retry briefly instead of failing. let status, body; for (let attempt = 0; attempt < 5; attempt++) { [status, body] = await kyc("/customers/face", { customer_id: req.user.email, livenessTransactionId: req.body.transactionId }); if (status !== 404) break; await new Promise((r) => setTimeout(r, 1000)); } res.status(status).json(body); // the verdict }); ``` #### Python ```python import os import time import httpx from fastapi import Depends, FastAPI from fastapi.responses import JSONResponse API = "https://securysign.com/api/kyc" AUTH = (os.environ["SECURYSIGN_CLIENT_ID"], os.environ["SECURYSIGN_CLIENT_SECRET"]) app = FastAPI() def kyc(path: str, body: dict | None = None) -> httpx.Response: return httpx.post(API + path, auth=AUTH, json=body or {}, timeout=30) def relay(r: httpx.Response) -> JSONResponse: return JSONResponse(r.json(), status_code=r.status_code) # keep SecurySign's status # current_user: your auth dependency. Every call identifies the signed-in customer by customer_id. @app.post("/kyc/start") def start(user=Depends(current_user)): return relay(kyc("/verifications", {"customer_id": user.email, "phone_number": user.phone})) # { customer_id, phone_number, status, created } @app.post("/kyc/document") def document(payload: dict, user=Depends(current_user)): return relay(kyc("/customers/document", {"customer_id": user.email, "images": payload["images"]})) @app.post("/kyc/liveness") def liveness(user=Depends(current_user)): r = kyc("/customers/liveness-session", {"customer_id": user.email}) if r.status_code != 200: return relay(r) return {"url": r.json()["url"], "token": r.json()["token"]} # only these reach the browser @app.post("/kyc/face") def face(payload: dict, user=Depends(current_user)): # The capture reports "done" a moment before its upload reaches SecurySign, # which answers 404 until it lands. Retry briefly instead of failing. for _ in range(5): r = kyc("/customers/face", {"customer_id": user.email, "livenessTransactionId": payload["transactionId"]}) if r.status_code != 404: break time.sleep(1) return relay(r) ``` For an application reference, also send `rp_urn` when starting verification. These routes include it on every request while retaining `customer_id`. The reference comes from `req.user.id` or `user.id` in the same authenticated record: #### Node ```javascript import express from "express"; const API = "https://securysign.com/api/kyc"; const AUTH = "Basic " + Buffer.from(`${process.env.SECURYSIGN_CLIENT_ID}:${process.env.SECURYSIGN_CLIENT_SECRET}`).toString("base64"); async function kyc(path, body = {}) { const res = await fetch(API + path, { method: "POST", headers: { Authorization: AUTH, "Content-Type": "application/json" }, body: JSON.stringify(body), }); return [res.status, await res.json()]; } const app = express(); app.use("/kyc", express.json({ limit: "15mb" })); // requireUser: your auth middleware. The start sets your user ID as rp_urn, alongside // customer_id and phone_number; the calls after it send rp_urn with customer_id. app.post("/kyc/start", requireUser, async (req, res) => { const [status, body] = await kyc("/verifications", { customer_id: req.user.email, phone_number: req.user.phone, rp_urn: String(req.user.id) }); res.status(status).json(body); // { customer_id, phone_number, rp_urn, status, created } }); app.post("/kyc/document", requireUser, async (req, res) => { const [status, body] = await kyc("/customers/document", { customer_id: req.user.email, rp_urn: String(req.user.id), images: req.body.images }); res.status(status).json(body); }); app.post("/kyc/liveness", requireUser, async (req, res) => { const [status, body] = await kyc("/customers/liveness-session", { customer_id: req.user.email, rp_urn: String(req.user.id) }); if (status !== 200) return res.status(status).json(body); res.status(status).json({ url: body.url, token: body.token }); // only these reach the browser }); app.post("/kyc/face", requireUser, async (req, res) => { // The capture reports "done" a moment before its upload reaches SecurySign, // which answers 404 until it lands. Retry briefly instead of failing. let status, body; for (let attempt = 0; attempt < 5; attempt++) { [status, body] = await kyc("/customers/face", { customer_id: req.user.email, rp_urn: String(req.user.id), livenessTransactionId: req.body.transactionId }); if (status !== 404) break; await new Promise((r) => setTimeout(r, 1000)); } res.status(status).json(body); // the verdict }); ``` #### Python ```python import os import time import httpx from fastapi import Depends, FastAPI from fastapi.responses import JSONResponse API = "https://securysign.com/api/kyc" AUTH = (os.environ["SECURYSIGN_CLIENT_ID"], os.environ["SECURYSIGN_CLIENT_SECRET"]) app = FastAPI() def kyc(path: str, body: dict | None = None) -> httpx.Response: return httpx.post(API + path, auth=AUTH, json=body or {}, timeout=30) def relay(r: httpx.Response) -> JSONResponse: return JSONResponse(r.json(), status_code=r.status_code) # keep SecurySign's status # current_user: your auth dependency. The start sets your user ID as rp_urn, alongside # customer_id and phone_number; the calls after it send rp_urn with customer_id. @app.post("/kyc/start") def start(user=Depends(current_user)): return relay(kyc("/verifications", {"customer_id": user.email, "phone_number": user.phone, "rp_urn": str(user.id)})) # { customer_id, phone_number, rp_urn, status, created } @app.post("/kyc/document") def document(payload: dict, user=Depends(current_user)): return relay(kyc("/customers/document", {"customer_id": user.email, "rp_urn": str(user.id), "images": payload["images"]})) @app.post("/kyc/liveness") def liveness(user=Depends(current_user)): r = kyc("/customers/liveness-session", {"customer_id": user.email, "rp_urn": str(user.id)}) if r.status_code != 200: return relay(r) return {"url": r.json()["url"], "token": r.json()["token"]} # only these reach the browser @app.post("/kyc/face") def face(payload: dict, user=Depends(current_user)): # The capture reports "done" a moment before its upload reaches SecurySign, # which answers 404 until it lands. Retry briefly instead of failing. for _ in range(5): r = kyc("/customers/face", {"customer_id": user.email, "rp_urn": str(user.id), "livenessTransactionId": payload["transactionId"]}) if r.status_code != 404: break time.sleep(1) return relay(r) ``` Include `return_selfie: true` or `1` in `/face` to request the capture image. A `verified` result includes it when available; see [Selfie fields](#/docs/kyc#when-you-get-the-selfie) for cases that return `null`. ### Add the pass-through The browser component sends capture requests to `/api/kyc/faceapi/*` on the application's origin. Relay those requests from the backend to the same path on SecurySign, preserving the scoped liveness token supplied by the session route. Mount the raw-body relay before the JSON body parser: #### Next.js ```typescript // app/api/kyc/faceapi/[...path]/route.ts import { NextRequest } from "next/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const UPSTREAM = "https://securysign.com/api/kyc/faceapi"; const FORWARD = ["authorization", "content-type", "accept", "x-client-key"]; async function relay(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { const { path } = await params; if (path.some((s) => s === "." || s === "..")) return Response.json({ error: "bad_path" }, { status: 400 }); const target = new URL(`${UPSTREAM}/${path.map(encodeURIComponent).join("/")}`); target.search = req.nextUrl.search; const headers = new Headers(); for (const name of FORWARD) { const value = req.headers.get(name); if (value) headers.set(name, value); } const upstream = await fetch(target, { method: req.method, headers, body: req.method === "GET" ? undefined : await req.arrayBuffer(), cache: "no-store", signal: AbortSignal.timeout(path.at(-1) === "video" ? 120_000 : 30_000), }).catch((err) => { console.error(`[kyc/faceapi] ${req.method} /${path.join("/")}:`, err?.name, err?.cause?.code ?? err?.message); return null; }); if (!upstream) return Response.json({ error: "unavailable" }, { status: 502 }); const out = new Headers(); for (const name of ["content-type", "cache-control"]) { const value = upstream.headers.get(name); if (value) out.set(name, value); } return new Response(upstream.body, { status: upstream.status, headers: out }); } export { relay as GET, relay as POST, relay as PUT }; ``` #### Express ```javascript // Express 5. Mount before any global body parser. // Express 4: use "/api/kyc/faceapi/*" and `const segments = req.params[0].split("/")`. const UPSTREAM = "https://securysign.com/api/kyc/faceapi"; const FORWARD = ["authorization", "content-type", "accept", "x-client-key"]; app.all("/api/kyc/faceapi/*sub", express.raw({ type: () => true, limit: "25mb" }), async (req, res) => { if (!["GET", "POST", "PUT"].includes(req.method)) return res.sendStatus(405); const segments = req.params.sub; if (segments.some((s) => s === "." || s === "..")) return res.status(400).json({ error: "bad_path" }); const sub = segments.map(encodeURIComponent).join("/"); const headers = {}; for (const name of FORWARD) if (req.get(name)) headers[name] = req.get(name); const query = req.originalUrl.includes("?") ? req.originalUrl.slice(req.originalUrl.indexOf("?")) : ""; try { const upstream = await fetch(`${UPSTREAM}/${sub}${query}`, { method: req.method, headers, body: req.method === "GET" ? undefined : req.body, signal: AbortSignal.timeout(sub.endsWith("/video") ? 120_000 : 30_000), }); for (const name of ["content-type", "cache-control"]) { const value = upstream.headers.get(name); if (value) res.set(name, value); } res.status(upstream.status).send(Buffer.from(await upstream.arrayBuffer())); } catch (err) { console.error(`[kyc/faceapi] ${req.method} /${sub}:`, err?.name, err?.cause?.code ?? err?.message); res.status(502).json({ error: "unavailable" }); } }); ``` In another backend framework, preserve the `GET`, `POST` or `PUT` method, sub-path, query string and raw body, along with the `authorization`, `content-type`, `accept` and `x-client-key` headers. Allow bodies of at least 25 MB. The example uses a 30-second timeout, extended to 120 seconds for `/liveness/video`. ```callout warning **Intermittent `ETIMEDOUT` after about 250 ms on Node 20 or later** means your network cannot route the IPv6 address that `securysign.com` resolves to. Start Node with `--dns-result-order=ipv4first --no-network-family-autoselection`. ``` ### Install the component #### npm ```bash npm install @securysign/identity-capture@0.2.0 ``` #### CDN ```html ``` These examples use version `0.2.0` and its TypeScript types. Pin that version to keep the installed component API consistent with the code below. ### Capture the ID in the browser Start the document screen with `/kyc/start`, then open the rear camera. Capture the front and back of a national identity card, or the photo page of a passport. Upload the array to `/kyc/document`; the backend adds the customer identifier and forwards it to SecurySign. #### JavaScript ```javascript await fetch("/kyc/start", { method: "POST" }); // your backend identifies the signed-in customer const video = document.querySelector("video"); video.srcObject = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false, }); // Current frame as base64 JPEG, ~1600 px long edge, no data: prefix. function grabFrame(v, maxEdge = 1600) { const scale = Math.min(1, maxEdge / Math.max(v.videoWidth, v.videoHeight)); const canvas = document.createElement("canvas"); canvas.width = Math.round(v.videoWidth * scale); canvas.height = Math.round(v.videoHeight * scale); canvas.getContext("2d").drawImage(v, 0, 0, canvas.width, canvas.height); const url = canvas.toDataURL("image/jpeg", 0.85); return url.slice(url.indexOf(",") + 1); } const images = []; document.querySelector("#capture").onclick = async () => { images.push(grabFrame(video)); if (images.length < 2) return; // national ID: front, back. Passport: stop at 1. video.srcObject.getTracks().forEach((t) => t.stop()); const verdict = await (await fetch("/kyc/document", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ images }), })).json(); // verdict.status: "document_verified", or "failed" with "document_not_authentic" -> retake }; ``` #### React ```tsx import { useEffect, useRef, useState } from "react"; function grabFrame(v: HTMLVideoElement, maxEdge = 1600): string { const scale = Math.min(1, maxEdge / Math.max(v.videoWidth, v.videoHeight)); const canvas = document.createElement("canvas"); canvas.width = Math.round(v.videoWidth * scale); canvas.height = Math.round(v.videoHeight * scale); canvas.getContext("2d")!.drawImage(v, 0, 0, canvas.width, canvas.height); const url = canvas.toDataURL("image/jpeg", 0.85); return url.slice(url.indexOf(",") + 1); } export function IdCapture({ onUploaded }: { onUploaded: (verdict: { status: string }) => void }) { const video = useRef(null); const [images, setImages] = useState([]); useEffect(() => { let stream: MediaStream | undefined; navigator.mediaDevices .getUserMedia({ video: { facingMode: "environment", width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false }) .then((s) => { stream = s; if (video.current) video.current.srcObject = s; }); return () => stream?.getTracks().forEach((t) => t.stop()); }, []); async function capture() { const next = [...images, grabFrame(video.current!)]; setImages(next); if (next.length < 2) return; // national ID: front, back const res = await fetch("/kyc/document", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ images: next }), }); onUploaded(await res.json()); } return ( <>