Brasil Bitcoindocs
Webhooks

Implementation

Complete Examples

import express from 'express';

interface PixWebhookPayload {
  event: 'CashIn' | 'CashOut' | 'CashInReversal' | 'CashOutReversal';
  status: 'PENDING' | 'CONFIRMED' | 'ERROR';
  transactionType: 'PIX';
  movementType: 'CREDIT' | 'DEBIT';
  transactionId: string;
  externalId: string | null;
  endToEndId: string;
  pixKey: string | null;
  feeAmount: number;
  originalAmount: number;
  finalAmount: number;
  processingDate: string;
  errorCode: string | null;
  errorMessage: string | null;
  counterpart?: Counterpart;
  parentTransaction?: ParentTransaction;
  metadata: Record<string, unknown>;
}

interface Counterpart {
  name: string;
  document: string;
  bank: {
    bankISPB: string | null;
    bankName: string | null;
    bankCode: string | null;
    accountBranch: string | null;
    accountNumber: string | null;
  };
}

interface ParentTransaction {
  transactionId: string;
  externalId: string;
  endToEndId: string;
  processingDate: string;
  wasTotalRefunded: boolean;
  remainingAmountForRefund: number;
  metadata: Record<string, unknown>;
  counterpart: Counterpart;
}

const app = express();
app.use(express.json());

// Basic Auth authentication middleware
function validateBasicAuth(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Basic ')) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const base64Credentials = authHeader.split(' ')[1];
  const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
  const [username, password] = credentials.split(':');

  if (
    username !== process.env.WEBHOOK_USER ||
    password !== process.env.WEBHOOK_PASS
  ) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  next();
}

// Set for idempotency control
const processedTransactions = new Set<string>();

app.post('/webhooks/pix', validateBasicAuth, async (req, res) => {
  const payload: PixWebhookPayload = req.body;

  // Respond quickly (webhook requires response within 10s)
  res.status(200).json({ acknowledged: true });

  // Check idempotency
  if (processedTransactions.has(payload.transactionId)) {
    console.log(`Transaction ${payload.transactionId} already processed`);
    return;
  }

  // Mark as processed
  processedTransactions.add(payload.transactionId);

  // Process asynchronously
  try {
    switch (payload.event) {
      case 'CashIn':
        await handleCashIn(payload);
        break;
      case 'CashOut':
        await handleCashOut(payload);
        break;
      case 'CashInReversal':
        await handleCashInReversal(payload);
        break;
      case 'CashOutReversal':
        await handleCashOutReversal(payload);
        break;
    }
  } catch (error) {
    console.error(`Error processing ${payload.event}:`, error);
    processedTransactions.delete(payload.transactionId);
  }
});

async function handleCashIn(payload: PixWebhookPayload) {
  console.log(`[CashIn] Received: R$ ${payload.finalAmount}`);
}

async function handleCashOut(payload: PixWebhookPayload) {
  console.log(`[CashOut] Sent: R$ ${payload.originalAmount}`);
}

async function handleCashInReversal(payload: PixWebhookPayload) {
  console.log(`[CashInReversal] Refunded: R$ ${payload.originalAmount}`);
}

async function handleCashOutReversal(payload: PixWebhookPayload) {
  console.log(`[CashOutReversal] Returned: R$ ${payload.finalAmount}`);
}

app.listen(3000);
from flask import Flask, request, jsonify
from functools import wraps
import base64
import os
from typing import Dict, Any, Optional
from dataclasses import dataclass

app = Flask(__name__)
processed_transactions: set = set()

@dataclass
class PixWebhookPayload:
    event: str
    status: str
    transaction_id: str
    external_id: Optional[str]
    end_to_end_id: str
    fee_amount: float
    original_amount: float
    final_amount: float
    counterpart: Optional[Dict[str, Any]]
    parent_transaction: Optional[Dict[str, Any]]

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'PixWebhookPayload':
        return cls(
            event=data.get('event'),
            status=data.get('status'),
            transaction_id=data.get('transactionId'),
            external_id=data.get('externalId'),
            end_to_end_id=data.get('endToEndId'),
            fee_amount=data.get('feeAmount', 0),
            original_amount=data.get('originalAmount', 0),
            final_amount=data.get('finalAmount', 0),
            counterpart=data.get('counterpart'),
            parent_transaction=data.get('parentTransaction'),
        )

