Contents
KyneticID integration: development plan for your team
This is the step-by-step plan for integrating KyneticID face verification into your product, from the first day to production. Follow the phases in order. Each step says what to do and how to confirm it is done.
The API reference is at https://custonboard-kyneticid.oddinson.com/api-docs. It lists every transactions API parameter, response field and error, the exact request KyneticID sends to your backend for each result, and a Postman collection to download. This plan links to it wherever field-level detail is needed.
What you build
| Part | Where it runs | What it does |
|---|---|---|
| The scanner | Your web page (React) | Shows the camera, guides the person through two movements, uploads the pictures, and tells the person the outcome |
| The callback endpoint | Your backend | Receives each signed result from KyneticID: the verdict, the person's face_id and the pictures |
| Transaction reads | Your backend | Reads past results from the transactions API, to reconcile and to get pictures again |
Every decision that grants access is made on what your backend receives through the callback, never on what the page receives. The page receives only pass or fail and a sentence to show the person.
Two environments
KyneticID gives you two kinds of api_key. The scanner package selects the
environment from the key; there is nothing to configure in the page.
Test key (ak_test_ + 24 characters) |
Production key (ak_ + 24 characters) |
|
|---|---|---|
| Used for | Development on your computers and your test site | Your live product |
| Data | Separate from production: its own database, picture storage and face index. KyneticID may clear it; keep nothing there you need | Your real users |
| Transactions API address | https://demo-api-kyneticid.oddinson.com |
https://api-kyneticid.oddinson.com |
| Picture links | https://demo-media-kyneticid.oddinson.com |
https://media-kyneticid.oddinson.com |
Each key has its own callback URL, callback signing secret, API secret and allowed website origins. Faces registered with the test key do not exist in production.
Credentials
| Credential | Looks like | Keep it | Used for |
|---|---|---|---|
api_key |
ak_test_… or ak_… |
In your page. It is public | Starting a scan; usage counts against the key's monthly quota |
| Callback signing secret | whsec_ + 44 characters |
Backend only | Verifying that a result callback came from KyneticID |
| API secret | sk_ + 43 characters |
Backend only | Calling the transactions API |
- Do not use the
api_keyto authorise anything. The transactions API refuses it. - The signing secret and the API secret must never reach a browser, a mobile app, a repository or a log. Keep them in your backend's secret store.
- KyneticID shows each secret once. A lost secret is replaced, not recovered.
Phase 0: Prepare
Step 0.1: GitHub accounts
The scanner packages are private packages in KyneticID's GitHub organisation,
kyneticID. GitHub lets only members of that organisation read them, so every
developer who installs them needs a personal GitHub account, which KyneticID
invites into kyneticID. You do not create a GitHub organisation of your own.
As members, your accounts can read the two scanner packages and nothing else: they have no access to any repository and cannot create one. Members of the organisation can see the usernames of its other members.
- Developers without an account create one, free, at https://github.com/signup.
- CI (continuous integration) means a server that installs, builds and deploys your code automatically, such as GitHub Actions, GitLab CI or Jenkins. If one installs the packages, create a dedicated GitHub account for it rather than using a person's. Without CI, skip every CI item in this plan.
Done when: you have the GitHub username of every developer, and of the CI account if you use CI.
Step 0.2: Send KyneticID these details
| Item | Example | Notes |
|---|---|---|
| Company name | Example Sdn Bhd | |
| Expected scans per month, test and production | 500 test, 20,000 production | Sets each key's monthly quota. Once a calendar month (UTC) is used up, new scans are refused until the 1st |
| GitHub usernames | alice-dev, and example-ci for CI if you use it |
From step 0.1 |
| Local development origins, for the test key | http://localhost:3000 |
The exact scheme, host and port each developer runs the page on |
| Test site origin, for the test key | https://staging.example.com |
Scheme and host only. No path, no * |
| Production origin(s), for the production key | https://app.example.com |
https://example.com and https://www.example.com are two different origins |
| Callback URL for the test key | https://staging-api.example.com/kyneticid/callback |
HTTPS, reachable from the internet (step 3.2) |
| Callback URL for the production key | https://api.example.com/kyneticid/callback |
HTTPS |
The scanner works only on origins KyneticID has allowed. A page on any other origin loads, but every scan fails at the bot check.
Step 0.3: Receive from KyneticID
- A GitHub invitation to the
kyneticIDorganisation, by email, for each username. - The test
api_key, in the clear. - The test key's callback signing secret and API secret, through a secret-sharing channel. Store them in your backend's secret store at once.
- Confirmation that your origins are allowed.
The production key and its secrets come in Phase 7.
Done when: every developer has the invitation email, and the test key and its two secrets are stored.
Phase 1: Get the package
Step 1.1: Accept the invitation
Each person opens the invitation email and accepts it, signed in to GitHub as the account it was sent to. Without the email, open https://github.com/orgs/kyneticID/invitation while signed in. An invitation expires after 7 days; ask KyneticID to send a new one if it does.
Done when: kyneticID is listed at https://github.com/settings/organizations
for that account.
Step 1.2: Create a token
Each developer, and the CI account if you use CI, creates a token with the
read:packages scope. Either:
-
GitHub CLI (https://cli.github.com, for macOS, Windows and Linux):
gh auth login gh auth refresh -h github.com -s read:packagesRun
gh auth loginonly ifghis not signed in yet. Then put the token in the environment variableNODE_AUTH_TOKENfor the terminal you install from:Terminal Command macOS or Linux (bash, zsh) export NODE_AUTH_TOKEN=$(gh auth token)Windows PowerShell $env:NODE_AUTH_TOKEN = gh auth tokenWindows Command Prompt for /f %i in ('gh auth token') do set NODE_AUTH_TOKEN=%iThese last for that terminal session. To keep it: on macOS or Linux add the
exportline to your shell profile (~/.zshrcor~/.bashrc); on Windows runsetx NODE_AUTH_TOKEN "<the token>"once, which applies to terminals opened afterwards. -
Personal access token (classic): GitHub → profile picture → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic). Select only
read:packages, set an expiration date, and copy the token at once; GitHub does not show it again. Set it as the environment variableNODE_AUTH_TOKENthe same way:export NODE_AUTH_TOKEN=<the token>(macOS, Linux),$env:NODE_AUTH_TOKEN = "<the token>"(PowerShell),set NODE_AUTH_TOKEN=<the token>(Command Prompt), orsetxon Windows to keep it. Use this kind for CI, stored as a CI secret namedNODE_AUTH_TOKEN.
Fine-grained personal access tokens cannot read GitHub Packages.
Done when: in the terminal you install from, this prints set (do not
print the token itself):
| Terminal | Command |
|---|---|
| macOS or Linux | test -n "$NODE_AUTH_TOKEN" && echo set |
| Windows PowerShell | if ($env:NODE_AUTH_TOKEN) { "set" } |
| Windows Command Prompt | if defined NODE_AUTH_TOKEN echo set |
Step 1.3: Point npm at the registry
Create .npmrc next to the package.json of your React project:
@kyneticid:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
Commit this file. It holds no secret: the token stays in the environment
variable. npm reads ${NODE_AUTH_TOKEN} on macOS, Windows and Linux alike.
Step 1.4: Install
npm install @kyneticid/react
@kyneticid/sdk is installed with it. React 18 or newer is required.
Done when: npm ls @kyneticid/react shows version 1.1.0 or later. If it
fails with 401 or 403, see the troubleshooting table at the end.
Step 1.5: CI (only if you use CI)
Store the CI account's token as the CI secret NODE_AUTH_TOKEN and expose it
as an environment variable to the install step. The committed .npmrc does
the rest.
Done when: a CI build installs the package.
Phase 2: The scanner in your page (test key, on your computer)
Step 2.1: Page requirements
-
HTTPS, or
localhost. Browsers allow the camera only on HTTPS pages and onlocalhost. The page's origin must be one of those allowed in step 0.2. -
No iframe is needed. The scanner is a component inside your own page. Only if your page is itself shown inside an iframe on another page does that iframe need
allow="camera", because browsers block the camera in an iframe without it. If your site sends aPermissions-Policyheader, it must allowcamerafor the page. -
Next.js and other server-rendering frameworks: the component is a client component (the package declares
"use client") and renders nothing on the server. -
Content-Security-Policy. If your site sends one, allow the scanner's hosts:
Host Environment What the scanner loads or sends https://demo-api-kyneticid.oddinson.comtest key the scan session (requests from the browser) https://api-kyneticid.oddinson.comproduction key the scan session https://demo-cdn-kyneticid.oddinson.comtest key the on-device models and their runtime (scripts and WebAssembly) https://cdn-kyneticid.oddinson.comproduction key the on-device models and their runtime The scanner also uses four hosts outside KyneticID's domain, for both keys: the upload of the captured pictures (
PUT), the bot check (script and frame), and the face-tracking runtime (scripts and WebAssembly) and model. KyneticID sends you these four on request.The on-device models run as WebAssembly, which a
script-srcdirective blocks unless it includes'wasm-unsafe-eval'. To confirm a policy, run one scan with the browser's developer console open: a blocked request is reported there as a Content-Security-Policy violation.
Step 2.2: Add the component
import { KyneticID } from '@kyneticid/react';
export function VerifyFace({ user }) {
return (
<KyneticID
apiKey={process.env.NEXT_PUBLIC_KYNETICID_API_KEY}
referenceId={user.id}
onResult={(result) => showOutcome(result.decision, result.message)}
onError={(error) => showError(error.message)}
/>
);
}
Read the api_key from your build configuration, so that test and production
builds use their own key. The environment variable name above is an example.
| Prop | Required | What it does |
|---|---|---|
apiKey |
yes | Your api_key |
referenceId |
no | Your own identifier for the person (a user id, an application number). Returned unchanged in the result callback and the transactions API, so your backend knows whose scan it was. At most 128 characters, no control characters. Not used for matching |
faceId |
no | A face_id your backend stored for this person, for a repeat check (Phase 4). Omit it for a first check |
onResult(result) |
no | Called once, when the scan has a verdict (step 2.3) |
onPhase(phase) |
no | Called as the scan moves through idle, starting, positioning, holding, action, uploading, passed, failed |
onError(error) |
no | Called when a session could not run to a verdict (step 2.4) |
className, style |
no | Applied to the scanner's container |
The component brings its own camera view, instructions and styling. The camera starts when the person presses the scanner's start button. The person has 60 seconds from the start to line up with the camera and complete the movements, with 8 on-screen reminders; the whole scan, upload and judgement included, must finish within 120 seconds. The failure screen has a "Try again" button, which starts a new scan.
Changing apiKey, faceId or referenceId restarts the scanner. Do not pass
apiUrl: the package sets the address from the key.
Done when: the page shows the scanner, the camera starts on the button,
and a scan on your computer ends with onResult called.
Step 2.3: Handle onResult
onResult receives:
{ "session_id": "6b1f0c2a9e4d7731", "decision": "pass", "reason_code": "OK", "message": "Verified.", "elapsed_ms": 4180 }
| Field | Use |
|---|---|
session_id |
The identifier of this scan; the same value your backend receives in the callback. Send it to your backend if the page waits for that result |
decision |
pass or fail: what to show the person |
reason_code |
OK on a pass; on a fail, the reason (reference: reason codes) |
message |
A sentence written for the person. Show it as it is |
elapsed_ms |
How long the service took to judge the scan. Information only |
Use it to tell the person how it went. Grant nothing until your backend has
the result callback for that session_id.
Step 2.4: Handle onError
error.message is Server error (<HTTP status>) when the service refused to
start or run the session, or Server error (<reason>) with a short reason when
the verification step failed:
| Message | Cause | What to do |
|---|---|---|
Server error (401) |
The api_key is wrong, or switched off |
Check the key; ask KyneticID if it is right |
Server error (403) |
The bot check failed, or the page's origin is not allowed | Check that the exact origin was sent to KyneticID (step 0.2) |
Server error (429) |
The key's monthly quota is used up, or too many requests from one IP address | Ask KyneticID about the quota; otherwise retry later |
Server error (400) |
faceId was altered, belongs to another customer, or was issued in the other environment; or referenceId is too long or has control characters |
Pass the face_id stored for that user with the same key, exactly as received; check referenceId |
Server error (500) or Server error (503) |
A fault on KyneticID's side, or its bot check did not answer | Retry later; tell KyneticID if it persists |
onError is also called when the camera cannot start (permission refused, no
camera) and when the upload fails. In every case the scanner already shows the
person a message; use onError for your own logging or to offer another way
forward.
Phase 3: The callback endpoint (test key)
Step 3.1: Build the endpoint
For every scan that reaches a verdict, pass or fail, KyneticID sends one
POST to the key's callback URL. A scan abandoned before a verdict sends
nothing.
POST /kyneticid/callback HTTP/1.1
content-type: application/json
user-agent: KyneticID-Webhooks/1.0
webhook-id: 6b1f0c2a9e4d7731
webhook-timestamp: 1792137601
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"type": "verification.completed",
"timestamp": "2026-10-16T08:00:00.000Z",
"data": {
"session_id": "6b1f0c2a9e4d7731",
"reference_id": "user-123",
"decision": "pass",
"reason_code": "OK",
"face_id": "f_3c9a1e2b7d4f6a80.Xk2pQ7vN0aLm4RtY8wZc1bHs6uJe9dFg",
"is_new": true,
"similarity": null,
"possible_duplicates": [],
"images": [
{
"tag": "neutral",
"url": "https://demo-media-kyneticid.oddinson.com/6b1f0c2a9e4d7731/01.jpg?exp=1792138501&sig=q8Zr2mN5xT0bVc7LkP3sHw9YdJ4aFe6gUi1oRtQyXzA",
"expires_at": 1792138501000
}
]
}
}
The endpoint does these, in order:
- Verify the signature over the raw body, before parsing it. Reject the
request (for example with
401) if it does not verify, or ifwebhook-timestampis more than 5 minutes from your clock. Thestandardwebhookslibrary does both; it is published for Node (npm), Python (PyPI), Ruby (RubyGems), Rust (crates.io), Java and Kotlin (Maven Centralcom.standardwebhooks:standardwebhooks), C# (NuGetStandardWebhooks.StandardWebhooks) and Go. - Process each
webhook-idonce. It equalsdata.session_id. Store it, and ignore a request whosewebhook-idyou have already processed. - Answer with any 2xx status within 15 seconds, for example
204. The body of your answer is not read. Do slower work, such as downloading the pictures, after answering. - Store the result against the user named by
data.reference_id. On a pass, storedata.face_idwith that user: it is needed for every repeat check of this person (Phase 4). - Download the pictures you want to keep. Each
urlworks for 15 minutes from when the request was sent (expires_at, Unix milliseconds). After that, read the transaction again for new links (Phase 5). - Ignore other event types. If
typeis notverification.completed, answer 2xx and do nothing.
Do not use user-agent to decide that a request is genuine: anyone can send
it. Only webhook-signature proves it.
Node (Express)
import express from 'express';
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.KYNETICID_SIGNING_SECRET); // whsec_...
const app = express();
app.post('/kyneticid/callback', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers); // throws if forged, altered or stale
} catch {
return res.sendStatus(401);
}
await saveResult(req.headers['webhook-id'], event.data); // idempotent on webhook-id
res.sendStatus(204);
});
Python (Flask)
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["KYNETICID_SIGNING_SECRET"]) # whsec_...
app = Flask(__name__)
@app.post("/kyneticid/callback")
def kyneticid_callback():
headers = {k.lower(): v for k, v in request.headers.items()}
try:
event = wh.verify(request.get_data(), headers)
except Exception:
return "", 401
save_result(headers["webhook-id"], event["data"]) # idempotent on webhook-id
return "", 204
Without the library, the whole algorithm is:
key= base64-decode of the signing secret with thewhsec_prefix removed.content=webhook-id+.+webhook-timestamp+.+ the raw body.expected= base64(HMAC-SHA256(key,content)).webhook-signatureis a space-separated list ofv1,<signature>entries. Accept if anyv1signature equalsexpected, compared in constant time.- Reject if
webhook-timestampis more than 5 minutes from your clock.
Every field of the request is described in the API reference under Result callback.
Step 3.2: Make the test endpoint reachable
KyneticID sends the callback from the internet, so the test key's callback URL must be a public HTTPS address. A backend running on a developer's computer is not reachable: deploy the endpoint to your staging backend, or expose it through an HTTPS tunnel, and send that URL to KyneticID (step 0.2).
Delivery rules:
- One attempt. If the endpoint is down, answers with a non-2xx status, takes longer than 15 seconds, redirects, or cannot be reached, the callback is recorded as failed and not sent again.
- Redirects are not followed: the registered URL must answer directly.
Step 3.3: Test the endpoint without a scan
- Download the Postman collection from https://custonboard-kyneticid.oddinson.com/api-docs and import it into Postman.
- In the collection's Variables tab, fill in
callbackUrl(your test callback URL) andcallbackSigningSecret(the test key'swhsec_secret), and save. - Run Test your callback. It sends a sample result signed exactly as KyneticID signs one.
Done when: your endpoint verifies the sample and answers 2xx, and refuses
it after you replace one character in the middle of callbackSigningSecret
(keep the whsec_ prefix). Postman signs the body again on every send, so
editing the body does not test refusal.
Step 3.4: Read the result correctly
| Field | Meaning |
|---|---|
data.decision |
pass or fail |
data.reason_code |
OK on a pass; the reason on a fail |
data.face_id |
On a pass, the person's identifier, signed for your account. null on a fail |
data.is_new |
true when this scan registered a new person. false on a matching repeat check, or when a first check recognised a face already registered with you and returned that face's original face_id |
data.similarity |
How close the face was (0 to 1) to the face it was compared with. null for a new face and on a fail |
data.possible_duplicates |
Only when a new face_id was created: your other registered faces that look similar (similarity 0.55 to 0.70), each with face_id, similarity and earliest. A lead for human review, not a verdict |
data.reference_id |
Your referenceId, unchanged; null if the page passed none |
data.images |
The pictures, each with tag, url and expires_at |
One face, one face_id. On a first check (no faceId passed), the face is
compared with every face already registered under your key's account, never
another customer's:
- 0.70 or above: the same person. No new
face_idis created; you receive theface_idthis person was first registered with,is_new: false, and thesimilarity. Ifreference_idnames a different user of yours, the same person is opening a second account. - 0.55 to 0.70: a new
face_idis created, andpossible_duplicateslists who it resembles. - Below 0.55: a new person.
Identical twins, and sometimes close lookalikes, score above 0.70 and receive
one face_id. A newly registered face takes a few minutes to become
searchable: the same face registered again without its face_id within those
minutes receives a second face_id. Passing the stored face_id for every
later check (Phase 4) prevents this.
Done when: a real scan on your page reaches your endpoint, verifies, and
stores the face_id against the right user.
Phase 4: Repeat checks
For a returning user:
- Your backend looks up the
face_idit stored for that user. - Your backend gives it to your page.
- Your page passes it to the scanner:
<KyneticID apiKey={apiKey} faceId={storedFaceId} referenceId={user.id} onResult={showOutcome} />
The result arrives at your backend as in Phase 3, with is_new: false and a
similarity. A different face fails with FACE_ID_MISMATCH. A face_id that
was altered, or belongs to another customer, is refused before the scan
starts. A face_id works only for the account and environment that issued
it: a test-key face_id does not work with the production key.
Done when: the same person passes a repeat check, and another person fails
it with FACE_ID_MISMATCH.
Phase 5: The transactions API
Step 5.1: Call it from your backend
Call it from your backend only. Browsers are not allowed to send the
Authorization header to it, and an API secret in a web page can be read by
anyone.
- Address: the key's transactions API address (see "Two environments").
- Header on every request:
Authorization: Bearer sk_...
| Request | Returns |
|---|---|
GET /v1/transactions |
Your completed scans, newest first, one page at a time (limit 1 to 200, default 50; follow next_cursor with the same filters until it is null). Filters: from, to, status (success or failed), face_id |
GET /v1/transactions/summary |
How many scans passed and failed. Filters: from, to, status |
GET /v1/transactions/{session_id} |
One scan, with the same fields plus report, the detailed record of how the verdict was reached |
Only scans that reached a verdict are returned. Every response carries newly signed picture links, valid for 15 minutes.
Dates and times
- Every time in a response is Unix milliseconds (for example
created_at). fromandtoare dates writtenYYYY-MM-DD, read as UTC days. The API takes no time zone and does not convert. Give both or neither; the range may cover at most 31 days, both ends included. Without them, the last 31 UTC days are returned, today included.- Example for Malaysia (UTC+8): to read every scan of 16 October 2026 in
Malaysia time (2026-10-15 16:00:00 UTC to 2026-10-16 15:59:59 UTC), request
from=2026-10-15&to=2026-10-16and keep the transactions whosecreated_atis from1792080000000up to but not including1792166400000.
Errors: 400 with error naming the parameter at fault, 401 unauthorized (missing, wrong or revoked API secret, or the api_key is off),
404 not_found, 500. More than 600 requests in 10 seconds from one IP
address, counting the API and the picture links together, receive 429 with a
body that is not JSON: wait 10 seconds before trying again.
Every parameter, field and error, with examples:
https://custonboard-kyneticid.oddinson.com/api-docs. The reference shows the production
address; with the test key, use https://demo-api-kyneticid.oddinson.com.
Step 5.2: Try it in Postman
- In the collection's Variables tab, fill in
apiSecret(the key'ssk_secret). For the test key, also changebaseUrltohttps://demo-api-kyneticid.oddinson.com; the collection is preset to the production address. Save. - Run List transactions first: it fills in a session id, the next page cursor and a picture link for the other requests.
- Run Count transactions, Get a transaction and Get a picture.
Done when: each request answers 200, and Get a picture returns an image.
Step 5.3: Build reconciliation
Because a callback is sent only once, run a scheduled job that lists recent
transactions and processes every one whose callback_status is not
delivered (failed, pending, not_configured or null) as if its
callback had arrived. Use the same idempotent handling as the callback (keyed on
session_id).
Done when: a scan made while your callback endpoint was stopped is found and processed by the job.
Step 5.4: Picture links
- Open each
images[].urlexactly as received, from your backend or in an<img>tag. No credential is needed: the signature in the link is the permission, so treat a link like the picture itself and do not pass it on. - A link works for 15 minutes, then answers
403 expired. Do not store links; store the pictures you need to keep. - Do not build or change a link: any change answers
403 bad_signature.
Phase 6: Test on your test site
Deploy your page and backend to your test site, still with the test key. Then confirm each item:
| # | Check | How to confirm |
|---|---|---|
| 1 | The package installs on a clean machine, and in CI if you use it | npm install succeeds; the CI build passes |
| 2 | A first check passes | onResult gives decision: pass |
| 3 | The callback reached your backend and verified | Your endpoint's log; a request signed with a wrong secret is refused (step 3.3) |
| 4 | Delivery was recorded | GET /v1/transactions/{session_id} shows callback_status: delivered |
| 5 | The face_id is stored against the user |
Your database |
| 6 | A repeat check by the same person passes | is_new: false and a similarity in the callback |
| 7 | A repeat check by someone else fails | reason_code: FACE_ID_MISMATCH |
| 8 | The API secret works and the api_key does not |
GET /v1/transactions answers 200 with the secret, 401 with the api_key |
| 9 | The pictures open, for a pass and a fail | Each images[].url opens a JPEG; the same link after 15 minutes answers 403 expired |
| 10 | Your Content-Security-Policy allows the scanner | One scan with the developer console open shows no CSP violation |
| 11 | A missed callback is recovered | Stop your endpoint, run one scan, and see the reconciliation job process it |
Phase 7: Go live
- Ask KyneticID for the production key and its two secrets, if you do not have them yet. Confirm the production origin(s), the production callback URL and the monthly quota (step 0.2).
- Store the production secrets in your production backend's secret store: the production callback signing secret and the production API secret.
- Switch the production build:
- the page uses the production
api_key; - the backend verifies callbacks with the production signing secret;
- the backend calls
https://api-kyneticid.oddinson.comwith the production API secret; - your Content-Security-Policy allows the production hosts (step 2.1).
- the page uses the production
- Repeat checks 2 to 6, 9 and 10 of Phase 6 on the production site.
- Replace any secret that was exposed during development (Phase 8).
Done when: a scan on the production site passes, its callback is
delivered, and the face_id is stored.
Phase 8: Operations
| Need | What you do |
|---|---|
| Add a developer | Send KyneticID the GitHub username; the person does Phase 1 |
| Remove a developer | Ask KyneticID to remove the account from the organisation |
| Add a website | Send KyneticID the exact origin and the key it is for; the scanner works there once KyneticID confirms |
| Change a quota | Ask KyneticID |
| Change a callback URL | Send KyneticID the new HTTPS URL and the key it is for; the signing secret stays the same |
| Replace the signing secret | Ask KyneticID. The old secret stops verifying at once, so deploy the new one to your backend immediately |
| Replace the API secret without downtime | Ask KyneticID for a second API secret, switch your backend to it, then ask KyneticID to revoke the old one. Both work in the meantime |
| A secret is lost or exposed | Ask KyneticID for a new one; the old one is revoked |
| A token expires | The developer creates a new one (step 1.2) |
Reference: reason codes
reason_code |
When |
|---|---|
OK |
Every check passed |
NO_FACE |
No face found in the pictures, or the pictures were too large to process |
NOT_ENOUGH_EVIDENCE |
Too few usable pictures, or none facing the camera |
POOR_QUALITY |
Too dark, blurred or too far away to judge |
FACE_CHANGED |
The face changed during the scan |
ACTION_NOT_VERIFIED |
A requested movement could not be confirmed |
FACE_COVERED |
Part of the face was covered |
CHALLENGE_MISMATCH |
The pictures do not show the movements this scan asked for |
FACE_ID_MISMATCH |
Repeat check: not the same person as the faceId passed in |
SPOOF_SUSPECTED |
A photo, a screen or a video rather than a live person |
INJECTION_SUSPECTED |
The camera feed looks injected rather than captured |
REPLAY_DETECTED |
The same pictures were submitted more than once in the scan |
SESSION_EXPIRED |
The time ran out before the scan finished |
TOO_MANY_FAILURES |
All 8 on-screen reminders were used up |
SERVER_ERROR, INTERNAL_ERROR |
A fault on KyneticID's side |
Every scan that starts uses one scan of the key's monthly quota, whether it passes or fails.
Reference: troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
npm install fails with 401 or 403 |
No NODE_AUTH_TOKEN in that terminal or CI step; the token lacks read:packages, is fine-grained or has expired; the invitation was not accepted |
Phase 1 |
npm install fails with 404 for @kyneticid/react |
.npmrc is missing or not next to package.json |
Step 1.3 |
The scanner fails before the camera, with Turnstile error 110200 in the console |
The page's hostname is not allowed | Send the origin to KyneticID |
CORS error in the console |
The page's origin is not allowed for that key | Send the exact origin and the key to KyneticID |
| A Content-Security-Policy violation in the console | Your policy blocks one of the scanner's hosts | Step 2.1 |
| The camera does not start | The page is not on HTTPS or localhost, the person refused permission, or the page is shown inside an iframe that lacks allow="camera" |
Step 2.1 |
onError with Server error (...) |
See step 2.4 | |
| No callback arrives | The endpoint answered non-2xx, took over 15 seconds, redirected, is not reachable from the internet, or no callback URL is registered for that key | Check callback_status; run Test your callback in Postman |
| The signature does not verify | The body was parsed or re-serialised before verifying; the secret of the other key was used; your server clock is more than 5 minutes off | Verify the raw body with the signing secret of the key that made the scan |
The transactions API answers 401 |
The api_key was sent instead of the API secret; the secret is wrong, revoked, or belongs to the other key |
Phase 5 |
| The transactions API returns none of your scans | The address and the key are from different environments | Use the address that matches the key ("Two environments") |
A returning person fails with FACE_ID_MISMATCH |
A large change in appearance between scans, for example glasses in one and not the other | Let the person try again |
A picture link answers 403 expired |
More than 15 minutes since it was issued | Read the transaction again for a new link |
One person holds two face_ids |
They registered twice without a face_id within a few minutes |
Pass the stored face_id for every check after the first |
Reference: limits to design around
- A result callback is sent once and never retried; reconcile with the transactions API (step 5.3).
- Picture links last 15 minutes; store the pictures you need.
- A new face becomes searchable for duplicate matching only after a few minutes.
- Every scan that starts uses one scan of the key's monthly quota, whether it passes or fails.
- 600 requests per 10 seconds per IP address, across the API and the picture links.
- The scanner works only on origins KyneticID has allowed for that key.