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

# Connect with OAuth

> Let your product connect a customer's Filed workspace without handling their credentials

If you build a product your customers use alongside Filed, OAuth lets them
connect the two themselves. Your customer approves the connection in Filed, and
you receive a token scoped to the one workspace they chose. Nobody emails an API
key around, and the customer can disconnect you at any time.

This is the flow to use when you are integrating **on behalf of many Filed
customers**. If you are building for a single workspace you control, an
[API key](/guides/authentication) is simpler.

```mermaid theme={null}
flowchart TD
  A["Your product<br/>(customer's browser)"] -->|"1. redirect to /authorize"| B["Filed consent screen<br/>sign in, pick workspace, approve"]
  B -->|"2. redirect back with code"| C["Your backend"]
  C -->|"3. code + PKCE verifier + client secret"| D["/token<br/>web.apps.filed.com"]
  D -->|"4. access token (3 min) + refresh token (90 days)"| C
  C -->|"5. Bearer access token"| E["Filed API<br/>router.apps.filed.com"]
```

Filed implements the authorization code grant with
[PKCE](https://datatracker.ietf.org/doc/html/rfc7636). Confidential clients only:
every call to the token endpoint is authenticated with your client secret.

## Before you start

Filed registers each partner by hand. Contact Filed to receive:

| You receive       | Notes                                 |
| ----------------- | ------------------------------------- |
| `client_id`       | Public. Travels in the browser.       |
| `client_secret`   | Secret. Never leaves your backend.    |
| Registered scopes | The ceiling for what you may request. |

You supply your **redirect URI** during registration. Filed matches it exactly,
so a trailing slash or an extra query parameter is rejected. Register every
environment you need (production, staging) as a separate URI.

<Warning>
  Filed stores only a SHA-256 digest of your client secret. If you lose it, Filed
  issues a new one rather than recovering the old one, and your integration stops
  working until you deploy the replacement.
</Warning>

Your customers must already be Filed customers. Filed does not offer self-serve
signup, so a workspace is created under contract by the Filed team. Signing a
customer up in your product does not create a Filed account for them.

## Endpoints

| Environment | Base URL                                       |
| ----------- | ---------------------------------------------- |
| Production  | `https://web.apps.filed.com/api/partner-oauth` |
| Staging     | `https://web.wipfiled.com/api/partner-oauth`   |

Filed publishes
[RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata, so most OAuth
libraries can configure themselves:

```bash cURL theme={null}
curl https://web.apps.filed.com/api/partner-oauth/.well-known/oauth-authorization-server
```

```json theme={null}
{
  "issuer": "https://web.apps.filed.com/api/partner-oauth",
  "authorization_endpoint": "https://web.apps.filed.com/api/partner-oauth/authorize",
  "token_endpoint": "https://web.apps.filed.com/api/partner-oauth/token",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic"],
  "scopes_supported": [
    "workspace:read",
    "clients:read",
    "clients:write",
    "documents:write",
    "taxprep:write",
    "tasks:read"
  ]
}
```

## Scopes

Request only what you use. Your customer sees this list on the consent screen in
the wording below, so an unnecessary scope is a reason for them to decline.

| Scope             | What the customer is told               |
| ----------------- | --------------------------------------- |
| `workspace:read`  | See which workspace it is connected to  |
| `clients:read`    | Read your clients and their task status |
| `clients:write`   | Create clients and attach documents     |
| `documents:write` | Upload documents                        |
| `taxprep:write`   | Start tax prep runs                     |
| `tasks:read`      | Follow the status of those runs         |

Omit the `scope` parameter to request everything you are registered for.

<Note>
  Scopes are recorded on the grant and shown to the customer, but Filed does not
  yet enforce them field by field. Today a token's real limit is the workspace it
  belongs to, and whether the grant included any write scope at all. Treat scopes
  as a contract you keep, not a boundary Filed enforces for you.
</Note>

## Connect a customer

### 1. Send the customer to Filed

Generate a PKCE verifier and challenge, store the verifier against the user's
session, and redirect their browser to the authorization endpoint.

```http theme={null}
GET https://web.apps.filed.com/api/partner-oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyour-app.example.com%2Ffiled%2Fcallback
  &response_type=code
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &scope=clients%3Aread%20documents%3Awrite
  &state=OPAQUE_CSRF_VALUE
```

<ParamField path="client_id" type="string" required>
  The identifier Filed issued you.
</ParamField>

<ParamField path="redirect_uri" type="string" required>
  Must match one of your registered URIs exactly.
</ParamField>

<ParamField path="response_type" type="string" required>
  Always `code`.
</ParamField>

<ParamField path="code_challenge" type="string" required>
  Base64url SHA-256 of your verifier.
</ParamField>

<ParamField path="code_challenge_method" type="string" required>
  Always `S256`. Filed rejects `plain`.
</ParamField>

<ParamField path="scope" type="string">
  Space-delimited. Omit to request every scope you are registered for.
</ParamField>

<ParamField path="state" type="string">
  Returned to you unchanged. Use it to defend against CSRF.
</ParamField>

### 2. The customer approves

Filed asks them to sign in, choose which of their workspaces to connect, and
review what you are asking for. The grant covers that one workspace: your token
cannot reach the customer's other workspaces.

Only an opaque handle for the request travels in the browser. Your `client_id`
and the requested scopes stay on Filed's server, so nothing the customer can
edit in the URL bar changes what the grant permits.

### 3. Receive the code

Filed redirects back to your `redirect_uri`:

```http theme={null}
GET https://your-app.example.com/filed/callback
  ?code=8f14e45f-ceea-467a-9f0f-3c1e3b1c9d2a
  &state=OPAQUE_CSRF_VALUE
```

Check `state` matches what you stored. The code is single use and expires after
**60 seconds**, so exchange it immediately.

### 4. Exchange the code for tokens

Call the token endpoint from your backend. Authenticate with HTTP Basic,
sending your `client_id` as the username and your `client_secret` as the
password. This is the `client_secret_basic` method.

```bash cURL theme={null}
curl -X POST https://web.apps.filed.com/api/partner-oauth/token \
  -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=8f14e45f-ceea-467a-9f0f-3c1e3b1c9d2a \
  -d redirect_uri=https://your-app.example.com/filed/callback \
  -d code_verifier=YOUR_PKCE_VERIFIER
```

```json theme={null}
{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.PAYLOAD.SIGNATURE",
  "token_type": "Bearer",
  "expires_in": 180,
  "refresh_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.PAYLOAD.SIGNATURE",
  "scope": "clients:read documents:write",
  "workspace_id": "019f2c8a-4b1e-7c3d-9a4e-2f6b1c8d0e5a",
  "workspace_name": "Miller & Associates CPA"
}
```

<ResponseField name="access_token" type="string">
  A Filed workspace token. Send it as a bearer token to the Filed API.
</ResponseField>

<ResponseField name="token_type" type="string">
  Always `Bearer`.
</ResponseField>

<ResponseField name="expires_in" type="number">
  Seconds until the access token expires. Always `180`.
</ResponseField>

<ResponseField name="refresh_token" type="string">
  Store this against the customer. It is valid for 90 days and is the credential
  that represents the grant.
</ResponseField>

<ResponseField name="scope" type="string">
  Space-delimited scopes the customer approved. Returned on this exchange only.
</ResponseField>

<ResponseField name="workspace_id" type="string">
  The workspace the grant is pinned to. Returned on this exchange only, because it
  never changes for the life of the grant.
</ResponseField>

<ResponseField name="workspace_name" type="string">
  Display name of that workspace, so you can show the customer what they connected.
  Returned on this exchange only.
</ResponseField>

### 5. Call the Filed API

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

See [Making requests](/guides/making-requests) for the API itself.

## Pushing documents

Documents go in the same way as any other integration — [stage the
files](/guides/uploading-documents), then call
[`addClientDocuments`](/apis/clients#add-documents-to-a-client) — with one extra
step the first time you push for a client.

```mermaid theme={null}
flowchart TD
  S["Your backend"] -->|"1. stage files"| U["uploadIds"]
  S -->|"2. createConnectionClientLink<br/>your externalId to a Filed clientId"| L["Link<br/>your connection to that client"]
  S -->|"3. addClientDocuments<br/>clientId + uploadIds"| G{"Link exists?"}
  U --> G
  L --> G
  G -->|"no"| E["FAILED_PRECONDITION"]
  G -->|"yes"| D["Input document<br/>stamped with link_id"]
  D -->|"link_id resolves to your connection"| T["Traceable to your product"]
```

**Link the client to your connection first.** `addClientDocuments` takes a Filed
`clientId`, and the push is refused with `FAILED_PRECONDITION` until a link
exists between your connection and that client:

```graphql theme={null}
mutation LinkClient {
  createConnectionClientLink(
    clientId: "018f9c2a-3d5f-7a10-b2c4-9e8d7f6a5b4c"
    connectionId: "018f9c2a-9b7d-7e21-a3f5-1c4d8e2b6a90"
    externalId: "your-own-id-for-this-client"
    displayName: "Ada Lovelace"
  ) {
    id
  }
}
```

`externalId` is **your** identifier for the client. Filed cannot create the link
for you because only you know that value, and it is what lets you find the same
client again later.

The link is also how Filed attributes what you push. A document filed under your
connection's link is traceable back to your product; without one it would be
indistinguishable from a document the customer uploaded in Filed themselves —
the access token identifies the customer who approved your grant, not whoever
acted in your product.

<Note>
  Only `addClientDocuments` carries this attribution today. Documents attached
  through `createClient`, `initiateTaxReview`, or `initiateTaxAdvisor` are recorded
  as ordinary uploads.
</Note>

## Refreshing

Access tokens last **three minutes**. This is deliberate: the long-lived
credential is the refresh token you hold, and what reaches the API is not.
Exchange the refresh token whenever you need a fresh access token rather than
caching one.

```bash cURL theme={null}
curl -X POST https://web.apps.filed.com/api/partner-oauth/token \
  -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_REFRESH_TOKEN
```

The response has the same shape, without `scope`, `workspace_id`, and
`workspace_name`. Filed returns the **same** refresh token rather than rotating
it, so there is nothing to store after a refresh.

The refresh token is valid for 90 days. When it expires, send the customer
through the flow again.

<Warning>
  The refresh token grants access to a Filed workspace for as long as it is valid.
  Encrypt it at rest and never expose it to a browser or a mobile client.
</Warning>

## When a customer disconnects

Customers manage the connection in Filed under **Plugins**, where your
integration appears by name alongside their others. Disconnecting revokes the
grant.

Because access tokens are short lived, a disconnect takes effect within three
minutes at the outside. Your next refresh fails with `invalid_grant`. The same
happens if the customer who approved the grant leaves the workspace, or if the
90 days elapse.

Treat `invalid_grant` on refresh as "this connection is over": stop retrying,
clear the stored token, and prompt the customer to reconnect if they want to
continue.

## Handling failures

Some failures happen before Filed can trust your `redirect_uri`: an unknown
`client_id`, or a URI that is not registered. Filed renders an error page in the
customer's browser rather than redirecting, because bouncing a browser to an
unverified URI is how an open redirect is built. Check your registration if a
customer reports seeing this page.

Once the redirect URI is verified, everything comes back to you as query
parameters, per
[RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1):

```http theme={null}
GET https://your-app.example.com/filed/callback
  ?error=access_denied
  &error_description=the%20customer%20declined%20the%20request
  &state=OPAQUE_CSRF_VALUE
```

| `error`                     | Means                                                                   |
| --------------------------- | ----------------------------------------------------------------------- |
| `access_denied`             | The customer declined, or cannot connect. Read `error_description`.     |
| `invalid_request`           | A required parameter is missing, or the challenge method is not `S256`. |
| `invalid_scope`             | You asked for a scope you are not registered for.                       |
| `unsupported_response_type` | `response_type` was not `code`.                                         |

`access_denied` covers three distinct situations, and the description tells you
which:

| `error_description`                                             | What happened                                     | What to tell the customer                  |
| --------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------ |
| the customer declined the request                               | They pressed Cancel                               | Nothing. They chose this.                  |
| the signed-in Filed user does not belong to any Filed workspace | Their Filed account exists but is in no workspace | Ask their Filed contact to add them to one |
| the customer does not have an active Filed account              | They are not a Filed customer                     | They need to talk to Filed first           |

The last two are ordinary outcomes, not rare edge cases, because Filed accounts
are provisioned under contract. Filed hands the browser back to you rather than
stopping on a Filed page, so your product is the one that explains what to do
next.

The token endpoint returns JSON errors:

| `error`                   | HTTP | Means                                                                                                                                                |
| ------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_client`          | 401  | Client authentication failed. Same response for an unknown `client_id` and a wrong secret.                                                           |
| `invalid_grant`           | 400  | The code or refresh token is unknown, used, expired, issued to another client, or the grant was revoked. Also returned when PKCE verification fails. |
| `invalid_request`         | 400  | A required form field is missing.                                                                                                                    |
| `unsupported_grant_type`  | 400  | `grant_type` was not `authorization_code` or `refresh_token`.                                                                                        |
| `temporarily_unavailable` | 400  | Filed could not mint a token. Safe to retry.                                                                                                         |

## Current limits

Worth knowing before you build:

* Scopes are not enforced field by field yet, as described above.
* There is no token revocation endpoint. The discovery document omits it rather
  than advertising one that does not work. Customers revoke by disconnecting.
* Refresh tokens are not rotated. Refreshing returns the same token, so its
  90-day life is both how long a leaked copy stays usable and how often a
  customer reconnects.
* Authorization requests expire after 10 minutes, so a customer who leaves the
  consent screen open and comes back has to start again.
