Identity & access — how-to

Goal-oriented, runnable guides for modeling who acts in your tenant and scoping what they can touch. Every snippet uses synthetic data only and is grounded in calls that run against the live platform. For the concepts behind these steps, read explanation.md; for every option and limit, see reference.md.

Snippets use the Node SDK unless the CLI is the more natural surface. The API spec is currently at 0.45.0; every first-party client is on a matching pin. A call available only on a newer client is marked (SDK 0.26+) — relevant only if your own integration pins an older build. The generic identity-entity surface (/v1/entities/{namespace}, /v1/namespaces) shipped in 0.35.0 — it replaced the dedicated org/client endpoints in a clean break, so it needs at least a 0.35 client.

Construct a client

Every guide assumes a constructed client. You build it once with a token and an environment (the API base URL), then call sub-clients grouped by area.

import { VectrosClient } from '@vectros-ai/sdk';

const client = new VectrosClient({
  token: process.env.VECTROS_API_KEY!,      // sk_live_* for production, sk_test_* for dev
  environment: 'https://api.vectros.ai',    // staging: https://api.staging.vectros.ai
});

Sub-clients you'll use here: client.identity.* (users, identity entities, namespaces), client.auth.* (contexts, roles, access profiles, scoped keys, token minting), and client.records.* / client.search.* to exercise a scoped credential.


Create identity entities and a user

Goal: model one customer organization and one person inside it, using the reserved org namespace.

Prerequisites: a root sk_* key (or any credential permitted to manage identities).

Identities are tenant-wide, idempotent by the externalId you supply, and orthogonal to app contexts — you create them once and reference them everywhere. org and client are reserved namespace names, but not exempt from registration: register both once, the same one-time, root-key step as any other namespace (see Namespaces, below), before creating entities in them.

// 0. Register the org and client namespaces (one-time, per account — skip if already done).
await client.identity.registerNamespace({
  body: { namespace: 'org', entityBacked: true, specificityRank: 1000 },
});
await client.identity.registerNamespace({
  body: { namespace: 'client', entityBacked: true, specificityRank: 2000 },
});

// 1. Create the org entity. `externalId` is YOUR id for it; create is idempotent on it
//    within the `org` namespace.
const org = await client.identity.createEntity({
  namespace: 'org',
  body: {
    externalId: 'clinic-001',
    name: 'Northside Family Clinic',
    payload: { region: 'northeast' },   // free-form attributes, round-tripped as-is
  },
});
// org.id is the Vectros-assigned UUID — use it in a `scopes` entry ("org:<org.id>") below.

// 2. Create a HUMAN user. Users carry `email`, not `name`, and are not namespaced.
const user = await client.identity.createUser({
  body: {
    externalId: 'user-jdoe',
    email: 'jdoe@example.com',
    payload: { profile: { role: 'clinician' } },
  },
});

// 3. Create a client entity (an external customer you serve), owned by the org.
const customer = await client.identity.createEntity({
  namespace: 'client',
  body: {
    externalId: 'cust-1042',
    name: 'Jane Doe',
    scopes: [`org:${org.id}`],          // parent edge — must be a DIFFERENT namespace than "client"
  },
});

Expected result: each call returns the created identity with status: 'ACTIVE' and its Vectros id. An entity's scopes on read is its effective scopes — its own reference followed by its parents, e.g. ["client:<customer.id>", "org:<org.id>"]. Re-running any create with the same externalId (within the same namespace, for entities) returns the existing identity unchanged (the other fields on the second call are ignored) — so these calls are safe to repeat. Pass ?upsert=true on an entity create if you want a repeat call to overwrite instead.

To create a machine identity instead of a person, pass type: 'SERVICE' to createUser. A service user is the principal a long-running agent or scoped key acts as.

The same flow on the CLI:

vectros identity create --type org    --external-id clinic-001 --name "Northside Family Clinic"
vectros identity create --type user   --external-id user-jdoe  --email jdoe@example.com
vectros identity create --type client --external-id cust-1042  --name "Jane Doe" --scope org:<orgId>

# Make a SERVICE user (machine principal) instead of a HUMAN one:
vectros identity create --type user --external-id agent-bot --service

