7. Palm Verification & Operations¶
Palm enrollment and verification — the PalmVerifier port, the palm flows (pull/push), score model, restrictions, duplicate detection, and the small/large model split.
7.1 Overview¶
Identity Platform uses ports and adapters (hexagonal) for palm verification: the core domain defines the PalmVerifier interface, and vendor-specific adapters implement it.
7.2 Architecture¶
flowchart TB
subgraph IP["Identity Platform"]
subgraph CD["Core Domain"]
D["User, PalmTemplate, Enrollment, Challenge"]
end
CD --> Port["PalmVerifier Port (interface)"]
Port --> Adapter["X-Telcom BioWave Pass Adapter (sole MVP)"]
end
7.3 PalmVerifier Port (Interface)¶
The PalmVerifier Port defines the abstract interface that all palm verification vendor adapters must implement.
| Method | Parameters | Returns | Description |
|---|---|---|---|
enroll |
user_id, palm_template |
EnrollmentResult |
Store a palm template for a user |
verify |
user_id, palm_scan |
VerificationResult |
1:1 match against stored template |
identify |
palm_scan, tenant_id |
IdentificationResult |
1:N search across all templates |
get_status |
user_id |
EnrollmentStatus |
Check if user has an enrolled template |
delete_template |
user_id |
boolean |
Remove a stored palm template |
verify (1:1) vs identify (1:N) — the difference is whether the caller already names the user:
verify(1:1) checks a claimed identity: the caller already knows theuser_id(e.g. a verification challenge from an integrator carries it), and the platform compares against that one template. This is the public, challenge-based path for personal scanners (§8.2, §7.4).identify(1:N) answers "who is this?" from a bare palm with no claim. Device-initiated transactions are claim-less, so they always useidentify— and it runs internally inside the broker (§9), not as a public endpoint.
7.4 Palm Flows¶
Every palm enrollment and verification flow, grouped by initiation pattern. Pull = an integrator backend creates a challenge that a personal scanner polls for and completes. Push = a device (POS / gate / kiosk) initiates directly over mTLS. Device pairing and certificates live in §8; the broker authorize contract lives in §9.
7.4.1 Pull — challenge-based (integrator-initiated)¶
All palm operations on personal scanners — verification and enrollment — are challenge-based: an integrator backend creates a challenge (POST /v1/verify or POST /v1/enroll/both), the scanner polls GET /v1/challenges/pending (mTLS) and completes it via POST /v1/challenges/{id}/complete, and Identity calls the vendor and applies the tenant's match policy. The integrator calls with external_user_id/hardware_id; Identity resolves these to the internal user_id/device_id, the scanner only ever sees a user_id inside the challenge envelope, and Identity authoritatively maps user_id to palm_id. The two sequences below show verify and enroll, and both assume the scanner is already paired (§8.2).
The mechanism is generic — the integrator passes application data in metadata (echoed back on the webhook). E-signature is one integrator use case, not a platform feature: in the worked example below, InvestGlass creates a verify challenge and passes the document id/hash in metadata. The platform provides only the generic verify/enroll challenge + polling.
Diagram — Pull verify (personal-scanner challenge; InvestGlass document-signing example)
sequenceDiagram
autonumber
participant User
participant IGApp as InvestGlass Web App
participant IG as InvestGlass Backend
participant Identity as Identity Platform
participant Scanner as Personal Scanner
participant Palm as X-Telcom BioWave Pass Palm Server
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
User->>IGApp: Click "Sign Document"
IGApp->>IG: POST /documents/{id}/sign
IG->>Identity: POST /v1/verify<br/>Authorization: Bearer {token}<br/>{external_user_id: "user_456",<br/>hardware_id: "dev_789",<br/>metadata: {document_id: "doc_123",<br/>document_hash: "sha256:..."}}
Identity->>DB: Validate user enrolled
alt User not enrolled
DB-->>Identity: palm_enrolled: false
Identity-->>IG: 400 Bad Request<br/>"User not enrolled"
else User enrolled
Identity->>DB: Create challenge<br/>(type: verify, user_id,<br/>device_id, status: pending,<br/>expires_at, metadata)
Identity->>Audit: Log: challenge_created<br/>(challenge_id, user_id, device_id)
Identity-->>IG: {challenge_id: "ch_abc",<br/>status: "pending",<br/>expires_at: "..."}
end
IG-->>IGApp: "Please scan your palm<br/>on the scanner at your counter"
loop Scanner polling (every 2-3 sec)
Scanner->>Identity: GET /v1/challenges/pending<br/>(mTLS — device cert identifies scanner)
Identity->>DB: Get device from cert fingerprint
Identity->>DB: Get pending challenges for device_id
alt No pending challenges
Identity-->>Scanner: {challenges: []}
else Challenge available
Identity-->>Scanner: {challenges: [{<br/>challenge_id: "ch_abc",<br/>type: "verify",<br/>expires_at: "..."}]}
end
end
Scanner-->>Scanner: Display "Verification needed<br/>for document signing"
Scanner-->>User: "Place palm to sign document"
User->>Scanner: Place palm
Scanner->>Scanner: Capture palm<br/>Extract features
Scanner->>Identity: POST /v1/challenges/ch_abc/complete<br/>(mTLS — device cert identifies scanner)<br/>{palm_template}
Identity->>DB: Get challenge, validate not expired
Identity->>DB: Get user's enrolled palm reference
Note over Identity, Palm: X-Telcom BioWave Pass has no dedicated 1:1 endpoint.<br/>We use /KZ/query (1:N) and validate the<br/>returned user_id matches the challenge's expected user_id.<br/>For tighter binding, use §7.8 to set query_type<br/>per user.
Identity->>Palm: POST /KZ/query<br/>Headers: request_id: {uuid}<br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {is_encrypted: false}
alt Match returned — code: 0
Palm-->>Identity: {code: 0,<br/>data: {results: [{user_id, id,<br/>query_type, scores: [4 floats]}],<br/>thresholds: [4 floats]}}
Identity->>Identity: Verify returned user_id ==<br/>challenge.expected_user_id<br/>AND apply tenant score policy
alt user_id matches & policy passes
Identity->>DB: Update challenge<br/>(status: completed)
Identity->>Audit: Log: verification_complete<br/>(challenge_id, user_id, scores)
Identity-->>Scanner: {status: "completed",<br/>user_id, scores, timestamp}
Scanner-->>User: "Verification successful"
opt Webhook subscribed
Identity->>IG: Send webhook: verification.complete<br/>{challenge_id: "ch_abc",<br/>external_user_id: "user_456",<br/>scores, timestamp,<br/>metadata: {document_id, document_hash}}
end
IG->>IG: Mark document signed<br/>Store verification metadata as proof
IG-->>IGApp: Push notification
IGApp-->>User: "Document signed!"
else user_id mismatch (matched a different user)
Identity->>DB: Increment attempt count
Identity->>Audit: Log: verification_failed<br/>(challenge_id, attempt,<br/>reason: identity_mismatch,<br/>severity: high)
Identity-->>Scanner: {status: "failed",<br/>reason: "Identity mismatch"}
Scanner-->>User: "Wrong user — please try again"
end
else No match — platform-decided (scores < thresholds)
Palm-->>Identity: {code: 0}<br/>(no candidate ≥ thresholds → platform no-match)
Identity->>DB: Increment attempt count
Identity->>Audit: Log: verification_failed<br/>(challenge_id, user_id, attempt,<br/>reason: not_found)
Identity-->>Scanner: {status: "failed",<br/>reason: "Palm not recognized",<br/>attempts_remaining: 2}
Scanner-->>User: "Please try again"
else Low confidence — platform-decided
Palm-->>Identity: {code: 0}<br/>(scores < thresholds → platform low-confidence)
Identity->>DB: Increment attempt count
Identity->>Audit: Log: verification_failed<br/>(challenge_id, user_id, attempt,<br/>reason: low_confidence)
alt Max attempts reached
Identity->>DB: Update challenge (status: failed)
Identity-->>Scanner: {status: "failed",<br/>reason: "Max attempts exceeded"}
opt Webhook subscribed
Identity->>IG: Send webhook: verification.failed<br/>{external_user_id, challenge_id, reason}
end
else Attempts remaining
Identity-->>Scanner: {status: "failed",<br/>reason: "Low confidence",<br/>attempts_remaining: 2}
Scanner-->>User: "Please try again"
end
else Query type forbidden — code: 30008
Note over Palm: User has restriction set<br/>(see §7.8)
Palm-->>Identity: {code: 30008,<br/>msg: "DB query type is forbidden"}
Identity->>Audit: Log: verification_failed<br/>(challenge_id, reason: query_type_restricted)
Identity-->>Scanner: {status: "failed",<br/>reason: "Palm verification disabled<br/>for this user"}
end
Diagram — Pull enrollment at a personal scanner (both hands)
Enrollment always captures both hands via POST /v1/enroll/both — single-hand enrollment is not offered.
⚠ Open question (Q1): the diagram shows both palms captured and registered. The exact vendor fan-out to
/KZ/add(one combined call vs one per hand) and whether the scanner submits both templates in a singlecompleteare to be confirmed with the team; the sequence below does not assert the call shape.
sequenceDiagram
autonumber
participant Operator as Bank Operator
participant IGApp as InvestGlass Web App
participant IG as InvestGlass Backend
participant Identity as Identity Platform
participant Scanner as Personal Scanner
participant Palm as X-Telcom BioWave Pass Palm Server
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
Operator->>IGApp: Select user → "Enroll Palm"
IGApp->>IG: POST /users/{user_id}/enroll-palm<br/>{device_id}
IG->>Identity: POST /v1/enroll/both<br/>Authorization: Bearer {token}<br/>(client_credentials)<br/>{external_user_id, hardware_id, metadata}
Identity->>DB: Validate user exists,<br/>device paired and active
rect rgb(255, 245, 230)
Note over Identity, DB: Compliance gates<br/>(KYC + consent — same as duplicate-detection flow)
Identity->>DB: Check kyc_required_for_enrollment<br/>+ consent_required
alt KYC missing or consent missing
Identity-->>IG: 403 Forbidden<br/>{error: "kyc_required" or "consent_required"}
IG-->>IGApp: Display compliance error
end
end
alt User already enrolled
Identity-->>IG: 409 Conflict<br/>"User already enrolled"
else Ready to enroll
Identity->>DB: Create challenge<br/>(type: enroll, user_id,<br/>device_id, status: pending,<br/>expires_at, metadata: {palm_type: "both"})
Identity->>Audit: Log: challenge_created<br/>(type: enroll, user_id, device_id)
Identity-->>IG: {challenge_id: "ch_enroll_1",<br/>status: "pending"}
IG-->>IGApp: "Ask user to place palm<br/>on scanner"
end
loop Scanner polling (every 2-3 sec)
Scanner->>Identity: GET /v1/challenges/pending<br/>(mTLS — device cert identifies scanner)
alt No pending challenges
Identity-->>Scanner: {challenges: []}
else Challenge available
Identity-->>Scanner: {challenges: [{<br/>challenge_id: "ch_enroll_1",<br/>type: "enroll",<br/>user_id, palm_type: "both"}]}
end
end
Scanner-->>Operator: "Enrollment ready —<br/>ask user to place palm"
Operator->>Scanner: User places both palms
Scanner->>Scanner: Capture both palms<br/>Extract features (RGB + IR)
Scanner->>Identity: POST /v1/challenges/ch_enroll_1/complete<br/>(mTLS — device cert identifies scanner)<br/>{palm_templates: both hands<br/>(features_rgb, features_ir,<br/>image_rgb, image_ir per hand)}
Identity->>DB: Get challenge, validate not expired,<br/>type == enroll
Note over Identity, Palm: Recommended: §7.9<br/>pre-enrollment duplicate detection.<br/>⚠ Both-hands registration — vendor fan-out per Q1.
Identity->>Palm: POST /KZ/add (both palms)<br/>Headers: request_id: {uuid}<br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {user_id,<br/>is_encrypted: false}
alt Success — code: 0
Palm-->>Identity: {code: 0, msg: "Success",<br/>data: {user_id, id: 100001,<br/>query_type: "all"}}
Identity->>DB: Update user<br/>(palm_enrolled: true,<br/>palm_id: 100001, enrolled_at)
Identity->>DB: Update challenge<br/>(status: completed)
Identity->>Audit: Log: palm_enrolled<br/>(user_id, palm_id: 100001,<br/>palm_type: both, device_id)
Identity->>Identity: Publish PalmEnrolled event
opt Webhook subscribed
Identity->>IG: Send webhook: enrollment.complete<br/>{external_user_id, palm_type: "both",<br/>hardware_id, timestamp}
end
Identity-->>Scanner: {status: "enrolled",<br/>user_id, enrolled_at}
Scanner-->>Operator: "Palm enrolled successfully"
IG-->>IGApp: Update UI
IGApp-->>Operator: "User palm enrolled"
else Duplicate biometrics — code: 30007
Note over Palm: Different user_id but<br/>matching features in Milvus<br/>(potential identity fraud)
Palm-->>Identity: {code: 30007,<br/>msg: "DB ID already registered"}
Identity->>DB: Update challenge (status: failed)
Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: duplicate_biometrics,<br/>severity: high)
Identity-->>Scanner: {status: "failed",<br/>reason: "Biometrics already registered"}
Scanner-->>Operator: "Cannot enroll —<br/>contact support"
else User already enrolled — code: 30005
Palm-->>Identity: {code: 30005,<br/>msg: "DB register name duplicated"}
Identity->>DB: Update challenge (status: failed)
Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: already_enrolled)
Identity-->>Scanner: {status: "failed",<br/>reason: "Already enrolled"}
else Enrollment failed — code: 30002 (DB insert)
Palm-->>Identity: {code: 30002,<br/>msg: "DB insert failed"}
Identity->>DB: Update challenge (status: failed)
Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: license_capacity)
Identity->>Identity: Trigger ops alert:<br/>license capacity reached
Identity-->>Scanner: {status: "failed",<br/>reason: "Service unavailable"}
else Vendor error — code: 50000
Palm-->>Identity: {code: 50000,<br/>msg: "Milvus error"}
Identity->>Audit: Log: palm_vendor_unauthorized<br/>(severity: critical)
Identity->>Identity: Trigger ops alert
Identity-->>Scanner: {status: "failed",<br/>reason: "Service unavailable"}
end
7.4.2 Push — device-initiated¶
A device (POS / gate / kiosk) initiates the operation directly over mTLS. The platform identifies the user (large model) or records a device-supplied result (small model, §7.10 — small-model sequence variants live there); for transactions it then routes to the bound product through the broker (authorize contract and step list in §9.3).
Diagram — Device-initiated transaction (synchronous broker)
sequenceDiagram
autonumber
participant User
participant Device as POS / Gate (Identity-managed)
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass Palm Server
participant Product as Wallet / Access Backend (linked service)
participant Audit as Audit Log
Note over Identity, Audit: Device is bound to one {product, action}<br/>(bound_product, bound_action — §8.3)
User->>Device: Place palm on scanner
Device->>Device: Capture palm image (RGB + IR)
Device->>Identity: POST /v1/device/transactions<br/>(mTLS — device cert)<br/>{scan, context: {amount, currency},<br/>idempotency_key}
Identity->>Identity: Parse SAN URI → device_id, tenant_id<br/>Look up bound_product, bound_action
Identity->>Palm: POST /KZ/query (1:N)<br/>tenant-prefixed user_id namespace
alt Match — code: 0
Palm-->>Identity: {code: 0, data: {results: [{user_id, scores: [4]}],<br/>thresholds: [4]}}
Identity->>Identity: Apply tenant match_policy<br/>sign identity_assertion JWT (claims in §9.5)
Identity->>Product: POST {base_url}{authorize_path}<br/>Authorization: Bearer {identity_assertion}<br/>{user_id, action, context, idempotency_key}
alt Product responds within timeout_ms
Product->>Product: Verify JWT vs Identity JWKS<br/>business decision (balance / access rights)
Product-->>Identity: {decision: "allow" | "deny",<br/>display_message, reference_id, ttl}
Identity->>Audit: Log: device_transaction<br/>(user_id, device_id, product, decision, latency_ms)
opt Webhook subscribed
Identity->>Identity: Send webhook:<br/>device.transaction.completed
end
Identity-->>Device: {decision, display_message,<br/>product_reference} (in-band)
Device-->>User: Open gate / "Approved" (or decline)
else Timeout / circuit open
Note over Identity, Product: fail_mode per linked service<br/>(default: closed)
Identity->>Audit: Log: device_transaction<br/>(decision: fail_closed,<br/>reason: authorize_timeout)
Identity-->>Device: {decision: "deny"} (fail-closed)
Device-->>User: "Try again"
end
else No match — platform-decided (scores < thresholds)
Palm-->>Identity: {code: 0}<br/>(no candidate ≥ thresholds)
Identity->>Audit: Log: device_transaction (not_recognized)
Identity-->>Device: {decision: "not_recognized"}
Device-->>User: "Palm not recognized"
else Low confidence — platform-decided
Palm-->>Identity: {code: 0}<br/>(scores < thresholds)
Identity->>Audit: Log: device_transaction (not_recognized, low_confidence)
Identity-->>Device: {decision: "not_recognized"}
Device-->>User: "Palm not recognized — try again"
else Query type forbidden — code: 30008
Palm-->>Identity: {code: 30008, msg: "query type forbidden"}
Identity->>Audit: Log: device_transaction (forbidden, query_type_restricted)
Identity-->>Device: {decision: "deny", reason: "palm_type_not_allowed"}
Device-->>User: "Try other hand"
else Vendor error — code: 50000
Palm-->>Identity: {code: 50000, msg: "Milvus error"}
Identity->>Audit: Log: palm_vendor_unauthorized (severity: critical)
Identity-->>Device: 503 Service Unavailable
end
Diagram — Device-initiated enrollment (POS → consent → enroll, both hands)
sequenceDiagram
autonumber
participant User
participant Device as POS / Kiosk (Identity-managed)
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass Palm Server
participant SMS as SMS Provider
participant Audit as Audit Log
User->>Device: Tap "Enroll" / "Sign up"
Device->>Identity: POST /v1/device/signup (mTLS)<br/>{mobile}
Identity->>Identity: Find or create user by mobile<br/>(tenant-scoped)
Identity->>SMS: Send OTP
SMS-->>User: OTP code
User->>Device: Enter OTP
Device->>Identity: POST /v1/device/signup/verify (mTLS)<br/>{challenge_id, code}
Identity-->>Device: {user_id, is_new_user,<br/>palm_enrolled: false,<br/>consent_status, kyc_status}
opt Product requires KYC and kyc_status != verified
Device-->>User: Direct to complete KYC first<br/>(app / Nafath)
end
opt consent_required and consent_status = none
Device->>User: Show consent screen
User->>Device: Accept
Device->>Identity: POST /v1/device/consent (mTLS)<br/>{user_id, consent_type, version}
Identity->>Audit: Log: consent.granted
end
User->>Device: Place both palms
Device->>Device: Capture both palms (RGB + IR)
Device->>Identity: POST /v1/device/enroll (mTLS)<br/>{user_id, scan (both palms)}
alt consent satisfied
Identity->>Palm: POST /KZ/add (both palms)<br/>tenant-prefixed user_id
Palm-->>Identity: {code: 0, palm_id}
Identity->>Audit: Log: enrollment.complete<br/>(palm_type: both)
opt Webhook subscribed
Identity->>Identity: Send webhook: enrollment.complete<br/>{palm_type: "both"}
end
Identity-->>Device: {palm_enrolled: true}
Device-->>User: "Enrolled — palm now works across Link"
else consent missing — 403
Identity-->>Device: 403 {error: consent_required}
Device-->>User: "Consent required"
end
7.4.3 Enrollment paths¶
Account creation and palm enrollment are separate lifecycles (§13.2). The account is keyed by mobile number and can be created in a product app/dashboard or at a device in signup mode; the palm is always enrolled (both hands) at a physical device (pos/kiosk). All device steps are device→Identity over mTLS — no vertical backend in the path.
Three entry points converge on a single enrollment call. The account may pre-exist (created in an app) or be created at the device in signup mode; the palm is always captured at a physical device, and KYC/consent gates apply on the way:
Diagram — Enrollment paths (entry point → gates → enroll)
flowchart TD
Start([User needs palm enrollment]) --> Q{Account created where?}
Q -->|App or dashboard| App[Sign up in app<br/>OTP, email or social]
Q -->|At the device| POS[POST /v1/device/signup<br/>mobile OTP at POS or kiosk]
App --> KYC{Product requires KYC<br/>and user unverified?}
POS --> KYC
KYC -->|Yes| DoKYC[Complete KYC first<br/>platform surfaces status, product gates]
KYC -->|No| Consent
DoKYC --> Consent{consent_required<br/>and not yet granted?}
Consent -->|Yes| GetConsent[Capture consent<br/>POST /v1/device/consent mTLS, or in-app POST /v1/consent]
Consent -->|No| Enroll
GetConsent --> Enroll
Enroll[POST /v1/device/enroll mTLS<br/>both palms captured at the device] --> Done([palm_enrolled true<br/>recognized across all Link products])
All device-side palm enrollment converges at POST /v1/device/enroll (mTLS) and captures both hands. The platform does not enforce KYC — it surfaces KYC status and the product decides whether to gate on it. When consent_required: true, the platform enforces consent (§10.1). Once enrolled, the palm is recognized across all Link Holdings products (§4.6).
7.4.4 Complete onboarding (end-to-end)¶
Diagram — Complete user onboarding (signup → KYC → consent → palm)
sequenceDiagram
autonumber
participant User
participant App as Wallet Mobile App
participant Wallet as Wallet Backend
participant Identity as Identity Platform
participant Nafath as Nafath
participant NafathApp as Nafath App
participant Kiosk as POS / Kiosk
participant Palm as X-Telcom BioWave Pass
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
rect rgb(230, 245, 255)
Note over User, Audit: Phase 1: Mobile Signup
User->>App: Download app, enter mobile
App->>Identity: Send OTP
Identity->>Audit: Log: otp_sent
Identity-->>User: SMS with OTP
User->>App: Enter OTP
App->>Identity: Verify OTP
Identity->>Identity: Create user
Identity->>Audit: Log: user_created
Identity->>Audit: Log: token_issued
Identity-->>App: {access_token, user_id}
App-->>User: "Welcome!"
end
rect rgb(255, 245, 230)
Note over User, Audit: Phase 2: KYC Verification
User->>App: Tap "Verify Identity"
App->>Wallet: Start KYC
Wallet->>Identity: Initiate KYC
Identity->>Audit: Log: kyc_initiated
Identity->>Nafath: Request verification
Nafath-->>Identity: {transId, random: "47"}
Identity-->>App: Display "47"
User->>NafathApp: Open, select "47"
NafathApp->>Nafath: Confirm
Nafath->>Identity: Callback (verified)
Identity->>Audit: Log: kyc_verified
Identity-->>App: "Identity verified!"
end
opt Consent from mobile (optional)
Note over User, Audit: Phase 2B: Mobile Consent
User->>App: Tap "Grant Consent"
App->>Wallet: Grant consent
Wallet->>Identity: POST /v1/consent<br/>{user_id, consent_type: "biometric_enrollment"}
Identity->>Audit: Log: consent_granted
Identity-->>App: {consent_id, status: "granted"}
App-->>User: "Consent recorded"
end
rect rgb(230, 255, 230)
Note over User, Audit: Phase 3: Palm Enrollment at POS (device-direct, mTLS)
User->>Kiosk: Go to kiosk, enter mobile
Kiosk->>Identity: POST /v1/device/signup (mTLS)<br/>{mobile} → Send OTP
User->>Kiosk: Enter OTP
Kiosk->>Identity: POST /v1/device/signup/verify (mTLS)
Identity-->>Kiosk: {user_id, palm_enrolled: false,<br/>kyc_status ✓, consent_status}
alt Consent already granted (from app)
Note over Kiosk: Skip consent screen
else Consent not on record
Kiosk-->>User: "Consent required for<br/>biometric enrollment"
User->>Kiosk: Accept consent
Kiosk->>Identity: POST /v1/device/consent (mTLS)
Identity->>Audit: Log: consent_granted
end
Kiosk-->>User: "Place both palms"
User->>Kiosk: Scan both palms
Kiosk->>Identity: POST /v1/device/enroll (mTLS)<br/>{user_id, scan (both palms)}
Note right of Identity: Optional: /KZ/query duplicate check<br/>(see §7.9)
Identity->>Palm: POST /KZ/add (both palms)<br/>(request_id header, tenant-prefixed user_id)
Palm-->>Identity: {code: 0, data: {id: 100001}}
Identity->>Audit: Log: palm_enrolled
Identity-->>Kiosk: {palm_enrolled: true}
end
rect rgb(245, 230, 255)
Note over User, Audit: Phase 4: First Transaction (device-initiated broker)
User->>User: Go to merchant, place palm on POS
Note right of User: POS → Identity POST /v1/device/transactions (mTLS)<br/>→ /KZ/query 1:N (4 scores/thresholds)<br/>→ broker authorize → Wallet decides (§9)
Identity->>Audit: Log: device_transaction (allow)
User->>User: Payment approved!
end
7.5 Score Model¶
X-Telcom BioWave Pass returns a 4-element scores array alongside a 4-element thresholds array per query — IR and RGB features evaluated against the large and small models. Each index corresponds to a model variant:
| Index | Variant |
|---|---|
| 0 | Large-model IR |
| 1 | Large-model RGB |
| 2 | Small-model IR |
| 3 | Small-model RGB |
Match Policy (configured per tenant via palm_match_policy):
| Policy | Behavior |
|---|---|
all_thresholds |
All 4 scores must be ≥ corresponding thresholds (default — most strict) |
majority |
At least 3 of 4 scores ≥ corresponding thresholds (relaxed) |
any |
At least 1 score ≥ corresponding threshold (least strict — discouraged) |
Under the large model, the platform applies the policy after receiving the vendor response — the matching decision (matched: true/false) is platform-controlled, not vendor-controlled. Under the small model the SDK/verification server decides and the platform records the device-reported result (§7.10).
7.6 Verification Result¶
{
"user_id": "user_123",
"matched": true,
"scores": [1.0, 1.0, 1.0, 0.9495],
"thresholds": [0.7018, 0.7211, 0.7072, 0.7253],
"match_policy": "all_thresholds",
"latency_ms": 180,
"vendor": "biowave",
"timestamp": "2026-02-25T10:00:00Z"
}
7.7 Identification Result¶
{
"matched": true,
"user_id": "user_123",
"palm_id": 100001,
"scores": [1.0, 1.0, 1.0, 0.9495],
"thresholds": [0.7018, 0.7211, 0.7072, 0.7253],
"match_policy": "all_thresholds",
"candidates": 1,
"search_pool_size": 124500,
"latency_ms": 850,
"vendor": "biowave",
"timestamp": "2026-02-25T10:00:00Z"
}
7.8 Palm Type Restrictions¶
Per-user, verification-time restriction on which palm types can verify that user — it constrains verification/identification, not enrollment (enrollment is always both hands, §7.4.1).
Endpoint: PUT /v1/users/{user_id}/palm-restriction (Console session, Tenant Admin or Operator)
Body:
Allowed values for query_type:
| Value | Behavior |
|---|---|
left |
Only left-palm verifications allowed |
right |
Only right-palm verifications allowed |
all |
Both palms allowed (default after enrollment) |
disable |
All palm verifications blocked without deleting the template (used for soft-suspension) |
When a verification or identification call is made and the user's restriction does not allow the submitted palm type, the platform returns 403 Forbidden with error palm_type_restricted (mapped from vendor error code 30008 — see §14).
Audit event: palm_restriction_set (records user_id, query_type, and set_by).
Common patterns:
- After enrollment, lock to a specific palm type (e.g., left)
- On account suspension, set to disable
- On reinstatement, restore to original
Diagram — Lock a user to a specific palm type (verification-time)
sequenceDiagram
autonumber
participant Admin as Tenant Admin
participant Console as Web Console
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass Palm Server
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Admin, Audit: Use cases:<br/>- Lock to specific palm after enrollment<br/>- Suspend palm verification on account hold<br/>- Re-enable on reinstatement
Admin->>Console: Set palm restriction for user<br/>(e.g., "left only" or "disable")
Console->>Identity: PUT /v1/users/{user_id}/palm-restriction<br/>Authorization: Bearer {token}<br/>{query_type: "left"}
Identity->>DB: Validate user belongs to tenant
DB-->>Identity: User found
Identity->>Palm: POST /KZ/set_query_type<br/>Headers: request_id: {uuid}<br/>Content-Type: application/json<br/>Body: {user_id, query_type: "left"}
alt Success — code: 0
Palm-->>Identity: {code: 0, msg: "Success"}
Identity->>DB: Update user.palm_query_type = "left"
Identity->>Audit: Log: palm_restriction_set<br/>(user_id, query_type: "left",<br/>set_by: admin_id)
opt Webhook subscribed
Identity->>Identity: Send webhook: user.palm_restriction_changed<br/>{user_id, query_type}
end
Identity-->>Console: {user_id, query_type: "left",<br/>updated_at}
Console-->>Admin: "Restriction applied"
else User not found in X-Telcom BioWave Pass — code: 30006
Palm-->>Identity: {code: 30006,<br/>msg: "DB ID not found"}
Identity->>Audit: Log: palm_restriction_failed<br/>(user_id, reason: not_enrolled)
Identity-->>Console: 404 Not Found<br/>"User not enrolled in palm"
end
Note over Admin, Audit: Subsequent /v1/device/transactions or /v1/verify<br/>calls with mismatched palm_type<br/>will fail with code 30008<br/>(see §7.4.2 — query type forbidden)
7.9 Pre-Enrollment Duplicate Detection¶
Detects identity fraud and accidental re-enrollment before committing a new palm template. Opt-in per tenant via palm_duplicate_check_enabled.
Before committing a new template the platform runs a 1:N POST /KZ/query. A conflict exists when a match passes thresholds with a different user_id than the enrolling user (a true duplicate also surfaces vendor code 30007 on /KZ/add). The full sequence is shown below. On conflict, the tenant's palm_duplicate_action decides:
| Action | Behavior |
|---|---|
reject |
Enrollment fails with HTTP 409. User cannot enroll until ops reviews. Audit event: palm_duplicate_detected with severity: high and matched_user_ids. |
flag |
Enrollment proceeds but a review_case record is created for ops to investigate. |
When to enable: High-value tenants (banks, regulated finance) and any tenant where a single user enrolling under multiple identities is a fraud risk.
Performance impact: Adds one extra round-trip to the vendor per enrollment (~200–800ms). Not enabled by default.
Diagram — Pre-enrollment duplicate detection
sequenceDiagram
autonumber
participant Kiosk as POS / Kiosk
participant Wallet as Wallet Backend
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass Palm Server
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Kiosk: User has placed both palms,<br/>features extracted
Kiosk->>Wallet: POST /enroll/palm<br/>{user_id, features, images}
Wallet->>Identity: POST /v1/enroll/both<br/>{external_user_id, hardware_id, metadata}
Identity->>DB: Get tenant config
DB-->>Identity: {palm_duplicate_check_enabled: true,<br/>duplicate_action: "reject" | "flag"}
rect rgb(255, 245, 230)
Note over Identity, Palm: Step 1: Similarity search<br/>(top-5 candidates)
Identity->>Palm: POST /KZ/query<br/>Headers: request_id: {uuid}<br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {is_encrypted: false}<br/>(returns up to 5 candidates,<br/>deduped by user_id)
alt Candidates above threshold returned
Palm-->>Identity: {code: 0,<br/>data: {results: [up to 5 candidates,<br/>each with user_id, id, scores: [4]],<br/>thresholds: [4]}}
Identity->>Identity: Filter results where<br/>all 4 scores ≥ thresholds<br/>AND user_id != current user_id
alt Match found — possible fraud/duplicate
Identity->>Audit: Log: palm_duplicate_detected<br/>(enrolling_user_id, matched_user_ids,<br/>scores, severity: high)
alt Tenant config: reject
Identity-->>Wallet: 409 Conflict<br/>{error: "duplicate_palm",<br/>message: "Biometrics already registered"}
Wallet-->>Kiosk: "Cannot enroll —<br/>contact support"
else Tenant config: flag for review
Identity->>DB: Create review_case<br/>(enrolling_user_id, matched_user_ids,<br/>status: pending)
Note over Identity: Continue to /KZ/add<br/>but flag for ops review
end
else No conflicting match
Note over Identity: Safe to enroll —<br/>proceed to the /KZ/add enrollment step (§7.4.1)
end
else No candidate match (platform-decided)
Palm-->>Identity: {code: 0}<br/>(no candidate ≥ thresholds → platform no-match)
Note over Identity: Clean palm, proceed to enroll
end
end
rect rgb(230, 255, 230)
Note over Identity, Palm: Step 2: Proceed with enrollment
Note over Identity: See §7.4.1 for the full /KZ/add<br/>request/response, error codes 30007/30005<br/>in §14
Identity->>Palm: POST /KZ/add (see §7.4.1)
Palm-->>Identity: {code: 0, data: {user_id, id, query_type}}
Identity->>DB: Mark palm_enrolled = true,<br/>store palm_id
Identity->>Audit: Log: palm_enrolled
Identity-->>Wallet: {status: "enrolled"}
end
7.10 Palm Model (Small vs Large)¶
The palm match runs in one of two models, chosen once per deployment by a Platform Admin — deployment-wide, not per-tenant or per-user (like the global thresholds in §7.14.2). The two models differ in one thing: who runs the match.
- Large model — the platform matches. The device sends the scan to the platform, which calls the verification server over
/KZ/*, appliespalm_match_policy(§7.5), and owns the decision. This is the model the rest of §7 describes, and device-initiated verification runs a 1:N identify under it (§7.4.2). - Small model — the device matches. The device's client SDK calls the verification server directly; the platform sits outside the match path. The device reports the result — matched
user_id+ a pass/fail boolean — to the platform over its mTLS channel (§8.2). The platform records it and, forpos/gate, then authorizes the bound product with thatuser_id(§9).
The PalmVerifier port (§7.3) abstracts the large model only — the small model is not another adapter behind it. Under the small model the platform has no verification-server connection; one is added only to migrate (§7.11).
What each model supports:
| Feature | Large model | Small model |
|---|---|---|
palm_match_policy / 4-element score model (§7.5) |
platform decides | N/A — SDK/server decides; platform records boolean |
| Pre-enrollment duplicate detection (§7.9) | available | N/A |
| Global threshold config (§7.14.2) | applies | N/A (SDK/server-side) |
| Palm type restrictions (§7.8) | applies | N/A in matching path |
PalmVerifier-port identify/verify (§7.3) |
yes | no — device SDK direct |
| Platform role | matcher + router | recorder + router (device-trusted) |
Same endpoints, different payload. The active model decides what every device-facing palm endpoint carries: under the large model the device sends scans (the platform matches); under the small model it sends results (the platform records). This covers /v1/device/transactions, /v1/device/enroll, and the challenge endpoints /v1/verify + /v1/enroll/both + /v1/challenges/{id}/complete (transaction, enrollment, and verify flows). The platform implements both paths; migration (§7.11) flips which one is live.
Small-model results are device-trusted — mTLS authenticates the reporting device, not the match. Use the large model for production or fraud-sensitive scale.
This section is the canonical definition of the palm model (referenced by §13.7, §9.3, §16.1). The large-model sequences are in Palm Flows (§7.4.2); the small-model variants are shown below.
Small-model device flows (variants) — the device SDK matches or enrolls directly and the platform records the device-reported result:
Diagram — Small-model device transaction (client SDK direct)
sequenceDiagram
autonumber
participant User
participant Device as POS / Gate (client SDK)
participant Palm as X-Telcom BioWave Pass (verification server)
participant Identity as Identity Platform
participant Product as Wallet / Access Backend (linked service)
participant Audit as Audit Log
User->>Device: Place palm on scanner
Device->>Device: Capture palm image (RGB + IR)
rect rgb(255, 245, 230)
Note over Device, Palm: Device-trusted — match decided SDK/server-side,<br/>NOT by the platform
Device->>Palm: SDK 1:N match (direct — no platform on path)
Palm-->>Device: {user_id, pass/fail, scores}
end
alt Match (SDK pass)
Device->>Identity: POST /v1/device/transactions<br/>(mTLS — device cert)<br/>{user_id, matched: true,<br/>context, idempotency_key}
Identity->>Identity: Parse SAN URI → device_id, tenant_id<br/>record result (no /KZ/* call)<br/>skip server-side identify<br/>sign identity_assertion JWT
Identity->>Product: POST {base_url}{authorize_path}<br/>Authorization: Bearer {identity_assertion}<br/>{user_id, action, context, idempotency_key}
Product-->>Identity: {decision, display_message,<br/>reference_id, ttl}
Identity->>Audit: Log: device_transaction<br/>(source: device_reported, decision)
Identity-->>Device: {decision, display_message,<br/>product_reference} (in-band)
Device-->>User: Open gate / "Approved" (or decline)
else No match (SDK fail)
Device->>Identity: POST /v1/device/transactions<br/>{matched: false, context, idempotency_key}
Identity->>Audit: Log: device_transaction (not_recognized)
Identity-->>Device: {decision: "not_recognized"}
Device-->>User: "Palm not recognized"
end
Diagram — Small-model device enrollment (client SDK direct)
sequenceDiagram
autonumber
participant User
participant Device as POS / Kiosk (client SDK)
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass (verification server)
participant SMS as SMS Provider
participant Audit as Audit Log
User->>Device: Tap "Enroll" / "Sign up"
Device->>Identity: POST /v1/device/signup (mTLS)<br/>{mobile}
Identity->>Identity: Find or create user by mobile<br/>(tenant-scoped)
Identity->>SMS: Send OTP
SMS-->>User: OTP code
User->>Device: Enter OTP
Device->>Identity: POST /v1/device/signup/verify (mTLS)<br/>{challenge_id, code}
Identity-->>Device: {user_id, is_new_user,<br/>palm_enrolled: false,<br/>consent_status, kyc_status}
opt Product requires KYC and kyc_status != verified
Device-->>User: Direct to complete KYC first<br/>(app / Nafath)
end
opt consent_required and consent_status = none
Device->>User: Show consent screen
User->>Device: Accept
Device->>Identity: POST /v1/device/consent (mTLS)<br/>{user_id, consent_type, version}
Identity->>Audit: Log: consent.granted
end
Note over Device: Consent is gated HERE, device-side, BEFORE enrolling —<br/>the platform is off the enroll path under the small model.
User->>Device: Place both palms
Device->>Device: Capture both palms (RGB + IR)
rect rgb(255, 245, 230)
Note over Device, Palm: Device-trusted — enrollment done SDK/server-side,<br/>NOT by the platform
Device->>Palm: SDK enroll (direct — no platform on path)
Palm-->>Device: {palm_id, success}
end
Device->>Identity: POST /v1/device/enroll (mTLS)<br/>{user_id, enrolled: true}
Identity->>Identity: Record palm_enrolled (no /KZ/add)<br/>trust device-reported result
Identity->>Audit: Log: enrollment.complete<br/>(source: device_reported)
opt Webhook subscribed
Identity->>Identity: Send webhook: enrollment.complete
end
Identity-->>Device: {palm_enrolled: true}
Device-->>User: "Enrolled — palm now works across Link"
7.11 Small→Large Migration¶
A Platform Admin migrates a deployment from the small model to the large model. The migration is one-way and reprocesses the already-stored RGB+IR enrollments into large-model representations — no re-enrollment and no user action.
Prerequisite — verification-server endpoint. Under the small model the platform has no connection to the verification server (the device SDK does). Before migrating — and for large-model operation afterward — a Platform Admin configures the deployment's verification-server endpoint (connection URL) so the platform can reach it (GET/PUT /v1/admin/palm/verification-server, §12.2; stored deployment-level, §13.7).
Triggered via POST /v1/admin/palm/migrate (§12.2), the platform connects to the configured verification-server endpoint, runs the vendor-supplied migration script (reprocessing stored RGB+IR enrollments into large-model representations), and monitors progress (GET /v1/admin/palm/model). On success it atomically switches the deployment small→large — request handling moves from the small-model record path to the large-model /KZ/* broker path, with in-flight operations finishing under the old model — emitting palm_model_migration_started → palm_model_migration_completed + palm_model_changed. Failure emits palm_model_migration_failed and leaves the model unchanged. The full sequence is shown below.
Diagram — Small→large model migration
sequenceDiagram
autonumber
participant Admin as Platform Admin
participant Console as Web Console
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass (verification server)
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Admin, Identity: Prerequisite — verification-server endpoint configured<br/>(PUT /v1/admin/palm/verification-server — §13.7)
Admin->>Console: "Migrate to large model" (confirm)
Console->>Identity: POST /v1/admin/palm/migrate
Identity->>Audit: Log: palm_model_migration_started
rect rgb(255, 245, 230)
Note over Identity, Palm: Platform runs the vendor-supplied migration script<br/>against the configured verification-server endpoint
Identity->>Palm: Connect + run migration script<br/>(reprocess stored RGB+IR → large model)
loop Until complete
Identity->>Palm: Poll migration progress
Palm-->>Identity: {status}
end
end
alt Migration succeeded
rect rgb(230, 255, 230)
Identity->>DB: Switch deployment palm model small→large
Identity->>Audit: Log: palm_model_migration_completed + palm_model_changed
Identity-->>Console: {model: "large", migration_status: "completed"}
Console-->>Admin: "Migration complete — flows now use the large-model server API (§7.4.2)"
end
else Migration failed
Identity->>Audit: Log: palm_model_migration_failed (model unchanged)
Identity-->>Console: 500 {error: migration_failed}
Console-->>Admin: Error — deployment stays on the small model
end
7.12 Supported Palm Vendors¶
| Vendor | Technology | Strengths | Integration |
|---|---|---|---|
| X-Telcom BioWave Pass | Palm vein (IR + RGB; large/small models) | Sole MVP vendor; large-scale 1:N comparison; self-hostable | REST API (/KZ/*) |
Additional vendors can be plugged in behind the PalmVerifier port (§7.3) with no business-logic change; BioWave Pass is the only vendor documented for MVP.
7.13 Vendor Configuration (per Tenant)¶
{
"tenant_id": "wallet",
"palm_config": {
"vendor": "biowave",
"vendor_config": {
"base_url": "http://palm.internal.link.sa:8080",
"request_id_header": "request_id",
"timeout_ms": 2000
},
"match_policy": "all_thresholds",
"duplicate_check_enabled": true,
"duplicate_action": "reject"
}
}
7.14 Operational¶
7.14.1 Health Monitoring¶
The platform monitors vendor reachability (POST /KZ/connect) and version (GET /pv/version) on a periodic cadence (default: every 60s). Failed checks trigger a palm_vendor_unhealthy audit event and page the on-call rotation. Version changes trigger palm_vendor_version_change for traceability.
Diagram — X-Telcom BioWave Pass health check
sequenceDiagram
autonumber
participant Cron as Health Monitor
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass Palm Server
participant Audit as Audit Log
participant Alert as Ops Alerting
Cron->>Identity: Trigger health check<br/>(startup / interval / post-deploy)
par Connection test
Identity->>Palm: POST /KZ/connect<br/>Headers: request_id: {uuid}<br/>Content-Type: application/json<br/>Body: (none)
alt Reachable
Palm-->>Identity: {code: 0, msg: "Success"}
else Unreachable
Palm-->>Identity: timeout / network error
end
and Version probe
Identity->>Palm: GET /pv/version<br/>Headers: request_id: {uuid}<br/>(no body, response is text/plain)
alt Reachable
Palm-->>Identity: "0.0.13" (text/plain)
else Unreachable
Palm-->>Identity: timeout / 5xx
end
end
alt All checks passed
Identity->>Identity: Update health status: healthy
opt Version changed since last check
Identity->>Audit: Log: palm_vendor_version_change<br/>(old: "0.0.12", new: "0.0.13")
end
else Any check failed
Identity->>Identity: Update health status: unhealthy
Identity->>Audit: Log: palm_vendor_unhealthy<br/>(reason, severity: high)
Identity->>Alert: Page on-call —<br/>X-Telcom BioWave Pass unreachable
end
7.14.2 Threshold Configuration¶
Platform Admins can tune the vendor's 4 global thresholds (vendor POST /KZ/set_thresholds) via GET/PUT /v1/admin/palm/thresholds. Used for:
- Tightening after a fraud incident
- Per-environment calibration (staging vs. production)
- Trade-off tuning (false-accept vs. false-reject rate)
Thresholds are global at the vendor side (not per-tenant). Tenant-level relaxation is done through palm_match_policy instead.
Audit event: palm_thresholds_updated (records old and new threshold values).
Diagram — X-Telcom BioWave Pass threshold configuration
sequenceDiagram
autonumber
participant Admin as Platform Admin
participant Console as Web Console
participant Identity as Identity Platform
participant Palm as X-Telcom BioWave Pass Palm Server
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Admin: Use cases:<br/>- Tighten thresholds after fraud event<br/>- Loosen for lower-security environment<br/>- Tune per-model variant accuracy
Admin->>Console: Open palm threshold settings
Console->>Identity: GET /v1/admin/palm/thresholds<br/>Authorization: Bearer {admin-token}
Identity->>DB: Get current thresholds (cached)
DB-->>Identity: thresholds: [4 floats]
Identity-->>Console: {thresholds: [4],<br/>last_updated_at, updated_by}
Console-->>Admin: Display current thresholds<br/>(annotated with model variant per index)
Admin->>Console: Update thresholds<br/>(e.g., bump index 0 from 0.70 → 0.75)
Console->>Identity: PUT /v1/admin/palm/thresholds<br/>Authorization: Bearer {admin-token}<br/>{thresholds: [4 floats]}
Identity->>Identity: Validate: all values 0.0–1.0,<br/>array length == 4
Identity->>Palm: POST /KZ/set_thresholds<br/>Headers: request_id: {uuid}<br/>Content-Type: application/json<br/>Body: {thresholds: [4 floats]}
alt Success
Palm-->>Identity: {code: 0, msg: "Success"}
Identity->>DB: Persist thresholds<br/>(audit trail)
Identity->>Audit: Log: palm_thresholds_updated<br/>(updated_by: admin_id,<br/>old_thresholds, new_thresholds)
Identity-->>Console: {status: "updated",<br/>thresholds, updated_at}
Console-->>Admin: "Thresholds updated"
else Validation/permission error
Palm-->>Identity: {code: 10001, msg: "..."}
Identity-->>Console: 400 Bad Request
Console-->>Admin: Error message
end