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
- 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.
- Export it with the workspace URL. Both SDKs read these two names, and
WEXA_WORKSPACEis the workspace URL, not the API base. - Call
GET /v1/whoami, which reports the scope, grants andauth_kindyour 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.
- A credential beginning
fab_sk_is looked up in the API key store by SHA-256 hash. Nothing else is tried. - Otherwise it is verified as an access token issued by this gateway: an HS256 JWT carrying
token_use: "access". - 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 key | User token | OAuth 2.1 access token | SDK session | |
|---|---|---|---|---|
| Form | fab_sk_… opaque string | HS256 JWT | HS256 JWT, token_use: "access" | HS256 JWT, token_use: "session", plus a fab_srt_… refresh token |
| Issued by | POST /v1/apikeys | The platform, at sign-in; claims sub, role, org_id, dept_id, project_id, token_use | POST /oauth/token | POST /v1/auth/login |
| Scope | Pinned at creation to one organization, department and project; never widens | The session's own scope | The scope consented to | One project, as the logged-in user with their current role |
| Expiry | None; revocation only | The session's exp | 3600 seconds, refreshable for 30 days | 3600 seconds, refreshable for 30 days |
| Reaches tool routes | Yes | Yes | Yes | Yes |
Reaches /v1/apikeys | No | Yes, with an admin role | Depends on the granted scopes | No |
auth_kind | api_key | user_jwt | oauth | session |
| Right for | Servers, jobs, anything unattended | A person's own session, and credential management | An application acting for a person, including an editor connecting to the Wexa MCP server | Your 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:
| Field | Required | Notes |
|---|---|---|
name | Yes | A 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_id | Yes in practice | Read from the body, not from your token. Omit it and the key's scope has no organization. |
dept_id | No | Stamped into the scope as given. |
project_id | Yes | The project the key is pinned to. |
grants | No | An 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.
| Body | Meaning |
|---|---|
missing bearer credential | No Authorization: Bearer header. |
api key invalid or revoked | The credential matched fab_sk_ but no live key. |
token invalid | Not 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:
| Status | Body | Cause |
|---|---|---|
400 | invalid_request | The body is not valid JSON. |
401 | invalid_token | No bearer, or not a user token, including an API key. Applies to create, list and revoke. |
403 | API key creation requires an admin role on this project | A valid user token without an admin role. |
403 | forbidden | List or revoke with a valid user token that has no admin role on that project. |
502 | identity_unavailable | The token carried no role, so the gateway looked up your project role and could not. Membership is never taken on trust. |
502 | revoke_failed | The 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:
| Credential | Grants |
|---|---|
| API key, no grants requested | The default set for the creator's role (see Create an API key); eight when this was read back |
| User token | A 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 token | Exactly 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:
| Route | How it is authenticated |
|---|---|
GET /healthz, GET /v1/connection-info | Nothing. 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-server | Nothing. The 401 challenge points at them. |
POST /oauth/register | Nothing. An optional x-server-key header marks a first-party registration. |
POST /mcp, GET /mcp, DELETE /mcp, GET /sse | Nothing. Retired paths answering 410 Gone before authentication runs. |
POST /v1/codesync/webhook/github, POST /v1/catalog/om/webhook | A 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/ingest | A 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
- Credential management: who should hold a key, from an administrator's point of view.
- OAuth 2.1: registration, authorization, token and discovery endpoints.
- Errors and retries: what each refusal means and which are worth retrying.