--type takes user or any namespace — org and client are reserved names, and any namespace you've registered (next section) works identically once registered. Look identities up by your own id with vectros identity list --type org --external-id clinic-001.


Register a custom namespace and create entities in it

Goal: model an ownership axis of your own — a team — the same way org/client work, so it can be created, listed, looked up, existence-checked, and used to scope credentials.

Prerequisites: a root sk_* key. Registering (or changing) a namespace is a root-key-only operation; reading the registry is open to any credential.

// Register the namespace. entityBacked: true is what turns "team:<anything>" from a
// free-form label into an anchored, existence-checked entity reference. specificityRank
// is required — a unique integer position in your account's specificity order, used to
// break a tie when a caller holds two scope dimensions at once during schema resolution.
// Leave headroom between values (10, 20, 30, …) so a later namespace can slot in between.
await client.identity.registerNamespace({
  body: { namespace: 'team', entityBacked: true, specificityRank: 10 },
});

// Create entities in it exactly like org/client.
const team = await client.identity.createEntity({
  namespace: 'team',
  body: {
    externalId: 'team-eng-platform',
    name: 'Platform Engineering',
    scopes: [`org:${org.id}`],   // a team can itself have a parent org
  },
});

Expected result: GET /v1/namespaces now lists team alongside org and client (once you've registered those too — step 0 above). From this point, a scope:team value anywhere on the platform (record scopes, a token's dataScope, a profile's identityOverrides) must resolve to a real team entity or the write is rejected — the same guarantee org/client get once registered. A namespace cannot be deleted, or flipped back to entityBacked: false, while entities still exist in it.

Registering a namespace is a deliberate, root-key action — it is not something the CLI bootstrap loader or a blueprint's scope gate can do on your behalf (see explanation.md). A blueprint's identities: block can declare entities to create in a namespace you've already registered; it cannot register the namespace itself.

The registration above is tenant-wide — team is shared by every app context, the default. Pass ?contextId= (registerNamespace({ body: {...}, contextId: 'clinic-a' })) to make it context-owned instead: a second context can then register its own, unrelated team namespace with no collision, and every /v1/entities/team call (including this createEntity) must then also carry that contextId. Placement is fixed once set. See reference.md for the full contract, including membership backing and the one-time migration step an account created before this feature shipped owes org/client.


Create an app context and operate inside it

Goal: give one app (or one customer) its own isolated data partition.

Prerequisites: a root sk_* key. Creating or deleting an app context is a root-only operation — a scoped key or token (ssk_* / st_*) cannot create or tear down a context, even one carrying the wildcard * scope.

A context is the hard isolation boundary — all records, documents, folders, and schemas live inside one. The id must match ^[a-z][a-z0-9-]{2,30}$.

const ctx = await client.auth.createAppContext({
  body: {
    contextId: 'clinic-intake',
    name: 'Clinic Intake App',
    description: 'Intake records for the Northside pilot',
  },
});
vectros context create clinic-intake --name "Clinic Intake App"
vectros context list
vectros context get clinic-intake

Once the context exists, data written under a credential scoped to it is partitioned there and is unreachable from any other context — the isolation is enforced by the platform, not by your filters.

Tearing a context down is deliberate and irreversible. The delete is a confirm-gated asynchronous cascade: you must pass a confirm token equal to the contextId, and the context then drains all of its children in the background.

// Without `confirm` this rejects with 400 and touches nothing.
await client.auth.deleteAppContext({ contextId: 'clinic-intake', confirm: 'clinic-intake' });
// The context flips to `purging` immediately, reaching `deleted` once the drain completes.
# The CLI equivalent asks you to re-type the context id before doing anything;
# --force skips the prompt (required in scripts, where there is no terminal).
vectros context destroy clinic-intake

The dashboard's Contexts page offers the same teardown behind a typed confirmation dialog. All three paths run the identical confirm-gated cascade on the server.


Define a role and grant a principal access to a context

Goal: create a reusable, identity-agnostic permission shape and bind a principal to it inside a context.

Prerequisites: an existing context; a principal id (usr_<userId> or key_<keyId>).

A role is defined once and reused; an access profile binds a principal to either a role or inline scopes. Always author explicit resource:op action forms.

# A reusable read-only role in the context.
vectros role create --context clinic-intake \
  --role-id intake-reader --name "Intake Reader" \
  --actions records:r,search:r

# Bind a user principal to that role (the binding is a separate step from issuing a key).
vectros access grant --principal usr_<userId> --context clinic-intake --role intake-reader

# Or bind inline single-clause scopes without a named role:
vectros access grant --principal usr_<userId> --context clinic-intake --actions records:r,search:r

The same through the SDK, using inline scopes (the wire form is snake_case allowed_actions):

await client.auth.createAccessProfile({
  contextId: 'clinic-intake',
  body: {
    principalId: 'usr_<userId>',
    scopes: [{ allowed_actions: ['records:r', 'search:r'] }],
    status: 'active',
  },
});

Expected result: the profile is created (or, on a repeat, the existing one is returned unchanged). A profile carries exactly one of scopes or roleId — set one and the other is cleared. Binding to a roleId that doesn't exist in the context is rejected with 400 naming it, rather than being stored as a profile that could never authenticate. To see every context a principal can reach: vectros access list --principal usr_<userId>.

A profile-create accepts one scope clause per request today. For multi-clause shapes, define a multi-clause role (in a blueprint) and reference it by roleId.


Invite a sub-user by email

Goal: bring a person into a context without creating their user record and access profile yourself — let them complete onboarding.

Prerequisites: an existing context; either an existing roleId in it, or the inline scopes you'd give a standalone access profile.

const invite = await client.auth.createInvite({
  email: 'newhire@example.com',
  contextId: 'clinic-intake',
  accessProfile: { roleId: 'intake-reader' },   // or: { scopes: [{ allowed_actions: [...] }] }
  acceptUrl: 'https://my-app.com/accept',       // required unless sendEmail is false
});
// → { userId, inviteExpiresAt, emailSent: true }

To deliver the invitation yourself instead of through Vectros's email, set sendEmail: false — the response then carries the raw token and a ready-made accept link:

const invite = await client.auth.createInvite({
  email: 'newhire@example.com',
  contextId: 'clinic-intake',
  accessProfile: { roleId: 'intake-reader' },
  sendEmail: false,
});
// → { userId, inviteExpiresAt, emailSent: false, inviteToken, acceptLink }

Expected result: a PENDING user exists with its access profile already bound — unusable until accepted. Re-inviting the same email+contextId rotates the token and resends rather than duplicating — unless your credential lacks users:r+users:u, in which case the collision itself returns a 409 instead of silently resending. A 400 names the reason if roleId doesn't exist in the context, acceptUrl isn't an https URL, or ttlSeconds (default 7 days, 1 hour–30 days) is out of range. A 403 means the invite's resolved permissions exceed your own credential's scope. A 409 otherwise means the email already belongs to an active/suspended member of THIS SAME context, already has a PENDING invitation here without your credential holding users:r+users:u to resend it, or already resolves to a SUSPENDED member of a different context in this same tenant (a deliberate lockout, never silently attached) — with a scoped credential (ssk_*/st_*) these causes all return the identical, undifferentiated 409; only a root API key still gets the structured email_already_associated body for the cross-context-SUSPENDED cause. Treat any 409 from this endpoint as "address unavailable within this tenant" unless you're calling with a root key. An email that already resolves to an ACTIVE or PENDING member of a different context in this same tenant grants/attaches access to this context instead of 409ing — provided your credential holds the scope that grant/attach itself needs (users:r for ACTIVE, users:r+users:u for PENDING; see identity-access/reference.md for the exact dispatch) — without it, this 409s too, same undifferentiated shape as the causes above. An email that already has an identity in your OTHER tenant (test vs. live) is not a 409 — it creates a second, independent membership there.

The invitee accepts it themselves — typically from your acceptUrl landing page, after they authenticate with your own login system:

await client.identity.updateUser({
  id: invite.userId,
  body: {
    status: 'ACTIVE',
    inviteToken: '<token from the accept link>',
    externalSubject: '<their id in your auth system>',
    emailVerifiedAttestation: true,   // your attestation that YOU verified their email
  },
});
// The user flips PENDING → ACTIVE. `email` is editable again from this point.

This call does not, by itself, let the invitee sign in to anything. Calling updateUser yourself (server-to-server, with your own auth system's identifier) activates the user and records externalSubject, but it grants no sign-in — not to the Vectros dashboards, and not via token exchange. If the invitee needs to sign in themselves through token exchange, have them present inviteToken there directly with their own credential instead of calling updateUser on their behalf — Vectros verifies it and completes the same activation. See Inviting a sub-user by email for the full picture.

While the invitation is outstanding: email cannot be changed by editing the user directly — delete the pending user and re-invite to redirect it. Resending needs more than creating did, and takes the same request shape as creating — accessProfile and (unless sendEmail: false) acceptUrl are still required by the type, even though a resend cannot change the invitee's already-bound permissions:

await client.auth.resendInvite({
  email: 'newhire@example.com',
  contextId: 'clinic-intake',
  accessProfile: { roleId: 'intake-reader' },   // required by the type; ignored — permissions don't change
  acceptUrl: 'https://my-app.com/accept',       // required unless sendEmail is false
});
// Rotates the token, extends its expiry, and invalidates the previous accept link.
// Requires users:r and users:u in addition to the users:c that creating an invite needs.

Mint a least-privilege scoped key (ssk_*)

Goal: issue a permanent, identity-bearing credential that can never exceed a profile — the right shape for an agent or a bot.

Prerequisites: a principal that already has an access profile in the target context (the previous guide). The key inherits that profile and cannot exceed it. Minting also requires the caller to hold the identity the bound profile carries — issuing a key on behalf of a principal whose identity you don't hold is rejected with 403 (a root key is exempt).

# Issue an ssk_* for the bound principal. The raw secret is shown ONCE.
vectros key issue --principal usr_<userId> --context clinic-intake --name agent-key --format env
# → VECTROS_API_KEY=ssk_live_...

--format env prints VECTROS_API_KEY=ssk_live_… so you can drop it straight into an agent's environment. Other formats: human (a labeled block with the secret), raw (just the secret), json.

Expected result: the command prints the new key id, its binding, and the raw ssk_* once. There is no way to re-read a key's secret. If you lose it — or want to rotate — revoke and re-issue:

vectros key rotate --principal usr_<userId> --context clinic-intake --name agent-key --format env
vectros key list --context clinic-intake
vectros key revoke <keyId>      # stops working within ~5 minutes (authorizer cache)

The key authenticates as its bound principal: every call it makes is attributed to that identity, and it is confined to that principal's profile.


Mint a short-lived token (st_*) and the front-end-safe pattern

Goal: hand a browser a narrowed, short-lived credential without ever exposing a root key.

Prerequisites: a backend holding an sk_* (or ssk_*).

Mint an st_* scoped to exactly one user's data. The token carries its scope internally and cannot widen it.

// On your backend, in your login handler — NEVER in browser code.
const minted = await client.auth.mintToken({
  scope: {
    allowedActions: ['records:r', 'search:r'],
    dataScope: { userId: ['<that user\'s id>'] },   // narrow to one user's data
  },
  // expiresInSeconds defaults to 3600 (1h), which is also the maximum.
});
// → { token: "st_...", expiresAt: <unix-seconds> }

Hand minted.token to the browser. The browser constructs its own client with that token and calls Vectros directly:

// In the browser, with the st_* received from your backend:
const browserClient = new VectrosClient({
  token: minted.token,
  environment: 'https://api.vectros.ai',
});
const myRecords = await browserClient.records.listRecords({ type: 'intake_form' });

Expected result: the root key never leaves your backend; the browser's token is confined to one user for at most its lifetime; if the browser is compromised, blast radius is one user for one token-lifetime. The token cannot be revoked in flight — keep the lifetime short.

The mint endpoint accepts one scope clause per request. For a compound shape, bind the principal to a multi-clause role and mint via the scoped-key path instead.


Register a trusted external IdP issuer and exchange its token

Goal: let your own identity provider mint Vectros-scoped tokens for your users directly — register the IdP once, then trade its JWTs for st_* tokens with no backend of yours in the request path.

Prerequisites: a root sk_* key (or the CLI bootstrap's provisioning capability) to register; an existing app context; an IdP that issues JWTs and publishes a JWKS endpoint.

// Register the issuer once. issuerId is your own slug for it; issuer/jwksUri/audience come
// straight from your IdP's own configuration.
const issuer = await client.auth.registerIssuer({
  issuerId: 'auth0-prod',
  issuer: 'https://your-tenant.us.auth0.com/',
  jwksUri: 'https://your-tenant.us.auth0.com/.well-known/jwks.json',
  audience: 'https://api.your-app.example.com',
  contextId: 'clinic-intake',
});
// → { issuerId: 'auth0-prod', created: true, status: 'pending_verification',
//     verificationClaim: 'https://vectros.ai/claims/issuer_challenge',
//     verificationNonce: 'hB1v0x…', verificationExpiresAt: '2026-09-27T09:30:00Z', ... }

A new registration is pending_verification, and accepts no tokens until you prove you control the IdP (0.45.0+). Anyone holding a root key can name any issuer URL and audience, so registering alone proves nothing; verification is what activates it. (A registration scoped with restrictedToDomain to a domain you have already verified is active at once: it routes only tokens whose hd claim is that domain, so it can only ever serve your own domain's users — it proves you control the domain, not the issuer.)

  1. In your IdP, add an admin-controlled rule that stamps the returned verificationNonce into the verificationClaim claim of the tokens it issues. Never map the claim from an attribute your end users can edit. For Auth0, a post-login Action, with the nonce stored as an Action secret named VECTROS_ISSUER_NONCE:

    exports.onExecutePostLogin = async (event, api) => {
      const claim = 'https://vectros.ai/claims/issuer_challenge';
      api.idToken.setCustomClaim(claim, event.secrets.VECTROS_ISSUER_NONCE);
      api.accessToken.setCustomClaim(claim, event.secrets.VECTROS_ISSUER_NONCE);
    };
    

    Deploy the Action, then add it to the post-login flow (Actions → Triggers → post-login, then Apply); a deployed Action that is not in the flow never runs. Actions apply to the whole flow, so to limit this one to a single application, return early when event.client.client_id is not that application's. (Okta inline hooks, Entra claims-mapping policies and Keycloak protocol mappers work the same way.)

  2. Sign in once through that IdP, after the rule is in place (a token from an earlier login lacks the claim), and send the resulting JWT to the verify call. Either token type works, ID token or access token, provided its aud is the registration's audience: an Auth0 ID token's audience is the application's client id, an access token's is the API identifier.

    await client.auth.verifyIssuer({ issuerId: 'auth0-prod', token: aJwtFromThatLogin });
    // → { issuerId: 'auth0-prod', status: 'active', ... }
    

    The token is checked and discarded, never stored. It must verify against the key set your issuer publishes in its own OpenID Connect discovery document (<issuer>/.well-known/openid-configuration), and the jwksUri you registered must be exactly that document's jwks_uri. An issuer that publishes no discovery document, or an IdP with no way to add a claim (for example, raw "Sign in with Google" for personal accounts), cannot be verified this way.

The challenge expires after 7 days; an expired registration can't be verified — delete it and register again (the new registration has a new verificationNonce, so update the rule's secret to match). The rule can be removed once the registration is active.

If verify is refused, the message names the check that failed. The common 400s:

  • "does not carry the … claim" or "claim must be a string": the rule is not deployed and attached, its secret is unset, or the token came from a login made before it was; or the rule sets something other than a string. Sign in again.
  • "claim does not match this registration's verificationNonce": the claim carries a wrong or stale value, typically the nonce of an earlier registration.
  • "not issued for this registration's issuer" or "audience does not include": the token is for a different issuer or audience than the registration. Send the token whose aud is the registration's audience: with Auth0 that is the access token when the audience is your API identifier, and the ID token when it is the application's client id.
  • "'token' is required" or "'token' must be a JWT": the value is missing or is not a signed, compact JWT. An opaque access token can't be used; configure the IdP to issue a JWT for the audience.
  • "could not be verified against the issuer's key set": the signature, expiry, issuer or audience check failed, or the signing key could not be found or fetched. Sign in again for a fresh token.
  • "discovery document could not be fetched", "must use the https:// scheme", "names a different issuer" or "jwksUri does not match the jwks_uri this issuer publishes": the issuer's discovery document must be reachable over https, name exactly the registered issuer, and publish exactly the registered jwksUri. Delete the registration and register it again with the published values.
  • "verification challenge has expired": delete the registration and register it again.
  • "already registered by another registration": another registration holds the (issuer, audience) pair. Give this one its own audience.
  • "already has an active issuer registered" or "does not name an existing app context": an app context may have exactly one active IdP, and it must exist. Deregister the existing one, or create the context.
  • "not awaiting verification": the registration is no longer pending_verification. It is active or suspended, it was registered before verification existed, or its challenge changed or expired while your request was running. Read it back (getIssuer) to see which.

Other statuses: 403 means the caller lacks a root key or the provisioning capability; 404 means no registration with that issuerId is visible to you; 409 means the registration was changed or removed while it was being verified and nothing was activated. A timeout or 5xx says nothing about whether the platform finished, so read the registration back before retrying: a retry of a verify that did succeed answers "not awaiting verification".

Until verify succeeds, a token exchange against the registration answers 404, the same as for an issuer that was never registered.

Alternative: register scoped to a domain you own (0.44.0+). If everyone who will sign in through this IdP is on a company domain you have verified, and the IdP puts that domain in an hd claim (Google does for Workspace accounts; other IdPs need a claims-mapping rule that emits a claim literally named hd), register with restrictedToDomain instead. There is no challenge step: the registration is active straight away, and it only ever accepts tokens whose hd claim equals that domain.

const scoped = await client.auth.registerIssuer({
  issuerId: 'workspace-prod',
  issuer: 'https://accounts.google.com',
  jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
  audience: 'your-client-id.apps.googleusercontent.com',
  contextId: 'clinic-intake',
  restrictedToDomain: 'acmecorp.com',
});
// → { issuerId: 'workspace-prod', created: true, status: 'active', restrictedToDomain: 'acmecorp.com', ... }

The domain has to be verified for your account first. Your account owner does that in the developer portal under Domains (the same place webhook domains are verified) — the API key that makes this call can't do it. If it hasn't been, the register call is refused with 400:

{ "message": "restrictedToDomain 'acmecorp.com' is not a VERIFIED domain for your account. Your account owner must verify ownership first via the developer portal ..." }

Verify the domain, then repeat the call. A second registration for the same (issuer, audience) and the same domain is also refused with 400 — give each environment or context that shares an IdP account its own audience. A token without an hd claim is never accepted by a domain-scoped registration. To change the domain later use updateIssuer — clearing it (restrictedToDomain: "") is refused unless the registration holds the unrestricted (issuer, audience) pair, which one created scoped to a domain never did (see the reference).

Re-registering the same issuerId is idempotent — it returns the existing registration unchanged (created: false), not an error. Register through the SDK/API directly, with the CLI (vectros issuers register, then vectros issuers verify <issuerId> --context <ctx> --idp-token-file <path>, @vectros-ai/cli 0.23.0 or later), or declare issuers: in a blueprint and let vectros blueprint apply (or vectros bootstrap) provision it; either ends its output with the challenge and the verify command when a registration is pending.

Exchange a token. This is the one call in this guide that carries no Vectros credential at all — the subject_token from your own IdP is the credential:

const resp = await fetch('https://api.vectros.ai/v1/auth/token/exchange', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
    subject_token: idpIssuedJwt,           // the JWT your IdP just handed this user
    subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
  }),
});
if (!resp.ok) {
  const { error, error_description } = await resp.json();
  throw new Error(`token exchange failed: ${resp.status} ${error} ${error_description}`);
}
const { access_token, expires_in } = await resp.json();
// access_token is an st_* token — use it exactly like any other.
const scoped = new VectrosClient({ token: access_token, environment: 'https://api.vectros.ai' });

