Jetons d'accès

Comment le widget obtient un identifiant de courte durée au début de chaque mesure, sans jamais détenir votre clé d’API

Le widget ne détient aucun secret. Avant chaque mesure il demande un jeton d’accès de courte durée, et options.proxy.retrieveAccessToken est votre façon de répondre.

C’est la seule option obligatoire. Sans elle, le widget parcourt tout de même ses écrans : l’accueil, le consentement et les consignes fonctionnent. Mais au moment où une mesure devrait démarrer, il échoue : token:error avec le code NO_PROXY, puis measure:failed avec le code UNDEFINED_ACCESS_TOKEN_PROXY.

Pourquoi un jeton, et pourquoi à chaque fois

Votre clé d’API est permanente, sans portée, et ouvre toutes les routes de l’API. Elle n’a rien à faire dans une page.

Un jeton d’accès est l’inverse : limité à la permission pour laquelle il a été émis, valide quelques minutes, et consommé par la poignée de main qui ouvre la session de mesure. C’est cet usage unique qui rend sa présence dans un navigateur acceptable. C’est aussi pourquoi le widget en redemande un au début de chaque mesure, plutôt que d’en garder un. Une reconnexion à une session déjà ouverte ne consomme rien : une coupure réseau pendant une mesure ne demande pas de second jeton.

Un utilisateur qui abandonne une mesure puis recommence provoquera deux demandes de jeton. C’est attendu : le premier jeton a été dépensé par la tentative abandonnée.

Les trois stratégies

StratégieQui appelle l’APICe que vous fournissezÀ utiliser quand
delegateLe widget, contre votre point d’accèsurl, avec headers et body facultatifsVotre serveur peut exposer un point d’accès. Le choix habituel.
handleVotre propre codefetch, une fonction qui retourne le jetonL’obtention du jeton demande un état applicatif — une session, une file d’attente, un jeton déjà en mémoire.
unsafe-api-keyLe widget, contre l’API SaphereapiKeyDémonstrations et essais, sur le Test uniquement. Jamais en Production.

delegate — le widget appelle votre point d’accès

const instance = SaphereScan.create("#scan", {
    proxy: {
        retrieveAccessToken: {
            strategy: "delegate",
            url: "/api/saphere-token"
        }
    }
})

url est soit une URL http(s) absolue, soit un chemin enraciné sur /, servi par la page elle-même.

Le widget envoie un POST en JSON dont le corps porte la chaîne de mesure demandée, et rien d’autre par défaut :

{ "measurement": "scan" }

Il n’y a rien de la mesure à transmettre, puisqu’elle n’existe pas encore. Ce qui décrit la mesure — données patient, étiquettes, variables voulues — est une option à part, createMeasureOptions : le jeton autorise, il ne décrit pas. Votre point d’accès a seulement à lire quelle chaîne on lui demande, à décider si cet utilisateur peut lancer une mesure, et à répondre par un jeton.

Les cookies ne vont qu'à l'origine de votre page

La requête part avec le mode d’identification par défaut du navigateur : votre cookie de session voyage vers un chemin de l’origine de votre page, et vers rien d’autre. Un point d’accès sur une autre origine ne reçoit aucun cookie et doit s’authentifier par headers — et, la requête portant Content-Type: application/json, il doit aussi répondre à la requête préalable CORS du navigateur. Un chemin de même origine est le moyen le plus simple de conserver votre session existante.

Deux formes de réponse sont acceptées :

{ "id": "3f1a…", "value": "ivat-8Kx2mQ…", "description": null, "permissions": [{ "name": "measurement.scan", "maxSessions": 1 }], "expiresAt": "2026-08-24T16:00:00.000Z", "createdAt": "2026-08-24T15:45:00.000Z" }
"ivat-8Kx2mQ…"

La première est la réponse de l’API elle-même à POST /access-tokens, ce qui vous permet de la relayer telle quelle sans la démonter : le widget en lit la value. La seconde est le jeton seul, pour un point d’accès qui ne rend que le strict nécessaire — en texte brut, ou sérialisé en chaîne JSON, guillemets compris.

C'est `value` qui ouvre, jamais `id`

La base ne stocke qu’un condensé du jeton, de sorte que l’id de la ligne n’ouvre absolument rien. Un point d’accès qui relaie l’id comme s’il était le jeton verra chaque poignée de main refusée — avec un jeton d’apparence parfaitement bien formée. Celui qui relaie un objet portant l’id sans la value obtient UNUSABLE_RESPONSE.

headers authentifie l’appel, et body s’ajoute au corps pour le cas où le point d’accès que vous visez est une API exigeant des paramètres plutôt qu’un point d’accès à vous. Le measurement du widget est fondu par-dessus le vôtre : la chaîne annoncée est celle qui est mesurée, jamais une valeur recopiée qui aurait vieilli.

retrieveAccessToken: {
    strategy: "delegate",
    url: "/api/saphere-token",
    headers: { "X-Session": sessionId }
}

handle — vous le récupérez vous-même

Prenez cette stratégie dès que l’obtention d’un jeton demande plus d’une requête : une session applicative à consulter, un jeton déjà détenu en mémoire, une file d’attente, un doublure de test à substituer.

