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

# Send events to Brein

> Send contacts from any system to your CRM with a signed webhook.

Any system can send things to Brein through one door: your backend, a no-code tool or an agent. Each request is a **CloudEvent 1.0** signed with **Standard Webhooks 1.0.0**. Brein stores it exactly as it arrived, applies it to your CRM and keeps a trail of everything that happened.

```text theme={"theme":"github-light"}
Your system  →  signed POST  →  Source  →  Arrival  →  Contact · Company · Opportunity
```

## 1. Create a Source

A **Source** is where things enter Brein. Create one in **Settings → Integrations**. Only the workspace owner or an admin can.

When you create it you get two things:

* **The address** you send to: `https://api.trybrein.com/api/v1/door/<source id>`.
* **The secret**, `whsec_` followed by base64. It is shown **once**. Keep it in your secrets manager; never in a browser or a repository.

If you want each Arrival to also open an Opportunity, pick the Pipeline and stage when you create it. You can change it later.

## 2. Build the event

The body is a CloudEvent 1.0 in JSON, up to 256 KiB.

```json theme={"theme":"github-light"}
{
  "specversion": "1.0",
  "id": "lead-48213",
  "source": "https://yourcompany.com/signup",
  "type": "contact.created",
  "time": "2026-10-15T18:04:11Z",
  "data": {
    "name": "Ana Torres",
    "email": "ana@constructoranorte.mx",
    "phone": "+52 81 5555 0101",
    "role": "Head of purchasing",
    "company": "Constructora Norte",
    "website": "constructoranorte.mx"
  }
}
```

| Field | Rule |
| - | - |
| `specversion` | `"1.0"` |
| `id` | Your event id, up to 200 characters. `source` + `id` identify the event: send it twice and it is the same one. |
| `source` | Where it comes from, up to 300 characters. |
| `type` | A type from the [catalog](/api/events#catalog). Today `contact.created` and `contact.updated` are applied to the CRM; the rest are stored and set aside. |
| `subject` | Optional, up to 160 characters. |
| `time` | Optional. When it happened, ISO 8601. |
| `data` | For `contact.*`: `name`, `email`, `phone`, `role`, `company`, `website`. All text and optional, but at least `email` or `phone` is required. |

`data` does carry email and phone: that is what identifies the person. The events Brein writes afterwards never carry them.

## 3. Sign it and send it

Sign `webhook-id`, `webhook-timestamp` and the body, **byte for byte as you send it**, with HMAC-SHA256. The key is the secret without `whsec_`, base64-decoded.

```js theme={"theme":"github-light"}
import crypto from 'node:crypto'

const secret = process.env.BREIN_SOURCE_SECRET // whsec_…
const key = Buffer.from(secret.slice('whsec_'.length), 'base64')

const body = JSON.stringify(event) // serialize once and send these same bytes
const id = `msg_${crypto.randomUUID()}` // no dots or spaces, up to 200 characters
const timestamp = Math.floor(Date.now() / 1000).toString()
const signature = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64')

const response = await fetch(`https://api.trybrein.com/api/v1/door/${sourceId}`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'webhook-id': id,
    'webhook-timestamp': timestamp,
    'webhook-signature': `v1,${signature}`,
  },
  body,
})
```

Any [Standard Webhooks](https://www.standardwebhooks.com) library signs the same way. When you retry, **reuse the same `webhook-id`**: Brein recognizes it and duplicates nothing.

## 4. Read the response

| Status | Meaning | Retry? |
| - | - | - |
| `202` | Received. Returns `{ "arrival": "<id>" }`; it is processed in a queue. | — |
| `200` | Already received: same `webhook-id`, or same `source` + `id`. Returns the same Arrival and its status; nothing new happens. | no |
| `400` | Signature headers missing or malformed (`webhook_headers`), or the body is not a CloudEvent 1.0 of a catalog type (`invalid_event`). | no, fix it |
| `401` | The signature does not match, the Source does not exist, or the timestamp is more than 5 minutes from Brein's clock (`invalid_signature`, `stale_timestamp`). | no, fix it |
| `410` | The Source is archived. | no |
| `413` | The body is over 256 KiB. | no |
| `429` | More than 120 requests a minute from your IP. Wait for `Retry-After`. | yes |
| `503` | The Source is paused. | yes, later |

A `401` never reveals whether the Source exists. For 24 hours after you rotate the secret, Brein accepts signatures from both the new and the previous one, so you can rotate without downtime.

## 5. What happens next

Each request becomes an **Arrival** with its trail. See them on their Integration's page, in **Settings → Integrations**, or with the `arrival_read` tool from your agent.

| Status | What happened |
| - | - |
| accepted | It reached the CRM: a Contact, its Company by domain and, if the Source asks for it, an Opportunity. |
| pending decision | The email belongs to one Contact and the phone to another. A person chooses. |
| set aside | It could not be applied as is: a type not applied yet, or no email and no phone. It can be reprocessed. |
| no person | Only on a `webhook` or template Source: the notice has no email, phone or LinkedIn, or its email is a test or shared-mailbox one. It can be reprocessed from the Source. In the API and MCP it is `no_person`. |
| not understood | Only on a `webhook` or template Source: the notice does not fit the mapping, or the Source has no mapping yet. It can be reprocessed from the Source. In the API and MCP it is `unmapped`. |
| failed | It failed five times in a row. It can be reprocessed. |
| rejected | Bad signature, invalid event, or the person asked for their data to be erased. Final. |

Nothing is lost silently: every request ends in one of those states, and each step is in the [event ledger](/api/events).

## `webhook` Sources

Besides the signed contract above, a Source can be of type `webhook`: it receives any tool's notice as that tool sends it, with no CloudEvent, and a **mapping** that a person confirms says what each field is. This is what [Integrations](/integrations) use. Create one with `POST https://api.trybrein.com/api/v1/workspaces/<slug>/door/sources`, with `type: "webhook"` and, optionally, a `template` (`calcom`, `calendly`, `stripe`, `tally`, `typeform`, `jotform`, `webflow`, `shopify`, `hotmart`, `clerk` or `posthog`).

