# Receber webhook e validar a assinatura

> Criar o webhook, receber os eventos e validar a assinatura Standard Webhooks (HMAC-SHA256) em 7 linguagens.

Em vez de consultar o job, deixe a RMF avisar o seu servidor. As entregas seguem o padrão **Standard Webhooks** e são assinadas com HMAC-SHA256. O corpo leva só o identificador, o tipo do evento e o estado: **nunca dado fiscal**. Para ler o resultado, chame `GET /v1/arquivos/{id}` ou `GET /v1/relatorios/{id}`.

  Escopo webhooks:gerenciar

## 1. Crie o webhook

`POST /v1/webhooks`, com `Idempotency-Key`:

```json title="Corpo da requisição"
{
  "url": "https://integracao.exemplo.com.br/rmf/eventos",
  "eventos": ["arquivo.lido", "relatorio.concluido", "relatorio.falhou"],
  "descricao": "Integração com o ERP"
}
```

A resposta traz o **segredo `whsec_...` uma única vez**: guarde-o no cofre do seu sistema. A URL precisa ser **HTTPS na porta 443**, com nome público; ela é conferida de novo a cada entrega e a entrega é descartada se o nome passar a resolver para endereço interno. URL fora da regra: 422 [`url_invalida`](/erros/url_invalida).

| Evento | Quando |
|---|---|
| `arquivo.lido` | A leitura terminou, em `lido` ou em `periodo_ja_lido` (o `estado` do corpo diz qual) |
| `arquivo.rejeitado` | A leitura foi recusada; o `motivo` está em `GET /v1/arquivos/{id}` |
| `relatorio.concluido` | O relatório terminou |
| `relatorio.falhou` | O relatório falhou |

```json title="Corpo de uma entrega"
{
  "id": "evt_01JC8F8D7S2T5W8Y1B4E7GHKMN",
  "tipo": "arquivo.lido",
  "criado_em": "2026-10-29T11:41:12-03:00",
  "recurso": { "tipo": "arquivo", "id": "arq_01JC8F4P0D5S3Y8E2G6H7JKMNP" },
  "estado": "lido"
}
```

## 2. Valide a assinatura

Cada entrega traz três cabeçalhos: `webhook-id`, `webhook-timestamp` e `webhook-signature` (`v1,<base64>`; pode vir mais de uma, separadas por espaço, durante a troca de segredo).

1. Monte o texto `webhook-id` + `.` + `webhook-timestamp` + `.` + o **corpo bruto** (os bytes exatos recebidos, antes de interpretar o JSON).
2. Calcule o HMAC-SHA256 desse texto usando como chave os bytes do segredo (a parte depois de `whsec_`, decodificada de Base64).
3. Compare em **tempo constante** com cada assinatura `v1,...` do cabeçalho.
4. Recuse entregas com `webhook-timestamp` muito distante do seu relógio (os exemplos usam 5 minutos).

Os exemplos rodam offline, com um vetor fixo de teste, e provam as duas pontas: a assinatura certa é aceita e um corpo adulterado é recusado.

**Exemplos em 7 linguagens: Verificação de assinatura de webhook**

**cURL** (`webhook.sh`)

```shell
#!/bin/sh
# Verifica a assinatura de um webhook (Standard Webhooks, HMAC-SHA256). Roda offline, com um vetor fixo.
# Requer openssl. Em produção, leia o corpo BRUTO recebido e o segredo do seu cofre.
set -eu

SEGREDO='whsec_cm1mLXZldG9yLWRlLXRlc3RlLW5hby1lLXNlZ3JlZG8='
WEBHOOK_ID='evt_01JC8F8D7S2T5W8Y1B4E7GHKMN'
WEBHOOK_TIMESTAMP='1793284930'
WEBHOOK_SIGNATURE='v1,3uSpSrd3OHruCljCqZp0mkughVpJT/ydTLRFDED/vSg='
CORPO='{"id":"evt_01JC8F8D7S2T5W8Y1B4E7GHKMN","tipo":"arquivo.lido","criado_em":"2026-10-29T11:41:12-03:00","recurso":{"tipo":"arquivo","id":"arq_01JC8F4P0D5S3Y8E2G6H7JKMNP"},"estado":"lido"}'
AGORA=1793284990 # no seu servidor: $(date +%s)

verificar() { # verificar <segredo> <id> <timestamp> <assinaturas> <corpo> <agora>
  # recusa horário distante do seu relógio (5 minutos)
  atraso=$(($6 - $3))
  [ "${atraso#-}" -le 300 ] || return 1
  # a chave são os bytes do segredo depois de "whsec_", decodificados de Base64
  chave_hex=$(printf '%s' "${1#whsec_}" | base64 -d | od -An -v -tx1 | tr -d ' \n')
  esperada=$(printf '%s.%s.%s' "$2" "$3" "$5" |
    openssl dgst -sha256 -mac HMAC -macopt "hexkey:$chave_hex" -binary | base64)
  # o cabeçalho pode trazer várias assinaturas "v1,<base64>" separadas por espaço
  for item in $4; do
    [ "${item%%,*}" = v1 ] && [ "${item#v1,}" = "$esperada" ] && return 0
  done
  return 1
}

if verificar "$SEGREDO" "$WEBHOOK_ID" "$WEBHOOK_TIMESTAMP" "$WEBHOOK_SIGNATURE" "$CORPO" "$AGORA"; then
  echo "assinatura=valida"
else
  echo "assinatura=invalida" >&2
  exit 1
fi
if verificar "$SEGREDO" "$WEBHOOK_ID" "$WEBHOOK_TIMESTAMP" "$WEBHOOK_SIGNATURE" "${CORPO}x" "$AGORA"; then
  echo "corpo adulterado foi aceito" >&2
  exit 1
fi
echo "adulterado=recusado"
```

