Skip to main content

Tenant Config (Control Plane)

The tenant control plane config lets platform operators customize the Steward experience for each tenant — which policies are editable, what the UI looks like, which features are enabled, and how approvals work. This is the layer between a raw multi-tenant Steward deployment and a white-labeled platform-specific experience. Base path: GET/PUT /tenants/:id/config
Auth: Tenant-level (X-Steward-Key)

Get Tenant Config

GET /tenants/:id/config
curl https://api.steward.fi/tenants/my-platform/config \
  -H "X-Steward-Key: your-tenant-key"
Response:
{
  "ok": true,
  "data": {
    "tenantId": "my-platform",
    "displayName": "My Platform",
    "policyExposure": {
      "spending-limit": "visible",
      "rate-limit": "enforced",
      "approved-addresses": "hidden"
    },
    "policyTemplates": [...],
    "secretRoutePresets": [...],
    "approvalConfig": {
      "autoExpireSeconds": 86400,
      "approvers": { "mode": "owner" },
      "webhookCallbackEnabled": true
    },
    "featureFlags": {
      "showFundingQR": true,
      "showTransactionHistory": true,
      "showSpendDashboard": true,
      "showPolicyControls": true,
      "showApprovalQueue": true,
      "showSecretManager": false,
      "enableSolana": true,
      "showChainSelector": false,
      "allowAddressExport": true
    },
    "theme": {
      "primaryColor": "#8B5CF6",
      "accentColor": "#A78BFA",
      "backgroundColor": "#0F0F0F",
      "surfaceColor": "#1A1A2E",
      "textColor": "#FAFAFA",
      "colorScheme": "dark",
      "logoUrl": "https://assets.example.com/logo.png",
      "faviconUrl": "https://assets.example.com/favicon.ico"
    }
  }
}
If no config has been saved, returns a default empty config (all features enabled, no policy restrictions). Built-in defaults ship for a small set of known tenant ids. If your tenant id matches one, its preset config is used as the fallback; otherwise the default empty config applies.

App Clients And Environments

Steward supports tenant-scoped app clients for grouping local, preview, staging, and production integration settings. App-client mutations require an owner/admin session with recent MFA because they change auth and browser allowlists.
{
  "appClients": [
    {
      "id": "web-prod",
      "name": "Production Web",
      "environment": "production",
      "enabled": true,
      "isDefault": true,
      "allowedOrigins": ["https://app.example.com"],
      "allowedRedirectUrls": ["https://app.example.com/auth/callback"],
      "allowedBundleIds": ["com.example.app"],
      "allowedPackageNames": ["com.example.android"],
      "loginMethods": {
        "oauth": { "google": true, "discord": false }
      }
    },
    {
      "id": "web-dev",
      "name": "Local Dev",
      "environment": "development",
      "enabled": true,
      "allowedOrigins": ["http://localhost:3000"],
      "allowedRedirectUrls": ["http://localhost:3000/auth/callback"]
    }
  ]
}
OAuth authorize accepts client_id or clientId. When supplied, Steward validates redirect_uri against that enabled app client’s redirects instead of the tenant-global fallback and applies that client’s OAuth login-method overrides. Native app clients should also pin platform identifiers with allowedBundleIds for iOS and allowedPackageNames for Android. Steward rejects wildcards, single-label values, and malformed identifiers before persistence, so native callback trust must be expressed as explicit bundle/package allowlists. When a native integration supplies X-Steward-Native-Bundle-Id or X-Steward-Native-Package-Name on app-client-bound auth requests, Steward also accepts equivalent JSON fields: native_bundle_id, native_package_name, nativeBundleId, and nativePackageName. It validates identifier syntax and rejects values that are not allowlisted on the enabled app client. For OAuth device authorization, the accepted native identifier is bound to the issued device code and must match again during token polling. Mismatches return invalid_client and do not mint a session. These headers and JSON fields provide deterministic server-side allowlist enforcement. They do not prove the caller is an untampered native binary; real device attestation, signed app claims, and anti-tamper guarantees must come from the native platform or an attestation provider.
GET /auth/oauth/google/authorize?tenant_id=my-platform&client_id=web-prod&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
curl -X POST https://api.steward.fi/auth/device/code \
  -H "Content-Type: application/json" \
  -H "X-Steward-Native-Bundle-Id: com.example.app" \
  -d '{
    "tenantId": "my-platform",
    "client_id": "ios-prod"
  }'

curl -X POST https://api.steward.fi/auth/device/token \
  -H "Content-Type: application/json" \
  -H "X-Steward-Native-Bundle-Id: com.example.app" \
  -d '{
    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
    "device_code": "stwd_dc_...",
    "client_id": "ios-prod"
  }'
