# Overview and authentication

> Base URLs, the OAuth 2.0 Authorization Code flow, scopes and workspace binding, the JSON:API document shapes, and rate limits.

The Maxforms API is authenticated with OAuth 2.0, every access token is bound to one workspace, and every response under `/v1/*` is a [JSON:API 1.1](https://jsonapi.org/) document.

## Base URLs

The API and the OAuth consent screen live on different hosts:

| What | Base |
| --- | --- |
| API calls (`/v1/*`, `/docs.openapi`, `/oauth/token`, `/oauth/device/code`) | `https://api.maxforms.com` |
| The OAuth consent screen (`/oauth/authorize`, `/oauth/device`) | `https://app.maxforms.com` |

Locally, or on any deployment that has not configured a dedicated API subdomain, the same endpoints are served under `/api` on the single application host instead: `<your-app-url>/api/v1/forms`, `<your-app-url>/api/oauth/token`, and so on. `GET /` (or `GET /api` on the single-host fallback) returns a discovery document with the API's name, version, `documentation_url`, `authorization_url`, and the absolute URL of every other endpoint, so a client that reads it never has to hard-code which branch it is on. It needs no authentication and is rate limited to 60 requests per minute per IP.

Send `Accept: application/json`. Every `/v1/*` response, and every error document, carries `Content-Type: application/vnd.api+json` — except a successful discovery call, which is plain `application/json` (it is not a JSON:API document). A discovery request that fails, for example on rate limiting, still comes back as `application/vnd.api+json`, so do not assume the two share a content type.

## Authentication (OAuth 2.0)

Maxforms uses the OAuth 2.0 Authorization Code grant. Credentials are issued on request: email [partners@maxforms.com](mailto:partners@maxforms.com) with your integration name and redirect URI.

- **Authorization URL**: `https://app.maxforms.com/oauth/authorize`
- **Token URL**: `https://api.maxforms.com/oauth/token`
- **Token lifetime**: access and refresh tokens are both valid for 1 year. Refresh before expiry with `grant_type=refresh_token` to avoid asking the user to re-authorize.
- **Auth header**: `Authorization: Bearer <access_token>`

```text
https://app.maxforms.com/oauth/authorize
  ?client_id=<client_id>
  &redirect_uri=<your_redirect_uri>
  &response_type=code
  &scope=forms:read
  &state=<random_csrf_value>
```

The user lands on a consent screen listing what your app can do and, separately, which of their workspaces to authorize it for. **The token is bound to that workspace permanently**: it can read or change resources in that workspace alone, and a form or subscription in any other workspace answers `404`, indistinguishable from one that does not exist. If the user needs a second workspace connected, they authorize a second time and you store a second token.

Exchange the returned code for a token:

```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<authorization_code>
&client_id=<client_id>
&client_secret=<client_secret>
&redirect_uri=<your_redirect_uri>

HTTP/1.1 200 OK
{
  "token_type":    "Bearer",
  "expires_in":    31536000,
  "access_token":  "eyJ0eXAiOi…",
  "refresh_token": "def5020087…"
}
```

If the token owner is later removed from the bound workspace, the token is revoked and every call returns `401`.

### Device authorization grant

Clients without a browser (a CLI, a TV app) use the OAuth 2.0 device grant (RFC 8628) instead. `POST /oauth/device/code` returns a `user_code` the person enters at `https://app.maxforms.com/oauth/device`; your client then polls `POST /oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`. Requires a device-flow-enabled client, so ask for one when you request credentials.

## Scopes and who can grant them

Scopes are listed on the consent screen, recorded on the issued token, and enforced on every request. A token missing an endpoint's scope gets `403` with error code `insufficient_scope`.

| Scope | Grants | Minimum workspace role to grant it |
| --- | --- | --- |
| `forms:read` | See your form names and fields | Viewer |
| `submissions:read` | Read the responses people submit to your forms | Viewer |
| `webhooks:write` | Send new submissions to this app as they arrive | Admin |

`forms:read` and `submissions:read` are view-level capabilities every workspace member has, so anyone who belongs to the workspace can approve them. `webhooks:write` lets your app configure outbound integrations, which only an Admin or the workspace's Owner can approve: a Viewer or Editor who tries is refused with a `403` naming the scope, before the consent screen ever issues a code.

Request only the scopes you use. An embed integration needs `forms:read` alone; a webhook client typically wants `forms:read` (to populate a form picker) and `webhooks:write` together, adding `submissions:read` only if it also pulls sample data.

## JSON:API document shapes

Every `/v1/*` response is a JSON:API 1.1 document: `{"jsonapi": {"version": "1.1"}, "data": …}`, plus `included`, `links` or `meta` where the endpoint has them.

**A single resource** (`GET /v1/forms/{code}`):

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "forms",
    "id": "aZ3kP9mQ7nB4",
    "attributes": {
      "name": "Contact form",
      "available": true,
      "unavailable_reason": null,
      "url": "https://form.maxforms.com/aZ3kP9mQ7nB4",
      "hidden_fields": ["ref"],
      "updated_at": "2026-08-20T10:14:32+00:00"
    }
  }
}
```

**A collection** (`GET /v1/forms`), carrying `links` and `meta` alongside `data`:

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    { "type": "forms", "id": "aZ3kP9mQ7nB4", "attributes": { "name": "Contact form", "available": true, "unavailable_reason": null, "url": "https://form.maxforms.com/aZ3kP9mQ7nB4", "hidden_fields": [], "updated_at": "2026-08-20T10:14:32+00:00" } }
  ],
  "links": {
    "first": "https://api.maxforms.com/v1/forms?page%5Bnumber%5D=1",
    "last":  "https://api.maxforms.com/v1/forms?page%5Bnumber%5D=1",
    "prev":  null,
    "next":  null
  },
  "meta": { "page": 1, "size": 50, "total": 1 }
}
```

