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

# POST /v1/webhook/events

> Emit a CRM event from an external system — it lands on the contact's ticket and writes its values onto the ticket and the contact.

Records a [CRM event](/dev/crm/events) against a contact. The event lands on that contact's open ticket; if they have none, Keebai opens one.

This is the **inbound** direction — your ERP, POS, or backoffice calling Keebai. It is not related to [`/v1/webhooks`](/dev/webhooks/manage) (plural), which manages the outbound subscriptions Keebai delivers *to* you.

<Note>
  The event type must already exist in your account, and its `key` is what you send as `type`. Create it in the portal under **CRM → Event types**; the same screen shows this endpoint, generates an API key for it, and renders the exact body for that type.
</Note>

## Endpoint

```
POST https://api.keebai.com/v1/webhook/events
```

## Required scope

`crm:events:write`

## Headers

| Header          | Required | Value                    |
| --------------- | -------- | ------------------------ |
| `Authorization` | Yes      | `Bearer kbai_pk_<token>` |
| `Content-Type`  | Yes      | `application/json`       |

## Body

| Field             | Type     | Required    | Description                                                                                                                         |
| ----------------- | -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `type`            | `string` | Yes         | The event type's `key`, for example `custom.pedido_confirmado`. Must match an **active** type in your company.                      |
| `phone`           | `string` | Conditional | Contact's phone in canonical format: digits only, country code included, no `+` and no separators. 6–20 characters.                 |
| `chat_user_id`    | `string` | Conditional | `ObjectId` of the contact. Skips the phone lookup.                                                                                  |
| `customer_id`     | `string` | Conditional | `ObjectId` of the CRM customer. Resolves to the contact linked to it.                                                               |
| `ticket_id`       | `string` | Conditional | `ObjectId` of a specific open ticket. Short-circuits contact resolution entirely.                                                   |
| `data`            | `object` | No          | Values for the fields the event type declares, keyed by each field's `key`.                                                         |
| `summary`         | `string` | No          | Short label shown on the timeline. Up to 280 characters. Defaults to the event type's name.                                         |
| `occurred_at`     | `string` | No          | ISO 8601 timestamp of when it happened. Defaults to now.                                                                            |
| `idempotency_key` | `string` | No          | Your own unique id for this event. Up to 200 characters. Replaying it returns the original event instead of recording a second one. |

**Send exactly one contact identifier.** Resolution runs in order — `ticket_id`, then `chat_user_id`, then `customer_id`, then `phone` — and stops at the first one present.

<Warning>
  **Never send `company` or `project` — not in the body, not as a header.** The token is the only thing that decides which tenant a write lands in, and it is not overridable.

  Sending either returns `400`. There is no `x-company` header on the public API; if you find one in an old snippet, it belongs to an internal service call and does not apply here.

  They are absent from the response too: the event you get back carries no tenant identifiers.
</Warning>

## Example request