First-time subjects need a binding path — pass exactly one:

// A subject with a pending invite (see "Invite a sub-user by email" above):
body: JSON.stringify({ /* ...as above... */, invite_token: 'inv_...' })

// A subject with no invite, against an issuer that declared selfSignupPolicies:
body: JSON.stringify({ /* ...as above... */, signup_type: 'practitioner' })

Expected result: a subject with an existing Vectros identity, a valid invite_token, or a matching signup_type gets back { access_token: "st_...", token_type: "Bearer", expires_in: 3600 }. Every other case — bad signature, expired token, unregistered issuer, no binding path applies — collapses to the same generic OAuth error shape ({"error": ..., "error_description": ...}), deliberately uniform so a caller can't distinguish why from the response alone. If you're building a browser SPA against Auth0, @vectros-ai/react's Auth0AuthProvider already wraps this exact call (exchangeToken/mintPartnerApiToken) — wire its config to your Auth0 application and this endpoint instead of writing the fetch yourself.

Clean up. Deregistering is refused if the issuer has ever bound a user — deactivate affected users first, or replace the issuer, rather than silently orphaning their access:

await client.auth.deleteIssuer({ issuerId: 'auth0-prod' });
// 409 if any user was ever created or matched via this issuer.

See reference.md for the full field grammar, every error code, the one-active-issuer-per-context rule, and the context_id disambiguator for an issuer registered against more than one context.


