> ## Documentation Index
> Fetch the complete documentation index at: https://docs.payments.bob.company/llms.txt
> Use this file to discover all available pages before exploring further.

# Configure webhooks

> Receive automatic notifications when events happen in your project.

Webhooks let your application be notified automatically when events occur — such as a confirmed payment or an expired transaction. Instead of polling, Bob Payments notifies you.

## Configuring a webhook

<Steps>
  <Step title="Open the Dashboard">
    Go to **Webhooks → Add webhook** in your project.
  </Step>

  <Step title="Provide the URL">
    Enter your application's HTTPS URL that will receive the events (e.g. `https://yourapi.com/webhooks/bob`).
  </Step>

  <Step title="Copy the secret">
    A secret is generated automatically. Store it to verify the authenticity of the requests.
  </Step>
</Steps>

<Note>
  Webhooks work normally in the sandbox. Use them to test the full flow before going to production.
</Note>

## Payload versions (v1 and v2)

The webhook payload is **versioned**. The version is pinned per channel (dashboard webhooks) or per session (checkout):

|           | **v2** (recommended)                                            | **v1** (legacy, frozen)    |
| --------- | --------------------------------------------------------------- | -------------------------- |
| Amount    | `data.amountCents` in **cents** — consistent with the whole API | `data.amount` in **reais** |
| Envelope  | includes `apiVersion: "v2"`                                     | no marker                  |
| Signature | `t=<unix>,v1=<hmac>` with **anti-replay protection**            | plain HMAC-SHA256 hex      |

* **New channels** created in the dashboard start on **v2**.
* **Existing channels** stay on v1 until you migrate — once your consumer is ready, update the channel with `webhookApiVersion: "v2"` (via the dashboard or `PATCH /api/v1/notification-channels/:id`).
* The v1 shape is **frozen**: it never changes. All payload evolution happens on v2.

Example of `transaction.paid` on **v2** (note `apiVersion` and `amountCents`):

```json theme={"system"}
{
  "event": "transaction.paid",
  "apiVersion": "v2",
  "type": "transaction",
  "title": "Transação Paga",
  "message": "Transação de R$ 150,00 confirmada",
  "data": {
    "transactionId": "clx1abc123",
    "externalId": "pedido-001",
    "amountCents": 15000,
    "product": "Plano Premium",
    "customerName": "João Silva",
    "isSandbox": false,
    "createdAt": "2026-03-10T14:30:00.000Z"
  },
  "timestamp": "2026-03-10T14:35:00.000Z"
}
```

## Available events

<Note>
  The examples below show the **v1** (legacy) format. On **v2**, the only data difference is that `data.amount` (reais) is replaced by `data.amountCents` (cents) and the envelope gains `apiVersion: "v2"` — all other fields are identical.
</Note>

