> ## Documentation Index
> Fetch the complete documentation index at: https://developers.apps.filed.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Create an API key, understand its scope, and exchange it for an access token

The Filed API uses a **two-step token model**. You create a long-lived **API key**
in the Filed web app, then exchange it at request time for a short-lived
**access token** that you send as a `Bearer` token on every GraphQL call.

```mermaid theme={null}
flowchart LR
  A["API key<br/>(long-lived, workspace-scoped)"] -->|exchange| B["Access token<br/>(short-lived, 3 min)"]
  B -->|"Bearer"| C["GraphQL API<br/>(router.apps.filed.com)"]
```

All requests go to a single endpoint:

```
https://router.apps.filed.com/graphql
```

## 1. Create an API key

API keys are created from the Filed web app, per workspace:

1. Open the workspace you want to grant access to.
2. Go to **Plugins** and open the **API** tile, under **Prowork**.
3. Click **Create an API Key**.
4. Choose an **Expiry** and an **Access** level (see below).
5. Copy the key. **It is shown only once**, so store it in a secret manager. If you
   lose it, revoke it and create a new one.

<Note>
  An API key is **personal**: it authenticates as **you**, the user who created
  it. Every call made with a token minted from the key acts on your behalf and is
  limited to what your account is allowed to do in that workspace (the `userToken`
  from the exchange identifies you). If you leave the workspace or your access
  changes, the key's access changes with you. For a shared or service integration,
  create the key from an account you intend to own that integration.
</Note>

<Warning>
  An API key is a credential. Treat it like a password: never commit it to source
  control, never expose it in a browser or mobile client, and rotate it if it may
  have leaked.
</Warning>

### Expiry

The key is valid for the window you pick at creation time. After it expires, the
exchange step (below) stops working and you must create a new key.

| Option | Key lifetime      |
| ------ | ----------------- |
| `30`   | 30 days           |
| `60`   | 60 days           |
| `90`   | 90 days (default) |
| `365`  | 1 year            |

## 2. Scope: one key, one workspace

An API key is **scoped to the single workspace it was created in**. The access
token you get from it can only read and write data in that workspace. To
integrate with several workspaces, create one key per workspace.

<Note>
  This is different from legacy partner API keys, whose token could reach every
  workspace the partner created. New Filed API keys are deliberately
  workspace-scoped for tighter, per-workspace access control.
</Note>

### Access levels: read vs read-write

The **Access** level you pick at creation time is baked into every access token
minted from that key:

| Access             | Value        | What the token can do                                                                                       |
| ------------------ | ------------ | ----------------------------------------------------------------------------------------------------------- |
| **Read only**      | `read_only`  | Run **queries** to look up clients, tasks, documents, and other workspace data. All mutations are rejected. |
| **Read and write** | `read_write` | Everything read-only can do, **plus mutations**: upload documents, trigger runs, and modify workspace data. |

Pick the narrowest level that fits your integration. If you only pull data, use
**Read only** so a leaked key can never mutate your workspace.

## 3. Exchange the API key for an access token

Send your API key as the `refreshToken` argument to
`exchangeSurfaceRefreshTokenForAccessTokens`. This is a **public** mutation: it
is the only call you make *without* a `Bearer` token.

```graphql theme={null}
mutation ExchangeApiKey($apiKey: String!) {
  exchangeSurfaceRefreshTokenForAccessTokens(refreshToken: $apiKey) {
    userToken
    workspaceToken
  }
}
```

### Arguments

<ParamField path="refreshToken" type="String!" required>
  Your API key, exactly as copied from the Filed web app.
</ParamField>

<ParamField path="expiresInSeconds" type="Int">
  Request a shorter access-token lifetime. Values above the maximum for the key's
  surface are clamped to it, so this can only shorten the token, never extend it.
  Omit it to get the maximum.
</ParamField>

### Returns: `AccessTokens`

```graphql theme={null}
type AccessTokens {
  userToken: String!
  workspaceToken: String!
}
```

<ResponseField name="userToken" type="String!">
  A short-lived token identifying **you** (the user the key belongs to) across
  your account, not tied to any one workspace.
</ResponseField>

<ResponseField name="workspaceToken" type="String!">
  A short-lived token that is **also you**, scoped to **the key's workspace**. This
  is the token you use for API calls. It carries the key's access level: a
  `read_only` key mints a read-only `workspaceToken`.
</ResponseField>

Both tokens authenticate as the user who created the key; neither is a separate
service identity. The `userToken` is you account-wide, the `workspaceToken` is you
within the key's workspace at the key's access level.

Both tokens are short-lived: an API key mints tokens that last **3 minutes**.
API and MCP keys are capped shorter than Filed's other surfaces, which run to 30
minutes. When the tokens expire, call the exchange again with the same API key
to mint fresh ones. The API key itself lasts until its expiry.

### Example

<RequestExample>
  ```bash cURL theme={null}
  curl -X POST https://router.apps.filed.com/graphql \
    -H "Content-Type: application/json" \
    -d '{
      "query": "mutation ExchangeApiKey($apiKey: String!) { exchangeSurfaceRefreshTokenForAccessTokens(refreshToken: $apiKey) { userToken workspaceToken } }",
      "variables": { "apiKey": "YOUR_API_KEY" }
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json theme={null}
  {
    "data": {
      "exchangeSurfaceRefreshTokenForAccessTokens": {
        "userToken": "eyJhbGciOiJFUzI1NiI...",
        "workspaceToken": "eyJhbGciOiJFUzI1NiI..."
      }
    }
  }
  ```
