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

# Submit a form from your own code

> Send responses to a published Brudcast form from your backend, app or automation, with the same list, confirmation email and checks as the hosted page.

Use your own sign-up form, checkout or mobile app, and still let a Brudcast form decide what
happens next. Your code sends the answers to the form, and Brudcast treats them like a submission on
the hosted page. It adds the person to the form's list, sends the confirmation email, records
consent and shows the response on the form's **Submissions** or **Responses** tab.

**Screenshot coming soon**

The Submit via API section on a form's Share tab, showing the endpoint, the form ID, the question keys and an example request

> [!NOTE]
>
> **Before you start:** you need a **published** form, and an API key with **Form Submissions**
> set to **Full access**. The form's **Share** tab has a **Create a key** button that opens the
> create panel with that permission already chosen. See [API keys](/developers/api-keys.md).

> [!WARNING]
>
> Call this API from your server. An API key in browser or app code can be copied by anyone who
> opens it, and whoever holds it can submit to your forms as you.

**1. Find the form ID and its question keys**

Open the form and go to the **Share** tab. Under **Submit via API** you'll find the
**Endpoint**, the **Form ID**, and the **Question keys**, each with the question it belongs to.
The **Example request** there is already filled in with this form's keys, in cURL, JavaScript,
Python and PHP.

