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
| Method | Endpoint | Scope | Returns |
|---|---|---|---|
GET | forms | forms:read | The forms the caller may reach |
GET | forms/{form} | forms:read | The form as data: see Form JSON |
GET | forms/{form}/submissions | submissions:read | A page of submissions |
POST | forms/{form}/submissions | submissions:create | The new submission |
GET | forms/{form}/submissions/{submission} | submissions:read | One submission |
POST | forms/{form}/submissions/{submission}/actions/{action} | submissions:act | Runs 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
ExampleGET https://example.com/index.php?ACT=104&endpoint=forms
{
"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 template | Through 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 submission | A posted form | POST 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-Tokenheader or acsrf_tokenfield. 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
ExampleGET https://example.com/index.php?ACT=104&endpoint=forms/contact_us/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:
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:
{ "error": { "code": "invalid", "message": "…", "details": { "email_address": "…" } } }
| Status | code | When |
|---|---|---|
| 400 | bad_json | A JSON request body that is not a JSON object |
| 401 | unauthorised | No key, from outside the site |
| 401 | invalid_key | An unknown, disabled or mistyped key |
| 401 | key_required | No key, and Configuration requires one even from the site |
| 403 | origin_not_allowed | A key used from an origin it does not list |
| 403 | forbidden | The caller lacks the endpoint's scope |
| 403 | csrf | A submission from the site without a valid CSRF token |
| 404 | not_found | No such endpoint, form (or not one the key reaches), or submission |
| 404 | api_disabled | The API is switched off in config.php |
| 405 | method_not_allowed | For example, DELETE |
| 422 | invalid | Validation failed. details has one message per field. |
| 422 | rejected | The form is closed, over its limit, or the caller is rate limited |
Reading submissions
GET …&endpoint=forms/contact_us/submissions&page=2&per_page=50&status=new|read&orderby=created_at&sort=desc
| Query parameter | |
|---|---|
page, per_page | per_page defaults to 25, or the view's page size, and is capped at 100 |
status | Pipe-separated status handles |
view | A saved view's id: its filter, page size and sort |
orderby, sort | As on the tag: created_at, updated_at, name, status, id; asc or desc |
member_id | One member's submissions (for a key, or staff) |
include_handles | 1 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:
const response = await fetch(apiUrl + '&endpoint=forms/contact_us/submissions', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
});
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
$config['formidable_api'] = 'n';
Every request then answers 404 api_disabled.