KyneticID
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

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.

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

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:

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

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:

  1. Verify the signature over the raw body, before parsing it. Reject the request (for example with 401) if it does not verify, or if webhook-timestamp is more than 5 minutes from your clock. The standardwebhooks library does both; it is published for Node (npm), Python (PyPI), Ruby (RubyGems), Rust (crates.io), Java and Kotlin (Maven Central com.standardwebhooks:standardwebhooks), C# (NuGet StandardWebhooks.StandardWebhooks) and Go.
  2. Process each webhook-id once. It equals data.session_id. Store it, and ignore a request whose webhook-id you have already processed.
  3. 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.
  4. Store the result against the user named by data.reference_id. On a pass, store data.face_id with that user: it is needed for every repeat check of this person (Phase 4).
  5. Download the pictures you want to keep. Each url works for 15 minutes from when the request was sent (expires_at, Unix milliseconds). After that, read the transaction again for new links (Phase 5).
  6. Ignore other event types. If type is not verification.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:

  1. key = base64-decode of the signing secret with the whsec_ prefix removed.
  2. content = webhook-id + . + webhook-timestamp + . + the raw body.
  3. expected = base64(HMAC-SHA256(key, content)).
  4. webhook-signature is a space-separated list of v1,<signature> entries. Accept if any v1 signature equals expected, compared in constant time.
  5. Reject if webhook-timestamp is 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:

Step 3.3: Test the endpoint without a scan

  1. Download the Postman collection from https://custonboard-kyneticid.oddinson.com/api-docs and import it into Postman.
  2. In the collection's Variables tab, fill in callbackUrl (your test callback URL) and callbackSigningSecret (the test key's whsec_ secret), and save.
  3. 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:

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:

  1. Your backend looks up the face_id it stored for that user.
  2. Your backend gives it to your page.
  3. 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.

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

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

  1. In the collection's Variables tab, fill in apiSecret (the key's sk_ secret). For the test key, also change baseUrl to https://demo-api-kyneticid.oddinson.com; the collection is preset to the production address. Save.
  2. Run List transactions first: it fills in a session id, the next page cursor and a picture link for the other requests.
  3. 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.


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

  1. 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).
  2. Store the production secrets in your production backend's secret store: the production callback signing secret and the production API secret.
  3. 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.com with the production API secret;
    • your Content-Security-Policy allows the production hosts (step 2.1).
  4. Repeat checks 2 to 6, 9 and 10 of Phase 6 on the production site.
  5. 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