On this page

Get started

Authentication

Every authenticated route on the gateway takes one header, Authorization: Bearer <credential>, and you choose which of four credentials goes in it.

Anyone can sign in and use a credential they hold; creating, listing and revoking an API key needs an admin role (OWNER, ORG_ADMIN or PROJECT_ADMIN) on the project. Examples here use the production host https://fabric.wexa.ai.

Make your first signed request

  1. Get an API key from an admin, or create one under Create an API key. Copy the secret when it is shown, because it is never shown again.
  2. Export it with the workspace URL. Both SDKs read these two names, and WEXA_WORKSPACE is the workspace URL, not the API base.
  3. Call GET /v1/whoami, which reports the scope, grants and auth_kind your credential resolved to.
export WEXA_WORKSPACE=https://fabric.wexa.ai
export WEXA_API_KEY=fab_sk_EoWX5uQBDWVJfxXhgGdJOygW
curl -sS "$WEXA_WORKSPACE/v1/whoami" -H "Authorization: Bearer $WEXA_API_KEY"

Identity explains the answer. Every other call on this page uses the same header.

How it works

The gateway does not ask which kind of credential you are sending. It works it out in a fixed order, and the order decides which error you get.

  1. A credential beginning fab_sk_ is looked up in the API key store by SHA-256 hash. Nothing else is tried.
  2. Otherwise it is verified as an access token issued by this gateway: an HS256 JWT carrying token_use: "access".
  3. If that fails, it is verified as a Wexa user token, the session token the dashboard holds: the same signature without the token_use: "access" claim.
API keyUser tokenOAuth 2.1 access tokenSDK session
Formfab_sk_… opaque stringHS256 JWTHS256 JWT, token_use: "access"HS256 JWT, token_use: "session", plus a fab_srt_… refresh token
Issued byPOST /v1/apikeysThe platform, at sign-in; claims sub, role, org_id, dept_id, project_id, token_usePOST /oauth/tokenPOST /v1/auth/login
ScopePinned at creation to one organization, department and project; never widensThe session's own scopeThe scope consented toOne project, as the logged-in user with their current role
ExpiryNone; revocation onlyThe session's exp3600 seconds, refreshable for 30 days3600 seconds, refreshable for 30 days
Reaches tool routesYesYesYesYes
Reaches /v1/apikeysNoYes, with an admin roleDepends on the granted scopesNo
auth_kindapi_keyuser_jwtoauthsession
Right forServers, jobs, anything unattendedA person's own session, and credential managementAn application acting for a person, including an editor connecting to the Wexa MCP serverYour own login page

Create an API key

In the console, open the API keys page for your project and choose Create API key; Credential management walks that route and who should hold a key. The same call over REST is POST /v1/apikeys, with a user token as the bearer:

FieldRequiredNotes
nameYesA human label. The id is random, so the name is the only way to tell keys apart. Empty or missing is refused 400 name is required.
org_idYes in practiceRead from the body, not from your token. Omit it and the key's scope has no organization.
dept_idNoStamped into the scope as given.
project_idYesThe project the key is pinned to.
grantsNoAn explicit grant list. Omitted, the key gets the default set for your role.
curl -sS -X POST https://fabric.wexa.ai/v1/apikeys \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"nightly-report","org_id":"org_abc","dept_id":"dept_abc","project_id":"proj_abc"}'

The 201 Created response carries the secret exactly once:

{
  "id": "key_IEhcUZrVvqI9C9hOAkwA_yOM",
  "name": "nightly-report",
  "note": "store this secret now; it is not retrievable again",
  "scope": {
    "org_id": "org_abc", "dept_id": "dept_abc", "project_id": "proj_abc",
    "user_id": "u_abc", "role": "OWNER",
    "grants": ["fabric:query.read", "fabric:docs.read", "fabric:ontology.write",
               "fabric:orchestrate.read", "fabric:orchestrate.write", "fabric:agent.run",
               "fabric:skill.write", "fabric:model.write"],
    "auth_kind": "api_key"
  },
  "secret": "fab_sk_EoWX5uQBDWVJfxXhgGdJOygW"
}

Sign in as a person

