@koraidv/core 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -506,7 +506,24 @@ declare class KoraIDV {
506
506
  }
507
507
 
508
508
  /**
509
- * API Client for Kora IDV
509
+ * API Client for Kora IDV.
510
+ *
511
+ * Wire contract (matches the backend and the Android/iOS SDKs):
512
+ *
513
+ * - Request bodies: JSON with camelCase keys. The earlier multipart-
514
+ * form-data + snake_case combo was rejected by the backend (which
515
+ * only parses JSON on /document, /selfie, /liveness/challenge) and
516
+ * diverged from the native SDKs. Surfaced 2026-05-29 by Luckycat's
517
+ * Web SDK integration — backend returned 400 on the very first
518
+ * POST because `external_id` didn't match the server's `externalId`
519
+ * JSON tag.
520
+ *
521
+ * - Response bodies: snake_case auto-converted to camelCase by
522
+ * transformResponse() below. Domain models defined in
523
+ * types/ApiModels.ts are the source of truth for shape.
524
+ *
525
+ * - Authentication: Bearer-style Authorization header (raw API key)
526
+ * + X-Tenant-ID header. No cookies.
510
527
  */
511
528
  declare class ApiClient {
512
529
  private readonly baseUrl;
@@ -515,7 +532,22 @@ declare class ApiClient {
515
532
  private readonly baseDelay;
516
533
  constructor(configuration: Configuration);
517
534
  /**
518
- * Get supported countries and their document types
535
+ * Get supported countries and their document types.
536
+ *
537
+ * Backend has no dedicated /supported-countries endpoint — the
538
+ * countries catalog is bundled into the /document-types response
539
+ * alongside the per-type metadata. We fetch that bundled payload,
540
+ * then project it onto the SDK's `SupportedCountry` shape:
541
+ * - `id` ← `code` (ISO-3166 alpha-2)
542
+ * - `flagEmoji` derived from the ISO code
543
+ * - `documentTypes` filtered from the bundled types list by country
544
+ *
545
+ * Mirrors the iOS / Android pattern (SessionManager.fetchSupported-
546
+ * Countries on iOS, ApiService.getDocumentTypes on Android). The
547
+ * previous standalone /supported-countries call had been silently
548
+ * 404-ing since the Web SDK shipped — surfaced 2026-05-29 by
549
+ * Luckycat's integration, hot on the heels of the v1.7.1
550
+ * wire-format pass.
519
551
  */
520
552
  getSupportedCountries(): Promise<SupportedCountry[]>;
521
553
  /**
@@ -529,30 +561,50 @@ declare class ApiClient {
529
561
  /**
530
562
  * Upload document image.
531
563
  *
532
- * `decodedBarcodePayload` is the optional Phase 4 fast-path: when the
533
- * client decoded the PDF417 / QR / DataMatrix on-device using the
534
- * browser's BarcodeDetector API (or a polyfill), the AAMVA payload
535
- * travels here so the server can skip image-based barcode decoding
536
- * (~1-3 s round-trip savings). Empty/`undefined` = server falls
537
- * back to its zxing-cpp + pdf417decoder cascade. Only meaningful for
538
- * back captures on documents that carry a barcode.
539
- * See `docs/architecture/idv-decode-roadmap.md` Phase 4.
564
+ * Both sides now use the same JSON wire format as the Android/iOS SDKs.
565
+ * Front sends documentType + optional country; back sends optional
566
+ * decodedBarcodePayload (Phase 4 fast-path when the browser's
567
+ * BarcodeDetector decoded the PDF417 on-device, the AAMVA payload
568
+ * travels here so the server can skip image-based barcode decoding).
569
+ *
570
+ * The previous front-side multipart path returned HTTP 400 on every
571
+ * request because the server's /document handler only parses
572
+ * application/json — same trap iOS hit and fixed in v1.6.0.
540
573
  */
541
- uploadDocument(verificationId: string, imageData: Blob, side: 'front' | 'back', documentType: DocumentType, decodedBarcodePayload?: string): Promise<DocumentUploadResponse>;
574
+ uploadDocument(verificationId: string, imageData: Blob, side: 'front' | 'back', documentType: DocumentType, decodedBarcodePayload?: string, country?: string): Promise<DocumentUploadResponse>;
542
575
  /**
543
- * Upload selfie image
576
+ * Upload selfie image.
577
+ *
578
+ * JSON with imageBase64 — matches the server's UploadSelfieRequest
579
+ * { ImageBase64 string } and the Android/iOS wire format. Previous
580
+ * multipart path returned HTTP 400 since the SDK shipped.
544
581
  */
545
582
  uploadSelfie(verificationId: string, imageData: Blob): Promise<SelfieUploadResponse>;
546
583
  /**
547
- * Create liveness session
584
+ * Create liveness session.
585
+ *
586
+ * Decodes the wire DTO (matches backend's `models.LivenessSession`
587
+ * JSON), then projects it onto the domain `LivenessSession` the UI
588
+ * expects. Backend doesn't send `sessionId`/`expiresAt`, and per-
589
+ * challenge `id`/`instruction`/`order` aren't on the wire — domain
590
+ * values are synthesized client-side. Mirrors the iOS v1.7.0 pattern
591
+ * at koraidv-ios/.../SessionManager.swift::createLivenessSession.
592
+ *
593
+ * Without this DTO/domain split, the SDK accessed undefined fields
594
+ * on the response and produced a session with sessionId=undefined,
595
+ * which then failed every downstream challenge submission.
548
596
  */
549
597
  createLivenessSession(verificationId: string): Promise<LivenessSession>;
550
598
  /**
551
- * Submit liveness challenge
599
+ * Submit liveness challenge result.
600
+ *
601
+ * JSON with challengeType + imageBase64 — matches the server's
602
+ * SubmitLivenessChallengeRequest and the Android/iOS wire format.
603
+ * Previous multipart + snake_case path was double-broken.
552
604
  */
553
605
  submitLivenessChallenge(verificationId: string, challenge: LivenessChallenge, imageData: Blob): Promise<LivenessChallengeResponse>;
554
606
  /**
555
- * Check document quality before uploading
607
+ * Check document quality before uploading.
556
608
  */
557
609
  checkDocumentQuality(imageData: Blob, documentType: string): Promise<DocumentQualityResponse>;
558
610
  /**
@@ -583,9 +635,13 @@ declare class ApiClient {
583
635
  private shouldRetryNetworkError;
584
636
  private calculateDelay;
585
637
  private sleep;
586
- private blobToBase64;
587
638
  /**
588
- * Transform snake_case response to camelCase
639
+ * Transform snake_case response to camelCase.
640
+ *
641
+ * Some backend endpoints still return snake_case keys; the
642
+ * domain types in ApiModels.ts are camelCase. This walker turns
643
+ * `external_id` → `externalId` etc. recursively. Safe no-op for
644
+ * already-camelCase responses.
589
645
  */
590
646
  private transformResponse;
591
647
  }
package/dist/index.d.ts CHANGED
@@ -506,7 +506,24 @@ declare class KoraIDV {
506
506
  }
507
507
 
508
508
  /**
509
- * API Client for Kora IDV
509
+ * API Client for Kora IDV.
510
+ *
511
+ * Wire contract (matches the backend and the Android/iOS SDKs):
512
+ *
513
+ * - Request bodies: JSON with camelCase keys. The earlier multipart-
514
+ * form-data + snake_case combo was rejected by the backend (which
515
+ * only parses JSON on /document, /selfie, /liveness/challenge) and
516
+ * diverged from the native SDKs. Surfaced 2026-05-29 by Luckycat's
517
+ * Web SDK integration — backend returned 400 on the very first
518
+ * POST because `external_id` didn't match the server's `externalId`
519
+ * JSON tag.
520
+ *
521
+ * - Response bodies: snake_case auto-converted to camelCase by
522
+ * transformResponse() below. Domain models defined in
523
+ * types/ApiModels.ts are the source of truth for shape.
524
+ *
525
+ * - Authentication: Bearer-style Authorization header (raw API key)
526
+ * + X-Tenant-ID header. No cookies.
510
527
  */
511
528
  declare class ApiClient {
512
529
  private readonly baseUrl;
@@ -515,7 +532,22 @@ declare class ApiClient {
515
532
  private readonly baseDelay;
516
533
  constructor(configuration: Configuration);
517
534
  /**
518
- * Get supported countries and their document types
535
+ * Get supported countries and their document types.
536
+ *
537
+ * Backend has no dedicated /supported-countries endpoint — the
538
+ * countries catalog is bundled into the /document-types response
539
+ * alongside the per-type metadata. We fetch that bundled payload,
540
+ * then project it onto the SDK's `SupportedCountry` shape:
541
+ * - `id` ← `code` (ISO-3166 alpha-2)
542
+ * - `flagEmoji` derived from the ISO code
543
+ * - `documentTypes` filtered from the bundled types list by country
544
+ *
545
+ * Mirrors the iOS / Android pattern (SessionManager.fetchSupported-
546
+ * Countries on iOS, ApiService.getDocumentTypes on Android). The
547
+ * previous standalone /supported-countries call had been silently
548
+ * 404-ing since the Web SDK shipped — surfaced 2026-05-29 by
549
+ * Luckycat's integration, hot on the heels of the v1.7.1
550
+ * wire-format pass.
519
551
  */
520
552
  getSupportedCountries(): Promise<SupportedCountry[]>;
521
553
  /**
@@ -529,30 +561,50 @@ declare class ApiClient {
529
561
  /**
530
562
  * Upload document image.
531
563
  *
532
- * `decodedBarcodePayload` is the optional Phase 4 fast-path: when the
533
- * client decoded the PDF417 / QR / DataMatrix on-device using the
534
- * browser's BarcodeDetector API (or a polyfill), the AAMVA payload
535
- * travels here so the server can skip image-based barcode decoding
536
- * (~1-3 s round-trip savings). Empty/`undefined` = server falls
537
- * back to its zxing-cpp + pdf417decoder cascade. Only meaningful for
538
- * back captures on documents that carry a barcode.
539
- * See `docs/architecture/idv-decode-roadmap.md` Phase 4.
564
+ * Both sides now use the same JSON wire format as the Android/iOS SDKs.
565
+ * Front sends documentType + optional country; back sends optional
566
+ * decodedBarcodePayload (Phase 4 fast-path when the browser's
567
+ * BarcodeDetector decoded the PDF417 on-device, the AAMVA payload
568
+ * travels here so the server can skip image-based barcode decoding).
569
+ *
570
+ * The previous front-side multipart path returned HTTP 400 on every
571
+ * request because the server's /document handler only parses
572
+ * application/json — same trap iOS hit and fixed in v1.6.0.
540
573
  */
541
- uploadDocument(verificationId: string, imageData: Blob, side: 'front' | 'back', documentType: DocumentType, decodedBarcodePayload?: string): Promise<DocumentUploadResponse>;
574
+ uploadDocument(verificationId: string, imageData: Blob, side: 'front' | 'back', documentType: DocumentType, decodedBarcodePayload?: string, country?: string): Promise<DocumentUploadResponse>;
542
575
  /**
543
- * Upload selfie image
576
+ * Upload selfie image.
577
+ *
578
+ * JSON with imageBase64 — matches the server's UploadSelfieRequest
579
+ * { ImageBase64 string } and the Android/iOS wire format. Previous
580
+ * multipart path returned HTTP 400 since the SDK shipped.
544
581
  */
545
582
  uploadSelfie(verificationId: string, imageData: Blob): Promise<SelfieUploadResponse>;
546
583
  /**
547
- * Create liveness session
584
+ * Create liveness session.
585
+ *
586
+ * Decodes the wire DTO (matches backend's `models.LivenessSession`
587
+ * JSON), then projects it onto the domain `LivenessSession` the UI
588
+ * expects. Backend doesn't send `sessionId`/`expiresAt`, and per-
589
+ * challenge `id`/`instruction`/`order` aren't on the wire — domain
590
+ * values are synthesized client-side. Mirrors the iOS v1.7.0 pattern
591
+ * at koraidv-ios/.../SessionManager.swift::createLivenessSession.
592
+ *
593
+ * Without this DTO/domain split, the SDK accessed undefined fields
594
+ * on the response and produced a session with sessionId=undefined,
595
+ * which then failed every downstream challenge submission.
548
596
  */
549
597
  createLivenessSession(verificationId: string): Promise<LivenessSession>;
550
598
  /**
551
- * Submit liveness challenge
599
+ * Submit liveness challenge result.
600
+ *
601
+ * JSON with challengeType + imageBase64 — matches the server's
602
+ * SubmitLivenessChallengeRequest and the Android/iOS wire format.
603
+ * Previous multipart + snake_case path was double-broken.
552
604
  */
553
605
  submitLivenessChallenge(verificationId: string, challenge: LivenessChallenge, imageData: Blob): Promise<LivenessChallengeResponse>;
554
606
  /**
555
- * Check document quality before uploading
607
+ * Check document quality before uploading.
556
608
  */
557
609
  checkDocumentQuality(imageData: Blob, documentType: string): Promise<DocumentQualityResponse>;
558
610
  /**
@@ -583,9 +635,13 @@ declare class ApiClient {
583
635
  private shouldRetryNetworkError;
584
636
  private calculateDelay;
585
637
  private sleep;
586
- private blobToBase64;
587
638
  /**
588
- * Transform snake_case response to camelCase
639
+ * Transform snake_case response to camelCase.
640
+ *
641
+ * Some backend endpoints still return snake_case keys; the
642
+ * domain types in ApiModels.ts are camelCase. This walker turns
643
+ * `external_id` → `externalId` etc. recursively. Safe no-op for
644
+ * already-camelCase responses.
589
645
  */
590
646
  private transformResponse;
591
647
  }
package/dist/index.js CHANGED
@@ -538,10 +538,31 @@ var ApiClient = class {
538
538
  this.baseUrl = environmentUrls[configuration.environment];
539
539
  }
540
540
  /**
541
- * Get supported countries and their document types
541
+ * Get supported countries and their document types.
542
+ *
543
+ * Backend has no dedicated /supported-countries endpoint — the
544
+ * countries catalog is bundled into the /document-types response
545
+ * alongside the per-type metadata. We fetch that bundled payload,
546
+ * then project it onto the SDK's `SupportedCountry` shape:
547
+ * - `id` ← `code` (ISO-3166 alpha-2)
548
+ * - `flagEmoji` derived from the ISO code
549
+ * - `documentTypes` filtered from the bundled types list by country
550
+ *
551
+ * Mirrors the iOS / Android pattern (SessionManager.fetchSupported-
552
+ * Countries on iOS, ApiService.getDocumentTypes on Android). The
553
+ * previous standalone /supported-countries call had been silently
554
+ * 404-ing since the Web SDK shipped — surfaced 2026-05-29 by
555
+ * Luckycat's integration, hot on the heels of the v1.7.1
556
+ * wire-format pass.
542
557
  */
543
558
  async getSupportedCountries() {
544
- return this.request("/supported-countries");
559
+ const response = await this.request("/document-types");
560
+ return response.countries.map((c) => ({
561
+ id: c.code,
562
+ name: c.name,
563
+ flagEmoji: countryCodeToFlagEmoji(c.code),
564
+ documentTypes: response.documentTypes.filter((dt) => dt.country === c.code).map((dt) => dt.type)
565
+ })).filter((country) => country.documentTypes.length > 0);
545
566
  }
546
567
  /**
547
568
  * Create a new verification
@@ -550,7 +571,7 @@ var ApiClient = class {
550
571
  return this.request("/verifications", {
551
572
  method: "POST",
552
573
  body: JSON.stringify({
553
- external_id: request.externalId,
574
+ externalId: request.externalId,
554
575
  tier: request.tier
555
576
  })
556
577
  });
@@ -564,23 +585,23 @@ var ApiClient = class {
564
585
  /**
565
586
  * Upload document image.
566
587
  *
567
- * `decodedBarcodePayload` is the optional Phase 4 fast-path: when the
568
- * client decoded the PDF417 / QR / DataMatrix on-device using the
569
- * browser's BarcodeDetector API (or a polyfill), the AAMVA payload
570
- * travels here so the server can skip image-based barcode decoding
571
- * (~1-3 s round-trip savings). Empty/`undefined` = server falls
572
- * back to its zxing-cpp + pdf417decoder cascade. Only meaningful for
573
- * back captures on documents that carry a barcode.
574
- * See `docs/architecture/idv-decode-roadmap.md` Phase 4.
588
+ * Both sides now use the same JSON wire format as the Android/iOS SDKs.
589
+ * Front sends documentType + optional country; back sends optional
590
+ * decodedBarcodePayload (Phase 4 fast-path when the browser's
591
+ * BarcodeDetector decoded the PDF417 on-device, the AAMVA payload
592
+ * travels here so the server can skip image-based barcode decoding).
593
+ *
594
+ * The previous front-side multipart path returned HTTP 400 on every
595
+ * request because the server's /document handler only parses
596
+ * application/json — same trap iOS hit and fixed in v1.6.0.
575
597
  */
576
- async uploadDocument(verificationId, imageData, side, documentType, decodedBarcodePayload) {
598
+ async uploadDocument(verificationId, imageData, side, documentType, decodedBarcodePayload, country) {
599
+ const imageBase64 = await blobToBase64(imageData);
577
600
  if (side === "back") {
578
- const imageBase64 = await blobToBase64(imageData);
579
601
  return this.request(
580
602
  `/verifications/${verificationId}/document/back`,
581
603
  {
582
604
  method: "POST",
583
- headers: { "Content-Type": "application/json" },
584
605
  body: JSON.stringify({
585
606
  imageBase64,
586
607
  decodedBarcodePayload: decodedBarcodePayload ?? null
@@ -588,80 +609,97 @@ var ApiClient = class {
588
609
  }
589
610
  );
590
611
  }
591
- const formData = new FormData();
592
- formData.append("image", imageData, "document.jpg");
593
- formData.append("document_type", documentType);
594
- formData.append("side", side);
595
612
  return this.request(
596
613
  `/verifications/${verificationId}/document`,
597
614
  {
598
615
  method: "POST",
599
- body: formData,
600
- headers: {}
601
- // Let browser set Content-Type for FormData
616
+ body: JSON.stringify({
617
+ documentType,
618
+ imageBase64,
619
+ country: country ?? null
620
+ })
602
621
  }
603
622
  );
604
623
  }
605
624
  /**
606
- * Upload selfie image
625
+ * Upload selfie image.
626
+ *
627
+ * JSON with imageBase64 — matches the server's UploadSelfieRequest
628
+ * { ImageBase64 string } and the Android/iOS wire format. Previous
629
+ * multipart path returned HTTP 400 since the SDK shipped.
607
630
  */
608
631
  async uploadSelfie(verificationId, imageData) {
609
- const formData = new FormData();
610
- formData.append("image", imageData, "selfie.jpg");
632
+ const imageBase64 = await blobToBase64(imageData);
611
633
  return this.request(`/verifications/${verificationId}/selfie`, {
612
634
  method: "POST",
613
- body: formData,
614
- headers: {}
635
+ body: JSON.stringify({ imageBase64 })
615
636
  });
616
637
  }
617
638
  /**
618
- * Create liveness session
639
+ * Create liveness session.
640
+ *
641
+ * Decodes the wire DTO (matches backend's `models.LivenessSession`
642
+ * JSON), then projects it onto the domain `LivenessSession` the UI
643
+ * expects. Backend doesn't send `sessionId`/`expiresAt`, and per-
644
+ * challenge `id`/`instruction`/`order` aren't on the wire — domain
645
+ * values are synthesized client-side. Mirrors the iOS v1.7.0 pattern
646
+ * at koraidv-ios/.../SessionManager.swift::createLivenessSession.
647
+ *
648
+ * Without this DTO/domain split, the SDK accessed undefined fields
649
+ * on the response and produced a session with sessionId=undefined,
650
+ * which then failed every downstream challenge submission.
619
651
  */
620
652
  async createLivenessSession(verificationId) {
621
- const response = await this.request(`/verifications/${verificationId}/liveness/session`, {
622
- method: "POST"
623
- });
653
+ const dto = await this.request(
654
+ `/verifications/${verificationId}/liveness/session`,
655
+ { method: "POST" }
656
+ );
624
657
  return {
625
- sessionId: response.session_id,
626
- challenges: response.challenges.map((c) => ({
627
- id: c.id,
658
+ sessionId: dto.id,
659
+ challenges: dto.challenges.map((c, index) => ({
660
+ id: `${dto.id}_${index}`,
628
661
  type: c.type,
629
- instruction: c.instruction,
630
- order: c.order
662
+ instruction: instructionForChallengeType(c.type),
663
+ order: index
631
664
  })),
632
- expiresAt: new Date(response.expires_at)
665
+ // Backend doesn't return expiresAt; enforce a local 5-minute
666
+ // timeout consistent with the Android + iOS peers.
667
+ expiresAt: new Date(Date.now() + 5 * 60 * 1e3)
633
668
  };
634
669
  }
635
670
  /**
636
- * Submit liveness challenge
671
+ * Submit liveness challenge result.
672
+ *
673
+ * JSON with challengeType + imageBase64 — matches the server's
674
+ * SubmitLivenessChallengeRequest and the Android/iOS wire format.
675
+ * Previous multipart + snake_case path was double-broken.
637
676
  */
638
677
  async submitLivenessChallenge(verificationId, challenge, imageData) {
639
- const formData = new FormData();
640
- formData.append("image", imageData, "challenge.jpg");
641
- formData.append("challenge_type", challenge.type);
642
- formData.append("challenge_id", challenge.id);
678
+ const imageBase64 = await blobToBase64(imageData);
643
679
  const response = await this.request(`/verifications/${verificationId}/liveness/challenge`, {
644
680
  method: "POST",
645
- body: formData,
646
- headers: {}
681
+ body: JSON.stringify({
682
+ challengeType: challenge.type,
683
+ imageBase64
684
+ })
647
685
  });
648
686
  return {
649
- success: response.success,
650
- challengePassed: response.challenge_passed,
651
- confidence: response.confidence,
652
- remainingChallenges: response.remaining_challenges
687
+ success: response.success ?? true,
688
+ challengePassed: response.challengePassed ?? response.completed ?? false,
689
+ confidence: response.confidence ?? response.score ?? 0,
690
+ remainingChallenges: response.remainingChallenges ?? 0
653
691
  };
654
692
  }
655
693
  /**
656
- * Check document quality before uploading
694
+ * Check document quality before uploading.
657
695
  */
658
696
  async checkDocumentQuality(imageData, documentType) {
659
- const base64 = await this.blobToBase64(imageData);
697
+ const documentFrontBase64 = await blobToBase64(imageData);
660
698
  return this.request("/kyc/document-quality", {
661
699
  method: "POST",
662
700
  body: JSON.stringify({
663
- document_front_base64: base64,
664
- document_type: documentType
701
+ documentFrontBase64,
702
+ documentType
665
703
  })
666
704
  });
667
705
  }
@@ -710,7 +748,7 @@ var ApiClient = class {
710
748
  }
711
749
  headers.set("X-Tenant-ID", this.configuration.tenantId);
712
750
  headers.set("Accept", "application/json");
713
- if (!(options.body instanceof FormData) && !headers.has("Content-Type")) {
751
+ if (!headers.has("Content-Type")) {
714
752
  headers.set("Content-Type", "application/json");
715
753
  }
716
754
  const requestOptions = {
@@ -781,20 +819,13 @@ var ApiClient = class {
781
819
  sleep(ms) {
782
820
  return new Promise((resolve) => setTimeout(resolve, ms));
783
821
  }
784
- blobToBase64(blob) {
785
- return new Promise((resolve, reject) => {
786
- const reader = new FileReader();
787
- reader.onloadend = () => {
788
- const result = reader.result;
789
- const base64 = result.split(",")[1] || result;
790
- resolve(base64);
791
- };
792
- reader.onerror = reject;
793
- reader.readAsDataURL(blob);
794
- });
795
- }
796
822
  /**
797
- * Transform snake_case response to camelCase
823
+ * Transform snake_case response to camelCase.
824
+ *
825
+ * Some backend endpoints still return snake_case keys; the
826
+ * domain types in ApiModels.ts are camelCase. This walker turns
827
+ * `external_id` → `externalId` etc. recursively. Safe no-op for
828
+ * already-camelCase responses.
798
829
  */
799
830
  transformResponse(data) {
800
831
  if (Array.isArray(data)) {
@@ -811,6 +842,33 @@ var ApiClient = class {
811
842
  return data;
812
843
  }
813
844
  };
845
+ function instructionForChallengeType(type) {
846
+ switch (type) {
847
+ case "blink":
848
+ return "Blink your eyes slowly";
849
+ case "smile":
850
+ return "Smile naturally";
851
+ case "turn_left":
852
+ return "Slowly turn your head to the left";
853
+ case "turn_right":
854
+ return "Slowly turn your head to the right";
855
+ case "nod_up":
856
+ return "Slowly tilt your head up";
857
+ case "nod_down":
858
+ return "Slowly tilt your head down";
859
+ default:
860
+ return "Follow the on-screen prompt";
861
+ }
862
+ }
863
+ function countryCodeToFlagEmoji(code) {
864
+ if (!code || code.length !== 2) return "";
865
+ const upper = code.toUpperCase();
866
+ const a = upper.charCodeAt(0);
867
+ const b = upper.charCodeAt(1);
868
+ if (a < 65 || a > 90 || b < 65 || b > 90) return "";
869
+ const offset = 127462 - 65;
870
+ return String.fromCodePoint(a + offset, b + offset);
871
+ }
814
872
 
815
873
  // src/KoraIDV.ts
816
874
  var KoraIDV = class {
package/dist/index.mjs CHANGED
@@ -494,10 +494,31 @@ var ApiClient = class {
494
494
  this.baseUrl = environmentUrls[configuration.environment];
495
495
  }
496
496
  /**
497
- * Get supported countries and their document types
497
+ * Get supported countries and their document types.
498
+ *
499
+ * Backend has no dedicated /supported-countries endpoint — the
500
+ * countries catalog is bundled into the /document-types response
501
+ * alongside the per-type metadata. We fetch that bundled payload,
502
+ * then project it onto the SDK's `SupportedCountry` shape:
503
+ * - `id` ← `code` (ISO-3166 alpha-2)
504
+ * - `flagEmoji` derived from the ISO code
505
+ * - `documentTypes` filtered from the bundled types list by country
506
+ *
507
+ * Mirrors the iOS / Android pattern (SessionManager.fetchSupported-
508
+ * Countries on iOS, ApiService.getDocumentTypes on Android). The
509
+ * previous standalone /supported-countries call had been silently
510
+ * 404-ing since the Web SDK shipped — surfaced 2026-05-29 by
511
+ * Luckycat's integration, hot on the heels of the v1.7.1
512
+ * wire-format pass.
498
513
  */
499
514
  async getSupportedCountries() {
500
- return this.request("/supported-countries");
515
+ const response = await this.request("/document-types");
516
+ return response.countries.map((c) => ({
517
+ id: c.code,
518
+ name: c.name,
519
+ flagEmoji: countryCodeToFlagEmoji(c.code),
520
+ documentTypes: response.documentTypes.filter((dt) => dt.country === c.code).map((dt) => dt.type)
521
+ })).filter((country) => country.documentTypes.length > 0);
501
522
  }
502
523
  /**
503
524
  * Create a new verification
@@ -506,7 +527,7 @@ var ApiClient = class {
506
527
  return this.request("/verifications", {
507
528
  method: "POST",
508
529
  body: JSON.stringify({
509
- external_id: request.externalId,
530
+ externalId: request.externalId,
510
531
  tier: request.tier
511
532
  })
512
533
  });
@@ -520,23 +541,23 @@ var ApiClient = class {
520
541
  /**
521
542
  * Upload document image.
522
543
  *
523
- * `decodedBarcodePayload` is the optional Phase 4 fast-path: when the
524
- * client decoded the PDF417 / QR / DataMatrix on-device using the
525
- * browser's BarcodeDetector API (or a polyfill), the AAMVA payload
526
- * travels here so the server can skip image-based barcode decoding
527
- * (~1-3 s round-trip savings). Empty/`undefined` = server falls
528
- * back to its zxing-cpp + pdf417decoder cascade. Only meaningful for
529
- * back captures on documents that carry a barcode.
530
- * See `docs/architecture/idv-decode-roadmap.md` Phase 4.
544
+ * Both sides now use the same JSON wire format as the Android/iOS SDKs.
545
+ * Front sends documentType + optional country; back sends optional
546
+ * decodedBarcodePayload (Phase 4 fast-path when the browser's
547
+ * BarcodeDetector decoded the PDF417 on-device, the AAMVA payload
548
+ * travels here so the server can skip image-based barcode decoding).
549
+ *
550
+ * The previous front-side multipart path returned HTTP 400 on every
551
+ * request because the server's /document handler only parses
552
+ * application/json — same trap iOS hit and fixed in v1.6.0.
531
553
  */
532
- async uploadDocument(verificationId, imageData, side, documentType, decodedBarcodePayload) {
554
+ async uploadDocument(verificationId, imageData, side, documentType, decodedBarcodePayload, country) {
555
+ const imageBase64 = await blobToBase64(imageData);
533
556
  if (side === "back") {
534
- const imageBase64 = await blobToBase64(imageData);
535
557
  return this.request(
536
558
  `/verifications/${verificationId}/document/back`,
537
559
  {
538
560
  method: "POST",
539
- headers: { "Content-Type": "application/json" },
540
561
  body: JSON.stringify({
541
562
  imageBase64,
542
563
  decodedBarcodePayload: decodedBarcodePayload ?? null
@@ -544,80 +565,97 @@ var ApiClient = class {
544
565
  }
545
566
  );
546
567
  }
547
- const formData = new FormData();
548
- formData.append("image", imageData, "document.jpg");
549
- formData.append("document_type", documentType);
550
- formData.append("side", side);
551
568
  return this.request(
552
569
  `/verifications/${verificationId}/document`,
553
570
  {
554
571
  method: "POST",
555
- body: formData,
556
- headers: {}
557
- // Let browser set Content-Type for FormData
572
+ body: JSON.stringify({
573
+ documentType,
574
+ imageBase64,
575
+ country: country ?? null
576
+ })
558
577
  }
559
578
  );
560
579
  }
561
580
  /**
562
- * Upload selfie image
581
+ * Upload selfie image.
582
+ *
583
+ * JSON with imageBase64 — matches the server's UploadSelfieRequest
584
+ * { ImageBase64 string } and the Android/iOS wire format. Previous
585
+ * multipart path returned HTTP 400 since the SDK shipped.
563
586
  */
564
587
  async uploadSelfie(verificationId, imageData) {
565
- const formData = new FormData();
566
- formData.append("image", imageData, "selfie.jpg");
588
+ const imageBase64 = await blobToBase64(imageData);
567
589
  return this.request(`/verifications/${verificationId}/selfie`, {
568
590
  method: "POST",
569
- body: formData,
570
- headers: {}
591
+ body: JSON.stringify({ imageBase64 })
571
592
  });
572
593
  }
573
594
  /**
574
- * Create liveness session
595
+ * Create liveness session.
596
+ *
597
+ * Decodes the wire DTO (matches backend's `models.LivenessSession`
598
+ * JSON), then projects it onto the domain `LivenessSession` the UI
599
+ * expects. Backend doesn't send `sessionId`/`expiresAt`, and per-
600
+ * challenge `id`/`instruction`/`order` aren't on the wire — domain
601
+ * values are synthesized client-side. Mirrors the iOS v1.7.0 pattern
602
+ * at koraidv-ios/.../SessionManager.swift::createLivenessSession.
603
+ *
604
+ * Without this DTO/domain split, the SDK accessed undefined fields
605
+ * on the response and produced a session with sessionId=undefined,
606
+ * which then failed every downstream challenge submission.
575
607
  */
576
608
  async createLivenessSession(verificationId) {
577
- const response = await this.request(`/verifications/${verificationId}/liveness/session`, {
578
- method: "POST"
579
- });
609
+ const dto = await this.request(
610
+ `/verifications/${verificationId}/liveness/session`,
611
+ { method: "POST" }
612
+ );
580
613
  return {
581
- sessionId: response.session_id,
582
- challenges: response.challenges.map((c) => ({
583
- id: c.id,
614
+ sessionId: dto.id,
615
+ challenges: dto.challenges.map((c, index) => ({
616
+ id: `${dto.id}_${index}`,
584
617
  type: c.type,
585
- instruction: c.instruction,
586
- order: c.order
618
+ instruction: instructionForChallengeType(c.type),
619
+ order: index
587
620
  })),
588
- expiresAt: new Date(response.expires_at)
621
+ // Backend doesn't return expiresAt; enforce a local 5-minute
622
+ // timeout consistent with the Android + iOS peers.
623
+ expiresAt: new Date(Date.now() + 5 * 60 * 1e3)
589
624
  };
590
625
  }
591
626
  /**
592
- * Submit liveness challenge
627
+ * Submit liveness challenge result.
628
+ *
629
+ * JSON with challengeType + imageBase64 — matches the server's
630
+ * SubmitLivenessChallengeRequest and the Android/iOS wire format.
631
+ * Previous multipart + snake_case path was double-broken.
593
632
  */
594
633
  async submitLivenessChallenge(verificationId, challenge, imageData) {
595
- const formData = new FormData();
596
- formData.append("image", imageData, "challenge.jpg");
597
- formData.append("challenge_type", challenge.type);
598
- formData.append("challenge_id", challenge.id);
634
+ const imageBase64 = await blobToBase64(imageData);
599
635
  const response = await this.request(`/verifications/${verificationId}/liveness/challenge`, {
600
636
  method: "POST",
601
- body: formData,
602
- headers: {}
637
+ body: JSON.stringify({
638
+ challengeType: challenge.type,
639
+ imageBase64
640
+ })
603
641
  });
604
642
  return {
605
- success: response.success,
606
- challengePassed: response.challenge_passed,
607
- confidence: response.confidence,
608
- remainingChallenges: response.remaining_challenges
643
+ success: response.success ?? true,
644
+ challengePassed: response.challengePassed ?? response.completed ?? false,
645
+ confidence: response.confidence ?? response.score ?? 0,
646
+ remainingChallenges: response.remainingChallenges ?? 0
609
647
  };
610
648
  }
611
649
  /**
612
- * Check document quality before uploading
650
+ * Check document quality before uploading.
613
651
  */
614
652
  async checkDocumentQuality(imageData, documentType) {
615
- const base64 = await this.blobToBase64(imageData);
653
+ const documentFrontBase64 = await blobToBase64(imageData);
616
654
  return this.request("/kyc/document-quality", {
617
655
  method: "POST",
618
656
  body: JSON.stringify({
619
- document_front_base64: base64,
620
- document_type: documentType
657
+ documentFrontBase64,
658
+ documentType
621
659
  })
622
660
  });
623
661
  }
@@ -666,7 +704,7 @@ var ApiClient = class {
666
704
  }
667
705
  headers.set("X-Tenant-ID", this.configuration.tenantId);
668
706
  headers.set("Accept", "application/json");
669
- if (!(options.body instanceof FormData) && !headers.has("Content-Type")) {
707
+ if (!headers.has("Content-Type")) {
670
708
  headers.set("Content-Type", "application/json");
671
709
  }
672
710
  const requestOptions = {
@@ -737,20 +775,13 @@ var ApiClient = class {
737
775
  sleep(ms) {
738
776
  return new Promise((resolve) => setTimeout(resolve, ms));
739
777
  }
740
- blobToBase64(blob) {
741
- return new Promise((resolve, reject) => {
742
- const reader = new FileReader();
743
- reader.onloadend = () => {
744
- const result = reader.result;
745
- const base64 = result.split(",")[1] || result;
746
- resolve(base64);
747
- };
748
- reader.onerror = reject;
749
- reader.readAsDataURL(blob);
750
- });
751
- }
752
778
  /**
753
- * Transform snake_case response to camelCase
779
+ * Transform snake_case response to camelCase.
780
+ *
781
+ * Some backend endpoints still return snake_case keys; the
782
+ * domain types in ApiModels.ts are camelCase. This walker turns
783
+ * `external_id` → `externalId` etc. recursively. Safe no-op for
784
+ * already-camelCase responses.
754
785
  */
755
786
  transformResponse(data) {
756
787
  if (Array.isArray(data)) {
@@ -767,6 +798,33 @@ var ApiClient = class {
767
798
  return data;
768
799
  }
769
800
  };
801
+ function instructionForChallengeType(type) {
802
+ switch (type) {
803
+ case "blink":
804
+ return "Blink your eyes slowly";
805
+ case "smile":
806
+ return "Smile naturally";
807
+ case "turn_left":
808
+ return "Slowly turn your head to the left";
809
+ case "turn_right":
810
+ return "Slowly turn your head to the right";
811
+ case "nod_up":
812
+ return "Slowly tilt your head up";
813
+ case "nod_down":
814
+ return "Slowly tilt your head down";
815
+ default:
816
+ return "Follow the on-screen prompt";
817
+ }
818
+ }
819
+ function countryCodeToFlagEmoji(code) {
820
+ if (!code || code.length !== 2) return "";
821
+ const upper = code.toUpperCase();
822
+ const a = upper.charCodeAt(0);
823
+ const b = upper.charCodeAt(1);
824
+ if (a < 65 || a > 90 || b < 65 || b > 90) return "";
825
+ const offset = 127462 - 65;
826
+ return String.fromCodePoint(a + offset, b + offset);
827
+ }
770
828
 
771
829
  // src/KoraIDV.ts
772
830
  var KoraIDV = class {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koraidv/core",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Kora IDV Core SDK - API Client and Utilities",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",