How it is verified depends on the Source:

| Verification | Address the tool uses | Authentication |
| - | - | - |
| Template that signs | `POST /api/v1/door/<Source id>` | The tool's own signature, in its own headers. |
| Standard Webhooks | `POST /api/v1/door/<Source id>` | The `webhook-*` headers above. |
| Secret address | `POST /api/v1/door/<Source id>/<token>` | The address token. Brein stores only its fingerprint. It is shown **only once**, when the Source is created or rotated; rotating invalidates the old one immediately. |

The body can be JSON, `application/x-www-form-urlencoded` or `multipart/form-data`, up to 256 KiB. A signature or token that does not match gives `401`, the same as a Source that does not exist. Responses are those of the table above, with these differences:

* `202` or `200` with `{ "arrival": "<id>", "status": "unmapped" }` when the Source has no mapping yet: the notice is stored as the example.
* `200` with `{ "ignored": true, "reason": "unsupported_event" }` when the notice's type is not one the mapping accepts. No Arrival is created.
* A notice with a list of people (a batch) answers `{ "arrivals": [ { "arrival", "status", "duplicate" } ], "ignored": <number> }`, with one Arrival per element.
* A duplicate is recognized by the notice id the mapping points to, or by a fingerprint of its content.

To read a Source with its example and its mapping proposal, confirm the mapping and reprocess, all inside your workspace and with your session:

| Route | What for |
| - | - |
| `GET /api/v1/workspaces/<slug>/door/templates` | Lists the templates. |
| `GET /api/v1/workspaces/<slug>/door/sources/<id>` | The Source with its mapping, example, proposal and undecided new fields. |
| `PUT /api/v1/workspaces/<slug>/door/sources/<id>/mapping` | Confirms the mapping. Owner or admin. Each confirmation raises the version. |
| `POST /api/v1/workspaces/<slug>/door/sources/<id>/reprocess-unmapped` | Reprocesses `unmapped` and `no_person` Arrivals. Optional body: `{ "arrival_id": "<id>" }`. Answers `{ requeued, changed, unchanged, expired, created }`. |

The raw notice and the example are deleted after 30 days; without them there is nothing to reprocess (`expired`).

## Test without risk

A **test workspace** copies your workspace's structure (Pipelines, Forms and Sources) without your data or members. In it, no credits are spent, no email is sent, no provider is called and nothing is delivered to a Destination. The owner or an admin creates one with `POST https://api.trybrein.com/api/v1/workspaces/<slug>/sandboxes`; up to five per workspace. The response carries its Sources' new secrets, once.

Inside a test workspace you can send a sample Arrival to any Source without signing anything, with the `arrival_send_test` tool or with `POST …/workspaces/<slug>/door/sources/<id>/test-arrivals` and `{ "type": "contact.created" }`.


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