def require_basic_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get('Authorization')
        if not auth_header or not auth_header.startswith('Basic '):
            return jsonify({'error': 'Unauthorized'}), 401
        try:
            credentials = base64.b64decode(auth_header.split(' ')[1]).decode('utf-8')
            username, password = credentials.split(':')
            if username != os.environ.get('WEBHOOK_USER') or password != os.environ.get('WEBHOOK_PASS'):
                return jsonify({'error': 'Invalid credentials'}), 401
        except Exception:
            return jsonify({'error': 'Invalid auth header'}), 401
        return f(*args, **kwargs)
    return decorated

@app.route('/webhooks/pix', methods=['POST'])
@require_basic_auth
def handle_pix_webhook():
    data = request.get_json()
    payload = PixWebhookPayload.from_dict(data)
    if payload.transaction_id in processed_transactions:
        return jsonify({'acknowledged': True}), 200
    processed_transactions.add(payload.transaction_id)
    if payload.event == 'CashIn':
        print(f"[CashIn] R$ {payload.final_amount:.2f}")
    elif payload.event == 'CashOut':
        print(f"[CashOut] R$ {payload.original_amount:.2f}")
    elif payload.event == 'CashInReversal':
        print(f"[CashInReversal] R$ {payload.original_amount:.2f}")
    elif payload.event == 'CashOutReversal':
        print(f"[CashOutReversal] R$ {payload.final_amount:.2f}")
    return jsonify({'acknowledged': True}), 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=3000)
<?php

$WEBHOOK_USER = getenv('WEBHOOK_USER') ?: 'brbtc';
$WEBHOOK_PASS = getenv('WEBHOOK_PASS') ?: 'secret';
$PROCESSED_FILE = '/tmp/processed_transactions.json';

function validateBasicAuth($user, $pass): bool {
    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (empty($authHeader) || !str_starts_with($authHeader, 'Basic ')) {
        return false;
    }
    $credentials = base64_decode(substr($authHeader, 6));
    list($u, $p) = explode(':', $credentials, 2);
    return $u === $user && $p === $pass;
}

function isProcessed($txId): bool {
    global $PROCESSED_FILE;
    if (!file_exists($PROCESSED_FILE)) return false;
    $processed = json_decode(file_get_contents($PROCESSED_FILE), true) ?? [];
    return in_array($txId, $processed);
}

function markProcessed($txId): void {
    global $PROCESSED_FILE;
    $processed = file_exists($PROCESSED_FILE)
        ? json_decode(file_get_contents($PROCESSED_FILE), true) ?? []
        : [];
    $processed[] = $txId;
    file_put_contents($PROCESSED_FILE, json_encode(array_slice($processed, -10000)));
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

if (!validateBasicAuth($WEBHOOK_USER, $WEBHOOK_PASS)) {
    http_response_code(401);
    echo json_encode(['error' => 'Unauthorized']);
    exit;
}

$payload = json_decode(file_get_contents('php://input'), true);

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['acknowledged' => true]);

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

if (isProcessed($payload['transactionId'])) {
    exit;
}

markProcessed($payload['transactionId']);

switch ($payload['event']) {
    case 'CashIn':
        error_log("[CashIn] R$ " . $payload['finalAmount']);
        break;
    case 'CashOut':
        error_log("[CashOut] R$ " . $payload['originalAmount']);
        break;
    case 'CashInReversal':
        error_log("[CashInReversal] R$ " . $payload['originalAmount']);
        break;
    case 'CashOutReversal':
        error_log("[CashOutReversal] R$ " . $payload['finalAmount']);
        break;
}

HMAC Signature (X-Avista-Signature)

