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

# Send leads from your own form

> Send a signup, demo request or waitlist entry from your own website's form into your workspace as a lead, with a lead-capture key.

If your website has its own form, your server can send each submission to
Goosy Bear as a **lead**. The submitter becomes a contact in your workspace,
automations that start on a new lead run, and the contact goes to your
connected GoHighLevel.

This page is for **your own website**, built and hosted by you. If the form is
on a page you built in Goosy, you do not need any of this: leads from your
Goosy pages are captured already.

<Warning>
  The key is a secret. Call this from your **server**, never from browser code.
  A key in a page's JavaScript is visible to everyone who opens the page, and
  anyone holding it can fill your workspace with leads.
</Warning>

## 1 · Create a lead-capture key

<Steps>
  <Step title="Open Connections → Lead capture">
    In the app, choose **Connections**, then **Lead capture**.
  </Step>

  <Step title="Create the key">
    The key is shown **once**. Copy it into your server's secret store straight
    away. We keep only a fingerprint, so we cannot show it again; if you lose
    it, create a new one and revoke the old one.
  </Step>
</Steps>

Who can create one: a member who holds **Use the API and MCP** (see [Who can use
the API](/permissions)). If you do not see **Lead capture**, ask an Owner or
Admin.

A lead-capture key is narrow on purpose:

* It carries the `leads.write` scope and can call **only** `leads.capture`. It
  is refused on every other action.
* It is tied to **one workspace**, the one you created it in. Leads always land
  there; you never name a workspace in the request.
* An ordinary full-access key cannot call `leads.capture`. Use a lead-capture
  key.

Other key basics (expiry, revoking, last used) are in
[Authentication](/authentication).

## 2 · Connect GoHighLevel

Leads are sent on to your workspace's GoHighLevel. Connect it under
**Connections** (it is an OAuth sign-in) so contacts flow there.

You can send leads **before** you connect. Each one is stored and its contact
is created in Goosy Bear as normal; its CRM status reads `connection_required`
and nothing is lost. When you connect GoHighLevel, those leads are pushed on
the next sweep, in the order they arrived.

## 3 · The request

```http theme={null}
POST https://app.goosybear.ai/api/v1/tools/leads.capture
Authorization: Bearer YOUR_LEAD_CAPTURE_KEY
Content-Type: application/json
Idempotency-Key: a-stable-id-for-this-submission
```

### Headers

