Integrate the mobile SDK

What the iOS and Android SDK can do, what your application remains responsible for, how to install it today, and why it is not yet distributable to an outside team.

On this page

The mobile SDK is a typed client of the public SealTrust API, plus three ready-to-place screens, for iOS and for Android. By the time you leave this page, you will know what it can do, what it will never do, what your application must remain responsible for, how to wire it in, and what state of distribution it is in today.

Let us start with that state, because it changes the answer to the question "can I use it this week".

#Where this SDK stands today

Both platforms are at version 0.1.0 and are published to no package repository.

On the iOS side, the Swift package manifest is not at the root of a git repository. The Swift package manager only resolves a package from a git address in that case. The .package(url:) declaration therefore does not work, and only the local path declaration works.

On the Android side, the two modules do declare Maven coordinates, io.sealtrust:sealtrust-core and io.sealtrust:sealtrust-ui, at version 0.1.0. The publication configuration declares no remote repository. The only possible publication is therefore local, on your own machine.

#How the work is divided

The SDK does not read the chip. That is the first thing to understand, because it determines everything else.

A SealTrust NFC chip proves its authenticity by computing a fresh signature at each read, with a key of its own. Verifying that signature requires the brand's chip keys, and those keys do not leave our servers.

WhoDoes what
The phone systemReads the address the chip emits, or the printed QR code.
Your applicationOwns the NFC session, the camera, the user sign-in.
The SDKReads that address, calls the API, turns the response into a displayable verdict.
The SealTrust APIVerifies the cryptography of the chip and replies.

Three concrete consequences.

The SDK requests no NFC permission and no location permission. It is the Android screens module that declares the internet access permission, and that is the only one it declares. The client module alone declares no permission.

Offline verification does not exist. Every check is a network call.

The SDK contains no secret. There is no mobile API key, no signing key, no embedded identifier. The only thing it can carry is the session token of a user already signed in by you. It sends it on ownership claim calls, and on passport reads when that token is available.

#A product can carry two codes, and they do not prove the same thing

The SDK names which of the two was read. The handling then differs for each.

What was scannedWhat it provesWhat you call
The NFC chipThe physical object was there. The chip recomputes a fresh signature at each read.verifyScan
The printed QR codeWhich item it is. A photograph of the label gives back the same string.passport, certificate, history, and requestClaimTicket from the serial number

Many products carry only a QR code. The platform serves that mode as a mode in its own right. The passport, the certificate and the history are then read normally, from the printed number alone. What is not provided is the proof of physical presence.

The address reading function tells you which of the two you have in hand, and returns an empty value when the address is neither. That last case happens often in production: a customer who points your scanner at the GS1 link of another brand deserves a clean answer.

import SealTrustKit

func traiter(_ chaineScannee: String, avec client: SealTrustClient) async throws {
    switch ScanURL.read(chaineScannee) {
    case .chip(let preuve):
        let verification = try await client.verifyScan(preuve)
        print(verification.productName ?? "")
    case .printedSerial(let numeroDeSerie):
        let passeport = try await client.passport(identifier: numeroDeSerie)
        print(passeport.productName ?? "")
    case nil:
        print("Ce code n'est pas un code SealTrust.")
    }
}

The printed QR code comes in two forms: /p/{serial} and the GS1 form /01/{gtin}/21/{serial}. A /01/{gtin} link with no serial number names a product reference, so the read returns no serial number. This is not a dead end: a reference has its own passport, the model's, and the client reads it with referencePassport. Keep the distinction in mind, a reference passport describes a model and proves nothing about the object you are holding.

The passport is served at two levels only: the model, by its GTIN product code, and the item, by its identifier. A model passport covers every unit that shares the same product code. The ESPR regulation also allows a batch passport, which the platform does not serve today.

The serial number is returned exactly as it is printed, never rewritten. It is the server that applies the normalization rules. A published application does not update at the same pace as the server, and a client that applied its own rules would answer "product not found" on a valid label the day the two sets of rules diverged.

#Platforms and minimum versions

