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 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 [email protected] 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_tokento avoid asking the user to re-authorize. - Auth header:
Authorization: Bearer <access_token>
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:
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}):
{
"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:
{
"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:
{
"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:
| 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: the interactive reference, generated from the code with Scalar. Every endpoint, every parameter, every example, always current.
/docs.openapion 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: [email protected]. For account or product questions: [email protected].