# Build an embed integration

> Let your users pick one of their Maxforms forms and drop it into a page: OAuth, the form list, the option descriptor, and the embed endpoint.

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](/developers/api/overview). You need one scope, `forms:read`, and four endpoints.

## What you will build

1. **Connect.** The user authorizes your app for one of their workspaces. You store the access token.
2. **Pick.** You call `GET /v1/forms` and show their forms.
3. **Configure.** You call `GET /v1/embed/options` and show only the options that apply to the chosen mode.
4. **Insert.** You call `GET /v1/forms/{code}/embed` and drop the returned `html` into the page.

## Before you start

Email [partners@maxforms.com](mailto:partners@maxforms.com) 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.

```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 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:

```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>
```

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](/developers/api/overview).

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:

```http
GET /v1/forms?filter[available]=true&filter[search]=news&page[size]=25&page[number]=1
Authorization: Bearer <access_token>
```

```json
{
  "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]=true` you get everything, including forms nobody can submit yet. A picker should almost always ask for `filter[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`.** Increment `page[number]` until `meta.page * meta.size >= meta.total`, or follow `links.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](/developers/embed/standard). 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`](/developers/api/reference) 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.

```json
{
  "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](#6-handle-the-failures-your-users-will-hit) below.

## 4. Fetch the embed

```http
GET /v1/forms/aZ3kP9mQ7nB4/embed?mode=standard&show_title=1&width=640px
Authorization: Bearer <access_token>
```

```json
{
  "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`:

```html
<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](/developers/embed/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.attributes` is 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_url` is still returned**, even with `no_javascript=1`. It is a constant telling you where the SDK lives, not a claim that this document loads it. A `no_javascript=1` document 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:

```text
?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:read` only.
- Store one token per connection, and refresh before the 1 year expiry.
- Store the form's `id` and the chosen options; re-fetch the embed when either changes.
- Load `script_url` once per page, never once per embed.
- Treat a missing `attributes.attributes` key as "this is full-page", not as an error.
- Surface `unavailable_reason` and the `403` `workspace_unavailable` reason to the user instead of rendering an empty block.

Building an automation instead, reacting to submissions as they arrive? See [Webhooks](/developers/api/webhooks).