CharacteristiciOSAndroid
Minimum versioniOS 16API level 26
DistributionSwift package SealTrustSDKGradle project, two modules
ClientSealTrustKit librarysealtrust-core module
ScreensSealTrustUI librarysealtrust-ui module
Client dependenciesNone, Foundation aloneNone
Screen dependenciesSwiftUI aloneJetpack Compose and androidx.lifecycle:lifecycle-viewmodel-compose

The Android module of the client is an ordinary Kotlin module, with nothing Android inside. It compiles to Java 17. The screens module targets compile level 35 and minimum level 26, the latter being imposed by the reading of dates. Go lower only with the core library desugaring active in your application.

#Installing

// Package.swift of your application.
// Replace the path with the one of your local copy of the sources.
dependencies: [
    .package(name: "SealTrustSDK", path: "<chemin-vers-les-sources>/ios"),
],
targets: [
    .target(
        name: "VotreApplication",
        dependencies: [
            .product(name: "SealTrustKit", package: "SealTrustSDK"),
            .product(name: "SealTrustUI", package: "SealTrustSDK"),
        ]
    ),
]

The name: parameter is mandatory on iOS. A package declared by path takes the name of its directory as its identity. Without this parameter, the Swift package manager does not find SealTrustSDK and the build fails.

On Android, a second route exists if the composite build does not suit you. From the directory of the SDK's Gradle project, ./gradlew publishToMavenLocal installs the two modules into your local Maven repository. You then consume them as versioned dependencies.

Kotlin
repositories {
    mavenLocal()
}

dependencies {
    implementation("io.sealtrust:sealtrust-core:0.1.0")
    implementation("io.sealtrust:sealtrust-ui:0.1.0")
}

If you only want the client, take SealTrustKit alone, or sealtrust-core alone. You then pay nothing for the screens.

One consequence on Android: it is the screens module that declares the internet access permission. If you take sealtrust-core alone, declare android.permission.INTERNET in your own manifest.

#Creating the client

The client is built with a configuration. Its four values:

ValueDefaultRole
Base addresshttps://api.sealtrust.ioTo be replaced to target another environment or your own relay.
Timeout15 secondsConnection and read timeout.
Client identifiersealtrust-sdk-ios/0.1.0 or sealtrust-sdk-android/0.1.0Sent in the X-SealTrust-Client header.
Token providerabsentReturns the session token of the signed-in user, or nothing.

The SDK signs nobody in. Ownership claim calls require an authenticated user, so your application provides the token. Return an empty value when nobody is signed in: the SDK then fails with an authentication error instead of sending a request that can only come back as a 401.

import Foundation
import SealTrustKit

enum SessionApplication {
    /// Token of the signed-in user, fed by your own sign-in.
    static var jeton: String?
}

let client = SealTrustClient(
    configuration: SealTrustConfiguration(
        accessTokenProvider: { SessionApplication.jeton }
    )
)

Two headers accompany every call: Accept: application/json and X-SealTrust-Client. Calls that carry a body add Content-Type: application/json.

The Authorization header is added on claim calls, and on passport reads when your application has provided a token. A signed-in owner then sees their read widened.

One difference in style between the two platforms, not to be missed. On iOS, every method that calls the network is asynchronous. Building the address of the certificate in PDF is synchronous, because it calls nothing. On Android, the methods are blocking: call them off the main thread. The three Android screens already do it for you.

#Verifying a scan

This is the central call. You give it the address read on the chip, it returns a verdict.

import SealTrustKit

func verifier(_ adresse: URL, avec client: SealTrustClient) async -> ScanOutcome {
    do {
        return ScanOutcome.from(try await client.verifyScan(url: adresse))
    } catch {
        return ScanOutcome.from(error)
    }
}

The verdict takes eight forms, and telling them apart matters for the user.

VerdictWhat happenedWhat the screen must say
AuthenticThe chip returned a signature that the server validated.Authentic product.
ReplayThis exact address has already been presented.Alert. This is the signal that a label may have been copied.
Invalid signatureThe signature does not match this chip.This is not a SealTrust chip, or it is a copy.
Minting in progressAuthentic chip, its certificate is being written.Ask the user to try again in a moment.
Never registeredAuthentic chip, nothing has ever been registered for it.The anomaly is on the brand's side. The buyer has nothing to correct.
Unreadable linkThe address carried no usable proof, or the API refused the parameters.Invite a rescan.
Too many callsThe per IP address limit is reached.Invite the user to try again in a minute.
FailureEverything else, including the network being unreachable.Technical error, with no authenticity verdict.

