You are building a site builder, CMS plugin, or page editor, and you want an "Add a Maxforms form" block. This guide takes you from zero to a working block: the user connects their Maxforms account once, picks a form from a list, chooses how it should look, and your product inserts the markup.
Everything here uses the Maxforms API. You need one scope, forms:read, and four endpoints.
What you will build#
- Connect. The user authorizes your app for one of their workspaces. You store the access token.
- Pick. You call
GET /v1/formsand show their forms. - Configure. You call
GET /v1/embed/optionsand show only the options that apply to the chosen mode. - Insert. You call
GET /v1/forms/{code}/embedand drop the returnedhtmlinto the page.
Before you start#
Email [email protected] with your integration name and redirect URI. You get back a client_id and client_secret.
Base URL for every API call below: https://api.maxforms.com. Send Accept: application/json. Every /v1/* response is Content-Type: application/vnd.api+json; /oauth/token below is plain application/json.
1. Connect the user's account#
Send the user to the consent screen on the app host, requesting forms:read only. Asking for scopes you never use makes the consent screen scarier than it needs to be, and gives you nothing.
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 picks which workspace to authorize on that screen. The token is bound to that workspace permanently. It can never see forms in another one, so if your product supports multiple sites or accounts, expect a user to connect Maxforms more than once and store one token per connection.
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>
Access and refresh tokens both last 1 year. Store the refresh token and refresh before expiry, otherwise the user has to authorize again. Full details are in Overview and authentication.
Want to label the connection in your UI with the account it belongs to? Call GET /v1/me, which returns the user's name and email and needs no scope.
2. Show the user their forms#
GET /v1/forms returns every form in the workspace by default, drafts and paused forms included: nothing is filtered unless you ask. For a picker, request filter[available]=true to get only the forms a visitor can fill in right now:
GET /v1/forms?filter[available]=true&filter[search]=news&page[size]=25&page[number]=1
Authorization: Bearer <access_token>
{
"jsonapi": { "version": "1.1" },
"data": [
{
"type": "forms",
"id": "aZ3kP9mQ7nB4",
"attributes": {
"name": "Newsletter signup",
"available": true,
"unavailable_reason": null,
"url": "https://form.maxforms.com/aZ3kP9mQ7nB4",
"hidden_fields": ["ref"],
"updated_at": "2026-08-20T10:14:32+00:00"
}
}
],
"links": { "first": "…", "last": "…", "prev": null, "next": null },
"meta": { "page": 1, "size": 25, "total": 1 }
}
Four things to get right here:
- Store
data[].id, not the markup. It is the form's public code and is the identifier every other endpoint takes. Re-fetch the embed when the user changes an option rather than editing stored HTML by hand. - Filter explicitly. Without
filter[available]=trueyou get everything, including forms nobody can submit yet. A picker should almost always ask forfilter[available]=true; an editor that needs to show a form the user picked earlier, even if it later stopped working, should not. - Page with
meta. Incrementpage[number]untilmeta.page * meta.size >= meta.total, or followlinks.next.page[size]is clamped to 100. - Search server-side. Pass
filter[search]rather than filtering a full list in your UI, and let the user type%or_safely: both are matched literally.
If the user picked a form weeks ago and it has since been unpublished or paused, GET /v1/forms/{code} still returns it, with attributes.available: false and an attributes.unavailable_reason of unpublished, inactive, not_started, or ended. Use that to tell the user what happened instead of showing an empty block.
attributes.hidden_fields lists the codes of the form's hidden fields, read from the published version and falling back to the draft for a form that has never been published. Each code is a pre-fill target: a code of ref is filled with data-maxforms-field-ref on the embed markup, as described in Standard inline embed. Codes are chosen by whoever built the form, so treat them as opaque strings and offer them to the user rather than expecting a naming convention.
3. Offer the options that actually exist#
Build your options UI from GET /v1/embed/options rather than hard-coding a list. It returns the modes and every option, with its type, default, accepted values, and the modes it applies to.
{
"jsonapi": { "version": "1.1" },
"data": [
{
"type": "embed-options",
"id": "show_title",
"attributes": {
"name": "show_title",
"type": "boolean",
"default": false,
"modes": ["standard", "popup"],
"attribute": "data-maxforms-show-title"
}
},
{
"type": "embed-options",
"id": "width",
"attributes": {
"name": "width",
"type": "integer",
"modes": ["popup"],
"attribute": "data-maxforms-width"
}
},
{
"type": "embed-options",
"id": "standard_width",
"attributes": {
"name": "width",
"type": "string",
"default": "100%",
"modes": ["standard"],
"attribute": "data-maxforms-width"
}
}
],
"meta": { "modes": ["standard", "popup", "full-page"] }
}
Each entry's id identifies the option and is unique per JSON:API's rules; attributes.name is the public query-parameter name you send back on the embed request, and it is not unique — width names both the popup entry above (a pixel count) and the standard one (a CSS length), because the same query parameter means something different in each mode. Group data by attributes.modes first, key your store by id, and render each entry from its attributes.type: a checkbox for boolean, a select built from attributes.values for enum, a number input for integer, a text input for string. When you build the query string for the embed request, send attributes.name, not id.
delay_seconds and hide_on_submit_delay are in seconds. Label them that way: the API converts to milliseconds when it renders the markup. Each is also conditional, and the descriptor's modes will not warn you: delay_seconds only produces data-maxforms-delay when open=auto (not exit_intent), so sending it with any other open value, or none, renders no delay attribute at all. hide_on_submit_delay only produces data-maxforms-hide-delay when hide_on_submit=1 and the delay is greater than zero.
Like the other form and embed endpoints, this one answers 403 with error code workspace_unavailable if the bound workspace is suspended or scheduled for deletion; see the failure table below.
4. Fetch the embed#
GET /v1/forms/aZ3kP9mQ7nB4/embed?mode=standard&show_title=1&width=640px
Authorization: Bearer <access_token>
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "embeds",
"id": "aZ3kP9mQ7nB4",
"attributes": {
"mode": "standard",
"html": "<div data-maxforms-form=\"aZ3kP9mQ7nB4\" data-maxforms-dynamic-height=\"1\" data-maxforms-show-title=\"1\" data-maxforms-width=\"640px\"><a href=\"https://form.maxforms.com/aZ3kP9mQ7nB4\" data-maxforms-fallback>Open Form</a></div>",
"attributes": {
"data-maxforms-form": "aZ3kP9mQ7nB4",
"data-maxforms-dynamic-height": "1",
"data-maxforms-show-title": "1",
"data-maxforms-width": "640px"
},
"script_url": "https://embed.maxforms.com/v1/embed.js",
"iframe_url": "https://form.maxforms.com/embed/aZ3kP9mQ7nB4"
}
}
}
Insert data.attributes.html as-is. It is authoritative in every mode. data.attributes.attributes is a convenience for editors that need to store the embed as structured data instead of a markup blob; it is read back out of that same html, so the two can never describe two different embeds.
5. Insert it into the page#
Standard and popup#
html is a single element with no <script> tag. Load the SDK yourself, once per page, from script_url:
<script async src="https://embed.maxforms.com/v1/embed.js"></script>
<!-- the html field, pasted verbatim -->
<div data-maxforms-form="aZ3kP9mQ7nB4" data-maxforms-dynamic-height="1" data-maxforms-show-title="1" data-maxforms-width="640px">
<a href="https://form.maxforms.com/aZ3kP9mQ7nB4" data-maxforms-fallback>Open Form</a>
</div>
One script tag covers any number of embeds on the page. Never add a second one.
Injecting the embed after the page has loaded, as a live editor preview does, works without any extra call: the SDK watches the DOM and mounts a standard embed as soon as it appears, and popup buttons open through a delegated click handler. The one exception is a popup set to open=auto or open=exit_intent, which is scheduled when the page loads. Insert one of those into an already-loaded page and it will not fire until the next page load. To open a popup on demand from your own code, use Maxforms('openPopup', …) from the JavaScript API.
The <a data-maxforms-fallback> inside the element is deliberate. It links to the hosted form, so the block still works if the SDK is blocked or fails to load. Keep it.
Full page#
mode=full-page returns a complete HTML document, not a fragment, because a 100% height iframe needs the html and body ancestors to have a height too. Serve it as its own page or route; do not inject it into an existing one.
Two consequences to plan for:
attributes.attributesis missing from the response entirely, not empty. There is nothing to compose, so there is no attribute map. Check whether the key exists before you iterate it.script_urlis still returned, even withno_javascript=1. It is a constant telling you where the SDK lives, not a claim that this document loads it. Ano_javascript=1document contains a plain<iframe src="…">and no script tag at all.
If you would rather build your own container, iframe_url gives you the form's bare embed URL. It never carries your options: full-page options are baked into the src inside html.
6. Handle the failures your users will hit#
| What happened | Response | What to show |
|---|---|---|
| The user left the workspace | 401, error code http_error on the request that discovers it (Maxforms revokes the token during that same request); every request after that gets 401 with error code unauthenticated |
Prompt them to reconnect on either code. The token is dead; refreshing will not help. |
| Your app's authorization was revoked | 401, error code unauthenticated |
Same as above. |
| The workspace is scheduled for deletion or can no longer accept submissions | 403, error code workspace_unavailable, meta.reason set |
Show the reason. workspace_deletion_pending and workspace_suspended are the two you will see in practice. |
| Your token lacks the scope | 403, error code insufficient_scope |
Re-authorize requesting forms:read. |
| The form was deleted, or belongs to another workspace | 404, error code not_found |
Ask the user to pick a form again. |
| An option value is not allowed | 422, error code validation, source.pointer names the option |
Fix the value. Your UI should not have offered it: the descriptor lists exactly what each option accepts. |
Both 403 cases share the status code with different error code values in the same errors document. Branch on code, not on the status alone.
The silent failure to know about#
An option value the API rejects returns 422. An option name it does not recognise is dropped without a word, and so is an option that does not apply to the mode you asked for:
?mode=standard&show_titel=1 -> 200 OK, the typo is ignored
?mode=standard&position=bottom-right -> 200 OK, position is popup-only and is ignored
?mode=popup&position=top-left -> 422, top-left is not an accepted value
If an option seems to do nothing, check the spelling against GET /v1/embed/options first, then look at data.attributes.attributes (or the iframe src inside html in full-page mode) to see what was really rendered. Options left at their default are often not emitted at all, so an absent attribute means "default", not "ignored".
7. Ship checklist#
- Request
forms:readonly. - Store one token per connection, and refresh before the 1 year expiry.
- Store the form's
idand the chosen options; re-fetch the embed when either changes. - Load
script_urlonce per page, never once per embed. - Treat a missing
attributes.attributeskey as "this is full-page", not as an error. - Surface
unavailable_reasonand the403workspace_unavailablereason to the user instead of rendering an empty block.
Building an automation instead, reacting to submissions as they arrive? See Webhooks.