An application can use Wexa sign-in instead of asking people for an API key. First list the user's organizations and projects, with their role in each, which makes no session; then log in to the chosen project.

curl -sS -X POST https://fabric.wexa.ai/v1/auth/projects \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct-horse-battery"}'

curl -sS -X POST https://fabric.wexa.ai/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct-horse-battery","project_id":"proj_abc"}'

Limits and behavior

A missing or wrong credential is a 401; the WWW-Authenticate header points a client at where to authenticate. Both SDKs map every one of these to AuthError; errors and retries covers the rest.

BodyMeaning
missing bearer credentialNo Authorization: Bearer header.
api key invalid or revokedThe credential matched fab_sk_ but no live key.
token invalidNot a key, and not a valid token of either JWT kind. Also what an API key gets on a route that requires a user token.

Refusals on key management:

StatusBodyCause
400invalid_requestThe body is not valid JSON.
401invalid_tokenNo bearer, or not a user token, including an API key. Applies to create, list and revoke.
403API key creation requires an admin role on this projectA valid user token without an admin role.
403forbiddenList or revoke with a valid user token that has no admin role on that project.
502identity_unavailableThe token carried no role, so the gateway looked up your project role and could not. Membership is never taken on trust.
502revoke_failedThe key store could not be reached while revoking.
Session tokens, key minting and OAuth

An SDK session access token, issued by login, carries token_use: "session". It resolves as the user who logged in, with their role in the project checked again on every call.

A key cannot mint a key, so a user token is the credential for key management: it is minted where who you are is known. If a key could mint a key, revoking the first would not contain the damage.

Do not embed a user token in a service, because it expires with the session and carries the person's scope rather than the job's.

OAuth is covered in OAuth 2.1, which publishes the discovery documents and the seven fabric: scopes.

Grants, secret storage and key defaults

Only a SHA-256 hash of the secret is stored, so the gateway can verify a key it is shown and can never reproduce one. There is no recovery endpoint and no rotation endpoint.

Without grants, the key gets the default set for the creator's role. In the current gateway source an OWNER, ORG_ADMIN or PROJECT_ADMIN key gets sixteen grants: the eight in the example above plus model.run, connector.run, connector.sync, connector.connect, flow.delete, kb.delete, table.read and table.write.

The example was captured before those were added. Org and project members get six (query.read, docs.read, orchestrate.read, agent.run, connector.run, connector.sync), and any other or unrecognised role gets query.read and docs.read.

An explicit list is honoured exactly, and a key can never do what its creator could not. Read the grants, not the role: a key created with {"grants":["fabric:query.read"]} held one grant and still reported "role": "OWNER", because the role is copied from the creator and the grants are not. Keep unattended keys narrow.

List, revoke and rotate keys

Listing needs project_id as a query parameter, and revoking needs it plus the key id. A missing project_id is refused 400 project_id query param required before authentication runs.

curl -sS "https://fabric.wexa.ai/v1/apikeys?project_id=proj_abc" \
  -H "Authorization: Bearer $USER_TOKEN"

curl -sS -X DELETE "https://fabric.wexa.ai/v1/apikeys/key_IEhcUZrVvqI9C9hOAkwA_yOM?project_id=proj_abc" \
  -H "Authorization: Bearer $USER_TOKEN"

The list returns keys, each with id, name, scope, created_by, created_at, last_used_at and revoked, and never the secret. Revoked keys stay listed, so the record of what existed survives.

A key with no recent last_used_at is cheap to revoke; a revoked key that keeps being used shows which caller still holds the old secret. Revoke answers {"ok": true} and takes effect on the next request.

The key id is checked against the project's own list first, so an admin cannot revoke another project's key by guessing its id: an id not in this project answers 404 no such API key in this project, whether it exists elsewhere or not.

To rotate a key, create the replacement with the same grants if the old key was deliberately narrow, deploy the new secret to every caller until last_used_at stops moving on the old key, then revoke the old one. Revoking first takes the integration down for the length of the deployment.

Creation and revocation append the audit events apikey.created and apikey.revoked, recording the actor, their role, the key id and the project. Read them at GET /v1/audit; lifecycle and audit describes the record.

