# Webhooks -- Overview

Webhooks allow your application to receive real-time notifications about events on the Monetarie platform. When an event occurs, Monetarie sends an HTTP POST to the registered URL.

## How It Works

1. [Register a webhook URL](/en/webhooks-register) in your account
2. When an event occurs (e.g., PIX received), Monetarie sends an HTTP POST to your URL
3. Your application processes the notification and responds with `2xx` status (200, 201, or 204)

## Available Events

Monetarie delivers **PIX**, **TEF between Monetarie accounts**, and operational webhook test events. Other products (boleto, accounts, STA) are out of scope. Any attempt to subscribe to events outside the table below is rejected with `events: contains invalid events: ...`.

| Event | Status body | Description | Dispatch |
|-------|-------------|-------------|----------|
| `pix.charge.created` | `created` | QR code generated or cash-in initiated | **Active** |
| `pix.charge.paid` | `paid` | PIX received and settled | **Active** |
| `pix.charge.expired` | `expired` | QR code expired without payment | **Active** |
| `pix.charge.cancelled` | `cancelled` | QR code cancelled before payment | Registered, not yet dispatched |
| `pix.payout.queued` | `queued` | PIX send queued by operational limit. Automatic retry with max TTL 2h | **Active** |
| `pix.payout.processing` | `processing` | PIX sent, awaiting BACEN confirmation | **Active** |
| `pix.payout.held` | `processing` | Outbound PIX held for review at the settlement agent (authorization/anti-fraud queue). It settles or rejects afterwards - do not resubmit | **Active** |
| `pix.payout.confirmed` | `settled` | PIX sent and confirmed (**terminal**) | **Active** |
| `pix.payout.failed` | `rejected` | PIX send rejected by SPI (**terminal**) | **Active** |
| `pix.payout.returned` | `returned` | Sent PIX was returned | **Active** |
| `pix.refund.requested` | `requested` | Refund request received (BACEN infraction); preventive block created on the client balance | **Active** |
| `pix.refund.completed` | `settled` / `completed` | Defense analysis finalized and refund executed (or released) | **Active** |
| `pix.refund.failed` | `failed` | Refund rejected by the SPI/settlement participant (**terminal**); carries the `reason_code` and `reason_description` | **Active** |
| `pix.return.received` | `settled` | PIX return received (credit) | **Active** |
| `pix.infraction.created` | `ACKNOWLEDGED` | PIX infraction reported by the counterparty through BACEN DICT; may require MED analysis and defense | **Active** |
| `pix.infraction.resolved` | `CLOSED` / `CANCELLED` | Infraction resolved by final decision or counterparty cancellation | **Active** |
| `pix.infraction.defense_submitted` | `defense_submitted` | Defense submitted by the merchant (portal or API); awaiting BACEN analysis | **Active** |
| `tef.transfer.sent` | `settled` | TEF between Monetarie accounts settled for the origin account | **Active** |
| `tef.transfer.received` | `settled` | TEF between Monetarie accounts settled for the destination account | **Active** |
| `tef.transfer.failed` | `failed` | TEF between Monetarie accounts rejected or not settled | **Active** |
| `webhook.test` | `test` | Manual test. **Available only via Admin/Merchant portal** - the External API does not expose an endpoint to dispatch tests | Manual dispatch (not External API) |

::: warning `pix.charge.cancelled` is not yet dispatched
The event is in the enum and can be subscribed to, but the system does not have a QR code cancellation flow today. If you subscribe, `POST /webhooks` responds 201 normally - but no notification will arrive. Continue monitoring `pix.charge.expired` for the QR's natural lifecycle.
:::

## Security

Each notification includes security and identification headers for validation:

| Header | Description |
|--------|-------------|
| `X-Monetarie-Signature` | **HMAC-SHA256** signature of the payload (prefix `sha256=`). In rare cases (webhook registered without `secret`), the literal value is `unsigned` - see note below |
| `X-Monetarie-Timestamp` | Unix timestamp in seconds of the delivery |
| `X-Monetarie-Event-Id` | Unique delivery UUID (for deduplication) |
| `X-Monetarie-Event-Type` | Event type (e.g., `pix.charge.paid`) |
| `Content-Type` | Always `application/json` |
| `User-Agent` | Always `Monetarie-Webhook/1.0` - use for whitelisting in firewalls/WAF. Future evolution will follow the pattern `Monetarie-Webhook/{version}`; filter by prefix `Monetarie-Webhook/` if you want to be immune to new versions |

::: warning Signature `unsigned` when webhook has no secret
If the webhook was registered without a `secret` field in a legacy record, the `X-Monetarie-Signature` header may be `unsigned`. This disables HMAC validation on your side. If you receive `unsigned`, register a new webhook with an explicit `secret` and remove the old one.
:::

::: tip SHA256 for webhooks vs SHA512 for the API
The API uses **HMAC-SHA512** to authenticate requests you send. Webhooks sent by Monetarie use **HMAC-SHA256** in the `X-Monetarie-Signature` signature. They are different algorithms -- each in its context.
:::

### Validating the Signature

Validate the signature to guarantee the notification was sent by Monetarie:

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

function validateWebhook(rawBody, timestamp, signature, secret) {
  // rawBody is the RAW request body string (before any JSON parse)
  // timestamp is unix seconds (e.g., 1712160000)
  const message = `${timestamp}.${rawBody}`;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}