**TypeScript** (`webhook.ts`)

```typescript
// Verifica a assinatura de um webhook (Standard Webhooks, HMAC-SHA256). Roda offline, com um vetor fixo.
// Node 22.18 ou mais novo. Em produção, use o corpo BRUTO recebido e o segredo do seu cofre.
import { createHmac, timingSafeEqual } from "node:crypto";

const SEGREDO = "whsec_cm1mLXZldG9yLWRlLXRlc3RlLW5hby1lLXNlZ3JlZG8=";
const WEBHOOK_ID = "evt_01JC8F8D7S2T5W8Y1B4E7GHKMN";
const WEBHOOK_TIMESTAMP = "1793284930";
const WEBHOOK_SIGNATURE = "v1,3uSpSrd3OHruCljCqZp0mkughVpJT/ydTLRFDED/vSg=";
const CORPO =
  '{"id":"evt_01JC8F8D7S2T5W8Y1B4E7GHKMN","tipo":"arquivo.lido","criado_em":"2026-10-29T11:41:12-03:00","recurso":{"tipo":"arquivo","id":"arq_01JC8F4P0D5S3Y8E2G6H7JKMNP"},"estado":"lido"}';
const AGORA = 1793284990; // no seu servidor: Math.floor(Date.now() / 1000)

function verificar(segredo: string, id: string, timestamp: string, assinaturas: string, corpo: string, agora: number): boolean {
  // recusa horário distante do seu relógio (5 minutos)
  if (Math.abs(agora - Number(timestamp)) > 300) return false;
  // a chave são os bytes do segredo depois de "whsec_", decodificados de Base64
  const chave = Buffer.from(segredo.replace(/^whsec_/, ""), "base64");
  const esperada = createHmac("sha256", chave).update(`${id}.${timestamp}.${corpo}`).digest();
  // o cabeçalho pode trazer várias assinaturas "v1,<base64>" separadas por espaço
  return assinaturas.split(" ").some((item) => {
    const [versao, valor] = item.split(",");
    if (versao !== "v1" || !valor) return false;
    const recebida = Buffer.from(valor, "base64");
    return recebida.length === esperada.length && timingSafeEqual(recebida, esperada);
  });
}

if (!verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO, AGORA)) {
  console.error("assinatura=invalida");
  process.exit(1);
}
console.log("assinatura=valida");
if (verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO + "x", AGORA)) {
  console.error("corpo adulterado foi aceito");
  process.exit(1);
}
console.log("adulterado=recusado");
```

**Python** (`webhook.py`)