Dedicated aliases:
GET /tenants/:id/app-clients
PUT /tenants/:id/app-clients
POST /tenants/:id/app-clients
DELETE /tenants/:id/app-clients/:clientId

App Client Secrets

App clients can also have backend-only app secrets for Privy-style REST API authentication and HMAC request signing. The app ID is stable and public in the form <tenantId>/<clientId>. The app secret is returned only once when rotated or created. Use Basic Auth with the app ID as the username and the app secret as the password, plus X-Steward-App-Id:
curl https://api.steward.fi/agents \
  -H "X-Steward-App-Id: my-platform/web-prod" \
  -H "Authorization: Basic $(printf 'my-platform/web-prod:stw_app_...' | base64)"
Secret management requires an owner/admin session with recent MFA:
GET /tenants/:id/app-clients/:clientId/secrets
POST /tenants/:id/app-clients/:clientId/secrets
DELETE /tenants/:id/app-clients/:clientId/secrets/:secretId
For signed API requests, use the same app secret as the SDK requestSigningSecret. Steward validates the Basic auth secret and then checks X-Steward-Signature against that tenant-scoped secret, so production deployments do not need a separate global signing secret for app-client server integrations.
const steward = new StewardClient({
  baseUrl: "https://api.steward.fi",
  appId: "my-platform/web-prod",
  appSecret: "stw_app_...",
  requestSigningSecret: "stw_app_...",
});

Request Signing Keys

Tenants can also manage standalone encrypted request-signing keys for server integrations that use tenant API keys, platform keys, or delegated signer credentials instead of app-client Basic auth.
GET /tenants/:id/request-signing-keys
POST /tenants/:id/request-signing-keys
DELETE /tenants/:id/request-signing-keys/:keyId
All routes require an owner/admin session with recent MFA. POST returns signingSecret once, stores the secret encrypted with the Steward keystore, and marks previous active keys as retiring for a seven-day overlap. GET returns metadata only.
const steward = new StewardClient({
  baseUrl: "https://api.steward.fi",
  tenantId: "my-platform",
  apiKey: "stw_...",
  requestSigningSecret: "stw_sig_...",
  requestSigningKeyId: "6dd98a21-fbb6-4a2d-a840-f89a527a4244"
});

Security Checklist

Tenant admins can read a production-readiness checklist for API headers, HSTS, request freshness, HMAC request signatures, browser allowlists, production app clients, and dashboard CSP coverage.
GET /tenants/:id/security-checklist
This endpoint requires an owner/admin session with recent MFA. It returns only status metadata, never raw secrets or environment values.
{
  "ok": true,
  "data": {
    "tenantId": "my-platform",
    "generatedAt": "2026-05-29T00:00:00.000Z",
    "summary": { "pass": 5, "warning": 1, "fail": 1 },
    "items": [
      {
        "id": "authorization-signatures",
        "label": "Authorization signatures",
        "status": "fail",
        "description": "Sensitive mutating requests need enforced HMAC signatures and configured signing secrets.",
        "remediation": "Set STEWARD_REQUIRE_AUTH_SIGNATURE=true and configure STEWARD_REQUEST_SIGNING_SECRETS or rotate an app client secret."
      }
    ]
  }
}

Idempotency Metrics

Tenant admins can inspect privacy-preserving idempotency counters for replay and conflict triage:
GET /tenants/:id/idempotency-metrics
The endpoint requires an owner/admin session with recent MFA. It returns only aggregated counters for the tenant; it never returns idempotency keys, request bodies, stored response bodies, or credential material.
{
  "ok": true,
  "data": {
    "tenantId": "my-platform",
    "generatedAt": "2026-05-30T00:00:00.000Z",
    "windowStartedAt": "2026-05-30T00:00:00.000Z",
    "lastSeenAt": "2026-05-30T00:00:01.000Z",
    "ttlMs": 86400000,
    "counters": {
      "observed": 3,
      "reserved": 1,
      "completed": 1,
      "replayed": 1,
      "conflicts": 1,
      "inFlightConflicts": 0,
      "suppressedAuthResponses": 0,
      "invalidKeys": 0,
      "storeErrors": 0,
      "skippedUnsafeContext": 0,
      "releasedOnError": 0
    }
  }
}
Tenant admins can export the same privacy-preserving snapshot as CSV for operator review or archival:
GET /tenants/:id/idempotency-metrics/export
curl "https://api.steward.fi/tenants/my-platform/idempotency-metrics/export" \
  -H "Authorization: Bearer user-session-jwt" \
  -o idempotency-metrics.csv