Restrict a credential to one customer's data with dataScope

Goal: confine reads and searches to a single org's (or client's) records, including the strict-scope rules.

Prerequisites: records tagged with a scopes entry (e.g. org:<id>) you can scope to.

dataScope is enforced as a server-side filter below any filter the caller supplies. It is strict: a scoped call must include the matching filter explicitly. userId is its own fixed dimension; every other dimension is a namespace, keyed scope:<namespace> in dataScope.

// Mint a token confined to one org. `orgId` is a Vectros org entity id — see
// "Create identity entities and a user" above for how to create one.
const orgId = '<org id>';
const minted = await client.auth.mintToken({
  scope: {
    allowedActions: ['records:r', 'search:r'],
    dataScope: { 'scope:org': [orgId] },
  },
});
const scoped = new VectrosClient({ token: minted.token, environment: 'https://api.vectros.ai' });

// The call MUST carry the matching scope filter — strict scope requires it explicitly.
const list = await scoped.records.listRecords({ type: 'intake_form', scope: `org:${orgId}` });
// Only org-tagged records come back; tenant-only (unowned) records are filtered out.

const hits = await scoped.search.content({
  query: 'follow-up appointment',
  mode: 'TEXT',
  limit: 100,
  scope: `org:${orgId}`,
});

Expected result: the credential sees only rows owned by that org. Omitting the scope filter on the call is rejected with a message naming the required dimension. To also reach tenant-level (owner-less) records under the same credential, opt in explicitly with a null in the value list at mint time: dataScope: { 'scope:org': ['<org id>', null] }. The null is never implied.

