# Notificações de webhook

> Receba atualizações em tempo real do status de pagamentos e saques via webhooks assinados com HMAC.

O sistema da 2328.io envia um webhook para sua `url_callback` sempre que o status de um pagamento muda. Esta é a forma recomendada de ser notificado sobre pagamentos bem-sucedidos.

## Formato da requisição

- **Método:** `POST`
- **Content-Type:** `application/json`
- **Assinatura:** campo `sign` no corpo da requisição

## Payload

O corpo do webhook segue o formato da resposta de `/v1/payment/info` e adiciona `tx_explorer_url`, além do campo `sign` usado para verificar a assinatura.

### Pagamento bem-sucedido

```json
{
  "uuid": "db17d490-15b6-47b9-9015-91d1d8b119f2",
  "order_id": "ORDER-12345",
  "amount": "180.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/db17d490-15b6-47b9-9015-91d1d8b119f2",
  "expires_at": "2026-05-09T16:56:58+03:00",
  "created_at": "2026-05-09T15:56:58+03:00",
  "payer_currency": "TON",
  "payer_amount": "0.95256917",
  "network": "TON",
  "address": "UQA0RevhkCQx-EltyNgPPeG8dqtnCz7ZslOzMdNQlLxVaNBb",
  "payment_status": "paid",
  "txid": "41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "tx_explorer_url": "https://tonviewer.com/transaction/41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "payment_amount": "0.95256917",
  "merchant_amount": "0.949711462490000000",
  "amount_usd": "2.41324380",
  "exchange_rate": "0.01340691",
  "sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d"
}
```

### Pagamento cancelado / com falha

Quando o pagamento não está em estado terminal `paid`, `txid`, `payment_amount` e `merchant_amount` são `null`:

```json
{
  "uuid": "48edaf2d-2c49-4638-8f86-88636f661c1f",
  "order_id": "ORDER-12345",
  "amount": "2800.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/48edaf2d-2c49-4638-8f86-88636f661c1f",
  "expires_at": "2026-05-09T06:19:04+03:00",
  "created_at": "2026-05-09T05:19:04+03:00",
  "payer_currency": "ETH",
  "payer_amount": "0.01620968",
  "network": "ETH-ERC20",
  "address": "0x37c20d6d96d130Bc5B33D832e43b8e16aACe0c59",
  "payment_status": "cancel",
  "txid": null,
  "tx_explorer_url": null,
  "payment_amount": null,
  "merchant_amount": null,
  "amount_usd": "37.53934800",
  "exchange_rate": "0.01340691",
  "sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342"
}
```

### Referência de campos

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `uuid` | string | UUID do pagamento |
| `order_id` | string | Seu ID de pedido |
| `amount` | decimal (8 dp) | Valor em fiat na moeda `currency` |
| `currency` | string | Moeda fiduciária solicitada pelo comerciante |
| `url` | string | URL do checkout hospedado |
| `expires_at` | string (ISO 8601) | Quando a sessão de pagamento expira |
| `created_at` | string (ISO 8601) | Quando a sessão de pagamento foi criada |
| `payer_currency` | string | Cripto em que o pagador está pagando |
| `payer_amount` | decimal (8 dp) | Valor em cripto esperado |
| `network` | string | Rede blockchain |
| `address` | string | Endereço de depósito |
| `payment_status` | string | Um dos: `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel`, `aml_lock` (veja [References](/docs/references)) |
| `txid` | string \| null | Hash da transação na blockchain, presente apenas após pagamento confirmado |
| `tx_explorer_url` | string \| null | URL da transação no explorador de blockchain. É `null` quando não há `txid` ou a transferência é P2P interna. |
| `payment_amount` | decimal \| null | Valor efetivamente pago, presente apenas após o pagamento |
| `merchant_amount` | decimal (18 dp) \| null | Valor creditado ao comerciante após as taxas |
| `amount_usd` | decimal (8 dp) | Valor em USD no momento da criação |
| `exchange_rate` | decimal | Taxa de câmbio cripto / fiat utilizada |
| `sign` | string (hex) | Assinatura HMAC-SHA256 do payload |

## Verificando a assinatura