| Header | Required | What it does |
| - | - | - |
| `Authorization` | Yes | `Bearer` plus your lead-capture key. |
| `Content-Type` | Yes | `application/json`. |
| `Idempotency-Key` | No, recommended | A value you choose that names this submission (1 to 200 visible characters, no spaces), so a retry never stores it twice. Without one, each call is a new submission. See [Retries](#idempotency-and-retries). |
| `x-request-id` | No | Your own id for the request, echoed back. Useful when you contact support. |

### Body fields

| Field | Type | Required | Notes |
| - | - | - | - |
| `email` | string | **Yes** | A well-formed email address, up to 320 characters. The contact is matched on it. A malformed one is refused with `leads.capture_invalid_email`. |
| `name` | string | No | As the person typed it. Up to 200 characters. |
| `phone` | string | No | Up to 64 characters. Sent to the CRM in international form when it can be read as one. |
| `form` | string | No | Your name for this form: lowercase letters, digits, `-` or `_`, up to 64 characters, such as `demo_request`. An automation can be set to start only on leads from one form. |
| `fields` | object | No | Everything else the form collected, as `"name": "value"` pairs. Values are strings of up to 4,000 characters, names up to 100 characters, and at most 100 fields. Stored with the lead as sent. |
| `utm` | object | No | The visit's first-touch campaign data, by URL name: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `fbclid`, `gclid`, `li_fat_id`. Any other key is dropped and listed in `ignored_fields`. |
| `consent` | object | No | See below. |
| `submission_id` | string | No | Same job as `Idempotency-Key`. Send one or the other. |

A top-level field we do not know is dropped, the lead is still stored, and the
response lists the field in `ignored_fields`. To keep a field of your own, put
it inside `fields`.

### The consent field

`consent` records what the person agreed to, **as you send it**. We store it
with the lead; we do not check it or assume it. If you collect consent, send it.

| Field | Type | Meaning |
| - | - | - |
| `email` | boolean | They agreed to be emailed. |
| `sms` | boolean | They agreed to be texted at the phone number given. |
| `text` | string | The consent wording they saw, up to 4,000 characters. |

You are responsible for collecting consent on your form and for sending it
truthfully. Leaving `consent` out does not mark anyone as consenting.

## 4 · The response

A stored lead answers `200`:

```json theme={null}
{
  "ok": true,
  "lead_id": "6f2c1c0e-5c0e-4f0a-9d0e-2a4a7d2f9c11",
  "contact_id": "b1f7a9a2-6d52-4a3c-8c1e-0f6f3a1e2d44",
  "submission_id": "a-stable-id-for-this-submission",
  "replayed": false,
  "ignored_fields": [],
  "workspace": "your-workspace"
}
```

* `lead_id` is the stored lead.
* `contact_id` is the workspace contact the submitter became. It can be `null`
  when the email names no one we can make a contact for.
* `submission_id` names this submission: your `Idempotency-Key` or
  `submission_id`, or one we made when you sent neither. Send it on a retry so
  the lead is not stored twice.
* `replayed` is `true` when this was a retry of a submission already stored
  (see below). Nothing new was written.
* `ignored_fields` lists every field we dropped: unknown top-level fields by
  name, unknown campaign keys as `utm.<key>`. Empty when nothing was dropped.
* `workspace` is the slug of the workspace the lead went to.

The answer comes back once the lead is stored, the contact exists and the
automations for a new lead have been started.

## 5 · Refusals

Refusals follow the [usual shape](/api/errors-and-refusals): `ok: false`, a
`code` and a `message` written to be read. **Branch on `ok`, not on the status
code.** A refused lead was **not stored**, except where noted.

### Refused by the action

These answer `200` with `"ok": false`.

| Code | What it means | What to do |
| - | - | - |
| `invalid_arguments` | A field is missing or the wrong type: no `email`, a value too long, a `fields` value that is not a string. The reply lists which. | Fix the request. Do not retry unchanged. |
| `leads.capture_invalid_email` | The `email` is not a valid address. | Send the address as the person typed it, like `name@example.com`. Do not retry unchanged. |
| `leads.capture_too_large` | The lead is over 32 KiB as JSON. | Send fewer or shorter fields. |
| `leads.capture_too_many_fields` | `fields` holds more than 100 entries. | Send fewer fields. |
| `tenant_api_key_scope_denied` | The key cannot call this action. Either it is a full-access key calling `leads.capture`, or a lead-capture key calling something else. | Create a key with the `leads.write` permission under **Connections → Lead capture**, and use it for this action only. |
| `leads.capture_workspace_mismatch` | The key's workspace is not the one the call resolved to. | Remove any `workspace` value from the body and use the key as created. |
| `leads.capture_unknown_site` | The workspace's lead source could not be found. | Contact support with the `x-request-id`. Retrying will not help. |
| `leads.capture_unavailable` | We could not store the lead just now. | Retry later with the **same** key. |
| `idempotency_key_reused` | That key was already used for a **different** submission. | Use a new key for a new submission. |
| `idempotency_in_progress` | An earlier call with this key is still running. | Retry shortly with the same key. |
| `idempotency_attempt_failed` | An earlier call with this key failed and changed nothing. | Retry with the same key. |
| `idempotency_unavailable` | We could not check earlier calls just now. Nothing ran. | Retry with the same key. |

### Refused at the door

| Status | Code | What to do |
| - | - | - |
| `400` | `tenant_api.malformed_body` | Send a JSON object. |
| `400` | `tenant_api.idempotency_key_invalid` | The `Idempotency-Key` is empty, too long, or has characters that are not allowed. |
| `400` | `tenant_api.idempotency_key_conflict` | The header and the body's `idempotency_key` differ. Send one. |
| `401` | `tenant_api.unauthenticated` | The key is missing, mistyped, revoked or expired. Check it under **Connections**, or create a new one. |
| `403` | `api_access.account_disabled`, `api_access.capability_denied`, `api_access.platform_disabled`, `tenant_mcp.surface_disabled` | Access is switched off or the key's holder lost **Use the API and MCP**. See [Errors and refusals](/api/errors-and-refusals). |
| `429` | `tenant_mcp.rate_limited` | Wait for `Retry-After` seconds, then retry. |

## 6 · Idempotency and retries

A network can fail after we stored the lead but before you heard back. Name
each submission with `Idempotency-Key` (or `submission_id`), and send the
**same value when you retry**.

The key is optional. Without one, each call is stored as a new submission and
the response's `submission_id` names it; send that value on any later retry.
A call whose response you never received cannot be matched that way, so if
your server retries on network errors, send your own key.

* A retry with the same key returns the first answer, with `"replayed": true`,
  and stores nothing again. No second lead, no second automation, no second
  CRM push.
* Choose one stable id per form submission, such as the id of the row you saved
  for it, and reuse it only for retries of that same submission. A different
  submission under a key you already used is refused with
  `idempotency_key_reused`.
* Keys are honoured for at least 24 hours.
* Retry on a network error, a `429` (after `Retry-After`) or `5xx`, and on
  `leads.capture_unavailable`, `idempotency_in_progress` and
  `idempotency_unavailable`. Do not retry `invalid_arguments` and the other
  errors that need a change from you.

## 7 · Limits

| Limit | Value |
| - | - |
| Calls | 60 per minute per key, a rolling window. Read `RateLimit-Remaining` and `Retry-After`; see [Rate limits](/api/rate-limits). |
| Body | 32 KiB of JSON per lead. |
| Fields | At most 100 entries in `fields`; each value up to 4,000 characters. |

There is no CAPTCHA on this door. Because your server holds the key, spam
protection on your form (a CAPTCHA, a honeypot, your own rate limit) belongs on
your side before you call us. If a key leaks, revoke it and create another.

## 8 · What happens after a lead arrives

<Steps>
  <Step title="The lead is stored">
    It is saved to your workspace with the form name, fields, campaign data and
    consent as you sent them.
  </Step>

  <Step title="The submitter becomes a contact">
    We find the contact with that email or create one. The response carries its
    `contact_id`.
  </Step>

  <Step title="Automations start">
    Automations whose trigger is **Lead captured** run for the new lead. If you
    sent `form`, an automation can be set to start only on that form.
  </Step>

  <Step title="The contact goes to GoHighLevel">
    With GoHighLevel connected, the contact is pushed to it. With none
    connected, the lead waits as `connection_required` and is pushed after you
    connect. A push that fails for another reason is retried.
  </Step>
</Steps>

<Note>
  An automation may email or text the person, and the contact's details reach
  your CRM. A lead sent cannot be unsent from the API, so test with an address
  you control (below).
</Note>

## 9 · Copy-paste examples

Run these on your server. Keep the key in an environment variable.

### Node (fetch)

```js theme={null}
// Server-side only. GOOSY_LEAD_KEY is a secret; never ship it to the browser.
export async function sendLead(submission) {
  const res = await fetch("https://app.goosybear.ai/api/v1/tools/leads.capture", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.GOOSY_LEAD_KEY}`,
      "Content-Type": "application/json",
      // Reuse the same value if you retry this submission.
      "Idempotency-Key": submission.id,
    },
    body: JSON.stringify({
      email: submission.email,
      name: submission.name,
      phone: submission.phone,
      form: "demo_request",
      fields: { company: submission.company, message: submission.message },
      utm: submission.utm, // e.g. { utm_source: "newsletter" }
      consent: { email: true, text: "I agree to be contacted about my request." },
    }),
  });

  if (res.status === 429) {
    const wait = Number(res.headers.get("retry-after") ?? 5);
    throw new Error(`Rate limited, retry in ${wait}s`);
  }
  const body = await res.json();
  if (!body.ok) throw new Error(`${body.code}: ${body.message}`);
  return body; // { ok, lead_id, contact_id, submission_id, replayed, ignored_fields, workspace }
}
```

### cURL

```bash theme={null}
export GOOSY_LEAD_KEY="your-lead-capture-key"

