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

# Web Forms: capture script and submissions endpoint

> Install the snippet or post submissions yourself, with the payload shape, the domain check and the error bodies.

Every submission lands here, whether it comes from the capture script embedded on your site or from a request you build yourself, and the token in the URL is the only thing that authenticates it.

<div className="pl-availability">
  <div className="pl-availability__row">
    <div className="pl-availability__label">Where</div>
    <div className="pl-availability__value"><span className="pl-path">Web Forms<span className="pl-path__sep">→</span>Install</span></div>
  </div>

  <div className="pl-availability__row">
    <div className="pl-availability__label">Your role needs</div>
    <div className="pl-availability__value">Read access to Web form capture endpoint <code>read-web-forms</code>. Admin, Member and Viewer have it by default.</div>
  </div>

  <div className="pl-availability__row">
    <div className="pl-availability__label">To create</div>
    <div className="pl-availability__value"><code>create-web-forms</code> on top of the permission above.</div>
  </div>
</div>

## Endpoint

| Method    | Path                                        | Purpose                                             |
| --------- | ------------------------------------------- | --------------------------------------------------- |
| `POST`    | `/api/public/web-forms/{token}/submissions` | Create one submission                               |
| `OPTIONS` | `/api/public/web-forms/{token}/submissions` | CORS preflight for the route above                  |
| `POST`    | `/api/public/web-forms/{token}/forms`       | Report the forms the capture script found on a page |
| `OPTIONS` | `/api/public/web-forms/{token}/forms`       | CORS preflight for the route above                  |

They live at `https://api.pipelime.ai`. `{token}` is the one credential they check. There's no API key and no `Authorization` header, so treat the URL itself as the secret.

