Skip to content

API

Overview and authentication

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

On this page

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_token to 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.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: [email protected]. For account or product questions: [email protected].

Keep reading

Was this helpful? Yes, it helped No, tell us why Still stuck? Contact support