The CSV has one row with timestamps, TTL, and aggregate counter columns such as observed, completed, replayed, conflicts, and store_errors. It does not include idempotency keys, request bodies, replayed responses, or credentials. POST rotates the secret, returns appSecret once, and marks the previous active secret as retiring for a short overlap window. GET returns metadata only: secret prefix, status, timestamps, and the public app ID.

OIDC / JWT Login Providers

Tenant OIDC providers support direct JWT exchange and enterprise authorization-code SSO. The authorization-code fields are optional; when present, Steward acts as the relying party, verifies the returned ID token, and redirects the app with a short-lived exchange code instead of putting Steward tokens in the URL.
{
  "id": "acme-sso",
  "enabled": true,
  "issuer": "https://idp.example.com",
  "audience": ["steward-api"],
  "jwksUri": "https://idp.example.com/.well-known/jwks.json",
  "clientId": "steward-dashboard",
  "clientSecretEnv": "ACME_SSO_CLIENT_SECRET",
  "authorizationUrl": "https://idp.example.com/oauth2/v1/authorize",
  "tokenUrl": "https://idp.example.com/oauth2/v1/token",
  "scopes": ["openid", "email", "profile"]
}
The SP-initiated SSO route requires app-side PKCE:
GET /auth/oidc/:provider/authorize?tenant_id=my-platform&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&code_challenge=...&code_challenge_method=S256
The provider callback validates the OIDC state, IdP PKCE verifier, ID-token issuer/audience/algorithm/JWKS signature, and nonce. Enterprise OIDC SSO requires a verified email claim whose domain is already verified in the tenant SSO domain registry. New JIT memberships created through this auth-code path default to viewer. Steward then reuses POST /auth/oauth/exchange for the app-bound one-time code exchange.

SSO Email Domains

Tenant admins can register verified email domains for dashboard/team SSO discovery. This is the domain-routing layer used before SAML/OIDC redirect flows.
GET /tenants/:id/sso-domains
POST /tenants/:id/sso-domains
POST /tenants/:id/sso-domains/:domain/verify
DELETE /tenants/:id/sso-domains/:domain
Create a domain:
{
  "domain": "example.com",
  "ssoRequired": true
}
The response includes a verificationToken. Publish it as an exact TXT value at _steward-sso.example.com, then call verify. Once verified, public discovery can route email addresses to the tenant:
POST /auth/sso/discover
{
  "email": "admin@example.com"
}

Team Invitations

Invite-only tenants can create pending team invitations without creating a membership up front. Invitation tokens are returned once and stored hashed.
GET /platform/tenants/:id/invitations
POST /platform/tenants/:id/invitations
DELETE /platform/tenants/:id/invitations/:invitationId
POST /user/me/tenants/:tenantId/invitations/accept
Create an invitation:
{
  "email": "alice@example.com",
  "role": "developer",
  "expiresInSeconds": 604800,
  "sendEmail": true
}
The platform response includes { invitation, token, emailSent }. Steward stores only the token hash and can send the invitation through the tenant email provider when sendEmail is true. If you do not send through Steward, deliver token through your own email channel. The user accepts it from a personal session with a verified matching email. Invite-only tenants must use POST /user/me/tenants/:tenantId/invitations/accept with the one-time token; email-matched invites are not accepted without the token. Invitation roles can be admin, developer, billing, viewer, or member; owner cannot be granted by invitation.

SAML Dashboard SSO