const instance = SaphereScan.create("#scan", {
    proxy: {
        retrieveAccessToken: {
            strategy: "handle",
            fetch: async signal => {
                const response = await fetch("/api/saphere-token", {
                    method: "POST",
                    signal,
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ userId: currentUser.id })
                })
                const { value } = await response.json()
                return value
            }
        }
    }
})

La fonction reçoit deux arguments : l’AbortSignal de la tentative, puis un contexte { measurement } qui dit la chaîne de mesure demandée. Transmettez le signal à fetch et une requête en vol est annulée quand l’utilisateur quitte l’écran, au lieu d’aboutir à une mesure que plus personne n’attend.

fetch: async (signal, { measurement }) => {
    // `measurement` vaut "scan" ou "realtime" : demandez la permission correspondante
}

Elle peut retourner le jeton sous forme de chaîne, ou la réponse de l’API elle-même — un objet portant value —, ou une promesse de l’un ou de l’autre, et elle peut être déclarée sans aucun paramètre — le widget ne suppose rien au-delà de « c’est une fonction ».

Une fonction sans `return`

L’erreur d’intégration la plus courante est un fetch qui exécute la requête et oublie d’en retourner le résultat. Le widget la rapporte en token:error avec le code UNUSABLE_RESPONSE, qui est exactement le cas à chercher en premier.

unsafe-api-key — essais uniquement

retrieveAccessToken: { strategy: "unsafe-api-key", apiKey: "2428fbbc-…" }

Le widget appelle l’API lui-même, avec votre clé client, et demande un jeton portant la seule permission dont la chaîne a besoin, limité à une session et à quinze minutes. Le nom dit ce que c’est.

La clé d’API est permanente et sans portée. La placer ici la place dans la page — dans le paquet, dans le cache du navigateur, dans les outils de développement de chaque utilisateur. Une fuite ne se révoque pas en faisant tourner un jeton : elle se révoque en changeant la clé, ce qui coupe toutes les intégrations de ce client d’un coup.

Elle existe pour qu’une page de démonstration fonctionne en trente secondes. Elle n’a aucune place dans un produit livré — et la clé que vous y placez doit être une clé de Test. Une clé de Production dans une page est la seule erreur de cette page qui ne se rattrape pas discrètement.

Pour quelle chaîne de mesure

Un jeton porte une permission, et il y en a deux : measurement.scan, la collecte dont le résultat est calculé à la fin, et measurement.realtime, la chaîne dont les grandeurs redescendent pendant la mesure. Un jeton frappé pour l’une se fait refuser par l’autre — il faut donc savoir laquelle demander.

Ce n’est pas à vous de le déclarer : le widget vous le dit. La chaîne n’est pas une option, elle est un fait de ce que le widget mesure, et elle voyage jusqu’à vous par chacune des trois stratégies :

StratégieComment la chaîne vous parvient
delegateUn champ measurement du corps JSON du POST, toujours présent. Votre body est fusionné dessous, de sorte que le widget garde le dernier mot sur ce point.
handleLe second argument de fetch, dans un objet de contexte : fetch: async (signal, { measurement }) => …. Une fonction écrite avant ce champ retrouve son AbortSignal là où elle l’a laissé.
unsafe-api-keyRien à faire : le widget demande directement la permission correspondante à POST /access-tokens.

Elle vaut "scan" pour le parcours certifié, et "realtime" quand le widget ouvre son écran temps réel — l’option realtime. Une intégration qui active cette option doit donc répondre aux deux valeurs : un point d’accès qui frappe toujours un jeton measurement.scan voit toutes les poignées de main temps réel refusées, sans que rien n’ait changé chez lui.

token:requesting porte la même valeur, de sorte qu’une supervision voit ce qui a été demandé.

Le point d’accès que vous devez écrire

Pour delegate et handle, il vous faut un point d’accès qui émet un jeton. Il doit appeler l’api du même environnement que celui depuis lequel le widget a été chargé — cette moitié-là vous revient, le widget ne peut pas la deviner. L’exemple ci-dessous vise la Production ; sur le Test, seul l’hôte change.

EnvironnementBase de l'API
Productionhttps://api.saphere.ai
Testhttps://api.test.saphere.ai

Le voici avec Express. Il lit la chaîne que le widget annonce, et demande la permission correspondante :

import express from "express"

const app = express()
app.use(express.json())

const SAPHERE_API = "https://api.saphere.ai"
const API_KEY = process.env.SAPHERE_API_KEY   // jamais dans le paquet client