Signing in, further

Every call then acts with the user's role in that project. When another process needs its own token lifecycle, POST /v1/auth/connect-project with a live session as the bearer returns a second session with its own refresh token (SDK: connectProject / connect_project). That separates refresh-token ownership only; requests still use Authorization: Bearer <credential>.

GET /v1/auth/my-projects lists the same projects from a session, and GET /v1/codesync/names returns org_name and project_name for any credential.

Grants per credential

The same person on the same project ends up with different grants depending on the credential. Read back from GET /v1/whoami for one OWNER:

CredentialGrants
API key, no grants requestedThe default set for the creator's role (see Create an API key); eight when this was read back
User tokenA fixed floor of six: query.read, ontology.write, docs.read, catalog.read, catalog.write, agent.run. Once the gateway confirms your membership of the project, the default set for your role is added on top, and nothing is ever taken away. If the lookup fails or you are not a member, you keep the six. It is the only one of the three that reaches the catalog write endpoints.
OAuth access tokenExactly the scope consented to, here query.read, docs.read

Grants gate the call, not the listing. A credential without a grant still sees every tool in tools/list, and is refused when it calls one: error (S2:resolve-scope): token missing required grant "…".

The 401 challenge

A refusal looks like this, and the WWW-Authenticate header points a client at where to authenticate. On a project-scoped connect URL it names /.well-known/oauth-protected-resource/mcp/proj_abc123 instead.

HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata="https://fabric.wexa.ai/.well-known/oauth-protected-resource"

{"error":"invalid_token","error_description":"missing bearer credential"}

A mistyped key that no longer starts with fab_sk_ falls through to the JWT checks and comes back as token invalid, while a revoked key gets api key invalid or revoked. Those two strings tell "wrong secret" from "wrong kind of secret".

Both SDKs map every one of these to AuthError; errors and retries covers the rest.

Rate limits

Two limiters exist, and conflating them leads to wrong retry behaviour.

A per-key limit of 60 requests per minute, in a fixed one-minute window counted per key id, applies only to POST /v1/agents/{agentflowId}/chat/completions. The 61st call is refused with 429, Retry-After: 1 and the OpenAI error shape ("code":"rate_limited"), not Wexa's.

Every other route is governed by the project and organization quota windows: 120 requests per project and 600 per organization in a sliding 60-second window by default, and 0 means unlimited. They answer Wexa's own body, S3:rate-quota, with a Retry-After carrying the real seconds remaining. Usage, quota and billing covers configuring them.

Routes that take no bearer credential

Some routes take no bearer credential, and not all are unauthenticated in the same way:

RouteHow it is authenticated
GET /healthz, GET /v1/connection-infoNothing. A probe and a client both run before they hold a credential.
GET /.well-known/oauth-protected-resource, its /mcp/{projectId} variant, GET /.well-known/oauth-authorization-serverNothing. The 401 challenge points at them.
POST /oauth/registerNothing. An optional x-server-key header marks a first-party registration.
POST /mcp, GET /mcp, DELETE /mcp, GET /sseNothing. Retired paths answering 410 Gone before authentication runs.
POST /v1/codesync/webhook/github, POST /v1/catalog/om/webhookA provider signature: HMAC-SHA256 over the raw body (X-Hub-Signature-256 for GitHub).
POST /actions/linkedin/notify/{connectorID}/{pin}, /actions/mail/notify/…, /actions/whatsapp/notify/…A path secret: the {pin} segment, checked downstream.
POST /unipile-webhook/{projectID}/{connectorID}Nothing. The provider signs nothing; downstream re-fetches the reported account from the provider before trusting it.
POST /blandai/{triggerID}, POST /falai/{triggerID}Nothing. The trigger id is resolved against registration records downstream, and an unknown one is refused there.
POST /v1/internal/catalog/ingestA shared-secret header: x-server-key must equal the gateway's server key. Service to service; an unset or wrong key answers 401 unauthorized.

POST /oauth/authorize is authenticated, but by a user_token form field, falling back to the Authorization header only if the field is empty, because a browser consent screen posts it. With neither it answers 401 consent requires a valid Fabric user token.

What's next