SMRT Integration
Site-aware Member Recognition Technology — v2. Three-tier header injection: anonymous preference signals (SMRT), identified recognition (PDaaS), and silent customer matching (PDaaS True). One signing key, one JWKS endpoint, one verification path.
Headers at a glance
| Header | Tier | Injected when | TTL |
|---|---|---|---|
X-BoxOwl-Smrt |
1 (SMRT) | Domain on the global registered-org list and no active connection. | 10 min |
X-BoxOwl-Identity |
2 (PDaaS) & 3 (PDaaS True) | Domain on the user's connected-org list. Mutually exclusive with the SMRT header. | 30 min |
Verifying a JWT (both headers)
Both shapes are RS256-signed with the same key. Fetch the JWKS, cache by kid, and verify locally.
/.well-known/smrt-jwks.json
Returns the JSON Web Key Set. Public, cacheable. Refresh on a kid miss (rate-limit refresh so a malformed token can't DOS your upstream).
Expected issuer on all v2 JWTs: boxowl.me. Identity-tier JWTs additionally carry an org claim; receivers MUST verify org matches the configured org slug.
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://api.boxowl.me/.well-known/smrt-jwks.json',
cache: true,
rateLimit: true,
});
function verifySmrtJwt(token, opts = {}) {
const decoded = jwt.decode(token, { complete: true });
const kid = decoded.header.kid;
return new Promise((resolve, reject) => {
client.getSigningKey(kid, (err, key) => {
if (err) return reject(err);
jwt.verify(token, key.getPublicKey(), {
algorithms: ['RS256'],
issuer: 'boxowl.me',
}, (err, claims) => {
if (err) return reject(err);
// For X-BoxOwl-Identity, also assert the org claim matches.
if (opts.expectedOrg && claims.org !== opts.expectedOrg) {
return reject(new Error('org claim mismatch'));
}
resolve(claims);
});
});
});
}
Reference implementation: backend/src/main/java/com/boxowl/api/controller/SmrtJwksController.java (the mint side) and demo/api/src/main/java/com/boxowl/perch/security/SmrtJwksVerifier.java (the verification side, Java/Spring).
JWT shape — X-BoxOwl-Smrt
Mintable via POST /api/v1/smrt/session (user JWT auth). The extension calls this at login and every 10 minutes; the rotating sub is what bounds cross-site behavioral tracking to a 10-minute window.
| Claim | Type | Example | Description |
|---|---|---|---|
sub | string (UUID) | "3f5c2d8e-7b13-4a91-9c47-1ab23456cdef" | Rotating session UID. Not the user's id, handle, or any stable identifier. |
smrt | object | see below | The user's org-agnostic preference profile (SMRT-015). |
iss | string | "boxowl.me" | Issuer. |
iat | number | 1748390400 | Issued at (Unix timestamp). |
exp | number | 1748391000 | Expiration (Unix timestamp, +10 min). |
jti | string | "e7b…c1d" | Unique token id. |
Embedded smrt claim
Every field is optional — empty values drop out entirely. Treat all keys as missing-by-default. All fields are intentionally low-entropy so the SMRT-tier surface stays anonymous; exact-value variants of the same signals are reserved for the PDaaS Identity JWT below.
| Key | Type | Example | Vocabulary |
|---|---|---|---|
ageBand | string | "25-34" | 13-17, 18-24, 25-34, 35-44, 45-54, 55-64, 65+ |
region | string | "CO" | 2–8 char ISO-style region code. Never precise location. |
styleRoots | string[] | ["minimalist","scandinavian"] | User-defined; common values: minimalist, scandinavian, mid-century, industrial, bohemian, modern, rustic, eclectic. |
colorPalette | string | "earth-tones" | earth-tones, monochrome, jewel-tones, pastels, neon, neutrals, warm, cool. |
interests | string[] | ["outdoor","cooking"] | User-defined; common values: outdoor, cooking, music, books, sports, travel, art, technology, fitness, fashion. |
language | string | "en" | Primary ISO 639-1 language code, derived from the user's Demographic record. Region tags (en-US) are stripped — region already covers that. Single value only. |
fit | string | "slim" | Categorical clothing fit preference: slim, regular, relaxed, athletic. Omitted when the user has not set one. |
sizeBuckets | object | {"top":"M","bottom":"M","shoes":"US-9-10"} | Bucketed clothing-size hints. top / bottom are XS–XL letter buckets; shoes is a pair-range like US-9-10, EU-40-41. Raw exact sizes never appear in this claim — they ride only in the PDaaS Identity JWT, where the org is already pairwise-identified. |
JWT shape — X-BoxOwl-Identity
Mintable via POST /api/v1/smrt/batch-mint (user JWT auth). The extension calls this on webNavigation.onCommitted for connected-org domains; the response is per-slug so a single call can warm multiple JWTs.
| Claim | Type | Example | Description |
|---|---|---|---|
sub | string | "a8b7-c2…orguid" | Pairwise orgUid — stable per (user, org), different for every connected org. Persist this on your side as the foreign key. |
name | string | "Alice Smith" | Display name (preferredName → firstName + lastName → handle resolution). |
preferredName | string | "Alice" | Addressable short form for Hi Alice. Sourced from Identity.preferredName, falling back to firstName then handle. Use this when greeting the user — don't split name yourself. |
verified | boolean | true | Whether the user's BoxOwl email is verified. |
smrt | object | see SMRT shape above | Same preference profile block as the Tier 1 JWT — same fields, same bucketed values. |
sizes | object | {"shirt":"M","pant":"32","shoe":"9.5","shoeSizeUnit":"us","sizeSystem":"us"} | Exact clothing sizes from UserPreferences. Keys: shirt, pant, dress, shoe, shoeWidth, shoeSizeUnit, sizeSystem, clothingGender. Empty fields are omitted. Use these for size-aware product filtering or pre-selection at checkout. (Per-org pairwise identification means exact sizes here add no cross-org correlation surface.) |
defaultShippingSpeed | string | "express" | User's preferred shipping speed: standard, express, overnight, pickup. Omitted when the user has not set one (entity default is prefer_not_to_say). Use to pre-pick the matching radio/select on checkout. |
org | string | "acme-coffee" | Org slug. Receivers MUST verify this matches their configured org slug. |
tier | string | "pdaas" | "pdaas_true" | Subscription tier. "pdaas_true" indicates a tags claim is also present. |
tags | object | {"customerId":"C123","memberTier":"gold"} | PDaaS True only. Org-stored key→value annotations from OrgUserTag. Use this to silently match the BoxOwl orgUid to your own customer record. |
iss | string | "boxowl.me" | Issuer. |
iat / exp | number | — | Issued / expires (30-min lifetime). |
The handle claim is intentionally absent — the pairwise orgUid is the only identifier.
Mint endpoints
The extension is the only normal caller. Each endpoint requires a user JWT in Authorization: Bearer.
/api/v1/smrt/session
Mints the global SMRT JWT. No body. Returns { "token": "<jwt>" } with sub = fresh UUID, smrt = profile claims, 10-minute TTL.
/api/v1/smrt/batch-mint
Body: { "orgSlugs": ["acme-coffee", "shop-b"] } (max 32). Returns { "jwts": { "acme-coffee": "<jwt>", "shop-b": "<jwt>" } }. Slugs without an active connection are silently omitted (extension treats absence the same as a revoked connection).
Domain-list endpoints
The extension drives its two stores from these endpoints. Both are ETag-conditional — send If-None-Match with the cached ETag; on 304 keep your current payload.
/api/v1/smrt/domains/all
Returns { "domains": ["acme.com", "shop.acme.com", ...] } — the global registered-org domain list. Public (no auth). 6-hour Cache-Control. Drives X-BoxOwl-Smrt injection.
/api/v1/smrt/connections/domains
Returns { "orgs": [{ "orgSlug", "displayName", "domains", "orgUid", "tier" }, ...] } — the calling user's active connections with each org's allowlist. Requires user JWT. 5-minute private cache. Drives X-BoxOwl-Identity upgrade.
User-side: SMRT profile settings
Users configure their SMRT profile in the BoxOwl app. These endpoints power the Android Settings > Privacy > SMRT Profile screen and the equivalent in the web SPA.
/api/v1/me/smrt-profile
Returns the user's current profile. Empty defaults ({}) if no row has been written. User JWT auth.
/api/v1/me/smrt-profile
Body matches the user-editable subset of the smrt claim: ageBand, region, styleRoots, colorPalette, interests. Blank values clear the field. Returns the post-save shape.
The derived signals language, fit, and sizeBuckets are not set here — they come from the user's existing Demographic / UserPreferences records. Edit those via the standard vault endpoints (PUT /api/v1/vault/demographics, PUT /api/v1/vault/preferences) and the next minted SMRT JWT will pick them up.
Server-side: receive + verify
Read the inbound header, verify via JWKS, then route on tier. The two header names are mutually exclusive on the wire (the extension's higher-priority rule strips X-BoxOwl-Smrt when X-BoxOwl-Identity is injected).
app.use(async (req, res, next) => {
const identityToken = req.get('X-BoxOwl-Identity');
if (identityToken) {
try {
const claims = await verifySmrtJwt(identityToken, { expectedOrg: 'acme-coffee' });
req.boxowlUser = {
orgUid: claims.sub,
name: claims.name,
preferredName: claims.preferredName, // "Hi Alice"
verified: claims.verified,
tier: claims.tier, // "pdaas" | "pdaas_true"
tags: claims.tags || null, // PDaaS True only
smrt: claims.smrt, // see SMRT shape
sizes: claims.sizes || null, // exact sizes for filtering / pre-pick
defaultShippingSpeed: claims.defaultShippingSpeed || null,
};
return next();
} catch (e) { /* fall through to SMRT tier */ }
}
const smrtToken = req.get('X-BoxOwl-Smrt');
if (smrtToken) {
try {
const claims = await verifySmrtJwt(smrtToken);
req.boxowlVisitor = {
sessionUid: claims.sub, // rotates every 10 min
smrt: claims.smrt, // anonymous preference signals
// claims.smrt also carries: language, fit, sizeBuckets
// — bucketed signals safe to consume even at SMRT tier.
};
} catch (e) { /* invalid; treat as anonymous */ }
}
next();
});
MV3 first-request constraint
Manifest V3 does not allow async-blocking HTTP requests. The extension installs identity-tier rules on webNavigation.onCommitted, which fires after the initial HTML request is already in flight.
- Tier 1 (
X-BoxOwl-Smrt) — installed at extension boot, present from the very first request. - Tier 2 + 3 (
X-BoxOwl-Identity) — the first navigation HTML request to a connected-org domain after extension startup will NOT carry the header. All subsequent in-page requests (XHR, fetch, form submissions, the next click) carry it.
This is acceptable for org integrations that read identity on API calls rather than the document request itself. If your authentication path absolutely needs identity on the first HTML request, fall back to the standard PDaaS exchange / connection flow.
Privacy model
- No PII at Tier 1 — the SMRT JWT carries no name, handle, email, or stable UID.
subrotates every 10 minutes. - Pairwise orgUid at Tier 2 + 3 — every (user, org) pair gets a unique
sub. Two orgs cannot link the same person. - User-controlled profile — every SMRT field is opt-in; empty values drop out of the JWT.
- Registered-domain-only injection — the extension never injects on unregistered sites. BoxOwl presence isn't revealed.
- BoxOwl doesn't see your traffic — JWTs travel browser → your server. BoxOwl signs and serves JWKS only.