app.post("/api/saphere-token", async (request, response) => {
    // Votre propre autorisation, quelle qu'elle soit : une session, un quota, un droit.
    if (!request.session?.userId)
        return response.status(401).end()

    // "scan" pour le parcours certifié, "realtime" pour l'écran temps réel
    const permission = request.body?.measurement === "realtime" ? "measurement.realtime" : "measurement.scan"

    const created = await fetch(`${SAPHERE_API}/access-tokens`, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${API_KEY}`
        },
        body: JSON.stringify({
            permissions: [{ name: permission, maxSessions: 1 }],
            duration: "15min"
        })
    })

    if (!created.ok)
        return response.status(502).end()

    const { value } = await created.json()
    // Ne relayez que ce qui ouvre. `id` et `expiresAt` peuvent être journalisés ; `value` non.
    response.json({ value })
})

Ce point d’accès est là où vivent vos règles métier. C’est le seul endroit capable de décider que cet utilisateur, en cet instant, peut lancer une mesure — le widget ne le peut pas, et l’API sait seulement que la clé est valide.

POST /access-tokens accepte vingt appels par minute et par clé d’API, et répond 429 au-delà — un jeton par mesure, émis au démarrage de la mesure, reste largement en deçà. La requête accepte aussi une description facultative, et la référence de l’API détaille les deux corps.

permissions

Un tableau, avec au moins une entrée. Deux permissions existent, une par chaîne :

NomCe qu’elle accorde
measurement.scanL’ouverture d’une session de mesure Saphere Scan, calculée à la fin.
measurement.realtimeL’ouverture d’une session de mesure temps réel.

C’est la permission demandée qui doit suivre le measurement que le widget annonce : un jeton frappé pour une chaîne se fait refuser par l’autre.

maxSessions borne le nombre de fois où le jeton peut ouvrir une session : un entier, au moins 1. Chaque session ouverte compte, que la mesure qui suit aboutisse ou non.

Omettre `maxSessions` produit un jeton rejouable

Le champ est nullable et sans valeur par défaut, et null signifie aucune limite — un tel jeton n’est borné que par sa durée. Rien ne le remplit à votre place, car une valeur par défaut écrite là bornerait silencieusement un jeton qu’un intégrateur a délibérément laissé sans borne. Écrivez maxSessions: 1 sauf raison contraire.

duration

Un intervalle, non une date : 15min, 2h, 48h. Le serveur l’évalue au moment de l’insertion, de sorte qu’aucune horloge n’a à être accordée entre votre machine et la nôtre.

Le plafond est de 48 heures, et une demande au-delà est refusée. Un jeton vit le temps de la mesure qu’il ouvre ; il n’a pas besoin de survivre à la session qui l’a demandé.

Attention à une abréviation, héritée de la grammaire des intervalles : m signifie minutes. Les mois s’écrivent mon. Comme la semaine la plus courte dépasse déjà le plafond, toute durée écrite avec une unité supérieure au jour est refusée d’emblée. Une unité ne peut apparaître qu’une fois — 1d 2h est accepté, 1h 1h ne l’est pas — et la durée doit être supérieure à zéro.

Révoquer un jeton

curl -X DELETE https://api.saphere.ai/access-tokens/3f1a2b4c-… \
     -H "Authorization: Bearer $SAPHERE_API_KEY"

Ici c’est l’id qu’il vous faut, non la value — l’identifiant est ce qui désigne le jeton, la valeur est ce qui l’ouvre.

Un jeton inconnu, déjà révoqué ou appartenant à un autre client répondent tous le même 404, de sorte que personne ne peut sonder l’existence de jetons qui ne lui appartiennent pas. Un jeton expiré reste supprimable, ce qui vous permet de faire le ménage.

Diagnostiquer un échec

Chaque échec sur ce chemin est rapporté en token:error avec un code qui dit à qui appartient le problème :

codeOù regarder
NO_PROXYproxy.retrieveAccessToken n’a pas été déclaré du tout.
STRATEGY_FAILEDVotre fonction a levé, ou la requête n’a pas pu partir — réseau tombé, refus CORS.
HTTP_ERRORVotre point d’accès a répondu hors de la plage 2xx — ou l’API elle-même, sous unsafe-api-key.
UNUSABLE_RESPONSELa réponse ne portait aucun jeton exploitable — le plus souvent une fonction sans return, ou un id relayé sans sa value.
TIMEOUTPlus de cinq secondes pour répondre.

Le jeton lui-même n’est jamais porté par un événement, sous aucun code. Pendant le parcours certifié, la tentative s’achève ensuite sur measure:failed, code ACCESS_TOKEN_ERROR, ACCESS_TOKEN_TIMEOUT ou UNDEFINED_ACCESS_TOKEN_PROXY. Sur l’écran temps réel, le widget passe à la mesure certifiée avec realtime:unavailable — et un jeton qui met plus de cinq secondes à venir n’y est signalé que par cet événement, sans token:error.

Un jeton obtenu mais refusé par le serveur n’est pas un token:error : token:retrieved arrive, puis la poignée de main est refusée. Le jeton a été émis sur l’autre environnement, pour l’autre chaîne, a expiré, ou a déjà ouvert toutes ses sessions. Sur l’écran temps réel, cela s’achève sur realtime:unavailable. Pendant le parcours certifié, le widget retente la connexion : transport:reconnecting se répète avec un attempt croissant, transport:connected ne vient jamais, la capture va tout de même au bout de ses trente secondes, et la tentative s’achève sur measure:failed, code WS_RESULT_TIMEOUT, dix minutes après. C’est cette suite qu’il faut reconnaître — l’environnement d’abord, voir Environnements.