```python
"""Verifica a assinatura de um webhook (Standard Webhooks, HMAC-SHA256). Roda offline, com um vetor fixo.
Python 3.9 ou mais novo, só biblioteca padrão. Em produção, use o corpo BRUTO recebido e o segredo do seu cofre."""
import base64
import hashlib
import hmac
import sys

SEGREDO = "whsec_cm1mLXZldG9yLWRlLXRlc3RlLW5hby1lLXNlZ3JlZG8="
WEBHOOK_ID = "evt_01JC8F8D7S2T5W8Y1B4E7GHKMN"
WEBHOOK_TIMESTAMP = "1793284930"
WEBHOOK_SIGNATURE = "v1,3uSpSrd3OHruCljCqZp0mkughVpJT/ydTLRFDED/vSg="
CORPO = (
    '{"id":"evt_01JC8F8D7S2T5W8Y1B4E7GHKMN","tipo":"arquivo.lido","criado_em":"2026-10-29T11:41:12-03:00",'
    '"recurso":{"tipo":"arquivo","id":"arq_01JC8F4P0D5S3Y8E2G6H7JKMNP"},"estado":"lido"}'
)
AGORA = 1793284990  # no seu servidor: int(time.time())


def verificar(segredo: str, id_: str, timestamp: str, assinaturas: str, corpo: str, agora: int) -> bool:
    # recusa horário distante do seu relógio (5 minutos)
    if abs(agora - int(timestamp)) > 300:
        return False
    # a chave são os bytes do segredo depois de "whsec_", decodificados de Base64
    chave = base64.b64decode(segredo.removeprefix("whsec_"))
    esperada = hmac.new(chave, f"{id_}.{timestamp}.{corpo}".encode(), hashlib.sha256).digest()
    # o cabeçalho pode trazer várias assinaturas "v1,<base64>" separadas por espaço
    for item in assinaturas.split(" "):
        versao, _, valor = item.partition(",")
        if versao == "v1" and hmac.compare_digest(base64.b64decode(valor), esperada):
            return True
    return False


if not verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO, AGORA):
    sys.exit("assinatura=invalida")
print("assinatura=valida")
if verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO + "x", AGORA):
    sys.exit("corpo adulterado foi aceito")
print("adulterado=recusado")
```

**PHP** (`webhook.php`)

```php
<?php
// Verifica a assinatura de um webhook (Standard Webhooks, HMAC-SHA256). Roda offline, com um vetor fixo.
// PHP 8.1 ou mais novo. Em produção, use o corpo BRUTO recebido (file_get_contents('php://input')) e o segredo do seu cofre.
const SEGREDO = 'whsec_cm1mLXZldG9yLWRlLXRlc3RlLW5hby1lLXNlZ3JlZG8=';
const WEBHOOK_ID = 'evt_01JC8F8D7S2T5W8Y1B4E7GHKMN';
const WEBHOOK_TIMESTAMP = '1793284930';
const WEBHOOK_SIGNATURE = 'v1,3uSpSrd3OHruCljCqZp0mkughVpJT/ydTLRFDED/vSg=';
const CORPO = '{"id":"evt_01JC8F8D7S2T5W8Y1B4E7GHKMN","tipo":"arquivo.lido","criado_em":"2026-10-29T11:41:12-03:00","recurso":{"tipo":"arquivo","id":"arq_01JC8F4P0D5S3Y8E2G6H7JKMNP"},"estado":"lido"}';
const AGORA = 1793284990; // no seu servidor: time()

function verificar(string $segredo, string $id, string $timestamp, string $assinaturas, string $corpo, int $agora): bool
{
    // recusa horário distante do seu relógio (5 minutos)
    if (abs($agora - (int) $timestamp) > 300) {
        return false;
    }
    // a chave são os bytes do segredo depois de "whsec_", decodificados de Base64
    $chave = base64_decode(substr($segredo, strlen('whsec_')));
    $esperada = base64_encode(hash_hmac('sha256', "$id.$timestamp.$corpo", $chave, true));
    // o cabeçalho pode trazer várias assinaturas "v1,<base64>" separadas por espaço
    foreach (explode(' ', $assinaturas) as $item) {
        [$versao, $valor] = array_pad(explode(',', $item, 2), 2, '');
        if ($versao === 'v1' && hash_equals($esperada, $valor)) {
            return true;
        }
    }
    return false;
}

if (!verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO, AGORA)) {
    fwrite(STDERR, "assinatura=invalida\n");
    exit(1);
}
echo "assinatura=valida", PHP_EOL;
if (verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO . 'x', AGORA)) {
    fwrite(STDERR, "corpo adulterado foi aceito\n");
    exit(1);
}
echo "adulterado=recusado", PHP_EOL;
```

**C#** (`webhook.cs`)

