# Guide to Belvo Webhooks: Features and Best Practices

Belvo webhooks tell your systems when something changes in a customer's open finance data or direct debit payments: a payment request succeeds or fails, a consent is approved or rejected, a payment method is registered, a customer is blocked, or a historical data load for a link finishes. If you build on Belvo in Mexico or Brazil, webhooks are how your application learns about these asynchronous results without polling the API.

This guide covers Belvo's three webhook products and how their payloads differ, how to authenticate requests with the optional `Authorization` header, the event codes for Mexico direct debit, the retry rules that decide whether a failed delivery is ever sent again, and the best practices for production.

## What are Belvo webhooks?

Belvo runs separate webhook systems for separate products, each configured in its own place and each with its own body shape:

* **Payments (Mexico)** covers direct debit: customers, consents, payment methods, payment requests and withdrawals. Bodies carry `eventType` and `eventCode`.
* **Aggregation** covers data retrieval for links: accounts, transactions, owners, invoices, consents and more. Bodies carry `webhook_type` (the resource) and `webhook_code` (the event).
* **Payments (Brazil)** has a V1 format with `webhook_type` and `webhook_code`, and a minimal V2 format that names only the resource and its ID.

None of them is signed. Each webhook can carry an optional static credential in the `Authorization` header, and comparing that header with the value you set is the only built-in way to tell a Belvo request from a forged one. Belvo also publishes three source IP addresses you can allowlist.

## Belvo webhook features

| Feature | Details |
| --- | --- |
| Configuration | Mexico: Direct Debit Portal > **Developers** > **Webhooks**. Aggregation: the **Data Webhooks** tab of the Belvo dashboard. Brazil: the **Payments Webhooks** tab |
| Webhooks per account | Mexico: up to 5. Brazil: several URLs, each receiving every event |
| Subscription | Mexico: by `eventType`, or **All events** |
| Authentication | Optional static value in `Authorization`. No signature |
| Mexico header | `Authorization: Bearer <token>` for the token you enter |
| Aggregation header | Exactly what you enter: `Bearer <token>` or `Basic <base64 username:password>` |
| Replay protection | None. No timestamp is signed |
| Source IPs | `3.130.254.46`, `18.220.61.186`, `18.223.45.212` |
| Mexico retries | Only on unreachable, timeout or 5xx. Every 60 minutes, 3 attempts in all. 4xx is never retried |
| Aggregation retries | Five retries with linear backoff on any non-2xx |
| Brazil retries | Respond 2xx within five seconds. Up to three retries, 60 seconds apart |
| Testing | Mexico: **Test** on the webhook row, with **Resend** in the **Event delivery** panel. Aggregation: trigger any event type from the dashboard |

## Common events

Hookdeck's Belvo source type points at the Payments (Mexico) documentation, so this section focuses on that product. You subscribe to an event type, and `eventCode` names the specific event.

| Event type | Event codes | Sent when |
| --- | --- | --- |
| `customer_update` | `customer_blocked`, `customer_unblocked` | A customer is blocked for suspicious activity, or unblocked after review |
| `consent_update` | `consent_submitted`, `consent_confirmed`, `consent_incomplete_information`, `consent_rejected` | All documents are uploaded, the consent is approved, documents are missing or invalid, or the consent is denied |
| `payment_method_update` | `payment_method_registration_successful`, `payment_method_registration_failed`, `payment_method_registration_canceled` | A direct debit payment method registration succeeds, fails or is cancelled |
| `payment_request_update` | `payment_request_successful`, `payment_request_failed`, `payment_request_chargeback` | A payout is confirmed, the payment infrastructure provider reports an error, or a customer makes a chargeback |
| `withdrawal_update` | `withdrawal_created` | A withdrawal is created and funds are being settled to you |

Two behaviours are easy to miss. `customer_blocked` is sent to every merchant, even ones that never registered the customer, because Belvo keeps one block list for all merchants, and each merchant receives it only once per customer. And no consent webhook is sent for the initial `awaiting_information` status.

A Mexico payload has four top-level fields:

```json
{
  "eventType": "payment_request_update",
  "eventCode": "payment_request_successful",
  "datetime": "2022-01-01T12:34:56.789Z",
  "details": {
    "id": "3118128a-6792-4b06-bd61-4acf6f6ad6b5",
    "reference": "your_reference_here",
    "status": "successful",
    "amount": 100.5,
    "failedReason": null,
    "failedMessage": null
  }
}

```

