Receber webhook e validar a assinatura
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}.
1. Crie o webhook
POST /v1/webhooks, com Idempotency-Key:
{
"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.
| 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 |
{
"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).
- Monte o texto
webhook-id+.+webhook-timestamp+.+ o corpo bruto (os bytes exatos recebidos, antes de interpretar o JSON). - Calcule o HMAC-SHA256 desse texto usando como chave os bytes do segredo (a parte depois de
whsec_, decodificada de Base64). - Compare em tempo constante com cada assinatura
v1,...do cabeçalho. - Recuse entregas com
webhook-timestampmuito 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.
- cURL
- TypeScript
- Python
- PHP
- C#
- Java
- Delphi
#!/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"
// 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");
"""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
// 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;
// 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;
}
// 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");
}
}
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
2xxpara 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-idcomo chave de deduplicação: a mesma entrega pode chegar mais de uma vez. POST /v1/webhooks/{id}/testemanda um evento de teste para a sua URL.- No sandbox há até 10 entregas por dia.
Esta página em Markdown: https://docs.rmfestrategia.com/guias/webhooks.md