```csharp
// Verifica a assinatura de um webhook (Standard Webhooks, HMAC-SHA256). Roda offline, com um vetor fixo.
// .NET 8 ou mais novo. Em produção, use o corpo BRUTO recebido e o segredo do seu cofre.
using System.Security.Cryptography;
using System.Text;

const string Segredo = "whsec_cm1mLXZldG9yLWRlLXRlc3RlLW5hby1lLXNlZ3JlZG8=";
const string WebhookId = "evt_01JC8F8D7S2T5W8Y1B4E7GHKMN";
const string WebhookTimestamp = "1793284930";
const string WebhookSignature = "v1,3uSpSrd3OHruCljCqZp0mkughVpJT/ydTLRFDED/vSg=";
const string Corpo = "{\"id\":\"evt_01JC8F8D7S2T5W8Y1B4E7GHKMN\",\"tipo\":\"arquivo.lido\",\"criado_em\":\"2026-10-29T11:41:12-03:00\",\"recurso\":{\"tipo\":\"arquivo\",\"id\":\"arq_01JC8F4P0D5S3Y8E2G6H7JKMNP\"},\"estado\":\"lido\"}";
const long Agora = 1793284990; // no seu servidor: DateTimeOffset.UtcNow.ToUnixTimeSeconds()

if (!Verificar(Segredo, WebhookId, WebhookTimestamp, WebhookSignature, Corpo, Agora))
{
    Console.Error.WriteLine("assinatura=invalida");
    return 1;
}
Console.WriteLine("assinatura=valida");
if (Verificar(Segredo, WebhookId, WebhookTimestamp, WebhookSignature, Corpo + "x", Agora))
{
    Console.Error.WriteLine("corpo adulterado foi aceito");
    return 1;
}
Console.WriteLine("adulterado=recusado");
return 0;

static bool Verificar(string segredo, string id, string timestamp, string assinaturas, string corpo, long agora)
{
    // recusa horário distante do seu relógio (5 minutos)
    if (Math.Abs(agora - long.Parse(timestamp)) > 300) return false;
    // a chave são os bytes do segredo depois de "whsec_", decodificados de Base64
    var chave = Convert.FromBase64String(segredo["whsec_".Length..]);
    var esperada = HMACSHA256.HashData(chave, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{corpo}"));
    // o cabeçalho pode trazer várias assinaturas "v1,<base64>" separadas por espaço
    foreach (var item in assinaturas.Split(' '))
    {
        var partes = item.Split(',', 2);
        if (partes.Length == 2 && partes[0] == "v1"
            && CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(partes[1]), esperada))
            return true;
    }
    return false;
}
```

**Java** (`Webhook.java`)

```java
// Verifica a assinatura de um webhook (Standard Webhooks, HMAC-SHA256). Roda offline, com um vetor fixo.
// Java 17 ou mais novo, só biblioteca padrão. Em produção, use o corpo BRUTO recebido e o segredo do seu cofre.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class Webhook {
    static final String SEGREDO = "whsec_cm1mLXZldG9yLWRlLXRlc3RlLW5hby1lLXNlZ3JlZG8=";
    static final String WEBHOOK_ID = "evt_01JC8F8D7S2T5W8Y1B4E7GHKMN";
    static final String WEBHOOK_TIMESTAMP = "1793284930";
    static final String WEBHOOK_SIGNATURE = "v1,3uSpSrd3OHruCljCqZp0mkughVpJT/ydTLRFDED/vSg=";
    static final String CORPO = "{\"id\":\"evt_01JC8F8D7S2T5W8Y1B4E7GHKMN\",\"tipo\":\"arquivo.lido\",\"criado_em\":\"2026-10-29T11:41:12-03:00\",\"recurso\":{\"tipo\":\"arquivo\",\"id\":\"arq_01JC8F4P0D5S3Y8E2G6H7JKMNP\"},\"estado\":\"lido\"}";
    static final long AGORA = 1793284990L; // no seu servidor: System.currentTimeMillis() / 1000

    static boolean verificar(String segredo, String id, String timestamp, String assinaturas, String corpo, long agora)
            throws Exception {
        // recusa horário distante do seu relógio (5 minutos)
        if (Math.abs(agora - Long.parseLong(timestamp)) > 300) return false;
        // a chave são os bytes do segredo depois de "whsec_", decodificados de Base64
        var chave = Base64.getDecoder().decode(segredo.substring("whsec_".length()));
        var mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(chave, "HmacSHA256"));
        var esperada = mac.doFinal((id + "." + timestamp + "." + corpo).getBytes(StandardCharsets.UTF_8));
        // o cabeçalho pode trazer várias assinaturas "v1,<base64>" separadas por espaço
        for (var item : assinaturas.split(" ")) {
            var partes = item.split(",", 2);
            if (partes.length == 2 && partes[0].equals("v1")
                    && MessageDigest.isEqual(Base64.getDecoder().decode(partes[1]), esperada)) {
                return true;
            }
        }
        return false;
    }

    public static void main(String[] args) throws Exception {
        if (!verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO, AGORA)) {
            System.err.println("assinatura=invalida");
            System.exit(1);
        }
        System.out.println("assinatura=valida");
        if (verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO + "x", AGORA)) {
            System.err.println("corpo adulterado foi aceito");
            System.exit(1);
        }
        System.out.println("adulterado=recusado");
    }
}
```