The capture script uses both paths. A direct integration (your own code, a Zapier zap, a platform's native webhook) only ever needs the submissions route. See [Install methods: JavaScript snippet or direct endpoint](/en/finding-leads/web-forms-install-methods) for the script tag itself and the platform-specific guides.

## Request body

| Field      | Type   | Rule                                                                                                       |
| ---------- | ------ | ---------------------------------------------------------------------------------------------------------- |
| `fields`   | object | Required. Your own form field names as keys; each value must be a plain string, number, boolean, or `null` |
| `form_key` | string | Optional. Names one of the form definitions this form already holds under that key                         |
| `context`  | object | Optional. Page and campaign metadata, listed below                                                         |
| `_hp`      | string | Optional. A honeypot: see [Honeypot and deduplication](#honeypot-and-deduplication)                        |

A checkbox group or a multi-select has to arrive as one value, not an array: join the selected options into a single string before you send them. A `fields` value that's an array or an object instead of a scalar, a field name that's too long, or a value that's too long all fail validation the same way; see [Responses](#responses).

A `form_key` is matched on the key alone. Nothing else about the definition is looked at. Send one that doesn't match anything, or leave it out entirely, and the submission is queued exactly the same way; what decides whether it becomes a lead automatically, or waits in the product for you to map it, is on [Field mapping and the submissions inbox](/en/finding-leads/web-forms-mapping-and-submissions).

Every `context` key is an optional string, and only the keys below are read. Anything else you put in `context` is accepted and then ignored:

| `context` key                                                                                                          | Stored as                                                                                              |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `page_url`                                                                                                             | The submission's own `page_url`                                                                        |
| `referrer`                                                                                                             | The submission's own `referrer`                                                                        |
| `page_title`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `gclid`, `fbclid`, `submitted_at` | Together, as the submission's attribution data: only the keys you send with a non-empty value are kept |

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST 'https://api.pipelime.ai/api/public/web-forms/YOUR_FORM_TOKEN/submissions' \
    -H 'Content-Type: application/json' \
    -d '{
      "fields": {
        "name": "Jane Doe",
        "email": "jane.doe@acme-demo.test",
        "message": "Interested in a demo"
      },
      "context": {
        "page_url": "https://acme-demo.test/contact",
        "utm_source": "linkedin"
      }
    }'
  ```

  ```json JSON body theme={"system"}
  {
    "fields": {
      "name": "Jane Doe",
      "email": "jane.doe@acme-demo.test",
      "message": "Interested in a demo"
    },
    "context": {
      "page_url": "https://acme-demo.test/contact",
      "utm_source": "linkedin"
    }
  }
  ```
</CodeGroup>

## The domain check

A request that carries an `Origin` or `Referer` header is checked against the form's configured domain: `Origin` when both are there. A request with neither is treated as server-to-server and let through on the strength of the token alone.

| Origin or Referer                                             | Result                                                 |
| ------------------------------------------------------------- | ------------------------------------------------------ |
| Absent                                                        | Allowed: server-to-server, the token is the credential |
| Present, host is `localhost`                                  | Always allowed                                         |
| Present, host matches the form's domain, or a subdomain of it | Allowed                                                |
| Present, host doesn't match                                   | `403 Forbidden`                                        |

Only hostnames are compared. The host is read out of the header value (so the scheme and any port are dropped), lowercased, and a leading `www.` removed. The form's configured domain is normalised the same way: scheme, leading `www.` and any trailing slash come off first. A form saved as `https://www.acme.test/` therefore matches a page served from `https://acme.test/contact`.

<Note>
  The `OPTIONS` preflight always answers `204`, even for a token that doesn't resolve to a form. So an unknown token doesn't get blocked at this stage; the actual `POST` still goes through and comes back with the real `404` your JavaScript can read. A domain mismatch is different: the preflight for a token it does recognize carries that form's own domain in its CORS header, not the calling page's, so the browser refuses to send the `POST` at all. You'll see a CORS failure in the console, never a `403`: only a server-to-server call, which skips preflight entirely, gets that body directly.
</Note>

An unknown token and an inactive form's token fail identically, with the same `404`. There's no way to tell "wrong token" from "this form is turned off" from the response alone.

## Responses

Every response from the submissions route:

| Status                     | Body                                                                 | When                                                                                                                                                                       |
| -------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200 OK`                   | `{"status": "accepted"}`                                             | The honeypot field was filled in, or this exact `fields` payload already arrived from the same address on the same form a moment ago: neither one is queued for processing |
| `201 Created`              | `{"status": "accepted"}`                                             | A genuine new submission was stored and queued                                                                                                                             |
| `403 Forbidden`            | `{"error": "Script not authorized for this domain"}`                 | An `Origin` or `Referer` header was present and didn't match the form's domain                                                                                             |
| `404 Not Found`            | `{"error": "Invalid form source"}`                                   | The token doesn't match a form, or the form isn't active                                                                                                                   |
| `422 Unprocessable Entity` | `{"error": "Payload too large"}`                                     | The whole request body is over the size limit                                                                                                                              |
| `422 Unprocessable Entity` | `{"error": "Invalid payload", "errors": {...}}`                      | The payload fails validation: `fields` is missing or has too many entries, or a `context` value runs over its length limit                                                 |
| `422 Unprocessable Entity` | `{"error": "Invalid payload"}`                                       | A `fields` key or value doesn't pass the shape check above                                                                                                                 |
| `429 Too Many Requests`    | none of the shapes above: the rate limiter answers, not the endpoint | Too many requests for this token, from this address, in a short window                                                                                                     |

The checks run in that order: token, then domain, then body size, then validation. The first one that fails is the response you get.

<Note>
  Two different problems both come back as `{"error": "Invalid payload"}`. One is Laravel's own validator failing. That one also carries an `errors` object naming the field. The other is a `fields` value that isn't a plain scalar, or a name or value that's too long, checked separately after the validator passes. It never gets an `errors` object. Check whether `errors` is present before assuming which one happened.
</Note>

## Honeypot and deduplication

Two things stop a submission before it is ever queued for processing, and they stop it in different ways:

* `_hp` is a honeypot: real visitors leave it empty. Any value in it that isn't blank marks the submission spam. The submission is still recorded, with that status, but nothing is queued.
* The exact same `fields` sent again from the same IP address, to the same form, shortly after the first attempt doesn't create a second submission. Nothing new is stored at all. Only `fields` is compared, so a different `context` on the retry (a different page, different UTM tags) doesn't change that.

Both cases return the same `200` `{"status": "accepted"}` as a genuine success, so neither a spam script nor a retried request can tell it was caught.

<Warning>
  Because a caught duplicate creates nothing, retrying a request you're not sure landed is safe, but only for a little while after the original attempt. Wait too long before retrying and the second call creates a real, separate submission instead of being caught.
</Warning>

## Form discovery

The capture script also reports the forms it finds on a page to `/api/public/web-forms/{token}/forms`: their fields, labels and how each one submits. We use that to build the list you map fields against in the product. A successful report answers `200` with `{"status": "ok"}`. A direct integration doesn't need to call this route at all, unless it's replicating what the script does.

The token check, the domain check and the size limit are the same as on the submissions route. Posting a form's `key` again refreshes what's known about its fields, but never touches whether you're tracking it or how its fields are mapped. Rediscovery can't downgrade a form you've already set up.

## Rate limit

Requests to this endpoint are throttled on their own, keyed by IP address and form token together: a different address posting to the same form, or the same address posting to a different form, each has its own budget. The submissions route and the discovery route share that budget: discovery calls from one address eat into what's left for submissions from that same address. Go over it and further calls return `429` until the window clears.

## Related

<CardGroup cols={2}>
  <Card title="Install methods: JavaScript snippet or direct endpoint" icon="code" href="/en/finding-leads/web-forms-install-methods">
    The exact script tag, the platform-specific guides, and the context fields each install method accepts.
  </Card>

  <Card title="Connect the forms on your website" icon="globe" href="/en/finding-leads/web-forms-connect">
    Scan your site, map the fields, and turn on the form this token belongs to.
  </Card>

  <Card title="API overview" icon="book-open" href="/en/developers/overview">
    Where this endpoint sits next to the versioned lead-intake API.
  </Card>
</CardGroup>