Para verificar a assinatura de um webhook:

1. Extraia o campo `sign` do payload
2. Remova o campo `sign` do objeto
3. Codifique os campos restantes em JSON
4. Codifique o JSON em base64
5. Calcule HMAC-SHA256 a partir da string base64 usando sua API_KEY
6. Compare a assinatura calculada com o valor de `sign` usando uma comparação em tempo constante

#### php

```php
<?php
function verifyWebhookSign(array $data, string $apiKey): bool {
    $receivedSign = $data['sign'] ?? '';
    unset($data['sign']);

    $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $base64 = base64_encode($json);
    $calculated = hash_hmac('sha256', $base64, $apiKey);

    return hash_equals($calculated, $receivedSign);
}

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

if (!verifyWebhookSign($payload, $apiKey)) {
    http_response_code(401);
    exit;
}

switch ($payload['payment_status']) {
    case 'paid':
    case 'overpaid':
        // Credit the order — check idempotency by order_id first
        break;
    case 'underpaid_check':
    case 'underpaid':
    case 'cancel':
        break;
}

http_response_code(200);
```

#### js

```js
import crypto from "crypto";
import express from "express";

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

function verifyWebhookSign(payload, apiKey) {
  const { sign, ...rest } = payload;
  const json = JSON.stringify(rest);
  const base64 = Buffer.from(json).toString("base64");
  const calculated = crypto
    .createHmac("sha256", apiKey)
    .update(base64)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(calculated),
    Buffer.from(sign || ""),
  );
}

app.post("/webhook", (req, res) => {
  if (!verifyWebhookSign(req.body, process.env.API_KEY)) {
    return res.sendStatus(401);
  }

  const { order_id, payment_status, txid } = req.body;

  if (payment_status === "paid" || payment_status === "overpaid") {
    // Credit the order — check idempotency by order_id first
  }

  res.sendStatus(200);
});
```

#### python

```python
import json
import hmac
import hashlib
import base64
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload: dict, api_key: str) -> bool:
    received = payload.pop("sign", "")
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    b64 = base64.b64encode(body.encode("utf-8")).decode()
    calculated = hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(calculated, received)

@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.json()
    if not verify_webhook_sign(payload, API_KEY):
        raise HTTPException(401)

    if payload["payment_status"] in ("paid", "overpaid"):
        # Credit the order — check idempotency by order_id first
        pass

    return {"ok": True}
```

#### go

```go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
)

func verifyWebhookSign(body []byte, apiKey string) (map[string]any, bool) {
    var payload map[string]any
    if err := json.Unmarshal(body, &payload); err != nil {
        return nil, false
    }
    received, _ := payload["sign"].(string)
    delete(payload, "sign")

    var buf bytes.Buffer
    enc := json.NewEncoder(&buf)
    enc.SetEscapeHTML(false)
    enc.Encode(payload)
    reencoded := bytes.TrimRight(buf.Bytes(), "\n")

    b64 := base64.StdEncoding.EncodeToString(reencoded)
    h := hmac.New(sha256.New, []byte(apiKey))
    h.Write([]byte(b64))
    calculated := hex.EncodeToString(h.Sum(nil))

    return payload, hmac.Equal([]byte(calculated), []byte(received))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    payload, ok := verifyWebhookSign(body, apiKey)
    if !ok {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    status, _ := payload["payment_status"].(string)
    if status == "paid" || status == "overpaid" {
        // Credit the order — check idempotency first
    }
    w.WriteHeader(http.StatusOK)
}
```

#### ruby

```ruby
require "json"
require "openssl"
require "base64"
require "sinatra"

API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload, api_key)
  received = payload.delete("sign") || ""
  body = payload.to_json
  b64 = Base64.strict_encode64(body)
  calculated = OpenSSL::HMAC.hexdigest("SHA256", api_key, b64)
  OpenSSL.fixed_length_secure_compare(calculated, received)
end

post "/webhook" do
  payload = JSON.parse(request.body.read)
  halt 401 unless verify_webhook_sign(payload, API_KEY)

  if %w[paid overpaid].include?(payload["payment_status"])
    # Credit the order — check idempotency by order_id first
  end

  status 200
end
```