**Delphi** (`Webhook.dpr`)

```pascal
program Webhook;
// Verifica a assinatura de um webhook (Standard Webhooks, HMAC-SHA256). Roda offline, com um vetor fixo.
// Delphi 10.3 ou mais novo. Em produção, use o corpo BRUTO recebido e o segredo do seu cofre.
// Exemplo escrito e revisado à mão: não roda no CI (não existe compilador Delphi em container).

{$APPTYPE CONSOLE}

uses
  System.SysUtils, System.Hash, System.NetEncoding;

const
  SEGREDO = 'whsec_cm1mLXZldG9yLWRlLXRlc3RlLW5hby1lLXNlZ3JlZG8=';
  WEBHOOK_ID = 'evt_01JC8F8D7S2T5W8Y1B4E7GHKMN';
  WEBHOOK_TIMESTAMP = '1793284930';
  WEBHOOK_SIGNATURE = 'v1,3uSpSrd3OHruCljCqZp0mkughVpJT/ydTLRFDED/vSg=';
  CORPO = '{"id":"evt_01JC8F8D7S2T5W8Y1B4E7GHKMN","tipo":"arquivo.lido","criado_em":"2026-10-29T11:41:12-03:00","recurso":{"tipo":"arquivo","id":"arq_01JC8F4P0D5S3Y8E2G6H7JKMNP"},"estado":"lido"}';
  AGORA: Int64 = 1793284990; // no seu servidor: DateTimeToUnix(Now, False)

// Comparação em tempo constante.
function IguaisEmTempoConstante(const A, B: string): Boolean;
var
  I, Diferenca: Integer;
begin
  Diferenca := Length(A) xor Length(B);
  for I := 1 to Length(A) do
    if I <= Length(B) then
      Diferenca := Diferenca or (Ord(A[I]) xor Ord(B[I]));
  Result := Diferenca = 0;
end;

function Verificar(const ASegredo, AId, ATimestamp, AAssinaturas, ACorpo: string; AAgora: Int64): Boolean;
var
  Chave, Esperada: TBytes;
  EsperadaB64, Item: string;
  Partes: TArray<string>;
begin
  Result := False;
  // recusa horário distante do seu relógio (5 minutos)
  if Abs(AAgora - StrToInt64(ATimestamp)) > 300 then Exit;
  // a chave são os bytes do segredo depois de "whsec_", decodificados de Base64
  Chave := TNetEncoding.Base64.DecodeStringToBytes(ASegredo.Substring(Length('whsec_')));
  Esperada := THashSHA2.GetHMACAsBytes(TEncoding.UTF8.GetBytes(AId + '.' + ATimestamp + '.' + ACorpo),
    Chave, THashSHA2.TSHA2Version.SHA256);
  EsperadaB64 := TNetEncoding.Base64.EncodeBytesToString(Esperada);
  // o cabeçalho pode trazer várias assinaturas "v1,<base64>" separadas por espaço
  for Item in AAssinaturas.Split([' ']) do
  begin
    Partes := Item.Split([','], 2);
    if (Length(Partes) = 2) and (Partes[0] = 'v1') and IguaisEmTempoConstante(Partes[1], EsperadaB64) then
      Exit(True);
  end;
end;

begin
  if not Verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO, AGORA) then
  begin
    Writeln(ErrOutput, 'assinatura=invalida');
    Halt(1);
  end;
  Writeln('assinatura=valida');
  if Verificar(SEGREDO, WEBHOOK_ID, WEBHOOK_TIMESTAMP, WEBHOOK_SIGNATURE, CORPO + 'x', AGORA) then
  begin
    Writeln(ErrOutput, 'corpo adulterado foi aceito');
    Halt(1);
  end;
  Writeln('adulterado=recusado');
end.
```


## 3. Responda e trate repetição

- Responda com qualquer `2xx` para confirmar. Demore pouco: a entrega espera até 10 segundos no total.
- Entrega perdida é repetida com espera exponencial e pode ser repetida à mão com `POST /v1/eventos/{id}/reenviar` (qualquer evento dos últimos 30 dias, até 100 por dia).
- Trate `webhook-id` como chave de deduplicação: a mesma entrega pode chegar mais de uma vez.
- `POST /v1/webhooks/{id}/teste` manda um evento de teste para a sua URL.
- No sandbox há até 10 entregas por dia.
