ENFORCER

ENFORCER

VERSION v3
BASE /api/v1/enforcer
STATUS IN PRODUCTION
Enforcer v3, Developer onboarding

The security front desk for your app.

Enforcer handles who someone is and what they are allowed to do, so you do not stitch together five vendors to ship an app that touches money or personal data. This guide takes you from zero to a working tenant.

Trusted in live fintech and identity apps
00Watch
00

Watch

See how it works

A one minute animation of how Enforcer works, plain enough for anyone to follow. Captions are on, so it plays with the sound off too.

Enforcer, how it works1 min

No sound needed. The captions tell the whole story.

01Overview
01

Overview

What Enforcer is

Think of Enforcer as the front desk for your application. When someone walks in, the front desk checks who they are, login plus optional identity checks, and then decides what they are allowed to do once inside. Enforcer is that desk, delivered as a ready made backend you call over HTTP.

Most teams building anything with payments or personal data end up assembling separate tools for login, identity verification, permissions, and record level access. Each one has its own model, and keeping them in agreement is constant work. Enforcer replaces that pile with one policy engine. The same engine that decides "can this person open this record" also decides "which records can they even see", and a test proves the two never disagree.

02Model
02

Mental model

Core concepts

Five ideas cover almost everything you will do. Read these once and the rest of the guide reads quickly.

02.1

Tenant

A tenant is your app inside Enforcer. It owns its users, groups, roles, theme, and keys. One Enforcer deployment can host many tenants, fully isolated from each other.

02.2

Person vs user

Identity is two layers. A cross tenant person is the human. A per tenant user is their login in one app. A verified person carries across apps and tenant switches without re verifying.

02.3

Groups

Groups are how a capability gets unlocked. A user is added to a group, and that membership is what opens a route or action. Pass an identity check, join the verified group, and a previously forbidden action becomes allowed.

02.4

Auth methods

Several ways to prove who someone is: passkey, email OTP, phone OTP over SMS, Google, SIWE for wallets, and Privy. You pick which ones your tenant accepts.

The moat, in one line

One policy brain makes every permission decision. Deciding what a person may do and deciding what a person may see come from the same engine, and a test proves they never disagree. A stack stitched from separate vendors cannot give you that guarantee.

Modules

The base, identity plus permissions, is the whole front desk above. Heavier capabilities are paid modules you switch on per tenant:

  • Banking and KYC, identity verification and money movement, runs as an async module and reports back by webhook.
  • Messaging, sending email and SMS to your users.
  • Storage, files tied to a tenant and account.
  • Wallet, on chain wallet capability.

How KYC connects to access: KYC is a module, not a core route. When a user passes verification, the result of that step is that they get added to your verified group. From then on the policy engine lets them through the gated routes. That "passing KYC becomes a permission" step is the thing a glued together stack does not give you.

03Quickstart
03

Get going

Quickstart

The live sandbox is open, no setup needed. Base URL is https://api.instruxi.dev/api/v1/enforcer and the sandbox tenant code is 2OPX-BYIE-GDUH. Watch the 30 second walkthrough, run it yourself right here, or do it by hand below.

Watch, using the sandbox30 sec
Try it live

Walk into the front desk

A real sign in, a real identity check, and a real access gate, shown as a little world. Sign in, prove who you are with a sandbox ID check, and watch the locked room open.

This is the front desk for your app. Sign in to walk through.