> **DANGER:** **Sempre verifique a assinatura** antes de creditar quaisquer fundos a um usuário. Um webhook não assinado ou com assinatura incorreta pode ser uma requisição forjada.

## Webhooks de saque

Quando o `status` de um saque muda, o sistema envia um webhook `POST` para a URL `url_callback` informada na criação do saque. Se `url_callback` não foi informado, nenhum webhook é enviado para esse saque.

> **WARNING:** Webhooks de saque devem ser verificados com sua **Payout API key** — não a API key comum. O algoritmo de assinatura é idêntico ao dos webhooks de pagamento (remover `sign`, codificar em JSON, em base64 e calcular HMAC-SHA256); apenas a chave muda.

### Payload

```json
{
  "uuid": "019dff1f-0dbd-7277-8d45-271e7775388f",
  "order_id": "4dfdcc84402b1185b71cbe399321533e",
  "status": "completed",
  "currency": "TRX",
  "network": "TRX-TRC20",
  "amount": "3.00",
  "merchant_amount": "3.00",
  "network_amount": "3.00",
  "amount_usd": "1.04",
  "to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x",
  "memo": null,
  "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "tx_explorer_url": "https://tronscan.org/#/transaction/9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "block_number": null,
  "error_type": null,
  "created_at": "2026-05-07T00:08:38+03:00",
  "updated_at": "2026-05-07T00:08:54+03:00",
  "from_currency": "USDT",
  "debited_amount": "1.050735",
  "debited_currency": "USDT",
  "sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a"
}
```

### Referência de campos

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `uuid` | string | UUID do saque |
| `order_id` | string | Seu ID de idempotência / referência, se você forneceu um |
| `status` | string | `pending`, `completed`, `failed`, `cancelled` (veja [References](/docs/references)) |
| `currency` | string | Moeda do saque |
| `network` | string | Rede blockchain |
| `amount` | decimal | Valor do saque (em `currency`) |
| `merchant_amount` | decimal | Valor cobrado do saldo do comerciante |
| `network_amount` | decimal | Valor efetivamente enviado on-chain |
| `amount_usd` | decimal | Valor em USD no momento do saque |
| `to_address` | string | Endereço blockchain do destinatário |
| `memo` | string \| null | Memo / tag de destino, se utilizado |
| `txid` | string \| null | Hash da transação na blockchain, definido em `completed` |
| `tx_explorer_url` | string \| null | URL da transação no explorador de blockchain. É `null` quando não há `txid` ou a transferência é P2P interna. |
| `block_number` | integer \| null | Altura do bloco da transação on-chain |
| `error_type` | string \| null | Motivo quando `status = failed` (ex.: `aml_risk`, veja [References](/docs/references)) |
| `created_at` | string (ISO 8601) | Quando o saque foi criado |
| `updated_at` | string (ISO 8601) | Quando o status mudou pela última vez |
| `from_currency` | string | Saldo de origem do qual o saque foi debitado quando houve conversão automática (ex.: `USDT` para um saque em `BTC`) |
| `debited_amount` | decimal | Valor debitado do saldo `from_currency` |
| `debited_currency` | string | Moeda do débito |
| `sign` | string (hex) | Assinatura HMAC-SHA256 do payload, assinada com a **Payout API key** |

## Boas práticas

- **Idempotência** — Sempre verifique se o pagamento já foi processado (por `order_id` ou `uuid`). Webhooks podem chegar mais de uma vez.
- **Resposta rápida** — Retorne HTTP 200 o mais rápido possível. Mova trabalho pesado para uma fila em background.
- **Retentativas** — Se o sistema não receber HTTP 200, o webhook é reenviado após 2 minutos. Máximo de 5 tentativas.
- **Processamento assíncrono** — Trate eventos de webhook de forma assíncrona para não bloquear a resposta.
- **Segurança** — SEMPRE verifique a assinatura `sign` antes de confiar no payload.

> **WARNING:** Webhooks podem chegar fora de ordem. Não assuma que o primeiro webhook recebido é o estado final — sempre faça nova consulta via `/v1/payment/info` (ou `/v1/payout/status/{uuid}`) se precisar de certeza.

