# Guide to Brevo Webhooks: Features and Best Practices

Brevo, formerly Sendinblue, sends transactional and marketing email and SMS, and its webhooks report what happens to those messages: delivered, opened, clicked, bounced, marked as spam, or unsubscribed. Marketing webhooks also cover contact changes, and inbound parsing webhooks deliver received emails as JSON. If you send through Brevo, webhooks are how delivery and engagement data reaches your CRM, suppression lists and analytics.

This guide covers how Brevo webhooks are created and secured, the event names you'll see in payloads (which aren't the names you subscribe with), batched delivery, the gaps in Brevo's documentation, and the best practices for running a Brevo receiver in production.

## What are Brevo webhooks?

A Brevo webhook is a notify URL plus a set of events. Each webhook has a `type` (`transactional`, `marketing` or `inbound`) and, for transactional and marketing webhooks, a `channel` (`email` or `sms`, defaulting to `email`). When a subscribed event happens, Brevo sends an HTTP `POST` with a JSON body to the URL.

Brevo doesn't sign webhooks. Its "Secure webhook calls" page lists four protections, all set when you create the webhook:

* **An IP allowlist.** Brevo publishes its IP ranges in its help centre.
* **A username and password in the URL**, in the form `https://username:password@example.com/`.
* **A bearer token**, set with `"auth": {"type": "bearer", "token": "..."}`.
* **Custom headers**, set with `"headers": [{"key": "...", "value": "..."}]` and sent with every call.

Brevo doesn't say which header the URL credentials or the bearer token arrive in. HTTP clients conventionally send URL credentials as an `Authorization: Basic` header, which is what Hookdeck's Brevo source type checks. Confirm it with one test delivery before you rely on it.

## Brevo webhook features

| Feature | Details |
| --- | --- |
| Configuration | The Brevo dashboard, or `POST https://api.brevo.com/v3/webhooks` |
| Webhook types | `transactional`, `marketing`, `inbound`. Channel `email` or `sms` |
| Limit | 40 webhooks (marketing and transactional combined) |
| Signature | None |
| Authentication | URL username and password, bearer token, or custom headers, plus an IP allowlist |
| Event field | `event` (email), `msg_status` (SMS), `event_name` (coupons), none (inbound) |
| Batching | Optional. Events are collected in 5-minute windows of up to 500 events per batch |
| Retries | Not documented for email and SMS. Coupon events are at least once, with possible retries on `5xx` |
| Timestamps | `ts_event` (seconds, UTC), `ts_epoch` (milliseconds, UTC), `date` (CET/CEST) |

## Common events

The names in a payload are not the names you subscribe with. The create-webhook `events` list uses camelCase (`hardBounce`, `uniqueOpened`, `listAddition`), and subscribes to the email "Sent" event as `sent` or `request`. The body uses snake_case, and the "Sent" event arrives as `request`.

| `event` value | Where | Fires when |
| --- | --- | --- |
| `request` | Transactional email | The email is sent |
| `delivered` | Transactional and marketing email | The recipient's server accepts it |
| `opened`, `unique_opened` | `opened`: both. `unique_opened`: transactional | The email is opened, or opened for the first time |
| `proxy_open`, `unique_proxy_open` | `proxy_open`: both. `unique_proxy_open`: transactional | The email is opened through a privacy proxy |
| `click` | Transactional and marketing email | A link is clicked |
| `soft_bounce`, `hard_bounce` | Transactional and marketing email | Delivery fails temporarily or permanently |
| `spam` | Transactional and marketing email | The recipient marks it as spam |
| `deferred`, `blocked`, `invalid_email`, `error` | Transactional email | The send is delayed, blocked, addressed to an invalid email, or fails |
| `unsubscribed` | Transactional email | The recipient unsubscribes |
| `unsubscribe` | Marketing email | The recipient unsubscribes from a campaign |
| `list_addition`, `contact_updated`, `contact_deleted` | Marketing (contacts) | A contact is added to a list, updated or deleted |

A transactional `delivered` event looks like this. Brevo's own examples contain placeholders, so the values here are illustrative:

```json
{
  "event": "delivered",
  "email": "example@example.com",
  "id": 26224,
  "date": "2026-10-21 11:30:09",
  "ts": 1761039009,
  "message-id": "<202610210930.12345678@smtp-relay.mailin.fr>",
  "ts_event": 1761039009,
  "subject": "Subject Line",
  "tag": "[\"transactionalTag\"]",
  "sending_ip": "185.41.28.109",
  "ts_epoch": 1761039009223,
  "tags": ["myFirstTransactional"]
}

```