A question's key is a short name such as `email`, `first_name` or `company_size`. Questions
mapped to a contact field use that field's name. Other questions are named after their wording.
You can rename a key in the question's editor on the **Questions** tab. See
[Question keys](#question-keys).

To read the same details from code, call `GET /contact-forms/{id}/schema` with a key that has
`forms:read`.

**2. Send the answers**

`POST /contact-forms/{id}/submissions` takes the answers in `values`, keyed by question key.

```bash cURL theme={"system"}
curl https://core-service.brudcast.com/api/v1/user/contact-forms/FORM_ID/submissions \
  -H "X-API-Key: bk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5f0c2d8e-2b1a-4c61-9a57-3f1d0e6b7a42" \
  -d '{
    "values": {
      "email": "jane@example.com",
      "first_name": "Jane",
      "consent": "true"
    },
    "context": {
      "ipAddress": "203.0.113.7",
      "userAgent": "Mozilla/5.0",
      "referrer": "https://example.com/pricing"
    }
  }'
```

```javascript Node.js theme={"system"}
const response = await fetch(
  "https://core-service.brudcast.com/api/v1/user/contact-forms/FORM_ID/submissions",
  {
    method: "POST",
    headers: {
      "X-API-Key": process.env.BRUDCAST_API_KEY,
      "Content-Type": "application/json",
      "Idempotency-Key": crypto.randomUUID(),
    },
    body: JSON.stringify({
      values: { email: "jane@example.com", first_name: "Jane", consent: "true" },
      context: { ipAddress: request.ip, userAgent: request.headers["user-agent"] },
    }),
  },
);

const result = await response.json();
```

```python Python theme={"system"}
import os
import uuid
import requests

response = requests.post(
    "https://core-service.brudcast.com/api/v1/user/contact-forms/FORM_ID/submissions",
    headers={
        "X-API-Key": os.environ["BRUDCAST_API_KEY"],
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "values": {"email": "jane@example.com", "first_name": "Jane", "consent": "true"},
        "context": {"ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0"},
    },
)

result = response.json()
```

```php PHP theme={"system"}
<?php
$ch = curl_init("https://core-service.brudcast.com/api/v1/user/contact-forms/FORM_ID/submissions");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "X-API-Key: " . getenv("BRUDCAST_API_KEY"),
    "Content-Type: application/json",
    "Idempotency-Key: " . bin2hex(random_bytes(16)),
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "values" => ["email" => "jane@example.com", "first_name" => "Jane", "consent" => "true"],
    "context" => ["ipAddress" => "203.0.113.7", "userAgent" => "Mozilla/5.0"],
  ]),
]);

$result = json_decode(curl_exec($ch), true);
```

**3. Read the result**

A new submission answers `201`. `data` is the submission as Brudcast stored it, after the
confirmation email was queued or the contact was added.

```json theme={"system"}
{
  "success": true,
  "message": "Submission accepted",
  "data": {
    "id": "0192a4f0-6c1e-7b3a-9d2f-5e8c1a7b4d60",
    "contactFormId": "0192a4e1-2b7d-7c40-8e19-3a6f0d9c2b15",
    "status": "pending",
    "email": "jane@example.com",
    "contactId": null,
    "ingress": "api",
    "apiKeyName": "Website backend",
    "botCheck": "skipped",
    "ipAddress": "203.0.113.7",
    "createdAt": "2026-09-28T10:15:00.000+00:00"
  }
}
```

The response has more fields than shown here. `status` tells you what happened next:

| `status` | Shown as | Meaning |
| - | - | - |
| `pending` | **Awaiting confirmation** | The form uses double opt-in. The confirmation email is on its way |
| `confirmed` | **Confirmed** | The person is on the list, because the form uses single opt-in |
| `received` | **Received** | The form doesn't add to a list. The answers are saved on the response |

## What the API checks

An API submission goes through the same checks as one from the hosted page, with three
exceptions. The hidden bot field, the minimum time on the page and the bot challenge only work in a
browser, so they're skipped, and the **Bot check** column shows **Not run**. Your API key already
says who's calling.

Everything else still applies:

* The form must be **Live**.
* **Submissions allowed per hour** and **Stop after this many responses** count hosted and API
  submissions together.
* **One response per email address**, your suppression list and your contact limit apply as usual.
* A form that uses double opt-in still sends its confirmation email. The API can't skip it, so a
  person is only added to the list once they confirm.
* Your API key's own [rate limits](/developers/rate-limits.md) apply on top.

Unlike the hosted page, the API tells you when it refuses a submission. The hosted page shows a
visitor the normal thank-you message for most refusals, so a stranger can't learn who is on your
list. Your code gets a real error instead, and the refused submission still appears on the form
with its reason.

## Answers

| Question | Send |
| - | - |
| Short answer, paragraph, email, URL, number | A string |
| Phone number | A string. It's converted to international format, such as `+2348000000001` |
| Date, time | A string, such as `2026-01-31` or `09:30` |
| Multiple choice, dropdown | One of the question's options, as a string |
| Checkboxes | A list of options, such as `["Email", "SMS"]` |
| Linear scale, rating | The number, as a string, such as `"4"` |
| Multiple choice grid | An object with one entry per row: `{ "Speed": "Good" }` |
| Checkbox grid | An object with a list per row: `{ "Speed": ["Good", "Fast"] }` |
| Consent checkbox | `"true"` when the person agreed |
| Hidden field | Any string. It's never required |
| File upload | Not supported. Use the hosted page for forms that collect files |

Every required question needs an answer. If an answer is missing, a key isn't on the form, or a
question is answered twice, the request fails with `422` and lists every problem at once.

You can key an answer by the question's `id` instead of its key. Keys are easier to read, and
they don't change if a question is deleted and added again.

## Question keys

Each question on a form has a key, unique on that form. Brudcast picks one when the question is
created. To change it, open the question on the form's **Questions** tab and edit its
**API key**. Keys use lowercase letters, numbers and underscores, and start with a letter. The
**Share** tab lists every key.

> [!WARNING]
>
> Renaming a key on a live form breaks any integration that still sends the old one. The question
> warns you before you save.

## Consent evidence

The request comes from your server, so its IP address says nothing about the person who filled in
your form. Pass what you know about them in `context`:

**context.ipAddress** `string`

The person's IP address, as your server saw it.

**context.userAgent** `string`

Their browser's user agent, up to 1,024 characters.

**context.referrer** `string`

The page they submitted from, up to 2,048 characters.

These values are stored on the submission and its consent record as your code reported them,
together with the name of the API key that sent them. Leave `context` out and they stay empty.

## Retry safely

Send an `Idempotency-Key` header, up to 128 characters, that's unique for each submission. If a
request times out, retry with the **same** key. When Brudcast already accepted it, you get the
original submission back with `200` and an `Idempotent-Replayed: true` header, and nothing is
recorded or emailed twice.

A refused submission doesn't keep its key. Retrying it runs the checks again.

## Errors

| Status | `code` | Why | What to do |
| - | - | - | - |
| `404` | `E_ROW_NOT_FOUND` | No form with that ID in your organization | Check the **Form ID** on the **Share** tab |
| `409` | `E_FORM_NOT_PUBLISHED` | The form is a draft or paused | Publish it |
| `409` | `E_FORM_DUPLICATE_SUBMISSION` | **One response per email address** is on and this address already responded | Treat it as done |
| `410` | `E_FORM_CLOSED` | The form reached **Stop after this many responses** | Raise or remove the limit |
| `422` | `E_FORM_INVALID_VALUES` | An answer is missing, unknown, or for a file upload question | Fix the answers the message lists |
| `422` | `E_FORM_EMAIL_SUPPRESSED` | The address is on your [suppression list](/contacts/suppressions.md) | Don't retry |
| `422` | `E_VALIDATION_ERROR` | The body is malformed, such as an invalid `context.ipAddress` | Fix the fields in `errors` |
| `402` | `E_QUOTA_EXCEEDED` | Your contact limit is full | Free up contacts or upgrade |
| `403` | `E_FORM_SENDING_BLOCKED` | Your organization's sending is restricted, so the form can't add contacts | See [Deliverability overview](/deliverability/overview.md) |
| `403` | `E_INSUFFICIENT_SCOPE` | The key doesn't have `form_submissions:write` | Give the key **Form Submissions** access |
| `429` | `E_FORM_RATE_LIMITED` | The form is over **Submissions allowed per hour** | Retry later, or raise the limit |

See [Errors](/developers/errors.md) for the response shape. Earlier versions of the API sent these
codes without the `E_` prefix, for example `FORM_CLOSED`; update any code that matches on the old
spelling.

## Related

> **[Publish and share a form](/forms/publish-and-share.md)**
>
> The Share tab and the form's checks.

> **[Submissions and responses](/forms/submissions-and-responses.md)**
>
> Where API submissions show up.

> **[API keys](/developers/api-keys.md)**
>
> Create a key with Form Submissions access.

> **[Sync contacts from your app](/developers/guides/sync-contacts-from-your-app.md)**
>
> Write contacts directly, without a form.


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