Every webhook sent by Brasil Bitcoin to an account that has rotated a secret includes the X-Avista-Signature header with the HMAC-SHA256 signature of the request body. Validating this signature is the most robust way to guarantee the webhook is legitimate (not forged by a third party that discovered your URL) and that the payload was not altered in transit.

The secret is generated per account — a single key signs all event types. The possible values for the event field in the payload are: CashIn (PIX received), CashOut (PIX sent), CashInReversal (refund of a receipt), CashOutReversal (refund of a sent payment) and the MEDs (MedCreated, MedAccepted, MedRejected, etc.). Do not confuse with the type used when registering URLs (snake_case cash_in, cash_out, ...) — the event field in the payload is PascalCase.

For new clients (HMAC only): rotate the secret BEFORE deploying your validator to production. Your function must reject requests without the X-Avista-Signature header — not just when the signature is incorrect. Accounts that have not yet rotated receive webhooks without the header (backward-compat for legacy clients on Basic Auth); if you are implementing from scratch, that path should not pass your validation.

Generating and rotating the key

The key is generated and rotated via the Internet Banking — there is no public API endpoint for this. This flow ensures that only the account operator authenticated via the panel has access to the plaintext.

Where to find it:

Internet Banking → Settings → "Webhooks" tab

                                └─ 🔐 HMAC Signing Key (card at the top)

Card states

The "HMAC Signing Key" card has two visual states that reflect the actual state of your account:

Shown for accounts that have never rotated a key. Webhooks keep being delivered normally, without the X-Avista-Signature header (backward-compat for legacy clients on Basic Auth).

🔐 HMAC Signing Key

Allows your system to cryptographically validate that webhooks came
from Brasil Bitcoin and were not modified. Optional — without it, webhooks
keep being delivered normally.

┌──────────────────────────────────────────────────────┐
│ 🔒 You don't have a key configured yet               │
│    Webhooks are delivered without a signature.       │
│    Generate a key to start validating the origin     │
│    of webhooks on your system.                       │
│                                                      │
│           [🔐 Generate my key]                        │
└──────────────────────────────────────────────────────┘

Shown after you rotate the key for the first time. The panel never displays the plaintext here — only the hint (last 4 characters) and the date of the last rotation.

🔐 HMAC Signing Key

Current key:   •••••••• a14c78
Last rotated:  05/19/2026 14:32

[🔄 Rotate]  [📖 How to validate in my system ↗]

Flow 1 — First-time generation

In the empty state card, click 🔐 Generate my key.

A confirmation modal appears explaining what will happen:

You're about to generate your first HMAC key

From now on, webhooks sent by Brasil Bitcoin will be signed with the X-Avista-Signature header.

When you generate:

  • The new key will be shown only once — copy and store it immediately in a secure location in your system.
  • All webhooks sent from now on will use the new key to sign the X-Avista-Signature header.

[Cancel] [Generate key]

After confirming, a blocking modal shows the plaintext key:

⚠️  This key will be displayed only once

Copy and store it in a secure location in your system (vault,
secret manager, environment variable). We cannot recover it later
— you'll need to generate a new one if you lose it.

Generated key:
┌────────────────────────────────────────────────────┐
│ whsec_8f3c9a1b...a14c78                       [📋] │
└────────────────────────────────────────────────────┘

Hint (end): a14c78 • Generated on 05/19/2026 14:32

☐ I confirm I copied the key and stored it safely

                          [Done] (disabled)

The modal does not close by:

  • Esc key
  • Backdrop click
  • Close button (does not exist)

You must:

  1. Click 📋 Copy (button turns green showing "Copied" briefly)
  2. Check the "I confirm I copied..." checkbox
  3. Click Done (only enables after the checkbox)

Paste the plaintext into the environment variable of the server that receives webhooks:

export BRBTC_WEBHOOK_SECRET=whsec_8f3c9a1b...a14c78

Ideally, store it in a secret manager (AWS Secrets, Azure Key Vault, HashiCorp Vault, Google Secret Manager, Doppler, etc.) — not in a versioned .env file.

The next webhook Brasil Bitcoin sends to your URL will include the X-Avista-Signature header. Use one of the validation snippets below to verify.

