Identity records

Decide how a verified identity is saved in the vault with your own IdentityRecordMapper.

Preview

This page describes a pre-release version of the SDK. Names, versions and APIs on this page can change before the release.

When a government id verification succeeds during an inquiry, the SDK saves the result in the user's vault. Your app decides what the saved record looks like: you pass an IdentityRecordMapper to FolioSdk.create, and the SDK calls it with the verified identity and writes the RecordContent it returns. The payload is your own JSON document, so your app reads it back with its own decoder.

An identity record is not a Folio document. A document an organization issues to the person arrives as a separate record with the schema digital, which the SDK receives and shows through its own stores; your mapper never sees it. See Folio documents.

VerifiedIdentity

The result of a verification, as the mapper receives it. The inquiry store also reports it in the identity field of its state; see Inquiry store.

FieldTypeMeaning
inquiryIdStringThe inquiry that produced the result.
selectedDocumentTypeIdentityDocumentType (optional)The type of the document the step verified. See Selected document type.
outputVerifierOutputThe fields the verifier extracted.
issuedAti64When the verification outcome was issued, milliseconds since the Unix epoch.
expiresAti64When the verification outcome expires, milliseconds since the Unix epoch.
files[IdentityFile]The stored verification artifacts, each with the id of the attachment that holds it.

issuedAt and expiresAt come from the signed verification outcome. They are Int64 on iOS, Long on Android and bigint on the web. JSON.stringify cannot encode a bigint, so on the web convert them with Number before you encode the identity, as the web mapper below does.

Selected document type

selectedDocumentType is the document type of the government id step: the type the person picked in a GovId step, or the chip document of a GovIdNfc step. It is empty when the step names none. Folio sets Passport, DrivingLicense, NationalIdentityCard or ResidencePermit for a GovId step, and Passport or NationalIdentityCard for a GovIdNfc step. IdentityDocumentType is a plain enumeration, so match every case:

CaseiOSAndroidWeb
Passport.passportIdentityDocumentType.PASSPORT'PASSPORT'
DiplomaticPassport.diplomaticPassportIdentityDocumentType.DIPLOMATIC_PASSPORT'DIPLOMATIC_PASSPORT'
ServicePassport.servicePassportIdentityDocumentType.SERVICE_PASSPORT'SERVICE_PASSPORT'
EmergencyPassport.emergencyPassportIdentityDocumentType.EMERGENCY_PASSPORT'EMERGENCY_PASSPORT'
PassportCard.passportCardIdentityDocumentType.PASSPORT_CARD'PASSPORT_CARD'
NationalIdentityCard.nationalIdentityCardIdentityDocumentType.NATIONAL_IDENTITY_CARD'NATIONAL_IDENTITY_CARD'
EnhancedIdentityCard.enhancedIdentityCardIdentityDocumentType.ENHANCED_IDENTITY_CARD'ENHANCED_IDENTITY_CARD'
DrivingLicense.drivingLicenseIdentityDocumentType.DRIVING_LICENSE'DRIVING_LICENSE'
ProvisionalDrivingLicense.provisionalDrivingLicenseIdentityDocumentType.PROVISIONAL_DRIVING_LICENSE'PROVISIONAL_DRIVING_LICENSE'
ChauffeurLicense.chauffeurLicenseIdentityDocumentType.CHAUFFEUR_LICENSE'CHAUFFEUR_LICENSE'
ResidencePermit.residencePermitIdentityDocumentType.RESIDENCE_PERMIT'RESIDENCE_PERMIT'
ResidentIdCard.residentIdCardIdentityDocumentType.RESIDENT_ID_CARD'RESIDENT_ID_CARD'
WorkPermit.workPermitIdentityDocumentType.WORK_PERMIT'WORK_PERMIT'
Visa.visaIdentityDocumentType.VISA'VISA'
VoterId.voterIdIdentityDocumentType.VOTER_ID'VOTER_ID'
TribalId.tribalIdIdentityDocumentType.TRIBAL_ID'TRIBAL_ID'
MunicipalId.municipalIdIdentityDocumentType.MUNICIPAL_ID'MUNICIPAL_ID'
ConsularId.consularIdIdentityDocumentType.CONSULAR_ID'CONSULAR_ID'
HealthCard.healthCardIdentityDocumentType.HEALTH_CARD'HEALTH_CARD'
Other.otherIdentityDocumentType.OTHER'OTHER'