An unrecognized conflict is read as a replay. The SDK therefore reports the anomaly to the reader.

#The electronic seal and the theft report

Two pieces of information travel with the verification and deserve particular handling.

The Tag Tamper seal is a three-value state: never opened, opened, or nothing to say. A standard chip and a chip whose seal has never been activated both return the third value, on authentic products. Never display the absence of information as a broken seal. The SDK exposes a property that combines this state with the policy chosen by the brand, and that returns an empty value as soon as there is nothing to show. Three policies exist: silence, information, and degraded displayed trust. The default value is information.

A theft report open on a unit does not make the verification fail. The scan replies normally, and a boolean carries the warning. Display it, otherwise it is said nowhere.

#Reading a passport, its proofs and its certificate

The client exposes seven reads, all public.

CallWhat it returns
passportThe published digital passport of a unit.
passportProofThe bundle of proofs of that passport.
referencePassportThe passport of the model, from a GTIN.
referencePassportProofThe bundle of proofs of the model.
certificateThe public certificate of authenticity.
certificatePDFURL on iOS, certificatePdfUrl on AndroidThe address of the same certificate in PDF. Nothing is called, the address is built.
historyOwnership movements and authentication scans, from the most recent to the oldest.

The passport is read at an access tier. The SDK exposes the six values the API accepts: public, end_user, repairer, recycler, upstream and authority. The three trade levels are distinct audiences. None of them contains the others: holding the repairer accreditation does not open the recycler's. The default is public. The higher tiers are controlled by the server, which replies 401 or 403 to a caller who has no right to them. The tier actually served appears in the response, and it is not always the one requested: a signed-in owner sees their public read widened automatically.

let passeport = try await client.passport(
    identifier: "0x0000000000000000000000000000000000000000000000000000000000000000"
)
print(passeport.accessTier, passeport.productName ?? "")

let preuves = try await client.passportProof(
    identifier: "0x0000000000000000000000000000000000000000000000000000000000000000"
)
print(preuves.dataHash ?? "", preuves.ipfsURI ?? "")

let modele = try await client.referencePassport(gtin: "03701234567890")
print(modele.productName ?? "")

The bundle of proofs carries the fingerprint of the content, the copy on IPFS, the anchoring state on Base and the state of the verifiable certificate. The anchoring state takes three distinct values: anchored, never anchored, and anchored then modified. The third is the only one this mechanism exists to raise. Never display it as a "not anchored".

It takes a fourth form, an empty value, when the server served no anchoring block. Treat this case separately and never display this empty value as a "not anchored". It means that you have no information on the anchoring of this product. Anchoring on Base is an operation that SealTrust triggers by hand, and most products are never anchored. A brand cannot trigger it itself.

#Claiming ownership of a product

The journey registers a scanned product in the name of the signed-in user. It fits in two calls, plus one state read.

The first call requests a ticket from the read of the chip. A ticket is issued only as long as the product belongs to nobody.

A success response with no ticket is normal. The detail field then says why: already claimed, already transferred, unknown identifier, competing claim in progress, or purchase code required.

import SealTrustKit

func revendiquer(_ chaineScannee: String, avec client: SealTrustClient) async throws {
    guard case .chip(let preuve) = ScanURL.read(chaineScannee) else {
        print("Ce code n'est pas une lecture de puce.")
        return
    }
    let demande = try await client.requestClaimTicket(for: preuve)
    guard let ticket = demande.ticket else {
        print(demande.detail ?? "Aucun ticket délivré.")
        return
    }
    let resultat = try await client.completeClaim(ticket)
    print(resultat.txHash ?? "")
}

The second call turns the ticket into ownership recorded on the chain. It requires the token of the signed-in user. The response is written as soon as the transfer is broadcast, before it is engraved, so the transaction hash it carries designates a pending operation.

Some brands hand over a purchase code with the product. Only ask the user for one after the API has replied a first time that it is required. The customers of brands that issue none will then never see this field.