`id` is the webhook's ID, not an event ID. `date` is in CET/CEST, while `ts_event` and `ts_epoch` are UTC.

Other families use other shapes:

* **SMS webhooks** (`"channel": "sms"`) have no `event` field. They carry `msg_status`, with values such as `sent`, `accepted`, `delivered`, `replied`, `soft_bounce`, `hard_bounce`, `subscribe`, `unsubscribed`, `skip`, `bl` (blacklisted) and `rej` (rejected), alongside `to`, `messageId`, `date`, `type` and `reference`.
* **Coupon webhooks** carry `event_name: "unique_coupon_sent"` and an `event_id` UUID that Brevo says to use for deduplication.
* **Inbound parsing webhooks** send a top-level `items` array of received emails and no event field.

## Setting up Brevo webhooks

You can add webhooks in the Brevo dashboard, but the API exposes every option, including authentication and batching:

```bash
curl --request POST "https://api.brevo.com/v3/webhooks" \
  --header "api-key: $BREVO_API_KEY" \
  --header "content-type: application/json" \
  --data '{
    "description": "Delivery events",
    "url": "https://hookdeck-user:LONG_RANDOM_PASSWORD@example.com/webhooks/brevo",
    "type": "transactional",
    "events": ["delivered", "hardBounce", "softBounce", "spam", "unsubscribed"]
  }'

```

To use a bearer token instead, keep the URL plain and add `"auth": {"type": "bearer", "token": "..."}`. To send your own header, add `"headers": [{"key": "x-brevo-secret", "value": "..."}]`. Set `"channel": "sms"` for SMS events, and `"batched": true` to batch.

## Securing Brevo webhooks

With credentials in the URL, check the `Authorization: Basic` header: decode it, split at the first colon, and compare the username and password in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  const left = Buffer.from(a, 'utf8');
  const right = Buffer.from(b, 'utf8');
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

function verifyBrevoBasicAuth(authorization, username, password) {
  const match = /^Basic ([A-Za-z0-9+/=]+)$/.exec(authorization || '');
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64').toString('utf8');
  const separator = decoded.indexOf(':'); // passwords may contain ':'
  if (separator === -1) return false;
  const userOk = safeEqual(decoded.slice(0, separator), username);
  const passOk = safeEqual(decoded.slice(separator + 1), password);
  return userOk && passOk;
}

const app = express();