On the web the constant IdentityDocumentType holds the values, for example IdentityDocumentType.PASSPORT. The same enumeration is the type of documentType in GovIdOutput.

VerifierOutput

output names the verifier and carries its fields. Only the two government id verifiers reach the mapper:

CasePayloadVerifierFields the SDK uses to recognize a document
GovIdGovIdOutputDocument capture and recognitiondocumentNumber, birthdate
GovIdNfcGovIdNfcOutputReading the chip of a passport or id carddocumentNumber, birthdate

The other cases, Document, Selfie, OtpEmail and OtpSms, never reach the mapper and never appear in InquiryUiModel.identity. They exist because VerifierOutput is the type of every Folio verifier; a switch in Swift or a when in Kotlin still has to cover them, for example with a default or else branch. Document (.document(value:) in Swift, VerifierOutput.Document(value) in Kotlin, { type: 'DOCUMENT', value } in TypeScript) carries a DocumentOutput, the result of a general document verification: the optional files capturedDocumentFront and capturedDocumentBack, the optional strings documentGroup and documentClass, and extracted, a list of DocumentExtractedValue, each a key and a value string.

Every field of the two government id outputs is optional:

OutputFields
GovIdOutputgivenName, surname, middleName, birthdate, sex, nationality, countryCode, documentNumber, documentType, cardAccessNumber, documentSubType, issueDate, expirationDate, issuingAuthority, issuingSubdivision, mrzLine1, mrzLine2, mrzLine3, and the files capturedDocumentFront, capturedDocumentBack and capturedPortraitOcr
GovIdNfcOutputdocumentNumber, birthdate, expirationDate, surname, givenName, nationality, and the file capturedPortraitNfc

birthdate, issueDate and expirationDate are strings of the type IsoDate. A file is a FileRef with a reference and an optional mimeType. documentType is an IdentityDocumentType and documentSubType a DocumentSubType.

switch identity.output {
case .govId(let value):
    print(value.documentNumber ?? "")
case .govIdNfc(let value):
    print(value.documentNumber ?? "")
default:
    break
}

IdentityFile

FieldTypeMeaning
artifactArtifactFieldWhich verification artifact the file holds.
attachmentIdStringThe id of the vault attachment that holds the artifact's bytes.
imageImageMeta (optional)The image metadata of a captured image, such as the document front or a portrait. Empty for the other artifacts, such as outcome.jwt.

The first file is always the signed verification outcome, outcome.jwt. The files that follow are the other artifacts the verifier returned for the result, in the order of the ArtifactField table. The same ids appear in header.attachments of the saved record.

ImageMeta describes the image the SDK read from the artifact's bytes:

FieldTypeMeaning
fileFileMetaThe file of the image.
widthu32The width in pixels.
heightu32The height in pixels.
orientationOrientationThe orientation the image declares, Up when it declares none.
FileMeta fieldTypeMeaning
idStringThe attachment id, the same as attachmentId.
nameStringThe artifact's wire name with the extension of the image format, such as captured_portrait.jpg.
formatBinaryFormatImage with the ImageFormat of the bytes.
sizeBytesu32The size of the image in bytes.
checksumString (optional)Empty for an identity file.

width, height and sizeBytes are numbers on every platform: UInt32 on iOS, Long on Android and number on the web, so JSON.stringify encodes them as they are. An image artifact whose bytes are not an image the SDK can read fails the save with RecordMapping.

