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

# Webhooks

> Receive events from Nuvia on your server: payload format, available events, and delivery guarantees

A webhook is the reverse path of the API: instead of you querying Nuvia, Nuvia sends a `POST` to
your server whenever something happens in your operation: a message arrives, a contact is
created, a field is filled in by the agent.

<Note>
  Each company has **at most one** webhook. It's not a collection: you configure one URL and
  choose which events it receives. Trying to create a second one returns `409`.
</Note>

## Configure

`POST /v1/webhooks` with the URL and the list of events. Required scope: `webhooks:create`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.nuvia.ai/v1/webhooks \
    -H "Authorization: Bearer $NUVIA_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-server.com/nuvia/webhook",
      "events": ["MESSAGE_RECEIVED", "CONTACT_CREATED"],
      "custom_headers": { "X-My-Token": "a-secret-of-yours" },
      "status": "ACTIVE"
    }'
  ```

  ```javascript JavaScript theme={null}
  await fetch("https://api.nuvia.ai/v1/webhooks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.NUVIA_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://your-server.com/nuvia/webhook",
      events: ["MESSAGE_RECEIVED", "CONTACT_CREATED"],
      custom_headers: { "X-My-Token": "a-secret-of-yours" },
      status: "ACTIVE",
    }),
  });
  ```

  ```python Python theme={null}
  import os, requests
  requests.post(
      "https://api.nuvia.ai/v1/webhooks",
      headers={"Authorization": f"Bearer {os.environ['NUVIA_API_KEY']}"},
      json={
          "url": "https://your-server.com/nuvia/webhook",
          "events": ["MESSAGE_RECEIVED", "CONTACT_CREATED"],
          "custom_headers": {"X-My-Token": "a-secret-of-yours"},
          "status": "ACTIVE",
      },
  )
  ```
</CodeGroup>

| Field            | Type                   | Required | Description                                                         |
| ---------------- | ---------------------- | -------- | ------------------------------------------------------------------- |
| `url`            | string                 | Yes      | URL that will receive the `POST` requests.                          |
| `events`         | string\[]              | Yes      | At least one event from the table below.                            |
| `custom_headers` | object                 | No       | Extra headers sent with every delivery. Use to validate the origin. |
| `status`         | `ACTIVE` \| `INACTIVE` | No       | Defaults to `ACTIVE`. With `INACTIVE`, nothing is delivered.        |

## What Nuvia sends

Every delivery is a `POST` with these headers:

| Header            | Value                                            |
| ----------------- | ------------------------------------------------ |
| `Content-Type`    | `application/json`                               |
| `X-Webhook-Event` | The event name, same as the body's `event` field |
| *(your headers)*  | Everything you configured in `custom_headers`    |

And the body always has the same three-field structure:

```json theme={null}
{
  "event": "MESSAGE_RECEIVED",
  "timestamp": "2026-08-13T14:03:00.000Z",
  "data": {}
}
```

* **`event`**: the event that triggered the delivery.
* **`timestamp`**: ISO 8601 UTC, generated when the delivery was queued.
* **`data`**: the resource related to the event (see the table below).

<Warning>
  The body **doesn't** identify the company: there's no `companyId` in the envelope. If your
  server serves multiple Nuvia companies, use one URL per company or a distinct value in
  `custom_headers` to know whose event it is.
</Warning>

## Available events

| Event                  | When it fires                     | What comes in `data`                         |
| ---------------------- | --------------------------------- | -------------------------------------------- |
| `MESSAGE_RECEIVED`     | Message received from a contact   | The message                                  |
| `MESSAGE_SENT`         | Message sent by your operation    | The message                                  |
| `MESSAGE_DELIVERED`    | WhatsApp confirmed delivery       | The message, with the status already updated |
| `MESSAGE_READ`         | The contact read the message      | The message, with the status already updated |
| `CONVERSATION_CREATED` | New conversation opened           | The conversation                             |
| `CONVERSATION_UPDATED` | Conversation changed              | The conversation                             |
| `CONTACT_CREATED`      | New contact created               | The contact                                  |
| `CONTACT_UPDATED`      | Contact changed                   | The contact                                  |
| `FOLLOWUP_SENT`        | Follow-up triggered by the agent  | The follow-up                                |
| `FIELD_UPDATED`        | Custom field filled in or changed | Its own structure (see below)                |

For message, conversation, and contact events, `data` carries the resource in the same format the
[API reference](/en/api-reference) returns on the corresponding `GET` requests, for example,
`GET /v1/messages/conversation/{id}` for messages.

<Note>
  Treat `data` tolerantly: read the fields you use and ignore the rest. New fields may appear as
  the platform evolves, and that isn't considered a breaking change.
</Note>

### `FIELD_UPDATED` is different

This event doesn't return an entity, but a description of the change:

```json theme={null}
{
  "event": "FIELD_UPDATED",
  "timestamp": "2026-08-13T14:03:00.000Z",
  "data": {
    "field": {
      "_id": "65f1a2b3c4d5e6f7a8b9c0d1",
      "slug": "orcamento",
      "title": "Orçamento",
      "context": "contact"
    },
    "value": "50000",
    "contactId": "65f1a2b3c4d5e6f7a8b9c0d2",
    "conversationId": "65f1a2b3c4d5e6f7a8b9c0d3",
    "companyId": "65f1a2b3c4d5e6f7a8b9c0d4"
  }
}
```

`field.context` indicates what the field belongs to: `contact`, `conversation`, or `business`.
`value` has the field's type: string, number, boolean, or list. `conversationId` only comes
through when the change happened inside a conversation.

## Validate that the call came from Nuvia

<Warning>
  **There's no HMAC signature.** Nuvia doesn't sign the request body, so there's no way to
  cryptographically verify the origin.
</Warning>

The available mechanism is `custom_headers`: register a secret value and check it on every
request.

```javascript Node.js theme={null}
app.post("/nuvia/webhook", (req, res) => {
  if (req.get("X-My-Token") !== process.env.NUVIA_WEBHOOK_TOKEN) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // respond first
  processInBackground(req.body); // process afterward
});
```

Use HTTPS and a long, random value. Since the secret travels in a header on every delivery, treat
it as a credential: rotate it with `PUT /v1/webhooks/{id}` if you suspect a leak.

## Delivery guarantees

These are the actual characteristics of delivery. Read this before building anything that depends
on a webhook:

<AccordionGroup>
  <Accordion title="One attempt per event, no retry" icon="rotate-left">
    If your server is down, returns `5xx`, or times out, **the event is lost**. The failure is
    logged on Nuvia's side, but nothing is resent. For critical data, reconcile periodically with
    the API (for example, by listing a conversation's messages) instead of relying solely on the
    webhook.
  </Accordion>

  <Accordion title="10-second timeout" icon="clock">
    Nuvia waits at most 10 seconds for your response. Respond `2xx` immediately and process in the
    background. If you process before responding, legitimate deliveries turn into timeouts.
  </Accordion>

  <Accordion title="Order isn't guaranteed" icon="arrow-down-up-across-line">
    Status events come from WhatsApp, which may deliver them out of order (a `MESSAGE_DELIVERED`
    arriving after the `MESSAGE_READ` for the same message). Nuvia doesn't reorder them. Treat each
    event by the state it carries, without assuming sequence.
  </Accordion>

  <Accordion title="Status events only fire on a real transition" icon="filter">
    `MESSAGE_DELIVERED` and `MESSAGE_READ` are only sent when the status actually changes. WhatsApp
    resends the same status multiple times, and those repeats are discarded: you don't get a
    duplicate because of it. Still, write an idempotent handler: use the message identifier as the
    key.
  </Accordion>

  <Accordion title="Nothing is delivered while the webhook is inactive" icon="power-off">
    With `status: "INACTIVE"`, or for events you didn't include in `events`, Nuvia doesn't queue
    any delivery, and there's no history to recover afterward. Reactivating doesn't recover the
    time it was stopped.
  </Accordion>
</AccordionGroup>

## Manage the configuration

| Action | Call                       | Scope             |
| ------ | -------------------------- | ----------------- |
| View   | `GET /v1/webhooks`         | `webhooks:read`   |
| Update | `PUT /v1/webhooks/{id}`    | `webhooks:update` |
| Remove | `DELETE /v1/webhooks/{id}` | `webhooks:delete` |

The `GET` returns **a single object** with the company's configuration, or `null` if there is
none. It's not a list. The `id` used in `PUT` and `DELETE` comes from that object. After removal,
the company can create a new webhook again.

To pause temporarily without losing the configuration, prefer `PUT` with `status: "INACTIVE"`
instead of deleting.

## Next steps

<CardGroup cols={2}>
  <Card title="Integration examples" icon="plug" href="/en/guias/exemplos-integracao">
    The webhook inside a complete use case, with an automatic reply.
  </Card>

  <Card title="API reference" icon="code" href="/en/api-reference/webhooks">
    Parameters and responses for the webhook endpoints.
  </Card>
</CardGroup>