## Contrato de entrega e processamento

Use a seguinte ordem dentro do seu endpoint webhook:

1. Leia o corpo da solicitação sem registrar segredos ou a assinatura completa.
2. Identifique se é um evento de pagamento/carteira estática ou um evento de pagamento para que você selecione a chave de API correta.
3. Remova `sign`, reproduza os bytes JSON documentados, calcule o HMAC-SHA256 e compare em tempo constante.
4. Valide os identificadores obrigatórios, strings decimais e valores de status.
5. Insira atomicamente um registro de caixa/idempotência. Se ele já existir, retorne HTTP 200 sem repetir efeitos colaterais.
6. Confirme a mutação do pedido/livro-razão e enfileire e-mails, análises ou notificações não críticas.
7. Retorne HTTP 200 rapidamente.

Não chame serviços de terceiros lentos enquanto mantém a transação de idempotência. Um tempo limite após a confirmação, mas antes de retornar, pode causar uma reexecução; o duplicado deve observar a chave da caixa de entrada confirmada e tornar-se uma operação nula.

### Chaves de idempotência recomendadas

| Evento | Identidade principal | Notas |
|-------|------------------|-------|
| Sessão de pagamento | `uuid` + evidência de status/versão | A mesma fatura pode emitir múltiplas mudanças de status legítimas. |
| Recarga de pagamento parcial | fatura `uuid` + `txid` | Mais de uma transferência pode pertencer à mesma fatura com pagamento insuficiente. |
| Depósito em carteira estática | rede + `txid` | `order_id` é reutilizado por cada depósito nesta carteira. |
| Pagamento | pagamento `uuid` + status | Nunca crie um segundo pagamento a partir da lógica de re-tentativa do webhook. |

Se seu esquema não tiver id de evento, armazene o hash do payload verificado como evidência adicional de auditoria, mas não substitua as identidades comerciais acima por um carimbo de data/hora.

## Ordenação e reconciliação

A entrega é pelo menos uma vez e mensagens de status podem se sobrepor. Implemente regras de negócio monotônicas em vez de “a última solicitação vence”:

- nunca mova um pedido já atendido de volta para `check` porque um evento mais antigo chegou atrasado;
- permita que `underpaid_check` receba txids adicionais sem repetir créditos anteriores;
- trate `paid` e `overpaid` como estados de liquidação bem-sucedidos, preservando seus diferentes valores;
- mantenha `underpaid` como um resultado final de pagamento parcial, a menos que a API autoritativa reporte outro estado posteriormente;
- encaminhe `aml_lock` para revisão e não permita que um trabalhador genérico de nova tentativa o cumpra;
- consultar informações de pagamento/saque sempre que a transição for impossível, o contexto estiver ausente ou financeiramente ambíguo.

Executar conciliação programada mesmo quando a entrega do webhook parecer saudável. Compare o estado do seu terminal local e o valor creditado com `/v1/payment/info`, `/v1/static-wallet/transactions` ou `/v1/payout/status/{uuid}` e alerte sobre diferenças em vez de sobrescrever silenciosamente o histórico do livro-razão.

## Segurança do endpoint do webhook

- Exigir HTTPS e manter o callback publicamente acessível; destinos privados/de loopback são rejeitados durante a criação do pagamento.
- Aplicar um limite pequeno para o corpo da requisição e tipo de conteúdo JSON.
- Limitar a taxa antes de trabalhos custosos, mas deixar espaço suficiente para picos legítimos e novas tentativas.
- Nunca autorize um webhook apenas pelo IP de origem. Listas de permissão de rede são defesa em profundidade; a verificação HMAC é obrigatória.
- Oculte `sign`, chaves de API, endereços quando exigido pela política, e metadados pessoais dos registros de aplicativos.
- Mantenha tanto as chaves atuais quanto as explicitamente programadas para substituição disponíveis durante uma janela de rotação controlada; nunca tente adivinhar qual chave assinou um evento.
- Retorne um corpo de erro genérico em assinaturas inválidas para que o endpoint não se torne um oráculo de chave ou conta.