> ## Documentation Index
> Fetch the complete documentation index at: https://docs.allgoodhq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Form capture API

> Post a submission yourself, from your own client or your own server.

The capture endpoint is a documented HTTP contract, not only something the allGood script talks to. Use this when you're building the request yourself.

For an ordinary web page, prefer [the script](/mk/developer/web-edge/capture-a-form) — it handles the honeypot, the bot widget, field checks and double submissions for you.

## The request

```
POST https://mk.brand.com/_ag/f/{formShortId}
Content-Type: application/json
```

| Part         | Value                                                                                                |
| ------------ | ---------------------------------------------------------------------------------------------------- |
| Host         | Your connected domain. Every connected domain serves the same endpoint                               |
| Path         | `/_ag/f/` followed by the eight-character form id                                                    |
| Content-Type | `application/json` only. Form-encoded bodies are refused                                             |
| Body         | A flat JSON object. Not an array, not a scalar                                                       |
| Credentials  | Send them if the page is on the same site as your allGood domain, so the identity cookie rides along |

There is **no API key and no authorization header** here. The form id is a public identifier; the [origin allowlist](/mk/developer/web-edge/allowed-origins) is the control.

```js theme={null}
const response = await fetch("https://mk.brand.com/_ag/f/f7k2m9qp", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  credentials: "include",
  body: JSON.stringify({
    email: "ada@example.com",
    firstName: "Ada",
    company: "Northwind Analytics",
    utm_source: "linkedin",
    _hp: "",                          // honeypot — send empty, or omit
    "cf-turnstile-response": token,   // only if this form requires a bot check
  }),
});

const { status, submissionId, redirectUrl } = await response.json();
```

## The body

Send whatever your form collects. There's no fixed schema at the endpoint — a field the form definition doesn't know about is still stored rather than rejected.

Three keys have special meaning:

| Key                                   | Handling                                                                              |
| ------------------------------------- | ------------------------------------------------------------------------------------- |
| `email`                               | Lifted to the submission's identity, which is what ties it to a known person          |
| The honeypot field (`_hp` by default) | Must be empty or absent. A non-blank value is refused. Kept in the record as evidence |
| `cf-turnstile-response`               | The bot-check token. Verified, then discarded — it's single-use                       |

And one is ignored if you send it: `consentTextId`. allGood deletes it and substitutes the form's own bound value from its own records, so the consent evidence can't be forged from the page.

Everything else is stored as sent.

## Size limit

Your account setting, 8 KB by default, and never more than 16 KB whatever the setting says. Measured in bytes, not characters. Both the declared length and the real length are checked.

## The order of checks

Checks stop at the first failure, so exactly one outcome describes a submission and it names the first thing that was wrong.

| # | Check                                 | Failure                                      |
| - | ------------------------------------- | -------------------------------------------- |
| 1 | Origin is same-origin or allowlisted  | `unknown_source` · 403 · **no CORS headers** |
| 2 | `Content-Type` is `application/json`  | `validation` · 400                           |
| 3 | Body within the size limit            | `validation` · 400                           |
| 4 | Body parses as a JSON object          | `validation` · 400                           |
| 5 | The form is registered                | `unknown_source` · 404                       |
| 6 | Rate-limit budgets not exhausted      | `rate_limited` · 429                         |
| 7 | Honeypot field is empty               | `bot` · 403                                  |
| 8 | Bot-check token verifies, if required | `bot` · 403                                  |
| — | Otherwise                             | `accepted` · 200                             |

<Warning>
  Note where check 1 sits. A refused origin gets a bare `403` with no CORS headers, so a browser sees an opaque network error rather than a readable body — it cannot tell "not allowlisted" apart from "network down". Use the [runtime config endpoint](/mk/developer/web-edge/reference/endpoints) to find out, or read the console warning the script writes.
</Warning>

## Preflight

A cross-origin JSON POST is never a simple request, so the browser always sends `OPTIONS` first.

| Outcome        | Response                                                           |
| -------------- | ------------------------------------------------------------------ |
| Origin allowed | `204`, with the origin echoed back exactly and credentials allowed |
| Origin refused | `403`, no CORS headers                                             |

The origin is echoed exactly, never `*` — the path is credentialed, and `*` is invalid with credentials.

## Responses

```json theme={null}
{ "status": "accepted", "submissionId": "0f2c4b1e-…", "redirectUrl": "/thanks" }
```

`redirectUrl` is present only when the form configures one and it passed the safety check.

| Code             | HTTP | Cause                                                         |
| ---------------- | ---- | ------------------------------------------------------------- |
| `validation`     | 400  | Wrong content type, over the size limit, or not a JSON object |
| `unknown_source` | 403  | Origin not allowlisted                                        |
| `unknown_source` | 404  | Form not registered                                           |
| `bot`            | 403  | Honeypot filled, or bot-check token missing or invalid        |
| `rate_limited`   | 429  | A budget was exhausted                                        |

This endpoint **never** returns `consent_missing`. See [Reason codes](/mk/developer/web-edge/reference/reason-codes).

## Retries

Accepted responses carry a `submissionId`, and everything downstream deduplicates on it. Retrying the same HTTP request mints a **new** one, so a retry does create a second record. Guard against double submission on your side, as the script does.

## Rate limits

| Budget     | Limit     | Keyed on                            |
| ---------- | --------- | ----------------------------------- |
| Per IP     | 30 / 60s  | The form id plus the caller's IP    |
| Per origin | 300 / 60s | The account plus the posting origin |

Event capture has separate budgets. A caller with no `Origin` header — a server — is covered by the per-IP budget only.

## Posting from a server

You can. There's no origin to check, so the allowlist doesn't apply and the per-IP budget is the limit. Be aware that bot protection needs a browser-minted token, so a server-side caller is effectively unauthenticated — put your own rate limiting and validation in front of it.