<CodeGroup>
  ```bash curl theme={"system"}
  curl -X POST https://api.keebai.com/v1/webhook/events \
    -H "Authorization: Bearer kbai_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "type": "custom.pedido_confirmado",
      "phone": "56912345678",
      "occurred_at": "2026-05-02T14:31:07.221Z",
      "idempotency_key": "orden-9821",
      "data": {
        "monto": 15000,
        "sucursal": "Centro"
      }
    }'
  ```

  ```js JavaScript theme={"system"}
  const response = await fetch("https://api.keebai.com/v1/webhook/events", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KEEBAI_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "custom.pedido_confirmado",
      phone: order.customerPhone,
      occurred_at: order.confirmedAt.toISOString(),
      idempotency_key: `orden-${order.id}`,
      data: { monto: order.total, sucursal: order.branchName },
    }),
  });

  if (response.status === 422) {
    const { error } = await response.json();
    // error.code tells you whether the contact is unknown or the payload is wrong.
    throw new Error(`${error.code}: ${error.message}`);
  }

  const { event } = await response.json();
  ```

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

  resp = requests.post(
      "https://api.keebai.com/v1/webhook/events",
      headers={"Authorization": f"Bearer {os.environ['KEEBAI_API_TOKEN']}"},
      json={
          "type": "custom.pedido_confirmado",
          "phone": order.customer_phone,
          "occurred_at": order.confirmed_at.isoformat(),
          "idempotency_key": f"orden-{order.id}",
          "data": {"monto": order.total, "sucursal": order.branch_name},
      },
      timeout=10,
  )

  if resp.status_code == 422:
      error = resp.json()["error"]
      raise RuntimeError(f"{error['code']}: {error['message']}")

  resp.raise_for_status()
  event = resp.json()["event"]
  ```
</CodeGroup>

## Response

### 202 Accepted

```json theme={"system"}
{
  "event": {
    "_id": "65a1f2b3c4d5e6f7a8b9c0d1",
    "type": "custom.pedido_confirmado",
    "ticket_id": "65f4b5c6d7e8f9a0b1c2d3e4",
    "chat_user": "66c1d2e3f4a5b6c7d8e9f0a1",
    "summary": "Pedido confirmado",
    "data": {
      "monto": 15000,
      "sucursal": "Centro"
    },
    "occurred_at": "2026-05-02T14:31:07.221Z",
    "idempotency_key": "orden-9821"
  },
  "duplicated": false
}
```

`202`, not `201`: the event is recorded synchronously, but the work it triggers downstream — projecting it onto the conversation timeline, pushing it to the portal in realtime, firing `crm.ticket.updated` — completes after the response.

`duplicated` is `true` when the `idempotency_key` had already been used. The original event comes back untouched and nothing is written a second time.

### 400 Bad Request

A malformed body: missing `type`, a `phone` outside 6–20 characters, an `occurred_at` that is not ISO 8601, a malformed `ObjectId`, or a property the endpoint does not accept — including `company` and `project`, which come from the token.

### 401 Unauthorized

Missing, invalid, revoked, or expired token.

### 403 Forbidden

The token does not have the `crm:events:write` scope.

### 422 Unprocessable Entity

The request was well-formed but the event was not recorded. Branch on `error.code`:

| `code`                    | Meaning                                                    | What to do                                                                                                             |
| ------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `NO_CONTACT`              | No contact matches the identifier you sent.                | Create the contact first, or send `chat_user_id` / `customer_id` / `ticket_id` instead of a phone. Do not retry as-is. |
| `NO_OPEN_TICKET`          | The contact exists but no ticket could be opened for them. | Usually a pipeline with no stages. Check the CRM configuration; retrying will not help.                                |
| `CONTACT_LOOKUP_FAILED`   | The contact lookup itself failed.                          | Transient. Retry with backoff.                                                                                         |
| `UNKNOWN_EVENT_TYPE`      | No event type with that `key` in your company.             | Fix the `key`, or create the type in the portal.                                                                       |
| `INACTIVE_EVENT_TYPE`     | The type exists but is switched off.                       | Re-activate it in the portal, or stop emitting it.                                                                     |
| `MISSING_REQUIRED_FIELDS` | A field the type marks as required is absent from `data`.  | `error.details.fields` lists the keys. Fix the payload.                                                                |
| `INVALID_FIELD_VALUE`     | A value in `data` does not match its field's type.         | `error.details.fields` lists the keys. Fix the payload.                                                                |

```json theme={"system"}
{
  "error": {
    "code": "MISSING_REQUIRED_FIELDS",
    "message": "Faltan campos requeridos por el tipo de evento en `data`.",
    "details": {
      "fields": ["monto"]
    }
  }
}
```

### 429 Too Many Requests

You hit the ingest rate limit. See below.

## Rate limits

This route carries its own limits, well above the [platform defaults](/dev/rate-limits), because an ingest exhausts a quota sized for interactive use in an afternoon:

| Window     | Limit           |
| ---------- | --------------- |
| Per minute | 300 requests    |
| Per hour   | 5,000 requests  |
| Per day    | 50,000 requests |

Still per project, and still shared across every token in that project.

## Operational notes

* **Always send an `idempotency_key`.** Network retries are cheap and duplicated timeline entries are not. Use whatever identifier the event already has in your system — an order id, a transaction id, a row id.
* **`data` writes through.** The values you send are not only stored on the event: each field the type declares is written onto the ticket or onto the contact, depending on its scope, and **overwrites** whatever was there. See [CRM events](/dev/crm/events#values-are-written-through).
* **Keys outside the declared fields are kept but not written.** They stay in the event's payload and reach nothing else. Only fields associated with the type reach the ticket or the contact.
* **A `422` is not retryable except for `CONTACT_LOOKUP_FAILED`.** Everything else needs a fix on your side or in the CRM configuration. Dead-letter the payload and alert rather than looping.
* **`occurred_at` is when it happened, not when you sent it.** Backfilling a batch with the real timestamps puts the events in the right order on the timeline.


## OpenAPI

````yaml POST /v1/webhook/events
openapi: 3.0.0
info:
  title: Keebai Public API
  description: Superficie pública REST autenticada con Personal Access Tokens (PAT).
  version: 1.0.0
  contact: {}
servers:
  - url: https://api.keebai.com
    description: Production
security: []
tags: []
paths:
  /v1/webhook/events:
    post:
      tags:
        - webhook-ingest
      summary: Emitir un evento del CRM
      description: >-
        Registra un evento sobre el ticket abierto del contacto; si no tiene
        uno, se abre. Los campos asociados al tipo de evento validan `data` y
        sus valores se escriben en el ticket y en la ficha del contacto. La
        company y el project se toman del token; enviarlos en el body devuelve
        400.
      operationId: PublicWebhookEventsController_ingest
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicWebhookEventCreateDto'
      responses:
        '202':
          description: ''
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicEventEnvelopeDto'
        '422':
          description: >-
            El evento no se registró: contacto inexistente, tipo de evento
            desconocido o `data` inválido.
      security:
        - PAT: []
components:
  schemas:
    PublicWebhookEventCreateDto:
      type: object
      properties:
        type:
          type: string
          description: >-
            Key del tipo de evento configurado en el portal, por ejemplo
            `custom.pedido_confirmado`.
        phone:
          type: string
          description: >-
            Teléfono del contacto en formato canónico (sólo dígitos, con código
            de país y sin `+`). Alternativa a chat_user_id / customer_id /
            ticket_id.
        chat_user_id:
          type: string
          description: ObjectId del contacto (chat user)
        customer_id:
          type: string
          description: ObjectId del cliente del CRM
        ticket_id:
          type: string
          description: >-
            ObjectId del ticket. Si viene, el evento se adjunta a ese ticket y
            no se resuelve el contacto.
        data:
          type: object
          description: >-
            Valores de los campos asociados al tipo de evento, indexados por la
            key del campo. Los requeridos deben venir y cada valor debe respetar
            el tipo del campo.
          additionalProperties: true
        summary:
          type: string
          description: Texto corto del evento. Por defecto, el nombre del tipo.
        occurred_at:
          type: string
          description: Momento en que ocurrió, ISO 8601. Por defecto, ahora.
          format: date-time
        idempotency_key:
          type: string
          description: >-
            Identificador único del evento en tu sistema. Reenviar el mismo
            valor no duplica el evento.
      required:
        - type
    PublicEventEnvelopeDto:
      type: object
      properties:
        event:
          $ref: '#/components/schemas/PublicEventResponseDto'
        duplicated:
          type: boolean
          description: >-
            true cuando el `idempotency_key` ya existía: se devuelve el evento
            original y no se registra uno nuevo.
      required:
        - event
    PublicEventResponseDto:
      type: object
      properties:
        _id:
          type: string
          description: ObjectId del evento
        type:
          type: string
          description: Key del tipo de evento
        ticket_id:
          type: string
          description: ObjectId del ticket al que quedó adjunto
        chat_user:
          type: string
          description: ObjectId del contacto (chat user)
        customer:
          type: string
          description: ObjectId del cliente del CRM
        summary:
          type: string
          description: Texto corto del evento
        data:
          type: object
          description: Valores recibidos, tal cual quedaron registrados
          additionalProperties: true
        occurred_at:
          type: string
          format: date-time
        idempotency_key:
          type: string
          description: Clave de idempotencia recibida
      required:
        - _id
        - type
        - ticket_id
        - data
        - occurred_at
  securitySchemes:
    PAT:
      scheme: bearer
      bearerFormat: kbai_pk_<hex>
      type: http
      description: >-
        Personal Access Token con prefijo `kbai_pk_`. Generar desde el portal
        con permiso `developer.manage_tokens`.

````