<AccordionGroup>
  <Accordion title="transaction.created" icon="plus">
    Fired when a PIX charge is created. Includes `pixCode` and `expirationDate`.

    ```json theme={"system"}
    {
      "event": "transaction.created",
      "type": "transaction",
      "title": "Transação Criada",
      "message": "Nova transação de R$ 150,00 aguardando pagamento",
      "data": {
        "transactionId": "clx1abc123",
        "externalId": "pedido-001",
        "amount": 150.00,
        "product": "Plano Premium",
        "customerName": "João Silva",
        "customer": {
          "name": "João Silva",
          "email": "joao@email.com",
          "phone": "11999990000",
          "document": "12345678901",
          "documentType": "CPF",
          "address": {
            "street": "Rua das Flores",
            "streetNumber": "123",
            "neighborhood": "Centro",
            "complement": "",
            "zipCode": "01310100",
            "city": "São Paulo",
            "state": "SP",
            "country": "BR"
          }
        },
        "originDomain": "meusite.com.br",
        "isSandbox": false,
        "pixCode": "00020126580014br.gov.bcb.pix...",
        "expirationDate": "2026-03-10T15:30:00.000Z",
        "createdAt": "2026-03-10T14:30:00.000Z"
      },
      "timestamp": "2026-03-10T14:30:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="transaction.paid" icon="circle-check">
    Fired when a PIX payment is confirmed. Use it to release the product or service to the customer. Includes `fee` when a fee applies.

    ```json theme={"system"}
    {
      "event": "transaction.paid",
      "type": "transaction",
      "title": "Transação Paga",
      "message": "Transação de R$ 150,00 confirmada",
      "data": {
        "transactionId": "clx1abc123",
        "externalId": "pedido-001",
        "amount": 150.00,
        "product": "Plano Premium",
        "customerName": "João Silva",
        "customer": {
          "name": "João Silva",
          "email": "joao@email.com",
          "phone": "11999990000",
          "document": "12345678901",
          "documentType": "CPF",
          "address": {
            "street": "Rua das Flores",
            "streetNumber": "123",
            "neighborhood": "Centro",
            "complement": "",
            "zipCode": "01310100",
            "city": "São Paulo",
            "state": "SP",
            "country": "BR"
          }
        },
        "originDomain": "meusite.com.br",
        "isSandbox": false,
        "fee": { "amount": 3.50 },
        "createdAt": "2026-03-10T14:30:00.000Z"
      },
      "timestamp": "2026-03-10T14:35:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="transaction.expired" icon="clock">
    Fired when a transaction expires without payment. Use it to notify the customer or create a new charge.

    ```json theme={"system"}
    {
      "event": "transaction.expired",
      "type": "transaction",
      "title": "Transação Expirada",
      "message": "Transação de R$ 150,00 expirou",
      "data": {
        "transactionId": "clx1abc123",
        "externalId": "pedido-001",
        "amount": 150.00,
        "product": "Plano Premium",
        "customerName": "João Silva",
        "customer": {
          "name": "João Silva",
          "email": "joao@email.com",
          "phone": "11999990000",
          "document": "12345678901",
          "documentType": "CPF",
          "address": { "...": "..." }
        },
        "originDomain": "meusite.com.br",
        "isSandbox": true,
        "createdAt": "2026-03-10T14:30:00.000Z"
      },
      "timestamp": "2026-03-11T03:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="transaction.refunded" icon="rotate-left">
    Fired when a transaction is refunded.

    ```json theme={"system"}
    {
      "event": "transaction.refunded",
      "type": "transaction",
      "title": "Transação Reembolsada",
      "message": "Transação de R$ 150,00 reembolsada",
      "data": {
        "transactionId": "clx1abc123",
        "externalId": "pedido-001",
        "amount": 150.00,
        "product": "Plano Premium",
        "customerName": "João Silva",
        "customer": { "...": "same as paid" },
        "originDomain": "meusite.com.br",
        "isSandbox": false,
        "createdAt": "2026-03-10T14:30:00.000Z"
      },
      "timestamp": "2026-03-10T15:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="transaction.cancelled" icon="circle-xmark">
    Fired when a transaction is cancelled manually.

    ```json theme={"system"}
    {
      "event": "transaction.cancelled",
      "type": "transaction",
      "title": "Transação Cancelada",
      "message": "Transação de R$ 150,00 cancelada",
      "data": {
        "transactionId": "clx1abc123",
        "externalId": "pedido-001",
        "amount": 150.00,
        "product": "Plano Premium",
        "customerName": "João Silva",
        "customer": { "...": "same as paid" },
        "originDomain": "meusite.com.br",
        "isSandbox": false,
        "createdAt": "2026-03-10T14:30:00.000Z"
      },
      "timestamp": "2026-03-10T14:40:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="transaction.failed" icon="triangle-exclamation">
    Fired when a transaction fails during processing.

    ```json theme={"system"}
    {
      "event": "transaction.failed",
      "type": "transaction",
      "title": "transaction_failed",
      "message": "transaction_failed",
      "data": {
        "transactionId": "clx1abc123",
        "externalId": "pedido-001",
        "amount": 150.00,
        "product": "Plano Premium",
        "customerName": "João Silva",
        "customer": { "...": "same as paid" },
        "originDomain": "meusite.com.br",
        "isSandbox": false,
        "createdAt": "2026-03-10T14:30:00.000Z"
      },
      "timestamp": "2026-03-10T14:33:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Verifying the signature

Every request includes two security headers:

| Header                | Description                                                       |
| --------------------- | ----------------------------------------------------------------- |
| `X-Webhook-Signature` | HMAC-SHA256 signature (format depends on the version — see below) |
| `X-Webhook-Timestamp` | ISO 8601 of the send time (informational)                         |

Always verify the signature to make sure the request came from Bob Payments.

<Warning>
  Always use the **raw body** of the request to compute the HMAC. If your framework parses the JSON and you re-serialize it, the signature may not match.
</Warning>

### With the Node.js SDK (recommended)

The SDK automatically detects the format (v1 or v2), compares in constant time, and applies anti-replay protection:

```typescript theme={"system"}
import { constructWebhookEvent } from '@bobpayments/sdk';

app.post('/webhooks/bob', express.raw({ type: 'application/json' }), (req, res) => {
  const event = constructWebhookEvent(
    req.body, // raw Buffer
    req.headers['x-webhook-signature'],
    process.env.BOB_WEBHOOK_SECRET,
  );

  processEvent(event).catch(console.error); // asynchronous
  res.status(200).send('ok');               // immediate 200
});
```

### v2 signature (anti-replay)

On v2, the header has the format `t=<unix>,v1=<hex>`. The HMAC covers `timestamp + "." + body`, so a captured webhook can't be replayed later — reject events whose `t` is outside a tolerance window (recommended: 5 minutes).

<CodeGroup>
  ```javascript JavaScript theme={"system"}
  const crypto = require('crypto');

  function verifyV2(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
    const match = signatureHeader?.match(/^t=(\d+),v1=([0-9a-f]{64})$/);
    if (!match) return false;
    const [, t, digest] = match;

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${t}.${rawBody}`)
      .digest('hex');

    const valid = crypto.timingSafeEqual(
      Buffer.from(digest, 'hex'),
      Buffer.from(expected, 'hex'),
    );
    const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= toleranceSeconds;
    return valid && fresh;
  }
  ```

  ```python Python theme={"system"}
  import hmac, hashlib, re, time

  def verify_v2(raw_body: bytes, signature: str, secret: str, tolerance: int = 300) -> bool:
      match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", signature or "")
      if not match:
          return False
      t, digest = match.groups()
      expected = hmac.new(
          secret.encode(),
          f"{t}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(digest, expected) and abs(time.time() - int(t)) <= tolerance
  ```
</CodeGroup>

### v1 signature (legacy)

On v1, the header is the HMAC-SHA256 hex of the body, with no timestamp:

<CodeGroup>
  ```javascript JavaScript theme={"system"}
  const crypto = require('crypto');

  // use express.raw() to receive the raw body (Buffer)
  function verifyV1(rawBody, signature, secret) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');

    const sigBuffer = Buffer.from(signature ?? '', 'utf8');
    const expectedBuffer = Buffer.from(expected, 'utf8');
    return (
      sigBuffer.length === expectedBuffer.length &&
      crypto.timingSafeEqual(sigBuffer, expectedBuffer)
    );
  }
  ```

  ```python Python theme={"system"}
  import hmac
  import hashlib

  def verify_v1(raw_body: bytes, signature: str, secret: str) -> bool:
      expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(signature or "", expected)
  ```
</CodeGroup>

<Tip>
  Return HTTP 200 immediately and process the event asynchronously to avoid timeouts. Deliveries may repeat on retry — process them **idempotently**.
</Tip>

## Next steps

<CardGroup cols={2}>
  <Card title="Idempotency" icon="rotate" href="/en/pages/idempotency">
    Handle repeated deliveries without releasing the order twice.
  </Card>

  <Card title="Test in the sandbox" icon="flask" href="/en/pages/sandbox">
    Fire `transaction.paid` and `transaction.expired` without real money.
  </Card>
</CardGroup>