curl --request POST \
  --url "https://app.goosybear.ai/api/v1/tools/leads.capture" \
  --header "Authorization: Bearer $GOOSY_LEAD_KEY" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: test-2026-10-19-001" \
  --data '{
    "email": "jane@example.com",
    "name": "Jane Doe",
    "form": "demo_request",
    "fields": { "company": "Example Co" },
    "consent": { "email": true }
  }'
```

## 10 · Test it

<Steps>
  <Step title="Send one lead to an address you control">
    Use the cURL call above with your own email and a fresh `Idempotency-Key`.
    You should get `"ok": true` and a `lead_id`.
  </Step>

  <Step title="Check it arrived">
    Open the contact in your workspace. If you have a **Lead captured**
    automation, confirm it ran.
  </Step>

  <Step title="Send the same call again">
    Same `Idempotency-Key`. The answer is the same ids with
    `"replayed": true`, and no second lead appears.
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 tenant_api.unauthenticated">
    The key is missing, mistyped, revoked or expired. Check for a stray space or
    newline in your secret, and look at the key under **Connections**.
  </Accordion>

  <Accordion title="tenant_api_key_scope_denied">
    You sent a full-access key to `leads.capture`, or a lead-capture key to
    another action. Use a key from **Connections → Lead capture** for this call.
  </Accordion>

  <Accordion title="leads.capture_invalid_email">
    The `email` is not a valid address. Check what the person typed, and that
    your form sends the email field and not another one.
  </Accordion>

  <Accordion title="invalid_arguments">
    Read the `issues` list in the reply. The usual causes are a missing
    `email`, or a value that is too long or not a string.
  </Accordion>

  <Accordion title="A field I sent is in ignored_fields">
    We do not know that top-level field, so it was dropped and the lead was
    stored without it. Move it inside `fields` to keep it.
  </Accordion>

  <Accordion title="The lead is there but not in GoHighLevel">
    GoHighLevel is probably not connected yet. Connect it under **Connections**
    and the waiting leads are pushed on the next sweep.
  </Accordion>

  <Accordion title="No automation ran">
    The automation must start on **Lead captured**. If it is limited to one
    form, the `form` you send must match.
  </Accordion>

  <Accordion title="429 tenant_mcp.rate_limited">
    You passed 60 calls a minute on this key. Wait for `Retry-After`, then
    retry with the same `Idempotency-Key`.
  </Accordion>
</AccordionGroup>

If you still need help, send support the `x-request-id` from the response.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.