`details.id` is the Belvo ID of the customer, payment method, payment request or withdrawal. What else sits in `details` depends on the event: `failedReason` and `failedMessage` for failures, `documentNumber` (an RFC or CURP) for customer events, `paymentMethodId` and sometimes `reasons` for consent events, and `fromDatetime`, `toDatetime` and `extra` for withdrawals.

Aggregation events look different. They carry `webhook_id`, `webhook_type` (such as `ACCOUNTS`, `TRANSACTIONS` or `LINK`), `webhook_code` (such as `historical_update`, `new_transactions_available`, `invalid_credentials` or `link_deleted`), `link_id`, `external_id` and a `data` object. The same `webhook_code` recurs across resources, so route on both fields. When retrieval fails, the errors arrive in `data.errors`.

## Setting up Belvo webhooks

Generate a long random token first, for example with `openssl rand -hex 32`. For Payments (Mexico):

1. Log in to the Direct Debit Portal for the environment you want (Sandbox or Production).
2. Go to **Developers** > **Webhooks** and click **New webhook**.
3. Enter your HTTPS URL.
4. Select the event types you want, or **All events**.
5. Enter your token in the authentication field. Belvo sends it as `Authorization: Bearer <token>`.
6. Click **Create webhook**, then use **Test** on its row to send a sample payload and check the response in the **Event delivery** panel.

For Aggregation, open the **Data Webhooks** tab, click **+New webhook**, and enter the URL and the authorization value. Here you type the whole header value yourself: `Bearer` followed by a token, or `Basic` followed by a base64-encoded `username:password`. For Brazil, the **Payments Webhooks** tab takes an optional bearer token.

To route deliveries through Hookdeck, create a Hookdeck source with the **Belvo** type and use its URL as the webhook URL. The source has one field, **Authorization Header**, and it must hold the full value Belvo sends, including the scheme prefix. For a Mexico webhook with token `abc123`, enter `Bearer abc123`. For an Aggregation webhook, enter exactly what you typed in Belvo's field. The source accepts `POST`.

For local development, `hookdeck listen 3000 belvo --path /webhooks/belvo` gives you a public URL that forwards to your machine. Note that some test services, including webhook.site by default, answer with `418`, which Belvo treats as a client error and never retries.

## Securing Belvo webhooks

Store the whole `Authorization` value you expect, prefix included, and compare it with the incoming header in constant time. Do not strip or parse the `Bearer ` or `Basic ` prefix. Reject the request if the header is missing or your expected value is not configured.

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

// The full header value, e.g. "Bearer 3f9c...". Never just the token.
const EXPECTED = process.env.BELVO_AUTHORIZATION;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/belvo', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('authorization');
  if (!EXPECTED || typeof received !== 'string' || !safeEqual(received, EXPECTED)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  if (event.eventCode) {
    // Payments (Mexico): route on event.eventCode, key on event.details.id.
  } else if (event.webhook_type) {
    // Aggregation or Brazil V1: route on webhook_type + webhook_code.
  } else if (event.resource) {
    // Brazil V2: fetch the resource by event.resource_id for its status.
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

EXPECTED = os.environ.get("BELVO_AUTHORIZATION", "")  # e.g. "Bearer 3f9c..."

def verify_belvo_request(headers) -> bool:
    if not EXPECTED:
        return False  # never accept when unconfigured
    received = headers.get("authorization")
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), EXPECTED.encode("utf-8"))