The same pattern works for any registered namespace (scope:team, scope:client, …) — substitute the dimension on both the mint and the call. ?scope= takes one entry per query; pair it with ?userId= when you need both dimensions on the same call.

A dimension your dataScope never names isn't just unnarrowed on reads — it's also a dimension you have no way to write into. If a role instead of a single mint needs to cover a dimension for every principal it's bound to without enumerating values, its clause can use the ${{ any }}, ${{ under.self.* }}, or "*" default-dimension placeholder forms — see reference.md.


Verify what a credential actually is

Goal: confirm the principal, tenant, and scope a credential resolves to.

A lightweight identity-binding check returns the authenticated principal's shape:

const who = await client.auth.ping();
// For an sk_* root key: principalType 'root_key' (no action list — it's wildcard).
// For an ssk_* scoped key: principalType 'scoped_key' + its allowedActions.
// For an st_* token: principalType 'token' + tokenExpiresAt.
// dataScope.scopes reports the resolved scope:<namespace> bindings, if any.

This is the fastest way to confirm a freshly minted scoped key carries the actions you expect before you ship it. An invalid credential is denied at the edge with a 403.


Where to go next

  • reference.md — every identity/access method, the full scope grammar, the data-plane allowlist, error codes, and an honest "notes & limits."
  • explanation.md — the why behind contexts, namespaces, scopes, profiles, and the three credential types.
  • The blueprint walkthroughs (getting-started, clinical-intake, agentic-sdlc, second-brain) — end-to-end builds that provision a context, principal, profile, and scoped key in one command.
  • The generated API reference (rendered from the OpenAPI specification) — canonical request and response shapes.