TypeiOSAndroidWeb
ImageFormat.png, .jpeg, .webP, .heicImageFormat.Png, ImageFormat.Jpeg, ImageFormat.WebP, ImageFormat.Heic{ type: 'PNG' }, { type: 'JPEG' }, { type: 'WEB_P' }, { type: 'HEIC' }
BinaryFormat.image(format:) for an imageBinaryFormat.Image(format) for an image{ type: 'IMAGE', value: { format } } for an image
Orientation.up, .upMirrored, .down, .downMirrored, .left, .leftMirrored, .right, .rightMirroredOrientation.Up, Orientation.UpMirrored, and so on{ type: 'UP' }, { type: 'UP_MIRRORED' }, and so on

BinaryFormat has more cases for other files, Pdf, Json, Enc, Txt, Pkpass, Bytes and Other with a mediaType; an identity file always has Image.

ArtifactField

ArtifactField names a verification artifact. It has no payloads.

iOSAndroidWeb
.outcomeJwtArtifactField.OUTCOME_JWT'outcome.jwt'
.verificationsArtifactField.VERIFICATIONS'verifications'
.dataArtifactField.DATA'data'
.contextArtifactField.CONTEXT'context'
.claimsArtifactField.CLAIMS'claims'
.configArtifactField.CONFIG'config'
.outputArtifactField.OUTPUT'output'
.nfcScalarArtifactField.NFC_SCALAR'nfc_scalar'
.prevVrfArtifactField.PREV_VRF'prev_vrf'
.capturedDocumentFrontArtifactField.CAPTURED_DOCUMENT_FRONT'captured_document_front'
.capturedDocumentBackArtifactField.CAPTURED_DOCUMENT_BACK'captured_document_back'
.capturedPortraitOcrArtifactField.CAPTURED_PORTRAIT_OCR'captured_portrait_ocr'
.capturedSignatureArtifactField.CAPTURED_SIGNATURE'captured_signature'
.capturedPortraitGhostArtifactField.CAPTURED_PORTRAIT_GHOST'captured_portrait_ghost'
.capturedBarcodeArtifactField.CAPTURED_BARCODE'captured_barcode'
.capturedColorDynamicArtifactField.CAPTURED_COLOR_DYNAMIC'captured_color_dynamic'
.capturedStampArtifactField.CAPTURED_STAMP'captured_stamp'
.capturedPortraitArtifactField.CAPTURED_PORTRAIT'captured_portrait'
.capturedPortraitNfcArtifactField.CAPTURED_PORTRAIT_NFC'captured_portrait_nfc'
.capturedNfcBundleArtifactField.CAPTURED_NFC_BUNDLE'captured_nfc_bundle'

The web values are the artifact wire names, such as outcome.jwt; on the web the constant ArtifactField holds them, for example ArtifactField.OUTCOME_JWT. When you encode an IdentityFile into your record, ArtifactField is written as that same wire name on every platform: by JSONEncoder on iOS, by kotlinx.serialization on Android and by JSON.stringify on the web, and decoding accepts only that wire name. A record your mapper writes on one platform therefore decodes on the others.

The mapper interface

IdentityRecordMapper has one method. It receives the identity and, when the SDK has saved the same document before, the body of that record. It returns the body to save.

protocol IdentityRecordMapper {
    func map(identity: VerifiedIdentity, existing: RecordContent?) throws -> RecordContent
}

The returned RecordContent follows the rules of every vault body: schema is your name for the document without the organization, version is your format version, and payload is a JSON string. See Client-shaped bodies.

Build the body from the arguments only. When the SDK resumes a save that an interruption stopped, it calls map again with the same identity and existing and checks that the body is the same JSON; the order of object keys does not matter. A different body, for example one that carries the current time, fails the verification with InvalidState. This error appears in InquiryUiModel.error as a banner and the inquiry stays open: unlike a mapping error, it sets no Failed lifecycle and no failure view.

The runtime keeps the mapper for its whole lifetime. Pass it when you create the runtime:

let sdk = try await FolioSdk.create(config: config, mapper: SavedIdentityMapper())

On iOS, implement the mapper as a class: the SDK holds it as an object, and FolioSdk.create expects a class instance. On Android, rememberFolioSdk creates the runtime again when it receives a different mapper instance, so pass the same object every time, such as a Kotlin object.

A complete mapper