**An errors document**, from a `422` on a rejected embed option:

```json
{
  "errors": [
    {
      "status": "422",
      "code": "validation",
      "title": "Invalid attribute",
      "detail": "The position option must be one of: center, bottom-right.",
      "source": { "pointer": "/position" }
    }
  ]
}
```

### `filter`, `sort`, `include` and `page`

Query parameters follow the JSON:API bracket convention. Which families a given endpoint accepts — and the exact `filter`, `sort` and `include` names it allows — is listed per endpoint in the [API reference](/developers/api/reference):

| Family | Example |
| --- | --- |
| `filter[…]` | `filter[available]=true`, `filter[search]=contact` |
| `sort` | `sort=name`, `sort=-updated_at` (`-` for descending) |
| `include` | `include=form` |
| `page[number]`, `page[size]` | `page[number]=2&page[size]=25`. `page[size]` is clamped to 100; the default is 50 for forms and 10 for submissions |

An unrecognised `filter`, `sort` or `include` name returns `400` with error code `invalid_query`, not a silent no-op. Check spelling against the reference if a query does nothing.

## Errors

Every error is a JSON:API errors document with one object per problem, each carrying a stable `code`:

| `code` | Status | Meaning |
| --- | --- | --- |
| `validation` | 422 | A request body or query value failed validation. `source.pointer` names the field. |
| `unauthenticated` | 401 | The token is missing, invalid, expired or revoked. Re-authorize. |
| `insufficient_scope` | 403 | The token lacks the endpoint's required scope. Re-authorize requesting it. |
| `workspace_unavailable` | 403 | The bound workspace can't be used right now. `meta.reason` is one of `workspace_deletion_pending`, `workspace_suspended`, or `workspace_unresolved`. |
| `not_found` | 404 | The resource does not exist, or belongs to a different workspace: the two are indistinguishable on purpose. |
| `invalid_query` | 400 | A `filter`, `sort` or `include` name the endpoint does not declare. |
| `rate_limited` | 429 | Too many requests. Retry after the `Retry-After` header. |
| `http_error` | varies | Any other framework-level error caught on a matched route, for example the `401` returned the moment Maxforms discovers, mid-request, that the token's user has left the bound workspace (the token is revoked in the same response; every later call on it gets `401` with `unauthenticated` instead). |

A wrong HTTP method on a path that exists (`405`), and any non-`GET` request to a path that does not exist, are rejected by the framework's router before a route is ever matched. Neither reaches this table: both come back as a plain `{"message": "..."}` object with `Content-Type: application/json`, not a JSON:API errors document. Handle that shape too, or treat any non-2xx response with no `errors` array as a framework-level failure.

## Rate limits

`/v1/*` endpoints are limited to **120 requests per minute per access token** (`config('api.rate_limit_per_minute')`). `/oauth/token`, `/oauth/device/code`, and the discovery document (`GET /`) are limited to **60 requests per minute per IP address**. A `429` carries `Retry-After` and error code `rate_limited`.

## Reference

This page covers authentication and the document shapes; it does not enumerate every parameter and response field. For that, see:

- [**/developers/api/reference**](/developers/api/reference): the interactive reference, generated from the code with Scalar. Every endpoint, every parameter, every example, always current.
- **`/docs.openapi`** on the API host: the same reference as a raw OpenAPI 3 document, for generating a client or importing into your own tooling.

### Debugging a connection

| Symptom | Cause |
| --- | --- |
| Every call returns `401` | The token was revoked, or its owner left the bound workspace. Re-authorize. |
| A form picker is empty | The bound workspace has no forms, or the connection is bound to a different workspace than the user expects. The workspace is chosen on the consent screen and is permanent. |
| One endpoint returns `403` with `insufficient_scope` | The connection was authorized without that scope. Re-authorize with every scope you call. |
| `/v1/me` passes but every other call fails | Expected. `/v1/me` requires no scope and answers even when the bound workspace is suspended, so it reports liveness independently of what the token may do or where the workspace stands. Read the failing endpoint's own error `code`. |
| A `403` with `workspace_unavailable` | The bound workspace is scheduled for deletion or can no longer accept submissions. See `meta.reason`. |

## Support

For OAuth credentials, raised rate limits, or integration partner inquiries: [partners@maxforms.com](mailto:partners@maxforms.com). For account or product questions: [support@maxforms.com](mailto:support@maxforms.com).