Real sign-ins here are logged so the Enforcer team can follow up. No marketing spam.

    Prefer the terminal? The same calls by hand are right below.

    The shared sandbox is offline right now. The tenant code below is not resolving, so the OTP call returns tenant_not_found and the live panel above will not complete. Use the by hand path with your own tenant until this is restored. Everything else on this page is current.

    Sign in to the sandbox

    Real email, real token. Ask for a one time code, exchange it for a session token, then call the API with that token.

    1, request a code
    curl -X POST "https://api.instruxi.dev/api/v1/enforcer/auth/otp/request" \
      -H "Content-Type: application/json" \
      -d '{ "email": "you@example.com", "tenant_code": "2OPX-BYIE-GDUH" }'
    
    # a 6 digit OTP is emailed to you, valid for 10 minutes

    Response

    { "success": true, "message": "code sent" }
    2, log in
    curl -X POST "https://api.instruxi.dev/api/v1/enforcer/auth/login" \
      -H "Content-Type: application/json" \
      -d '{ "provider": "email_otp", "email": "you@example.com", "otp": "123456", "tenant_code": "2OPX-BYIE-GDUH" }'
    
    # returns token (1 hour) plus a refresh_token

    Response

    {
      "success": true,
      "data": {
        "token": "eyJhbGciOi...",
        "refresh_token": "eyJhbGciOi...",
        "account": { "id": "acc_01J...", "email": "you@example.com", "tenant_id": "ten_01J...", "role": "user" }
      }
    }
    3, you now have a session
    # the login response gives you data.token and data.account.id
    # send the token on every call
    curl "https://api.instruxi.dev/api/v1/enforcer/auth/me" \
      -H "Authorization: Bearer <token>"

    See the gate flip

    Groups are how a capability gets unlocked. Create one, add yourself, and you are through the gate. In the sandbox you do it by hand to watch it work. In production a KYC pass or your own policy adds people to the group automatically.

    1, create a group (the gate)
    # the sandbox is shared, so pick a name and slug that are your own
    curl -X POST "https://api.instruxi.dev/api/v1/enforcer/groups" \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{ "name": "Verified yourname", "slug": "verified-yourname", "description": "passed the check", "is_public": false }'
    
    # response: data.id is your new group id
    2, add yourself to it
    curl -X POST "https://api.instruxi.dev/api/v1/enforcer/groups/<group_id>/members" \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{ "account_id": "<your account id from login>" }'

    Response

    { "success": true }
    3, confirm you are through
    curl "https://api.instruxi.dev/api/v1/enforcer/groups/<group_id>/members" \
      -H "Authorization: Bearer <token>"
    
    # your account is in the member list. that membership is the gate.

    That is the whole idea. Group membership flips a capability from blocked to allowed with no code change on the route. Swap the manual add for a KYC pass and you have the production flow.

    refresh the token when it expires
    curl -X POST "https://api.instruxi.dev/api/v1/enforcer/auth/refresh" \
      -H "Content-Type: application/json" \
      -d '{ "refresh_token": "<refresh_token>" }'

    Response

    { "success": true, "data": { "token": "eyJhbGciOi...", "refresh_token": "eyJhbGciOi..." } }

    Two ways to build on it:

    The fastest path

    Inside an AI coding tool such as Cursor, Claude Code, Lovable, or Windsurf, you describe the app you want and the Enforcer connector provisions a real tenant and scaffolds a working app wired to it.

    prompt
    build me a banking app with login and KYC

    The connector creates a tenant, mints the keys, wires up the auth methods you asked for, and leaves you with code that already talks to Enforcer. From there you keep building in plain language or drop down to the API below.

    The connector is the front door for new builds. When you need exact control, the by hand path uses the same endpoints under the hood.

    The sandbox above signs you into a shared tenant. To stand up your own tenant and gate an action, here is the full sequence. Self serve tenant creation is gated per account, so if the first call returns missing_token or a 403 that is the gate, not a mistake on your part. Your base URL is https://api.instruxi.dev (live now). Replace the placeholder values as you go. Values like $TENANT_CODE, $GROUP_ID and $ACCOUNT_ID come from the responses of earlier steps, noted inline below. Single quoted JSON does not expand shell variables, so substitute the real values by hand when you run these.

    1. Create your tenant, no human in the loop

      Self serve tenant creation makes the caller the tenant admin and hands back the new tenant. The response carries the tenant (note its id and code) and your tenant admin session token. Use that token as $ADMIN_JWT and the tenant code as $TENANT_CODE below. The exact field names are in the worked reference, generated from the live spec.

      request
      curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/tenants/self-serve" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "Acme Pay",
          "admin_email": "you@acme.com"
        }'
      
      # response: the new tenant (id, code) plus your tenant-admin session token
    2. Mint a server API key

      Use the tenant admin token from the step above as the bearer. The plaintext key is shown once, store it now.

      request
      curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/api-keys" \
        -H "Authorization: Bearer $ADMIN_JWT" \
        -H "Content-Type: application/json" \
        -d '{ "name": "server-key" }'

      Response

      {
        "success": true,
        "data": { "key": "ek_live_9f3c...a71b", "api_key": { "id": "key_01J...", "name": "server", "key_prefix": "ek_live_9f3c" } }
      }
    3. Register a user and request an OTP

      Register the account, then request an email OTP scoped to your tenant. In development the OTP is returned to you so you can script the flow.

      request
      # register
      curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/auth/register" \
        -H "Content-Type: application/json" \
        -d '{ "provider": "email_otp", "email": "user@acme.com" }'
      
      # request the OTP
      curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/auth/otp/request" \
        -H "Content-Type: application/json" \
        -d '{ "email": "user@acme.com", "tenant_code": "$TENANT_CODE" }'
    4. Log in to get a user JWT

      Exchange the OTP for a user bearer token plus a refresh token.

      request
      curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/auth/login" \
        -H "Content-Type: application/json" \
        -d '{
          "provider": "email_otp",
          "email": "user@acme.com",
          "otp": "123456",
          "tenant_code": "$TENANT_CODE"
        }'
      
      # response includes: token, refresh_token, expires_at, account
    5. Add the user to the gating group

      The group is the capability gate. Membership is what unlocks the route the group protects. List your tenant's groups to find the one you want to gate on (its id is $GROUP_ID), then add the account ($ACCOUNT_ID from the login response above). The API key goes in the X-API-Key header. Groups are provisioned with your tenant, confirm with your Enforcer contact how your gating groups are seeded.

      request
      # list this tenant's groups, take the id of the one you gate on
      curl "$ENFORCER_BASE_URL/api/v1/enforcer/groups" \
        -H "X-API-Key: $API_KEY"
      
      # add the account to it -> capability now unlocked
      curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/groups/$GROUP_ID/members" \
        -H "X-API-Key: $API_KEY" \
        -H "Content-Type: application/json" \
        -d '{ "account_id": "$ACCOUNT_ID" }'

    That last step is the whole point. The user existed and could log in, but the gated action was forbidden. Adding them to the group flips that to allowed, with no code change on the route. Swap the manual add for the KYC module reporting a pass and you have the production verification flow.

    04AI agents
    04

    For AI agents

    Authorization for AI agents

    An agent acting for a user is not the same as the user. It needs its own identity, only the permissions it actually needs, and the ability to be switched off without switching off the person it works for.

    Most authorization products are a decision layer: you hand them a subject id and a resource, and they answer true or false. Enforcer resolves the caller first and puts that object into the policy, so a rule is written over a real accessor with its roles, tenant and group memberships already attached. You are not maintaining a second copy of who everyone is.

    Give the agent its own identity

    Create the agent as an account in your tenant and put it in a group. The group is what rules read, so changing what every agent may do is one membership change rather than an edit to each rule.

    Gate the action, not the prompt

    A rule in a system prompt is advice. The model can talk itself past it, and a prompt injection can talk it past it faster. Put the check where the action actually happens, in the code that runs the tool, so the decision is made outside anything the model can reach.

    curl -sS -X POST "$BASE/authz/check" \
      -H "Authorization: Bearer $AGENT_JWT" \
      -H "Content-Type: application/json" \
      -d '{ "action": "issue", "resource": { "type": "payment", "id": "pay_123" }, "contexts": { "amount": 820 } }'
    
    # -> { "allow": false, "policy_id": "...", "reason": "..." }

    Act on allow. Show or log reason; it is the difference between an agent that says "that failed" and one that says why. The caller's identity comes from the token, so you never send it and the agent cannot claim to be someone else.

    Deny is the default

    A missing rule fails closed. An action nobody has written a rule for is refused, not allowed, so adding a new tool to your agent does not silently widen what it can do.

    Start switched off

    Create every rule with active: false, watch the decisions arrive on GET /audit-events, then switch it on with PUT /policies/{id}. A rule that starts blocking the moment it exists is a rule nobody dares create.

    05MCP
    05

    For AI agents

    MCP server authorization

    An MCP server hands an agent a set of tools. That makes every tool call an authorization question: this caller, this tool, these arguments, right now. Enforcer answers it.

    Connect Enforcer over MCP

    Enforcer is itself an MCP server, listed in the official registry as dev.instruxi.enforcer/v3.

    claude mcp add --transport http enforcer https://api.instruxi.dev/mcp \
      --header "X-API-Key: $ENFORCER_API_KEY"

    Any client that speaks Streamable HTTP works with the same URL.

    Check a tool call before you run it

    In your own MCP server, treat the tool name as the action and its target as the resource, and make the check before the handler does any work.

    // in your tool handler, before doing the work
    const d = await fetch(`${BASE}/authz/check`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${callerToken}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ action: toolName, resource: { type: 'document', id: args.id }, contexts: args })
    }).then(r => r.json());
    
    if (!d.allow) return { isError: true, content: [{ type: 'text', text: d.reason }] };

    Returning reason to the agent matters: it can then tell the user what would make the answer yes, instead of retrying the same refused call.

    Arguments are part of the question

    Allowing a tool is not the same as allowing every call to it. Pass the arguments in contexts so a rule can care about the amount, the recipient, or the destination, not just the tool name.

    06Recipes
    06

    How to

    Recipes

    Short, copyable answers to the things you will do in the first week.

    Gate a page on KYC

    This is the core mechanic. The route is protected by group membership. A new user is not in the group, so the action is forbidden. When the KYC module reports a pass, you add the account to the verified group and the same route now allows them.

    request
    # on KYC pass, add the account to the gating group
    curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/groups/$GROUP_ID/members" \
      -H "X-API-Key: $API_KEY" \
      -d '{ "account_id": "$ACCOUNT_ID" }'
    
    # to revoke access later, remove them
    curl -X DELETE "$ENFORCER_BASE_URL/api/v1/enforcer/groups/$GROUP_ID/members/$ACCOUNT_ID" \
      -H "X-API-Key: $API_KEY"

    The route itself does not change. You are only changing who is in the group.

    Add a login method

    Enforcer supports passkey, email OTP, phone OTP over SMS, Google, SIWE, and Privy. The provider field on login and register selects the method. Email OTP login looks like this:

    request
    curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/auth/login" \
      -d '{ "provider": "email_otp", "email": "user@acme.com", "otp": "123456", "tenant_code": "$TENANT_CODE" }'

    For Google or Privy you pass the provider token in the token field instead of an OTP. Set which method your tenant accepts with the dedicated auth-provider call:

    request
    curl -X PUT "$ENFORCER_BASE_URL/api/v1/enforcer/tenants/$TENANT_ID/auth-provider" \
      -H "Authorization: Bearer $ADMIN_JWT" \
      -H "Content-Type: application/json" \
      -d '{ "provider": "email_otp" }'

    Confirm the exact passkey and SIWE begin and finish routes against the live spec for your build.

    Invite teammates

    Create an invite for your tenant, then the invitee accepts it to join.

    request
    # create an invite (tenant admin)
    curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/tenants/$TENANT_ID/invites" \
      -H "Authorization: Bearer $ADMIN_JWT" \
      -d '{ "email": "teammate@acme.com" }'
    
    # invitee accepts
    curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/auth/invites/accept" \
      -H "Authorization: Bearer $USER_JWT" \
      -d '{ "code": "$INVITE_CODE" }'

    To set what a teammate can do, assign them a role with PUT /accounts/{id}/role. List the available roles with GET /roles.

    Theme your app

    Set the tenant name, logo, and theme with a single patch.

    request
    curl -X PATCH "$ENFORCER_BASE_URL/api/v1/enforcer/tenants/$TENANT_ID" \
      -H "Authorization: Bearer $ADMIN_JWT" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Acme Pay",
        "logo_url": "https://acme.com/logo.png",
        "theme": { "primary": "#6ce992" }
      }'
    Switch a user between tenants

    Because the person sits above per tenant users, one human can hold memberships in several tenants. List them, then switch. A switch returns a token scoped to the new tenant.

    request
    # list this person's tenant memberships
    curl "$ENFORCER_BASE_URL/api/v1/enforcer/auth/tenants" \
      -H "Authorization: Bearer $USER_JWT"
    
    # switch into another tenant
    curl -X POST "$ENFORCER_BASE_URL/api/v1/enforcer/auth/tenant/switch" \
      -H "Authorization: Bearer $USER_JWT" \
      -d '{ "tenant_id": "$OTHER_TENANT_ID" }'

    To join a tenant the person is not yet a member of, use POST /auth/tenant/join with a tenant_code.

    07Credentials
    07

    Credentials

    Auth model

    Three kinds of credential reach the API. Pick by who is calling.

    CredentialWhere it comes fromUse it for
    User JWT Returned by POST /auth/login as token User scoped calls. Anything done as the logged in person, reading their own data, switching tenants, accepting an invite.
    API key Returned by POST /api-keys, plaintext shown once Server and admin calls from your backend. Creating groups, adding members, managing accounts. Keep it server side, never ship it to a browser.
    Privy access token From your Privy integration Pass it directly as the bearer. Enforcer accepts a Privy access token in place of a user JWT.

    All three travel in the same header: Authorization: Bearer <value>. Refresh an expiring user JWT with POST /auth/refresh using its refresh_token.

    08Plans
    08

    Plans

    Modules and pricing

    Pricing works like a phone plan. There is a base everyone pays, then add on modules you switch on per tenant and pay for by usage. You can start on the base for free.

    06.1

    Base, free to start

    Identity and permissions, the whole front desk. Tenants, persons and users, groups, roles, all the auth methods, and the one policy engine. This is everything in the concepts and quickstart above.

    06.2

    Add on modules, metered

    • Banking and KYC, verification and money movement
    • Messaging, email and SMS
    • Storage, tenant scoped files
    • Wallet, on chain wallet capability

    You only pay for a module once you turn it on, and the charge follows usage. Modules are switched on per tenant. Current rates are not published here yet; ask for them when you scope your first module.

    09Reference
    09

    Reference

    API reference

    The browsable Swagger UI and the machine readable spec are live now. Point your tooling at:

    endpoints
    # browsable Swagger UI
    https://api.instruxi.dev/api/v1/enforcer/swagger
    
    # raw OpenAPI spec (JSON)
    https://api.instruxi.dev/api/v1/enforcer/swagger/doc.json
    
    # base path for every endpoint
    https://api.instruxi.dev/api/v1/enforcer

    The endpoints you will reach for most often, grouped by area. Treat the live Swagger UI as the authority for exact request and response shapes, this list is the map.

    Tenants

    POST/tenants/self-servecreate a tenant, caller becomes tenant admin
    POST/tenantscreate a tenant
    PATCH/tenants/{id}set name, logo, theme
    PUT/tenants/{id}/auth-providerconfigure accepted login methods
    POST/tenants/{id}/invitesinvite a teammate
    GET/directory/tenants/resolveresolve a tenant in the directory

    Auth

    POST/auth/registercreate an account
    POST/auth/otp/requestemail or phone OTP
    POST/auth/loginreturns user JWT plus refresh token
    POST/auth/refreshrefresh an expiring token
    POST/auth/passkey/register/begin · /finishpasskey enrollment
    POST/auth/passkey/login/begin · /finishpasskey sign in
    POST/auth/siwe/noncesign in with Ethereum nonce
    POST/auth/tenant/joinjoin a tenant by code
    POST/auth/tenant/switchswitch active tenant
    GET/auth/tenantsthis person's memberships

    Groups, capability gating

    GET/groupslist groups
    POST/groupscreate a group
    POST/groups/{id}/membersadd a member, unlocks the capability
    DELETE/groups/{id}/members/{account_id}remove a member
    GET/directory/accounts/{id}/groupsgroups an account belongs to

    Accounts and roles

    GET/rolesrole catalog
    GET/accountslist accounts
    GET/accounts/{id}one account
    PUT/accounts/{id}/roleset an account's role
    PUT/accounts/{id}/activeactivate or deactivate

    API keys

    POST/api-keysmint a key, plaintext shown once
    GET/api-keyslist keys
    DELETE/api-keys/{id}delete a key

    Worked reference

    The operations you will actually reach for, with what each one does and the shape it returns. Response shapes are generated from the live OpenAPI spec, so they track the API rather than drifting from it. The full set of 216 operations is in the Swagger UI.

    POST /authz/check Ask for a decision

    Evaluates an authorization decision for the caller (or, with user_id, for another account the caller may read). The resource is a description, not a lookup.

    Response 200, shape:

    {
      "allow": "boolean",
      "policy_id": "string",
      "reason": "string",
      "success": "boolean"
    }

    GET /policies List rules

    Custom Rego policies visible to the caller: every GLOBAL policy (tenant_id null — world-readable by design) plus the caller's own tenant's policies. Does not include the built-in static policy.rego engine, which every request runs regardless.

    Response 200, shape:

    {
      "data": [
        {
          "active": "boolean",
          "created_at": "string",
          "created_by": "string",
          "description": "string",
          "id": "string",
          "name": "string",
          "source": "string",
          "source_sha": "string",
          "tenant_id": "string",
          "updated_at": "string"
        }
      ],
      "limit": "integer",
      "offset": "integer",
      "success": "boolean",
      "total": "integer"
    }

    POST /policies Create a rule

    Compiles and stores a new custom Rego policy under the fixed enforcer.custom package, live immediately with no restart — every replica compiles and caches it by content hash on first evaluation, not at deploy time. The module is UNTRUSTED CODE by construction: network/host builtins (http.send, net.lookup_ip_addr, opa.runtime) are stripped, so a policy referencing them fails to compile rather than being allowed to exf

    Response 201, shape:

    {
      "data": {
        "active": "boolean",
        "created_at": "string",
        "created_by": "string",
        "description": "string",
        "id": "string",
        "name": "string",
        "source": "string",
        "source_sha": "string",
        "tenant_id": "string",
        "updated_at": "string"
      },
      "success": "boolean"
    }

    PUT /policies/{id} Update or switch a rule on

    Recompiles the stored Rego source in place, same untrusted-code constraints as createPolicy. Write access is role-gated, not ownership-gated: tenant_write for the policy's own tenant, cross_tenant (admin) for a global one — the row's creator (created_by) is provenance only and grants no standing write access, unlike the normal "write your own row" baseline, because a policy is executable authorization logic other ten

    Response 200, shape:

    {
      "data": {
        "active": "boolean",
        "created_at": "string",
        "created_by": "string",
        "description": "string",
        "id": "string",
        "name": "string",
        "source": "string",
        "source_sha": "string",
        "tenant_id": "string",
        "updated_at": "string"
      },
      "success": "boolean"
    }

    GET /audit-events Read past decisions

    The audit trail, newest first, filter-scoped by role: admin sees every tenant, tenant_admin sees only their own tenant, everyone else sees nothing (not even their own actions — use GET /me/audit-events instead). from/to accept RFC3339 or YYYY-MM-DD; a malformed bound is a 400, not a silently ignored filter.

    Response 200, shape:

    {
      "data": [
        {
          "action": "string",
          "actor_account_id": "string",
          "created_at": "string",
          "external_session_id": "string",
          "id": "string",
          "metadata": "object",
          "resource_id": "string",
          "resource_type": "string",
          "session_id": "string",
          "tenant_id": "string"
        }
      ],
      "limit": "integer",
      "offset": "integer",
      "success": "boolean",
      "total": "integer"
    }

    POST /auth/login Sign a user in

    Authenticates a credential and, on success, issues an enforcer access/refresh token pair (200, handler.AuthResponse). provider in the request selects the scheme and its required fields: email_otp needs email+code, phone_otp needs phone+code, siwe needs message+signature, every

    Response 200, shape:

    {
      "data": {
        "account": {
          "active": "boolean",
          "authenticated_at": "string",
          "claimed_at": "string",
          "created_at": "string",
          "email": "string",
          "email_verified": "boolean",
          "first_name": "string",
          "id": "string",
          "invited_by": "string",
          "is_discoverable": "boolean",
          "last_active_at": "string",
          "last_login_at": "string",
          "last_name": "string",
          "passkey_mfa_verified_at": "string",
          "person": "...",
          "person_id": "string",
          "privy_user_id": "string",
          "profile_completed": "boolean",
          "provisioned_via": "string",
          "role": "...",
          "role_id": "string",
          "tenant": "...",
          "tenant_id": "string",
          "updated_at": "string",
          "username": "string",
          "wallet_address": "string",
          "wallet_send_migrated_at": "string"
        },
        "expires_at": "string",
      ...
    }

    GET /auth/me Who is this caller

    The authenticated caller's own identity in one call: account (id, tenant, role, wallet address, login/activity timestamps), permissions, verification sessions plus the derived KYC-verified flag, resolved authz groups (id/slug/name), inline terms-acceptance posture, the caller's referral code, and — for a send-enforced tenant with a smart wallet not yet migrated — a wallet_send_migration_required flag. Te

    Response 200, shape:

    {
      "data": {
        "account_id": "string",
        "active": "boolean",
        "authenticated_at": "string",
        "created_at": "string",
        "groups": [
          {
            "group_type_id": "string",
            "id": "string",
            "name": "string",
            "slug": "string"
          }
        ],
        "is_discoverable": "boolean",
        "kyc_verified": "boolean",
        "last_active_at": "string",
        "last_login_at": "string",
        "passkey_mfa_verified_at": "string",
        "person": "...",
        "privy_user_id": "string",
        "profile_completed": "boolean",
        "referral": "...",
        "role": {
          "admin_managed": "boolean",
          "created_at": "string",
          "cross_tenant": "boolean",
          "description": "string",
          "id": "string",
          "name": "string",
          "slug": "string",
          "tenant_manage": "boolean",
          "tenant_read": "boolean",
          "tenant_write": "boolean",
          "updated_at": "string"
      ...
    }

    POST /groups Create a group

    Any authenticated member creates a group in their own tenant. 400 invalid_group_type if type_id is set but doesn't name a type visible to the caller (global or their tenant's).

    Response 201, shape:

    {
      "data": {
        "created_at": "string",
        "created_by": "string",
        "description": "string",
        "group_type_id": "string",
        "id": "string",
        "is_public": "boolean",
        "member_count": "integer",
        "name": "string",
        "slug": "string",
        "tenant_id": "string",
        "updated_at": "string"
      },
      "success": "boolean"
    }

    POST /groups/{id}/members Add someone to a group

    Adds one account to a group by account_id (ResourceAuth write on the group). 403 cross_tenant_member if the target account is not in the group's tenant — group membership never spans tenants.

    Response 201, shape:

    {
      "success": "boolean"
    }

    POST /api-keys Mint a server API key

    Mints a new API key for the CALLER's own account and returns the plaintext exactly once in this response — it is never retrievable again. name is required and cannot be blank: an unnamed key is a working credential nobody can identify to revoke later, so an empty or whitespace-only name is rejected with 400 missing_fields rather than silently minting an anonymous key.

    Response 201, shape:

    {
      "data": {
        "api_key": {
          "account_id": "string",
          "active": "boolean",
          "created_at": "string",
          "id": "string",
          "key_prefix": "string",
          "last_used_at": "string",
          "name": "string",
          "tenant_id": "string",
          "updated_at": "string"
        },
        "key": "string"
      },
      "success": "boolean"
    }
    10Errors
    10

    Reference

    Errors

    Every failure returns the same shape, so you can branch on error and show message.

    { "success": false, "error": "tenant_not_found", "message": "no tenant matches this credential" }

    These are the slugs observed in production. It is not yet the complete set; anything not listed still follows the shape above.

    SlugStatusWhat it meansWhat to do
    missing_token401No credential on a call that needs one.Send Authorization: Bearer or X-API-Key.
    missing_credential401The same, on the MCP endpoint.Send X-API-Key, or complete the OAuth flow.
    unauthenticated401The credential was rejected or has been revoked.Get a fresh one. Do not retry the same value.
    missing_fields400A required field is absent.Read the message; it names the fields.
    tenant_not_found404No tenant matches the code or credential you sent.Check the tenant code. A wrong code and a deleted tenant look the same.
    cross_tenant_member403You tried to act on someone in another tenant.Not a permission you can be granted; the engine refuses it.
    bad_request400The body did not parse, or a value is out of range.Check types against the OpenAPI spec.
    11FAQ
    11

    Questions

    FAQ

    Can I self host Enforcer?

    Enforcer ships as a deployable backend and runs with high availability in production today. Self hosting is available and terms depend on your environment, so it is quoted rather than listed.

    Where does the data live?

    Each tenant's users, groups, roles, and keys are stored and isolated per tenant inside your Enforcer deployment. The cross tenant person layer is what lets a verified identity carry between tenants, while per tenant data stays separated. Hosting region and data residency are set per environment; ask which applies to yours before you design around it.

    Is there a sandbox?

    Yes, work against a staging environment before production. There is a shared sandbox used by the quickstart above, but it is offline at the moment, so the tenant code in this guide will not resolve. Until it is back, create your own tenant with the by hand path or ask for a staging base URL.ask for a staging base URL, then set it as $ENFORCER_BASE_URL and every example here works unchanged.

    JWT or API key, which do I send?

    Send a user JWT for anything done as a logged in person, and an API key for server side admin work like creating groups and adding members. A Privy access token can be sent directly as the bearer in place of a user JWT. See the Auth model section above.