The mappers below keep one identity document per physical document and append every new verification of it to a list. Each one rejects an existing record it does not recognize, which ends the inquiry with a mapping error instead of overwriting data.

SavedIdentityMapper.swift
import FolioSDK

struct SavedIdentity: Codable {
    let identities: [VerifiedIdentity]
}

struct NotASavedIdentity: LocalizedError {
    let schema: String
    var errorDescription: String? { "a \(schema) record is no saved identity" }
}

final class SavedIdentityMapper: IdentityRecordMapper {
    func map(identity: VerifiedIdentity, existing: RecordContent?) throws -> RecordContent {
        var earlier: [VerifiedIdentity] = []
        if let existing {
            guard existing.schema == "identity", existing.version == 1 else {
                throw NotASavedIdentity(schema: existing.schema)
            }
            earlier = try JSONDecoder()
                .decode(SavedIdentity.self, from: Data(existing.payload.utf8))
                .identities
        }
        let document = SavedIdentity(identities: earlier + [identity])
        let payload = String(decoding: try JSONEncoder().encode(document), as: UTF8.self)
        return RecordContent(schema: "identity", version: 1, payload: payload)
    }
}

When the mapper runs

The SDK calls the mapper for every successful government id verification (GovId or GovIdNfc) in an inquiry, before the inquiry moves on, and again only when it resumes an interrupted save. It does not call it for document, selfie, email OTP and SMS OTP results: the SDK keeps those results for the inquiry itself and writes no record for them.

A government id verification needs a session that holds a vault. Without one the SDK saves nothing, does not call the mapper, and the verification fails with InvalidState, shown in InquiryUiModel.error while the inquiry stays open.

One record per document

The SDK keeps one record per physical document. It recognizes a document by a fingerprint of two fields of the verifier output:

  • documentNumber, with whitespace and hyphens removed and letters uppercased;
  • birthdate, with surrounding whitespace removed.

What happens next depends on the fingerprint:

SituationexistingWhat the SDK writes
The SDK saved a record for this fingerprint before, and that record is active and owned by the accountThat record's bodyUpdates that record with the returned body and adds the new artifacts as attachments.
The fingerprint is new, or its record was deleted or is not owned by the accountnoneCreates a record with the returned body and the artifacts as attachments, and remembers the fingerprint for it.
documentNumber or birthdate is missing or emptynoneCreates a record. No fingerprint is remembered, so the next verification of the same document creates another one.

On an update, return a body with the same schema as the existing record: the vault rejects an update that changes the schema.

Mapping errors

Throw from map when you cannot build a record, for example when existing holds a document your app does not recognize. The SDK then writes nothing, neither the record nor its attachments, and the inquiry ends with lifecycle Failed and error RecordMapping, whose message is the message of your error:

PlatformWhat you throwWhat the inquiry reports
iOSAny ErrorInquiryLifecycle.failed(error: .recordMapping(message:))
AndroidAny exceptionInquiryLifecycle.Failed(InquiryError.RecordMapping(message))
WebAny Error{ type: 'FAILED', value: { error: { type: 'RECORD_MAPPING', value: { message } } } }

On iOS the message is the errorDescription of an error that conforms to LocalizedError, and String(describing:) of any other error. On the web it is the error's message; a thrown value that is not an Error is converted with String.

The failed inquiry stays failed: InquiryIntent.Retry has no effect on it. InquiryUiModel.failure holds the generic failure copy with no retry; show it, and send Close from its close button. The lifecycle then becomes Closed with no outcome.

See Inquiry store for the lifecycle and Errors for the other inquiry errors.

Read the records back

The saved records are ordinary vault records. Mount VaultStore, pick your schema and decode the payload with the same types the mapper used.

let vault = try VaultStore.mount(runtime: sdk)
let identities = try vault.state.records
    .filter { $0.header.schema == "identity" }
    .map { try JSONDecoder().decode(SavedIdentity.self, from: Data($0.payload.utf8)) }

Filter on header.state as well if you want to leave out records the user has deleted. The ids in files of each saved identity match header.attachments of its record.

On this page