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:
{
"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 noeventfield. They carrymsg_status, with values such assent,accepted,delivered,replied,soft_bounce,hard_bounce,subscribe,unsubscribed,skip,bl(blacklisted) andrej(rejected), alongsideto,messageId,date,typeandreference. - Coupon webhooks carry
event_and anname: "unique_ coupon_ sent" event_idUUID that Brevo says to use for deduplication. - Inbound parsing webhooks send a top-level
itemsarray 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
Authorizationheader 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
2xxonly 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.