Skip to main content

JSON API

Introduction​

Forms, their submissions, and submitting them, as JSON, through one ExpressionEngine action. Every request goes to the same URL, with the endpoint in the endpoint query parameter:

https://example.com/index.php?ACT=<id>&endpoint=forms/contact_us

The action id differs between installs, so let {exp:formidable:api_url} print the URL.

Endpoints​

MethodEndpointScopeReturns
GETformsforms:readThe forms the caller may reach
GETforms/{form}forms:readThe form as data: see Form JSON
GETforms/{form}/submissionssubmissions:readA page of submissions
POSTforms/{form}/submissionssubmissions:createThe new submission
GETforms/{form}/submissions/{submission}submissions:readOne submission
POSTforms/{form}/submissions/{submission}/actions/{action}submissions:actRuns a submission action that allows the API; the submission after it

{form} is a form's handle. {submission} is a submission's public handle, never its sequential id.

Listing forms

Example
Request
GET https://example.com/index.php?ACT=104&endpoint=forms
Rendered outputHTTP 200. Captured from ExpressionEngine 7.5.26 on 2026-10-11, requested from the browser address bar while signed out. Host replaced with example.com.
{
"data": [
{
"id": 1,
"handle": "contact_us",
"name": "Contact Us",
"url": "https://example.com/index.php?ACT=104&endpoint=forms/contact_us"
}
],
"meta": {
"count": 1
}
}

The same code as the templates​

The API is not a second implementation. Each pair below runs the same code, so validation, page flow, Computed fields, limits, duplicate checks, storage, the formidable_submission_complete hook and workflows behave the same whichever way a submission arrives.

In a templateThrough the API
A form as data{exp:formidable:form output="json"}GET forms/{form}
Finding submissions{exp:formidable:submissions}GET forms/{form}/submissions
Reading an answer{handle}, {handle:label}answers, labels
Making a submissionA posted formPOST forms/{form}/submissions

Who may call it​

This site's own pages, with no key​

A request from the site itself needs no key. Browsers report where a request comes from in the Sec-Fetch-Site header, which no page can set. same-origin (the site's own scripts) and none (a URL typed into the address bar) are accepted. cross-site and same-site (a sibling subdomain) are not. An older browser without that header is judged by Origin, or by Referer when there is no Origin, which must be the site.

Such a caller can do what a page of the site already can:

  • Read a form. Forms are public, the same as rendering one.
  • Submit a form, sending the session's CSRF token as an X-CSRF-Token header or a csrf_token field. The token is in the form's JSON (submit.csrf_token) or EE's {csrf_token}.
  • Read submissions, as a logged-in member with View submissions, who sees all of them. With Configuration › API › Members Read Their Own Submissions on, any logged-in member may also read their own.

The origin check is a courtesy, not the protection: any non-browser client can claim an origin. The protection is the CSRF token and the member's session, exactly as for the page itself. Configuration › API › Allow This Site Without a Key switches this off, so every call needs a key.

Reading submissions without permission

Example
Request
GET https://example.com/index.php?ACT=104&endpoint=forms/contact_us/submissions
Rendered outputHTTP 403. Captured from ExpressionEngine 7.5.26 on 2026-10-11, requested from the browser address bar while signed out. Host replaced with example.com. The message's wording is reported as an issue: it should read “This caller may not read submissions.”.
{
"error": {
"code": "forbidden",
"message": "This caller may not submissions read."
}
}

Anyone else, with an API key​

Keys are issued on Formidable › Utilities, under API Keys, which needs the Manage API keys permission. Each key has:

  • Allowed to: any of Read forms (forms:read), Read submissions (submissions:read), Submit forms (submissions:create) and Run submission actions (submissions:act). The last runs only the submission actions that allow the API, and only with a key: this site's own pages don't get it.
  • Forms: the forms it reaches. Tick none for every form.
  • Allowed browser origins: the websites whose pages may use it from a visitor's browser. Leave it empty for a key used only from a server, and browsers are then refused, because a key in a web page is a published key.

Send the key in a header, never in the URL:

Request headers
Authorization: Bearer fmd_…

X-Formidable-Key: fmd_… works too. The key is shown once, when it is issued. Only a hash is stored, so a lost key is replaced, not recovered. Revoking or regenerating a key takes effect on the next request. An unknown, disabled or mistyped key is refused outright. It is never treated as "no key".

A key may submit without a CSRF token or honeypots. The form's own settings still apply. Key requests are not rate limited per IP, because a server submitting for many visitors would otherwise share one IP's allowance.

CORS. A browser on a listed origin gets Access-Control-Allow-Origin for that origin. Preflight (OPTIONS) requests are answered for any origin an enabled key lists, and the real request is then checked against its own key.

Responses​

Always JSON, with Cache-Control: private, no-store. Success is { "data": …, "meta": { … } }. Failure is:

Response (shape)
{ "error": { "code": "invalid", "message": "…", "details": { "email_address": "…" } } }
StatuscodeWhen
400bad_jsonA JSON request body that is not a JSON object
401unauthorisedNo key, from outside the site
401invalid_keyAn unknown, disabled or mistyped key
401key_requiredNo key, and Configuration requires one even from the site
403origin_not_allowedA key used from an origin it does not list
403forbiddenThe caller lacks the endpoint's scope
403csrfA submission from the site without a valid CSRF token
404not_foundNo such endpoint, form (or not one the key reaches), or submission
404api_disabledThe API is switched off in config.php
405method_not_allowedFor example, DELETE
422invalidValidation failed. details has one message per field.
422rejectedThe form is closed, over its limit, or the caller is rate limited

Reading submissions​

Request
GET …&endpoint=forms/contact_us/submissions&page=2&per_page=50&status=new|read&orderby=created_at&sort=desc
Query parameter
page, per_pageper_page defaults to 25, or the view's page size, and is capped at 100
statusPipe-separated status handles
viewA saved view's id: its filter, page size and sort
orderby, sortAs on the tag: created_at, updated_at, name, status, id; asc or desc
member_idOne member's submissions (for a key, or staff)
include_handles1 to include each submission's handle, which allows editing it

Each submission has answers, decrypted, and labels, which say what each answer means: option labels and file names. The response's meta has page, per_page and total.

Submitting​

Post the answers under their field handles, as JSON, form fields, or multipart/form-data (needed for files). Answers may be at the top level or under answers. From a page, also send the form JSON's submit.meta as _formidable_meta, which keeps the honeypots and any tag settings, and the CSRF token:

assets/js/contact.js
const response = await fetch(apiUrl + '&endpoint=forms/contact_us/submissions', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
body: JSON.stringify({answers: {first_name: 'Ada', email_address: '[email protected]'}}),
});

A successful submission answers 201 Created with data.submission (id, handle) and data.message, the form's success message. A honeypot or duplicate catch answers the same way without a submission, so a bot learns nothing.

Switching it off​

system/user/config/config.php
$config['formidable_api'] = 'n';

Every request then answers 404 api_disabled.