> ## 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.

# Receive events on your server

> A Destination delivers your workspace events to your server, signed and retried.

A **Destination** is where Brein's events leave for another system. Give it an address and the event types you care about; Brein sends each one, signed with **Standard Webhooks 1.0.0**, as soon as it happens.

## Create a Destination

Through the API, with an API key from the workspace owner or an admin.

* **Address:** public `https`, port 443 or 8443, up to 2048 characters. Private and internal addresses are refused, and Brein checks again on every send.
* **Events:** 1 to 12 types from the [catalog](/api/events#catalog). You can change them later; an added type only receives what happens from then on, not history.
* **Secret:** `whsec_…`, shown **once**.

A test workspace has no Destinations: nothing leaves Brein from there.

## What you receive

```http theme={"theme":"github-light"}
POST /your/path HTTP/1.1
content-type: application/cloudevents+json
webhook-id: 4d6f0c2e-6a1b-5f7e-9c3d-2b8a7e1f0a55
webhook-timestamp: 1792097051
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{ "specversion": "1.0", "id": "4d6f0c2e-…", "type": "opportunity.moved", … }
```

The body is the full [CloudEvent](/api/events#the-shape). `webhook-id` is the event `id` and **does not change between retries**: use it to drop duplicates.

## Verify the signature

Verify before trusting the body. Use the bytes exactly as they arrived, not re-serialized JSON.

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

function verify(rawBody, headers, secret) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64')
  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64')

  return headers['webhook-signature'].split(' ').some((part) => {
    const [version, signature] = part.split(',')
    return version === 'v1' && signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  })
}
```

More than one signature may come, separated by spaces: after you rotate the secret, for 24 hours Brein signs with the new one and the previous one. Accept if any matches.

## Responses and retries

Answer `2xx` quickly and process afterwards. Brein waits 10 seconds at most and does not follow redirects.

| Your response | What Brein does |
| - | - |
| `2xx` | Delivered. |
| `410` | Stops: the Destination is **disabled** and what was pending is abandoned. |
| Anything else, 10 s without an answer, or a network error | Retries. |

Ten attempts. After each failure it waits 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h: about 75 hours from the first to the last. Each retry carries the same `webhook-id` and a fresh signature. If the last five finished deliveries all ran out of attempts, the Destination is disabled.

**The same event can arrive more than once.** That is how Standard Webhooks works: better than losing it. Drop duplicates by `webhook-id`. Order is not guaranteed either: use `time` and `subject`.

## An agent that answers never wakes itself

An agent that receives a delivery often writes back through the MCP or the API, and that write can produce an event of the same type it listens to. To stop the loop, send the id of the event you received in the `X-Brein-Cause` header of every call you make in answer (on the MCP, the same header on the request). Brein then records that event as the `causationid` of whatever you write, and never delivers to your Destination an event whose cause it already delivered to you. Other Destinations still receive it. An id that is not an event of your workspace is refused with `X-Brein-Cause` as the field.

## Event types that were retired

The path of an arrival (`arrival.received`, `.accepted`, `.decided`, `.rejected`, `.quarantined`, `.reprocessed`) is no longer in the catalog. A Destination that was subscribed to one keeps the subscription stored but receives nothing for it, and it no longer shows in its list; saving the list again removes it. Deliveries already queued are still sent.

## Inspect, test and redeliver

All through the API, with the same key:

| Action | Route |
| - | - |
| Recent deliveries | `GET …/door/destinations/<id>/deliveries` |
| A delivery's attempts (time, duration, status) | `GET …/door/destinations/<id>/deliveries/<delivery>/attempts` |
| Redeliver | `POST …/door/destinations/<id>/deliveries/<delivery>/redeliver` |
| Send a sample event now | `POST …/door/destinations/<id>/test` with `{ "type": "<a subscribed type>" }` |
| Change the events | `PATCH …/door/destinations/<id>` with `{ "event_types": [...] }` |
| Pause · resume | `POST …/door/destinations/<id>/pause` · `/resume` |
| Re-enable a disabled one | `POST …/door/destinations/<id>/resume` |
| Rotate the secret | `POST …/door/destinations/<id>/rotate` |

Routes start with `https://api.trybrein.com/api/v1/workspaces/<slug>`. Brein does not store what your server answers, only the status and the time. A paused Destination keeps what is pending and sends it on resume. The sample event is not written to your ledger.

Missed something during a long outage? [Read the ledger](/api/events#read-the-ledger) from your last cursor.


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