The ticket carries a nonce. It is the proof that somebody physically read the label. Keep it in memory, do not log it, do not store it.

A third call reads the state of a ticket: issued, consumed, expired or canceled. It requires the user's token as well. The server returns this state as it is, without filtering. Treat it as an open string and plan a default display for a value you do not know.

There is a variant that requests a ticket from the printed serial number, without a chip. It is authenticated, and it works only on the very first claim of a product: a product already owned or already claimed is refused with a 409. A printed number proves much less than a read of a chip, and this lock is the consequence. It is not open on every account: if it is not open on yours, it replies 404, and that is the only reading to make of this code on this call. Check with us that it is open for your application before integrating it.

#The three ready-to-place screens

The interface module provides three screens built on the client. They exist in SwiftUI and in Compose, with the same parameters.

ScreenWhat it takesWhat it gives back to your application
VerificationThe client, the scanned address or the proof, optional coordinates, the texts.Two callbacks: open the passport, or start the claim.
ClaimThe client, the proof, a boolean saying whether a user is signed in, the product name, the texts.Two callbacks: ask for sign-in, or announce that the claim is done.
PassportThe client, the identifier, the access tier, the texts.Nothing, it is a reading screen.

The claim screen does not start your sign-in. You tell it whether a user is signed in, and it asks for sign-in instead of triggering a call that can only come back as a 401.

All visible texts can be overridden field by field. A complete English set and a complete French set are provided, and no visible sentence is frozen inside a screen.

The colors are fields of a theme and are replaced one by one. The three screens have only one appearance: the theme carries fixed colors and does not react to the dark mode of the system.

#The errors

The SDK brings everything back to five cases.

CaseWhen
Invalid scan addressThe link carried no usable proof.
Not authenticatedAn authenticated call was attempted without a session token.
API errorThe API replied with a 4xx or a 5xx. The case carries the HTTP code, the detail, and the machine code when the API prefixes one.
DecodingThe body of the response does not have the expected shape.
TransportThe request never got through: DNS, TLS, timeout, no network.

The machine code is extracted from the detail when the detail starts with an uppercase word containing at least one underscore, for example MINT_PENDING or CLAIM_EXPIRED. An uppercase word without an underscore is never extracted. The machine code is an empty value when the API replies with a free sentence.

#Call limits

The calls of the SDK target public endpoints, whose limits are counted per IP address, over a window of 60 seconds.

EndpointLimit
Scan verification30 per minute
Ticket request from a scan30 per minute
Passport and proofs60 per minute
Certificate60 per minute
Product history30 per minute
Claim20 per minute

Going over comes back as a 429, and the SDK translates it into the "too many calls" verdict on the verification path.

#Coordinates and privacy

Coordinates are optional on scan verification. Both parameters are an empty value by default, on both platforms: an integrator who never passes them transmits nothing.

They serve the anti-fraud checks on the server side. Only pass them with the consent of the user.

The SDK reads no position, requests no location permission and does not check that you obtained anything. The consent, its record and its mention in your privacy policy are your obligation as the publisher of the application.

What the response gives back is a scan area at city level, in the form of a city name and a country code, and only when you have provided coordinates. The response contains no coordinates.

On iOS, the package embeds an Apple privacy manifest. It declares the absence of tracking and two types of collected data.

Precise location, only when your application passes coordinates.

The user identifier, that is, the session token. The manifest says that it is sent only on POST /claims/from-serial, on POST /claims/complete, on GET /claims/{id}, and on GET /passport/{id} when a token is available. Take this list as it is if you fill in the App Store privacy declaration.

On Android, nothing equivalent is declared inside the module. It is the publisher of the application who fills in the data safety form of the store, and they must cover there the data collected by every embedded library, this one included.

#What is not in the SDK

  • Ownership transfer between two people. It requires a one-time code and a matching scan on both sides. That is another journey.
  • Product minting and destruction. These are authenticated operations, they are carried out from the console.
  • Reading chips, scanning QR codes, loading images, signing users in. Your application already owns all of that, and the SDK deliberately embeds no dependency to do them.

Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.

Suggest a correctionReport a problem