Flow 2 — Rotating an existing key

Rotate when:

  • ✓ You suspect the current key was leaked
  • ✓ Scheduled security rotation (e.g., every 90 days)
  • ✓ A team member with key access left the team

In the "key active" card, click 🔄 Rotate.

A confirmation modal different from the first generation appears — highlighting the destructiveness:

⚠️ The current key (•••• a14c78) will be replaced

After rotation, all webhooks sent by Brasil Bitcoin will use the new key.

When you rotate:

  • The new key will be shown only once — copy and store it immediately.
  • All webhooks sent from now on use the new key.
  • ⚠️ Keep the previous key accepted in your validator for ~30 minutes to absorb in-flight webhooks (already queued before rotation).

[Cancel] [Yes, rotate]

Ideally, your validator should accept both keys (old + new) for ~30 minutes during the transition. This avoids rejecting in-flight webhooks.

Suggested implementation on your server:

const SECRETS = [
  process.env.BRBTC_WEBHOOK_SECRET_CURRENT,
  process.env.BRBTC_WEBHOOK_SECRET_PREVIOUS, // optional, during rotation
].filter(Boolean);

function isValid(rawBody, received) {
  return SECRETS.some(secret => {
    const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
    return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
  });
}

After ~30 minutes, remove BRBTC_WEBHOOK_SECRET_PREVIOUS from your vault.

Same flow as the first generation: blocking modal with Copy button + confirmation checkbox.

Rotation window: rotation replaces the secret atomically — there is no coexistence window between the old and new secrets on Brasil Bitcoin's side. In-flight webhooks (already queued, not yet delivered) rendered before the rotation are delivered with the old signature; after rotation, all switch to the new key. That's why we recommend accepting both keys for ~30 minutes on your validator. In a future release we will support current/previous natively in the schema to eliminate this operational window.

Lost my key, now what? Just sign in to the Internet Banking and generate a new one via Rotate. There is no recovery of the previous plaintext — that's precisely the goal of the model (zero-knowledge for Brasil Bitcoin). Update the key on your server before or right after the rotation to minimize the window of rejected webhooks.

UI security mechanisms

The interface applies several safeguards to prevent accidental plaintext exposure:

SafeguardHow it works
Plaintext shown only onceThe reveal modal only appears after an active generation/rotation. Refreshing the page never displays the plaintext.
No automatic copyThe key is not automatically copied to the clipboard. You must actively click 📋 Copy.
Dismissal blockedThe reveal modal does not close via Esc, backdrop click, or close button. The only path is the Done button.
Mandatory checkboxThe Done button is disabled until you tick the "I confirm I copied the key and stored it safely" checkbox.
Confirmation before rotatingRotation (destructive) always goes through a confirmation modal explaining the consequences before generating the new key.
Hint visible, plaintext neverAfter closing the reveal modal, the panel only shows •••••••• a14c78 so you can visually identify which key is active.

How to validate

The signature covers exactly the raw request body (rawBody) in UTF-8, before any JSON.parse. If you reserialize the received object to compute the HMAC, any divergence (key order, whitespace, encoding conversion) will cause the signature to mismatch.

import crypto from 'crypto';
import express from 'express';

const app = express();

// Option A: dedicated route with broad matcher — captures any Content-Type
// (including `application/json; charset=utf-8` from Brasil Bitcoin).
app.use('/webhooks', express.raw({ type: '*/*' }));