```

::: danger Use the RAW body, not re-serialized
You **must** use the exact HTTP request body as the bytes arrived at your application. If you do `JSON.parse` and then `JSON.stringify`, the resulting bytes **will not** be identical to what Monetarie used for signing, and validation will fail.

In Express/Node: use `express.raw({ type: 'application/json' })` or capture the body before any parsing middleware.

In other frameworks: configure your stack to capture the raw body before JSON middleware.
:::

::: warning Key ordering in WEBHOOKS: NOT required
For **webhook validation** (HMAC-SHA256) you **do NOT need to sort keys** - use the raw body as received in the HTTP request from Monetarie.

⚠️ **Attention - difference vs sending requests**: In the [HMAC-SHA512 signature of REQUESTS you send](/en/hmac), alphabetical key ordering **IS mandatory** (the Monetarie server reorders before validating). Do not confuse the two scenarios:
- **Webhook received (HMAC-SHA256)**: validate the raw body without reordering
- **Request sent (HMAC-SHA512)**: sort your keys alphabetically before signing
:::

::: warning Always validate
Never process a webhook without validating the signature. This protects against forged requests.

Additionally, validate that `X-Monetarie-Timestamp` is within **± 5 minutes** of the current time (anti-replay protection - the server does **not** reject "old" webhooks by default; this check is up to your endpoint as defense-in-depth) and deduplicate events by `X-Monetarie-Event-Id` (protection against retries).
:::

## Event Ordering

Events for the same payment may reach your endpoint **out of order** within short windows (HTTP deliveries are independent and a PIX can settle in under a second). Handle statuses per the table:

| Event type | Semantics |
|---|---|
| `*.processing` / intermediate | Informational. It must **never** overwrite a terminal status you already received. |
| `*.confirmed`, `*.paid`, `*.completed`, `*.failed` | **Terminal (absorbing)**: once received, ignore any later intermediate event for the same payment. |

Practical rules for your consumer:

1. Apply state transitions based on the event **content** (the `status` field), not on arrival order.
2. A payment in a terminal state never regresses: discard a `processing` received after `confirmed`/`failed`.
3. Deduplicate by `X-Monetarie-Event-Id` (retries resend the same event with the same id).

## Retry Policy

If your URL returns a status different from `2xx` (or times out after 30 s), Monetarie performs up to **8 attempts** with exponential backoff. The total time between the first and the eighth attempt is approximately **7h45min**:

| Attempt | Delay since previous attempt | Cumulative time |
|---------|------------------------------|-----------------|
| 1st | - (immediate) | ~50-200 ms |
| 2nd | 30 seconds | ~30 s |
| 3rd | 2 minutes | ~2.5 min |
| 4th | 10 minutes | ~12.5 min |
| 5th | 30 minutes | ~42.5 min |
| 6th | 1 hour | ~1.75 h |
| 7th | 2 hours | ~3.75 h |
| 8th | 4 hours | ~7.75 h |

After 8 attempts without success, the `webhook_delivery` is marked with status `failed` and is not automatically resent. You can request a manual replay from Monetarie support providing the `X-Monetarie-Event-Id` (or have an operator with admin access replay via the portal).

::: tip Delivery status
Each delivery goes through statuses: `pending` (created, awaiting delivery) → `delivered` (2xx received) OR `failed` (8 attempts exhausted) OR `expired` (replay protection).

**About `expired`:** when a delivery attempt is too old before it can be sent, it may be discarded and marked as `expired`. This prevents late reprocessing from firing outdated notifications. Manual replays requested to Monetarie support follow a controlled flow and may resend the event to the client.
:::

::: tip Durability
Before attempting the first delivery, the event is persisted for retry. If there is a failure during delivery, the system resumes automatically on the next retry - no event is lost.
:::

::: tip Idempotency
Your application must be **idempotent**: if it receives the same event more than once (identified by `X-Monetarie-Event-Id`), it must process it without duplicating effects.
:::

### Manual replay via admin

If a delivery failed and you need to resend, the Monetarie team can perform a manual replay via the admin dashboard. Contact support with the delivery `event_id`.

### Duplicate deliveries (known race condition)

The system may use more than one delivery path to accelerate the first notification and keep durable retry. In high-concurrency scenarios, you may receive the same payload twice via HTTP, but with **the same `X-Monetarie-Event-Id`** - it is the same event, not a retry.

To avoid duplicate impact:

- **Dedupe by `X-Monetarie-Event-Id`** (recommended - unique UUID per delivery, stable across retries and in the race condition above)
- Or alternatively dedupe by `end_to_end_id` + `event_type` when it makes sense for the event

This is expected behavior, not an error. Legitimate retries (after 5xx/timeout) also reuse the same `X-Monetarie-Event-Id`.

## External ID in Webhooks

When a transaction was created with `external_id`, this field is included in the webhook payload within the `data` object. Use it to correlate the event with the order in your system without needing an additional query.

## Endpoint Requirements

- The URL must use **HTTPS** (unless `allow_insecure: true` on registration)
- Must respond with `2xx` status within **30 seconds**
- The response body is ignored
- Recommended to respond fast (`200 OK` immediately) and process the event asynchronously on your side; long delays reduce throughput and increase chance of retries

## Next Steps

- [Register Webhook](/en/webhooks-register) -- create, list, and remove webhooks
- [Event Payloads](/en/webhooks-payloads) -- examples of each event type
