Gareth Wilson Gareth Wilson

Guide to Brevo Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe Brevo dashboard, or POST https://api.brevo.com/v3/webhooks
Webhook typestransactional, marketing, inbound. Channel email or sms
Limit40 webhooks (marketing and transactional combined)
SignatureNone
AuthenticationURL username and password, bearer token, or custom headers, plus an IP allowlist
Event fieldevent (email), msg_status (SMS), event_name (coupons), none (inbound)
BatchingOptional. Events are collected in 5-minute windows of up to 500 events per batch
RetriesNot documented for email and SMS. Coupon events are at least once, with possible retries on 5xx
Timestampsts_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 valueWhereFires when
requestTransactional emailThe email is sent
deliveredTransactional and marketing emailThe recipient's server accepts it
opened, unique_openedopened: both. unique_opened: transactionalThe email is opened, or opened for the first time
proxy_open, unique_proxy_openproxy_open: both. unique_proxy_open: transactionalThe email is opened through a privacy proxy
clickTransactional and marketing emailA link is clicked
soft_bounce, hard_bounceTransactional and marketing emailDelivery fails temporarily or permanently
spamTransactional and marketing emailThe recipient marks it as spam
deferred, blocked, invalid_email, errorTransactional emailThe send is delayed, blocked, addressed to an invalid email, or fails
unsubscribedTransactional emailThe recipient unsubscribes
unsubscribeMarketing emailThe recipient unsubscribes from a campaign
list_addition, contact_updated, contact_deletedMarketing (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:

{
  "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:

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.

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:

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

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.

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 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 for free and handle Brevo webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.