function isValidSignature(rawBody: Buffer, received: string, secret: string): boolean {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(received, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/pix', (req, res) => {
  const signature = req.header('X-Avista-Signature');
  // CRITICAL: reject requests WITHOUT the header, not only when signature is wrong
  if (!signature || !isValidSignature(req.body, signature, process.env.BRBTC_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // ...process payload
  res.status(200).json({ acknowledged: true });
});
// Option B: preserve the rawBody in parallel to automatic parsing.
// Useful when you already have `express.json()` global and cannot replace it.
app.use(express.json({
  verify: (req, _res, buf) => {
    (req as any).rawBody = buf; // Buffer with the raw body
  },
}));

app.post('/webhooks/pix', (req, res) => {
  const signature = req.header('X-Avista-Signature');
  const rawBody = (req as any).rawBody as Buffer | undefined;
  if (!signature || !rawBody || !isValidSignature(rawBody, signature, secret)) {
    return res.status(401).json({ error: 'invalid signature' });
  }
  // req.body is already parsed here
  res.status(200).json({ acknowledged: true });
});
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['BRBTC_WEBHOOK_SECRET']

@app.route('/webhooks/pix', methods=['POST'])
def webhook():
    raw_body = request.get_data()  # UTF-8 bytes, before JSON parse
    received = request.headers.get('X-Avista-Signature', '')
    if not received:
        abort(401)
    expected = hmac.new(SECRET.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received):
        abort(401)

    payload = request.get_json()
    # ...process payload
    return {'acknowledged': True}, 200
func validSignature(rawBody []byte, received, secret string) bool {
    if received == "" {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(received))
}

http.HandleFunc("/webhooks/pix", func(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    if !validSignature(body, r.Header.Get("X-Avista-Signature"), os.Getenv("BRBTC_WEBHOOK_SECRET")) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    // json.Unmarshal(body, &payload) ...
    w.WriteHeader(http.StatusOK)
})
<?php
function validSignature(string $rawBody, string $received, string $secret): bool {
    if ($received === '') return false;
    $expected = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $received);  // timing-safe in PHP
}

$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_BRBTC_SIGNATURE'] ?? '';
if (!validSignature($rawBody, $received, getenv('BRBTC_WEBHOOK_SECRET'))) {
    http_response_code(401);
    echo json_encode(['error' => 'invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);
// ...process payload
http_response_code(200);
echo json_encode(['acknowledged' => true]);

Always use timing-safe comparison (crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals). Comparing with == opens a timing-attack vector.

Encoding: the body is UTF-8. In languages that require explicit bytes (Java/Kotlin/PHP), make sure you are reading the stream as bytes — do not convert to String in a different encoding before computing the HMAC. In Python, request.get_data() already returns bytes; in Node, Buffer is the correct type.

HMAC signature does not prevent replay. It proves authenticity and integrity — it does not stop an attacker who captures a legitimate webhook (compromised proxy, leaked log) from re-sending it. Always combine signature validation with dedup by transactionId/medId in your storage. We are evaluating including X-Avista-Timestamp covered by HMAC + acceptance window in a future release.

Compatibility with Basic Auth

If you still validate webhooks via Basic Auth in custom headers, keep validating — the X-Avista-Signature header is additional, not a replacement. We recommend migrating to HMAC and removing Basic Auth when ready.


Idempotency

Webhooks may be sent more than once (in case of retries). Implement idempotency handling to avoid duplicate processing.

Use the transactionId field as a unique key:

const isProcessed = await redis.get(`webhook:${payload.transactionId}`);

if (isProcessed) {
  console.log('Webhook already processed, ignoring');
  return;
}

await redis.set(`webhook:${payload.transactionId}`, '1', 'EX', 86400);

await processWebhook(payload);


Best Practices


Retries

If your endpoint does not respond with HTTP 200 within 10 seconds:

AttemptIntervalCumulative time
1stImmediate0 min
2nd (1st retry)5 minutes5 min
3rd (2nd retry)5 minutes10 min
4th (3rd retry)15 minutes25 min

After 4 unsuccessful attempts (total time ~25 minutes), the webhook is moved to a dead letter queue (DLQ). Implement periodic polling as a fallback to ensure no transaction is lost.


Response Codes

Your endpoint should return an appropriate HTTP code:

CodeDescriptionSystem Action
2xxSuccess (200, 201, 204, etc.)Webhook confirmed, will not be retried
3xxRedirectConsidered failure, will be retried
4xxClient errorConsidered failure, will be retried
5xxServer errorConsidered failure, will be retried

The system validates only the HTTP code. Any 2xx response (200-299) is considered success, regardless of body content.

On this page