```

Be clear about the limits of this check. A static header authenticates the sender only. It does not cover the body, so it cannot detect tampering, and with no timestamp a captured request can be replayed and will still pass. Use HTTPS only, a long random token, and rotate it if it might have leaked. The source IP allowlist is a useful second layer at your firewall, but not a substitute for the header.

> **Make Belvo webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `Authorization` value you configure, queues every payment and aggregation event durably, and lets you retry and replay deliveries long after Belvo has given up.

## Belvo webhook limitations and pain points

### The token is optional, and it is only a token

**The Problem:** A Belvo webhook created without a token sends no `Authorization` header at all, and one created with a token still proves nothing about the body.

**Why It Happens:** Belvo does not sign its webhooks. The authentication field is optional on all three products, and what it sends is a fixed string.

**Workarounds:**

* Always set a token, and reject requests without one.
* Allowlist Belvo's three source IPs at your firewall as a second check.
* For money movements, treat the webhook as a notification and confirm the payment request or withdrawal status through the API before acting on it.

**How Hookdeck Can Help:** Hookdeck's Belvo source compares the full `Authorization` value you enter. Hookdeck answers Belvo with a `200` either way, but a request with a missing or wrong value is recorded as failed verification and never reaches your destination.

### A 4xx in Mexico is final

**The Problem:** A Payments (Mexico) delivery that gets a `400`, `401` or `404` is never sent again, so a bug in your handler or a mismatched token loses the event.

**Why It Happens:** Belvo retries Mexico webhooks only when your server is unreachable, times out or returns `5xx`, and then only every 60 minutes for 3 attempts in all. It treats every `4xx` as a client error.

**Workarounds:**

* Return `5xx`, not `4xx`, for transient problems in your own code.
* Answer `200` to event codes you do not handle yet, rather than rejecting them.
* Reconcile payment request and consent status against the API on a schedule, so a lost webhook is caught.

**How Hookdeck Can Help:** Hookdeck stores each request as it arrives and delivers it to your service with [retries](/docs/retries) you control, so a bad deploy on your side no longer turns into a lost payment event. You can replay any event once your handler is fixed.

### Three products, three shapes

**The Problem:** A handler written for one product's payloads misreads another's.

**Why It Happens:** Mexico uses `eventType`, `eventCode` and `details`. Aggregation uses `webhook_type`, `webhook_code` and `data`. Brazil V1 uses `webhook_type` and `webhook_code`, while Brazil V2 sends only `schema_version`, `resource`, `resource_id`, `resource_version` and `timestamp`, and expects you to fetch the rest.

**Workarounds:**

* Use a separate endpoint, or a separate Hookdeck source, for each product.
* If you share one endpoint, detect the shape by which fields are present before routing.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on any body field, so one source can send Mexico events, Aggregation events and Brazil events to different destinations.

### Short and inconsistent retry windows

**The Problem:** An outage of a few hours can outlast every product's retries.

**Why It Happens:** Mexico tries for about two hours. Brazil retries up to three times 60 seconds apart and expects a response within five seconds. Aggregation retries five times with a linear backoff Belvo does not quantify.

**Workarounds:**

* Acknowledge quickly and do the work on a queue.
* Alert on endpoint errors so you notice an outage within minutes, not hours.

**How Hookdeck Can Help:** With Hookdeck in front, Belvo's retry budget is spent against Hookdeck rather than your service. Hookdeck holds each event until your service accepts it, and [issues](/docs/issues) alert you when deliveries fail.

## Best practices

### Store the whole Authorization value

Keep `Bearer <token>` or `Basic <base64>` as one string in your configuration and compare it whole. A stored token of `abc123` will never equal an incoming `Bearer abc123`. Use a separate token per webhook so you can rotate one without touching the others.

### Deduplicate on the resource and the event

Retries and multiple webhooks mean the same event can arrive more than once. For Mexico, key on `eventCode` plus `details.id`. For Aggregation, key on `webhook_id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Check status before writing

Payment events are state changes, and retries arrive an hour apart. Before you overwrite a stored status, compare `datetime` with the last update you recorded, or re-read the resource from the API.

### Handle `customer_blocked` for customers you do not have

Because `customer_blocked` goes to every merchant, look up `details.documentNumber` in your own records and ignore the event, with a `200`, if the customer is not yours.

### Acknowledge fast and process asynchronously

Brazil asks for a response within five seconds, and slow handlers cause timeouts that Belvo counts as failures. Verify the header, store the event, return `200`, and process afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Belvo sends unsigned webhooks from three products, each with its own payload shape and retry rules, and authenticates them only with an optional static `Authorization` value. Always set a token, compare the full header in constant time over HTTPS, allowlist Belvo's IPs, route on `eventCode` or `webhook_type` and `webhook_code`, and never answer a Mexico delivery with a `4xx` you would want retried.

[Hookdeck Event Gateway](https://hookdeck.com) checks that header, stores every Belvo event, and retries and replays on your schedule, so a short Belvo retry window stops being the only thing between an outage and a lost payment update.

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