Tenant admins can configure one SAML 2.0 IdP for dashboard/team SSO. The SAML configuration is separate from domain verification: verified SSO domains decide which tenant owns an email domain, while SAML metadata and ACS routes define the tenant’s service-provider integration.
GET /tenants/:id/saml-sso
PUT /tenants/:id/saml-sso
DELETE /tenants/:id/saml-sso
GET /auth/saml/:tenantId/login
GET /auth/saml/:tenantId/metadata
POST /auth/saml/:tenantId/acs
GET /tenants/:id/saml-sso returns the current config, if any, plus generated service-provider values:
{
  "serviceProvider": {
    "spEntityId": "https://api.example.com/auth/saml/acme/metadata",
    "acsUrl": "https://api.example.com/auth/saml/acme/acs",
    "metadataUrl": "https://api.example.com/auth/saml/acme/metadata"
  }
}
PUT /tenants/:id/saml-sso requires an owner/admin session with recent MFA and stores parsed IdP settings:
{
  "enabled": true,
  "idpEntityId": "https://idp.example.com/saml",
  "idpSsoUrl": "https://idp.example.com/sso",
  "idpCertPems": ["-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"],
  "emailAttribute": "email",
  "groupsAttribute": "groups",
  "groupRoleMappings": [
    { "group": "Engineering", "role": "developer" },
    { "group": "Finance", "role": "billing" }
  ],
  "allowJitProvisioning": false
}
Steward validates that the IdP SSO URL is a public https URL, certificate entries are PEM certificates without private key material, at most five IdP certs are configured, and JIT-created SSO memberships can only default to viewer unless a SAML group maps the new user to admin, developer, billing, viewer, or member. SAML group mappings cannot grant owner. The metadata route emits unsigned SP metadata with WantAssertionsSigned=true and stable Entity ID/ACS URLs derived from APP_URL, not the request host. The SP-initiated login route mirrors OIDC auth-code SSO and requires app-side PKCE:
GET /auth/saml/:tenantId/login?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&code_challenge=...&code_challenge_method=S256
Steward stores an opaque RelayState and InResponseTo request ID, redirects to the tenant IdP, then the ACS route atomically consumes the RelayState and validates the signed SAML response/assertion against the tenant IdP certs, SP Entity ID, ACS URL, request ID, assertion timestamps, configured email attribute, verified email domain, group attributes, and assertion replay table. Successful ACS returns the app to redirect_uri with a short-lived exchange code; the app redeems it through POST /auth/oauth/exchange. Client IDs must match ^[a-z0-9][a-z0-9_-]{2,63}$. Redirect URLs must be exact https URLs, except loopback development URLs. Origins must be exact origins; app clients cannot use wildcard origins.

MFA Policy

authAbuseConfig.mfa controls tenant-level step-up behavior for sensitive custody operations:
{
  "authAbuseConfig": {
    "mfa": {
      "maxAgeSeconds": 120,
      "maxAgeFor": {
        "vaultSigning": 120,
        "keyImport": 30,
        "keyExport": 300,
        "recoveryCodes": 120,
        "tenantAdmin": 120
      },
      "requireFor": {
        "vaultSigning": true,
        "keyImport": true,
        "keyExport": true,
        "recoveryCodes": true,
        "tenantAdmin": true
      },
      "disableFor": {
        "keyImport": false,
        "keyExport": false
      },
      "allowDelegatedSignerAutomation": false,
      "allowKeyQuorumAutomation": false
    }
  }
}
maxAgeSeconds is bounded from 30 seconds to 1 hour and acts as the default freshness window. maxAgeFor can override that window per sensitive surface with the same bounds; omitted actions fall back to maxAgeSeconds. Each requireFor flag defaults to true; setting a flag to false disables only that action’s MFA freshness gate. disableFor.keyImport and disableFor.keyExport default to false; setting either to true blocks private-key import or export at the tenant-policy layer even when the corresponding environment break-glass flags are enabled and the caller has fresh MFA. When signer or key-quorum automation is disabled, vault signing routes require an owner/admin session with recent MFA instead of signer-bound API credentials. Vault signing, key import/export, user-wallet import/export, and tenant-admin control-plane routes use the tenant MFA freshness window and per-action requireFor flags. TOTP, SMS, recovery-code, and passkey MFA step-up sessions can satisfy recent-MFA checks; passkey step-up reuses the signed-in user’s enrolled WebAuthn credentials.

Update Tenant Config

PUT /tenants/:id/config
Full replace — any fields omitted default to their zero values.
curl -X PUT https://api.steward.fi/tenants/my-platform/config \
  -H "X-Steward-Key: your-tenant-key" \
  -H "Content-Type: application/json" \
  -d '{
    "displayName": "My Platform",
    "policyExposure": {
      "spending-limit": "visible",
      "rate-limit": "enforced",
      "approved-addresses": "hidden",
      "auto-approve-threshold": "visible",
      "time-window": "hidden"
    },
    "approvalConfig": {
      "autoExpireSeconds": 86400,
      "approvers": { "mode": "owner" },
      "webhookCallbackEnabled": true
    },
    "featureFlags": {
      "showFundingQR": true,
      "showTransactionHistory": true,
      "showSpendDashboard": true,
      "showPolicyControls": true,
      "showApprovalQueue": true,
      "showSecretManager": false,
      "enableSolana": true,
      "showChainSelector": false,
      "allowAddressExport": true
    },
    "theme": {
      "primaryColor": "#FF6B35",
      "colorScheme": "dark",
      "logoUrl": "https://assets.example.com/logo.png",
      "faviconUrl": "https://assets.example.com/favicon.ico"
    }
  }'

Policy Exposure