app.post('/webhooks/brevo', express.raw({ type: '*/*' }), (req, res) => {
  const { BREVO_WEBHOOK_USERNAME, BREVO_WEBHOOK_PASSWORD } = process.env;
  if (!BREVO_WEBHOOK_USERNAME || !BREVO_WEBHOOK_PASSWORD) return res.sendStatus(500); // fail closed

  if (!verifyBrevoBasicAuth(req.get('authorization'), BREVO_WEBHOOK_USERNAME, BREVO_WEBHOOK_PASSWORD)) {
    return res.sendStatus(401);
  }

  const parsed = JSON.parse(req.body.toString('utf8'));
  const events = Array.isArray(parsed) ? parsed : [parsed]; // be ready for batched bodies
  // Route each event on event, msg_status or event_name, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hmac

def verify_brevo_basic_auth(authorization, username: str, password: str) -> bool:
    if not authorization or not authorization.startswith("Basic "):
        return False
    try:
        decoded = base64.b64decode(authorization[6:], validate=True).decode("utf-8")
    except (binascii.Error, UnicodeDecodeError):
        return False
    user, sep, pwd = decoded.partition(":")
    if not sep:
        return False
    user_ok = hmac.compare_digest(user.encode(), username.encode())
    pass_ok = hmac.compare_digest(pwd.encode(), password.encode())
    return user_ok and pass_ok

```

If you chose the bearer token or a custom header, compare that header's whole value with the one you configured, using the same constant-time comparison. Hookdeck's Brevo source type verifies the username and password mode. Leave verification off on the Hookdeck source if you didn't set credentials on the webhook.

> **Make Brevo webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the credentials on every Brevo delivery, durably queues each delivery, bounce and engagement event, and retries into your service on a schedule you control.

## Brevo webhook limitations and pain points

### No signature, and the credential header isn't documented

**The Problem:** You can't prove a request came from Brevo, and you have to guess what the credentials look like on the wire.

**Why It Happens:** Brevo signs nothing. Its protections are static credentials and an IP allowlist, and its docs say how to configure the URL credentials and bearer token but not how they're presented on delivery.

**Workarounds:**

* Use a long random password or token, and serve the endpoint only over HTTPS.
* Capture one delivery and check the `Authorization` header before you write the comparison.
* Add Brevo's IP ranges at your firewall as a second layer.

**How Hookdeck Can Help:** Every request to a Hookdeck source is stored with its full headers, so you can see exactly what Brevo sent, and the Brevo source type checks the Basic credentials before anything reaches your service.

### Subscription names and payload names differ

**The Problem:** You subscribe to `hardBounce` and your handler, matching on `hardBounce`, never fires.

**Why It Happens:** Subscriptions use camelCase and payloads use snake_case. The "Sent" event is `request` in the payload, and marketing and transactional unsubscribes use different words (`unsubscribe` and `unsubscribed`).

**Workarounds:**

* Match on the payload values listed above.
* Log and acknowledge any value you don't recognise.

**How Hookdeck Can Help:** Hookdeck filters match on the values that actually arrive, and the request log shows them, so you can build routing rules from real payloads.

### SMS and coupon events don't have an `event` field

**The Problem:** A handler that reads `event` drops every SMS status update.

**Why It Happens:** SMS webhooks carry `msg_status`, coupon webhooks carry `event_name`, and inbound parsing has no type field at all. Four values (`delivered`, `soft_bounce`, `hard_bounce`, `unsubscribed`) appear in both email `event` and SMS `msg_status`, with different bodies.

**Workarounds:**

* Use a separate webhook URL (or path) per family, so each handler knows the shape.
* If you share one URL, check which field is present before you route.

**How Hookdeck Can Help:** Point each Brevo webhook at its own Hookdeck source, or use filters on `msg_status` and `event_name`, to send each family to the right destination.

### The `error` event's example says `invalid_email`

**The Problem:** Matching on `error` may never fire.

**Why It Happens:** The transactional Error section's attribute table says `event` is `"error"`, but its JSON example sends `"event":"invalid_email"`.

**Workarounds:**

* Handle both values in the same branch until you've seen a real error event.

**How Hookdeck Can Help:** Hookdeck's request log lets you search for the first real error delivery and confirm which value Brevo sends.

### Batched bodies and retries are undocumented

**The Problem:** You turn on batching and your single-event parser breaks, or you can't tell whether a failed delivery will come back.

**Why It Happens:** Brevo describes batching (5-minute windows, up to 500 events per batch, several batches when more accumulate) but never shows the batched body. It documents no retry policy for email and SMS webhooks, and mentions retries on `5xx` only for coupon events.

**Workarounds:**

* Accept both a single object and an array when batching is on, as the code above does.
* Return `2xx` only once the events are safely stored, and reconcile against Brevo's API or reports for anything critical.

**How Hookdeck Can Help:** Hookdeck accepts each Brevo delivery, stores it, and retries delivery to your service until it succeeds, so your side of the pipeline doesn't depend on Brevo's undocumented retry behaviour.

## Best practices

### Keep credentials out of logs

URL credentials end up in configuration screens and request logs. Generate them randomly, rotate them by updating the webhook, and redact the `Authorization` header in your own logs.

### Route by family, then by value

Check `event`, `msg_status`, `event_name` or `items` to find the family, then switch on the value. Acknowledge anything you don't recognise.

### Deduplicate defensively

Email events have no event ID. Build a key from `message-id`, `event` and `ts_event` (or `ts_epoch`). For coupons, use `event_id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use UTC timestamps

`date` is in CET/CEST. Use `ts_event` or `ts_epoch` for ordering and storage.

### Respond fast

Brevo publishes no timeout, so don't depend on a generous one. Verify, store and return `200`, then process. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Brevo webhooks are unsigned, so protect them with URL credentials, a bearer token or a custom header, and check the value in constant time. Route on the snake_case payload values rather than the camelCase subscription names, handle the SMS and coupon shapes separately, accept arrays when batching is on, and build your own dedupe key because email events don't carry one.

[Hookdeck Event Gateway](https://hookdeck.com) checks the Brevo credentials, stores every delivery and retries into your service, which fills the gaps Brevo's documentation leaves around retries.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Brevo webhooks reliably in minutes.