</ResponseExample>

## 4. Call the API with the access token

Send the `workspaceToken` in the `Authorization` header on every subsequent
request:

```http theme={null}
Authorization: Bearer YOUR_WORKSPACE_TOKEN
```

Verify it works with a `me` query. `me` returns the `Me` union, which resolves to
`WorkspaceUser` when you authenticate with a `workspaceToken` (and to `User` with
an account-wide `userToken`). Because it is a union, select fields with an inline
fragment on the type you expect:

<RequestExample>
  ```graphql theme={null}
  query Me {
    me {
      __typename
      ... on WorkspaceUser {
        id
        role
        createdAt
        user {
          id
          name
          email
        }
        workspace {
          id
          name
        }
      }
    }
  }
  ```
</RequestExample>

<ResponseExample>
  ```json theme={null}
  {
    "data": {
      "me": {
        "__typename": "WorkspaceUser",
        "id": "019f0fb6-37b1-7800-b7bc-0d11288504b1",
        "role": "admin",
        "createdAt": "2026-06-14T09:31:20.000Z",
        "user": {
          "id": "019f0fb6-26e9-74b7-a842-cb43a2a41682",
          "name": "Jane Preparer",
          "email": "jane@example-firm.com"
        },
        "workspace": {
          "id": "019f0fb6-379a-7f72-b7ec-ebd8f41ccfa1",
          "name": "Example Tax Firm"
        }
      }
    }
  }
  ```
</ResponseExample>

Run it over HTTP the same way as the exchange, adding your token:

```bash cURL theme={null}
curl -X POST https://router.apps.filed.com/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_WORKSPACE_TOKEN" \
  -d '{ "query": "query Me { me { __typename ... on WorkspaceUser { id role createdAt user { id name email } workspace { id name } } } }" }'
```

### Types

```graphql theme={null}
union Me = User | WorkspaceUser

type WorkspaceUser {
  id: ID!
  role: WorkspaceRole!
  createdAt: Date!
  user: UserShortDetails!
  workspace: Workspace!
}

type UserShortDetails {
  id: ID!
  name: String!
  email: String!
  emailHash: String
  novuHash: String
}

enum WorkspaceRole {
  admin
  l1
  l2
  l3
}
```

<ResponseField name="me" type="Me">
  Union of `User` (returned with an account-wide `userToken`) and `WorkspaceUser`
  (returned with a workspace-scoped `workspaceToken`). Query it with
  `... on WorkspaceUser { ... }` to read workspace fields.
</ResponseField>

<ResponseField name="WorkspaceUser.id" type="ID!">
  The membership id that links this user to the workspace.
</ResponseField>

<ResponseField name="WorkspaceUser.role" type="WorkspaceRole!">
  The user's role in the workspace: `admin`, `l1`, `l2`, or `l3`.
</ResponseField>

<ResponseField name="WorkspaceUser.createdAt" type="Date!">
  When the user was added to the workspace.
</ResponseField>

<ResponseField name="WorkspaceUser.user" type="UserShortDetails!">
  The underlying user account. `emailHash` and `novuHash` back Filed's own
  in-product integrations; integrations can ignore them.
</ResponseField>

<ResponseField name="WorkspaceUser.workspace" type="Workspace!">
  The workspace this `workspaceToken` is scoped to.
</ResponseField>

## Putting it together

A typical integration:

1. **Once**, in the web app: create a workspace-scoped API key with the access
   level you need, and store it as a secret.
2. **On startup / on 401**: exchange the API key for a fresh `workspaceToken`.
3. **Per request**: send `Authorization: Bearer <workspaceToken>`.
4. **When the token expires (3 min)**: repeat step 2 with the same API key.

<Tip>
  Cache the `workspaceToken` and only re-exchange when it expires (or when a call
  returns an auth error) rather than exchanging on every request.
</Tip>

## Rate limits

API-key traffic is rate limited per key at **120 GraphQL requests per 60
seconds**. Over the limit, Filed returns HTTP `429` with a `Retry-After` header
in seconds and a GraphQL error whose `extensions.code` is `RATE_LIMITED`. Back
off for the stated interval rather than retrying immediately.

Two things fall outside the limit: the exchange call, which carries no bearer
token, and the resumable upload endpoint, which is not GraphQL. Every call made
with a `workspaceToken` counts, which is the other reason to cache the token for
its full 3 minutes rather than exchanging per request.

## Troubleshooting

**`exchangeSurfaceRefreshTokenForAccessTokens` returns an error**

* The API key is wrong, revoked, or past its expiry. Create a new one.
* Confirm you are posting to `https://router.apps.filed.com/graphql`.

**Queries work but mutations are rejected**

* The key was created as **Read only** (`read_only`), and the mutation came back
  with `extensions.code` set to `FORBIDDEN`. Create a **Read and write** key to
  allow mutations.

**Requests fail after 3 minutes**

* The `workspaceToken` expired. Re-exchange the API key for a new one.

**Requests return `429`**

* You crossed the rate limit. Back off for the number of seconds in the
  `Retry-After` header. See [Rate limits](#rate-limits).