policyExposure controls which policy types end users can see and edit in the <PolicyControls> component:
ValueBehavior
"visible"Policy is shown and editable
"enforced"Policy is applied but hidden from end users
"hidden"Policy is not shown and not automatically applied
{
  "policyExposure": {
    "spending-limit": "visible",
    "rate-limit": "enforced",
    "approved-addresses": "hidden",
    "auto-approve-threshold": "visible",
    "time-window": "hidden"
  }
}

Policy Templates

Policy templates appear in the <PolicyControls> component as one-click starting points. Define them with customizable fields so users can tune limits without editing raw policy configs:
{
  "policyTemplates": [
    {
      "id": "trading-agent",
      "name": "Trading Agent",
      "description": "For agents that trade on DEXs. Spending limits + approved routers.",
      "icon": "chart-line",
      "policies": [
        {
          "id": "tpl-spend",
          "type": "spending-limit",
          "enabled": true,
          "config": {
            "maxPerTx": "100000000000000000",
            "maxPerDay": "1000000000000000000",
            "maxPerWeek": "5000000000000000000"
          }
        }
      ],
      "customizableFields": [
        {
          "path": "spending-limit.maxPerDay",
          "label": "Daily Spending Limit",
          "type": "currency",
          "default": "1.0",
          "min": "0.01",
          "max": "100"
        }
      ]
    }
  ]
}

Secret Route Presets

Pre-configured route templates shown in the secret manager UI. Saves users from manually entering host/inject details for common APIs:
{
  "secretRoutePresets": [
    {
      "id": "openai",
      "name": "OpenAI API",
      "hostPattern": "api.openai.com",
      "pathPattern": "/*",
      "injectAs": "bearer",
      "injectKey": "Authorization",
      "injectFormat": "Bearer {value}",
      "provisioning": "platform"
    },
    {
      "id": "anthropic",
      "name": "Anthropic API",
      "hostPattern": "api.anthropic.com",
      "pathPattern": "/*",
      "injectAs": "header",
      "injectKey": "x-api-key",
      "injectFormat": "{value}",
      "provisioning": "user"
    }
  ]
}
provisioning: "platform" means the platform provides the credential. provisioning: "user" means the end user provides their own key.

Approval Config

Controls how the approval workflow behaves for this tenant:
{
  "approvalConfig": {
    "autoExpireSeconds": 86400,
    "approvers": { "mode": "owner" },
    "webhookCallbackEnabled": true
  }
}
FieldDescription
autoExpireSecondsHow long pending approvals last before auto-expiry (0 = never)
approvers.modeWho can approve: "owner" (tenant admins), "tenant-admin"
webhookCallbackEnabledWhether to fire tx.pending webhooks for this tenant

Feature Flags

Toggle UI features in @stwd/react components:
FlagDefaultDescription
showFundingQRtrueShow QR code in <WalletOverview>
showTransactionHistorytrueEnable <TransactionHistory> component
showSpendDashboardtrueEnable <SpendDashboard> component
showPolicyControlstrueEnable <PolicyControls> component
showApprovalQueuetrueEnable <ApprovalQueue> component
showSecretManagerfalseShow secret management UI
enableSolanatrueShow Solana address alongside EVM
showChainSelectorfalseShow chain-switching UI
allowAddressExporttrueAllow copying/exporting wallet addresses

Theme Config

Full color and typography control:
{
  "theme": {
    "primaryColor": "#8B5CF6",
    "accentColor": "#A78BFA",
    "backgroundColor": "#0F0F0F",
    "surfaceColor": "#1A1A2E",
    "textColor": "#FAFAFA",
    "mutedColor": "#6B7280",
    "successColor": "#10B981",
    "errorColor": "#EF4444",
    "warningColor": "#F59E0B",
    "borderRadius": 12,
    "fontFamily": "Inter, system-ui, sans-serif",
    "colorScheme": "dark",
    "logoUrl": "https://assets.example.com/logo.png",
    "faviconUrl": "https://assets.example.com/favicon.ico"
  }
}
These map directly to the CSS custom properties in @stwd/react. Any fields not provided fall back to the component library defaults. Theme updates are validated before persistence: color tokens must be exact 6-digit hex values, borderRadius must be between 0 and 32, colorScheme must be light, dark, or system, and fontFamily is restricted to common CSS font-family characters. logoUrl and faviconUrl must be absolute HTTPS URLs. Local development URLs using http://localhost, http://127.0.0.1, or *.localhost are accepted; javascript:, data:, blob:, file:, relative URLs, protocol-relative URLs, non-local HTTP URLs, credentials in URLs, and values over 2048 characters are rejected. Logos must end in .png; favicons must end in .ico or .png. Submit an empty string to clear a saved asset URL.