Configuração e segurança
Como proteger seu endpoint de webhook: verificação da assinatura Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), defesa contra replay, headers custom como camada adicional, TLS, o que responder a cada entrega e a assinatura legada X-IORQ-Signature.
1. Assinatura das entregas (Standard Webhooks)
Toda entrega de webhook segue a especificação aberta Standard Webhooks: três headers identificam e assinam cada POST, e qualquer biblioteca da spec, ou o código desta página, verifica a entrega. É a forma recomendada de validar, para integrações novas e para as existentes.
| Header | Conteúdo |
|---|---|
webhook-id | Identificador do evento na IORQ. É o mesmo em todas as tentativas do evento; o replay de um evento que já teve tentativa cria um evento novo e, portanto, um webhook-id novo |
webhook-timestamp | Instante desta tentativa, em unix time (segundos). Muda a cada reentrega |
webhook-signature | v1,<base64>: o HMAC-SHA256 de "{webhook-id}.{webhook-timestamp}.{corpo_bruto}", em base64. Durante uma rotação de secret vêm duas, separadas por espaço, a do secret vigente primeiro (ver seção 5) |
O secret no formato da spec é o standard_webhooks_secret (prefixo whsec_), que chega ao lado do signing_secret em duas respostas e só nelas: a do POST /api/webhook, que registra o webhook, e a do POST /api/webhook/{webhook_id}/secret/rotate. Guarde-o em cofre imediatamente; GET, PATCH e listagem não devolvem secret nenhum.
Os dois são o mesmo segredo em formatos diferentes: o standard_webhooks_secret é whsec_ seguido do base64 padrão dos bytes UTF-8 do signing_secret inteiro, prefixo incluído. Quem guardou só o signing_secret de um webhook registrado antes deste formato deriva o valor sozinho, sem rotacionar:
standard_webhooks_secret = "whsec_" + base64.b64encode(signing_secret.encode()).decode()const standardWebhooksSecret = 'whsec_' + Buffer.from(signingSecret, 'utf8').toString('base64');Além dos headers de assinatura, cada entrega pode levar três headers de identificação:
| Header | Conteúdo |
|---|---|
X-IORQ-Event-Id | Identificador do evento no registro de entregas da IORQ, estável entre as tentativas do mesmo fato; o mesmo valor do webhook-id |
X-IORQ-Event-Type | Como o fato foi publicado: o type v2 quando o produtor já publica em v2, e LOAN_UPDATE enquanto ele publica em v1. Não acompanha a contract_version do seu webhook |
X-IORQ-Delivery-Id | Identificador desta tentativa, novo a cada POST |
Eles servem para rotear e deduplicar sem abrir o corpo, e não entram em assinatura nenhuma. Nomes X-IORQ-* são reservados e recusados no cadastro de headers custom (ver seção 3); num webhook que já tinha um header custom homônimo gravado antes dessa regra, o seu prevalece sobre o de identificação da IORQ. Os headers de assinatura e Content-Type são sempre os da IORQ. Os três já chegam em sandbox, onde os produtores publicam v2, e acompanham a virada do v2 em produção, onde ainda não são enviados: não construa o seu roteamento assumindo que eles chegam (ver O que muda entre sandbox e produção). O webhook-id, com o mesmo valor do X-IORQ-Event-Id, chega em toda entrega, nos dois ambientes.
Toda entrega vai com Content-Type: application/json, nas duas versões de contrato. Registrar um header custom com esse nome é recusado com 422.
O corpo assinado é o corpo entregue. Num webhook v1 ele já vem com o iorq_fund_id e o event_timestamp que a IORQ acrescenta no topo; num webhook v2 é o envelope, que não recebe injeção nenhuma (ver Catálogo de eventos). Valide sempre sobre os bytes recebidos — reserializar o objeto depois de parsear muda os bytes e quebra a assinatura.
webhook-timestampeevent_timestampsão coisas diferentesO header é o instante em que esta tentativa foi assinada, em unix time, e existe para a defesa de replay abaixo — ele muda a cada reentrega, e é isso que faz a janela de 5 minutos funcionar. O
event_timestampdo corpo é o instante em que a IORQ gerou o evento, em ISO-8601, e é estável entre as tentativas. Não use o campo do corpo na tolerância de tempo: um evento legitimamente reentregue meia hora depois seria recusado.
Webhooks v1 criados antes da assinaturaWebhooks registrados antes da disponibilização da assinatura não têm secret e seguem sendo entregues sem
webhook-signature(e sem a assinatura legada);webhook-idewebhook-timestampchegam mesmo assim, e nada mais muda para eles até você agir. Trocar acontract_versionnão gera secret.Para passar a receber entregas assinadas, chame a rotação nesse webhook: ela gera o primeiro secret, sem recriar o webhook e sem janela de sobreposição, e a partir da próxima entrega a assinatura passa a vir. Guarde o
standard_webhooks_secretda resposta e só então passe a recusar entregas sem assinatura. Todo webhook criado hoje já nasce com secret, em qualquer versão de contrato.
Como validar do seu lado
- Leia
webhook-id,webhook-timestampewebhook-signature; rejeite se faltar qualquer um - Rejeite se
webhook-timestampnão for um inteiro de até 10 dígitos ou estiver a mais de 5 minutos do relógio do seu servidor, para mais ou para menos - Decodifique em base64 o que vem depois de
whsec_nostandard_webhooks_secret: esses bytes são a chave - Concatene:
conteudo_assinado = webhook-id + "." + webhook-timestamp + "." + corpo_bruto(use os bytes exatos do body recebido, antes de qualquer parse) - Calcule HMAC-SHA256(chave,
conteudo_assinado) e codifique em base64 - Separe
webhook-signaturepor espaço e compare o texto depois dev1,de cada entrada com o base64 calculado, como texto e sem normalizar o base64 recebido, em tempo constante, sem parar na primeira que bate; basta uma bater. Ignore entradas de outra versão
O que responder a uma assinatura inválida
Responda 401 e não processe o corpo. Não há o que "guardar para depois": uma requisição forjada não é reenviada por ninguém, e a IORQ não reenvia o que você recusou por assinatura de forma diferente de qualquer outro não-2xx. Se a IORQ estiver reentregando um evento que o seu validador recusa, o problema está no seu lado (secret errado, chave sem o decode do base64, corpo reserializado antes do cálculo, header lido do lugar errado): as reentregas da agenda dão tempo de corrigir sem perder o evento. Um 401 contínuo, como qualquer 4xx, conta para a suspensão depois de três dias sem 2xx.
O conselho "responda 2xx ao que você recebeu e trate o descarte do seu lado", de Configurando webhooks e Idempotência e reentrega, vale para um corpo autenticado que você não quer processar: uma duplicata, um tipo que não te interessa, uma operação que você não conhece. Nunca para assinatura inválida.
Implementações
Os dois validadores usam só a biblioteca padrão (Python 3.10+, Node.js 18+ como módulo ES), calculam a assinatura sobre os bytes recebidos, sem decodificar e reserializar, devolvem False se faltar um dos três headers ou o timestamp não tiver até 10 dígitos ou estiver fora da tolerância, comparam o texto de todas as entradas de webhook-signature em tempo constante, sem aceitar outra grafia do mesmo base64, o que os faz funcionar nos dois lados de uma rotação. Um secret que não é base64 válido é erro de configuração: os dois lançam exceção em vez de devolver False. São os mesmos arquivos que a IORQ testa contra entregas assinadas pelo próprio serviço de webhooks.
"""Verificação de uma entrega de webhook da IORQ no formato Standard Webhooks
(https://www.standardwebhooks.com), só com a biblioteca padrão do Python."""
import base64
import hashlib
import hmac
import time
from collections.abc import Mapping
TOLERANCE_SECONDS = 5 * 60
MAX_TIMESTAMP_DIGITS = 10
def verify(
raw_body: bytes,
headers: Mapping[str, str],
secret: str,
now: float | None = None,
) -> bool:
"""True quando a entrega é autêntica: `webhook-id`, `webhook-timestamp` e
`webhook-signature` presentes em `headers` (nomes sem diferenciar maiúsculas),
o timestamp a no máximo 5 minutos de `now` (epoch em segundos; o relógio local
quando omitido) e ao menos uma assinatura `v1` igual ao HMAC-SHA256 de
"{webhook-id}.{webhook-timestamp}.{raw_body}". O timestamp tem até 10 dígitos.
A chave é o base64 decodificado depois de `whsec_` em `secret`, o
`standard_webhooks_secret` do webhook; um `secret` que não é base64 válido
levanta `binascii.Error` em vez de devolver False. `raw_body` são os bytes
recebidos, antes de qualquer parse."""
key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
lowered = {name.lower(): value for name, value in headers.items()}
message_id = lowered.get("webhook-id")
timestamp = lowered.get("webhook-timestamp")
signature_header = lowered.get("webhook-signature")
if not message_id or not timestamp or not signature_header:
return False
if (
len(timestamp) > MAX_TIMESTAMP_DIGITS
or not timestamp.isascii()
or not timestamp.isdigit()
):
return False
current = time.time() if now is None else now
if abs(current - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = f"{message_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
return _matches_any(expected, signature_header)
def _matches_any(expected: bytes, signature_header: str) -> bool:
"""True quando alguma entrada `v1,<base64>` de `signature_header`, separadas
por espaço, tem o texto igual a `expected`, sem normalizar o base64. Compara
todas, em tempo constante, e ignora as de outra versão."""
matched = False
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1":
matched |= hmac.compare_digest(expected, signature.encode())
return matchedimport { createHmac, timingSafeEqual } from 'node:crypto';
export const TOLERANCE_SECONDS = 5 * 60;
/**
* True quando a entrega é autêntica: `webhook-id`, `webhook-timestamp` e
* `webhook-signature` presentes em `headers` (objeto simples, nomes sem diferenciar
* maiúsculas), o timestamp a no máximo 5 minutos de `now` (epoch em segundos; o
* relógio local quando omitido) e ao menos uma assinatura `v1` igual ao HMAC-SHA256
* de "{webhook-id}.{webhook-timestamp}.{rawBody}", comparando o texto base64 de
* todas em tempo constante. O timestamp tem até 10 dígitos. A chave é o base64
* depois de `whsec_` em `secret`, o `standard_webhooks_secret` do webhook; um
* `secret` que não é base64 canônico lança um Error em vez de devolver false.
* `rawBody` é o Buffer recebido, antes de qualquer parse.
*/
export function verify(rawBody, headers, secret, now = Date.now() / 1000) {
const encodedKey = secret.replace(/^whsec_/, '');
const key = Buffer.from(encodedKey, 'base64');
if (key.toString('base64') !== encodedKey) throw new Error('secret não é base64 válido');
const lowered = Object.fromEntries(
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
);
const messageId = lowered['webhook-id'];
const timestamp = lowered['webhook-timestamp'];
const signatureHeader = lowered['webhook-signature'];
if (!messageId || !timestamp || !signatureHeader) return false;
if (!/^\d{1,10}$/.test(timestamp)) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const signed = Buffer.concat([Buffer.from(`${messageId}.${timestamp}.`), rawBody]);
const expected = Buffer.from(createHmac('sha256', key).update(signed).digest('base64'));
let matched = false;
for (const entry of signatureHeader.split(' ')) {
const separator = entry.indexOf(',');
const version = separator === -1 ? entry : entry.slice(0, separator);
const signature = separator === -1 ? '' : entry.slice(separator + 1);
if (version !== 'v1') continue;
const given = Buffer.from(signature);
matched = (given.length === expected.length && timingSafeEqual(expected, given)) || matched;
}
return matched;
}O primeiro argumento é o corpo cru da requisição, como o seu framework o entrega antes de qualquer parse (em FastAPI, await request.body(); em Express, o req.body de express.raw({ type: 'application/json' })); o segundo, os headers recebidos (request.headers em FastAPI, req.headers em Express); o terceiro, o standard_webhooks_secret. O quarto, opcional, é o instante de referência em unix time, para testes.
Bibliotecas Standard Webhooks
Em vez de copiar o código acima, você pode usar uma das bibliotecas oficiais da spec, que existem para várias linguagens; a de Python aplica a mesma tolerância de 5 minutos. Elas recebem o standard_webhooks_secret como está, com o prefixo whsec_. Em Python, com o pacote standardwebhooks:
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
try:
Webhook(standard_webhooks_secret).verify(corpo_bruto, headers)
except WebhookVerificationError:
... # responda 4012. Defesa contra replay
- Tolerância de tempo — recuse entregas cujo
webhook-timestampesteja a mais de 5 minutos do horário do servidor (já incluída nos exemplos e nas bibliotecas acima). Como o timestamp entra no conteúdo assinado, quem captura uma entrega não consegue reenviá-la depois da janela trocando só o header. - Deduplicação — a entrega é at-least-once. O
webhook-iddeduplica as reentregas de um evento: é o mesmo em todas as tentativas. Ele não deduplica um replay de evento que já teve tentativa, que cria um evento novo comwebhook-idnovo, nem identifica o fato: dois eventos distintos podem descrever a mesma mudança. Por isso a chave de negócio continua valendo: num webhook v2, aidempotency_keydo envelope, a mesma em qualquer reentrega e em qualquer replay do mesmo fato; num webhook v1, o conteúdo dedata(ex.:entity_id+status+installment_code), prefixado peloiorq_fund_iddo corpo se você recebe mais de um fundo na mesma URL. Ver Idempotência e reentrega. - Ordem e agenda — a ordem de chegada não é garantida, e uma reentrega pode sair horas depois da primeira tentativa: as regras estão em Garantia de ordem e a agenda em Política de retentativa.
3. Headers custom (camada adicional)
Os headers registrados no webhook continuam sendo reenviados em cada entrega. Use-os como segunda barreira (ex.: um header secreto validado no seu lado), mas trate a assinatura como a defesa autoritativa — headers custom não cobrem integridade do corpo nem replay. Os nomes webhook-id, webhook-timestamp, webhook-signature e qualquer X-IORQ-* são reservados à IORQ, e Host, Content-Length, Transfer-Encoding, Connection, Keep-Alive, TE, Trailer, Upgrade, Content-Type e qualquer Proxy-* pertencem ao transporte: registrar ou alterar um webhook com um header custom com um desses nomes, sem diferenciar maiúsculas, é recusado com 422. Os valores são só de escrita: depois de gravados, a API devolve só os nomes, em header_names (ver Configurando webhooks).
4. TLS e certificado
- A URL registrada precisa ser HTTPS — URLs
http://são rejeitadas na criação e na atualização do webhook. - Certificado válido emitido por CA pública. Mínimo TLS 1.2; TLS 1.3 recomendado.
5. Rotação do signing_secret
signing_secretPOST /api/webhook/{webhook_id}/secret/rotate gera um secret novo e o devolve uma única vez, na mesma resposta do registro, nos dois formatos: signing_secret e standard_webhooks_secret. A chamada exige a permissão wsm_update_webhook; sem ela a resposta é 403, e a permissão se pede ao seu contato na IORQ (Permissões). O secret anterior continua válido por uma janela de sobreposição, expires_in_hours no corpo (1 a 72, padrão 24), e nesse período toda entrega leva as duas assinaturas, a do secret novo primeiro:
webhook-signature: v1,<base64_com_o_secret_novo> v1,<base64_com_o_secret_antigo>
O roteiro sem janela de rejeição é: chame a rotação, guarde o secret novo, troque-o no seu validador quando quiser dentro da janela, e pronto. Um validador que testa todas as assinaturas (os exemplos acima e as bibliotecas da spec) funciona nos dois lados da troca; um que só lê a primeira continua funcionando desde que troque o secret antes de a janela fechar. Depois de previous_secret_expires_at, que GET /api/webhook/{webhook_id} expõe, só o secret novo assina.
curl -X POST 'https://webhook-sender-manager-production.iorq.com.br/api/webhook/01K4Q8ZJ0000000000000000WH/secret/rotate' \
-H 'Authorization: Bearer eyJhbGciOi...' \
-H 'iorq-fund-id: 374485cf-f6df-467c-b91d-9f14082c6f36' \
-H 'Content-Type: application/json' \
-d '{"expires_in_hours": 24}'Três detalhes: rotacionar de novo dentro de uma janela descarta o secret mais antigo, não acumula três; um webhook anterior à assinatura, sem secret, ganha o primeiro com esta chamada, sem sobreposição; e GET, PATCH e a listagem seguem sem devolver secret nenhum, só previous_secret_expires_at. Em caso de comprometimento, rotacione com expires_in_hours curto e troque o validador em seguida.
Se a janela fechar sem você trocar o secret, a partir de previous_secret_expires_at toda entrega passa a ir só com a assinatura nova, e um validador que ainda usa o secret antigo recusa tudo. Se ele responde 4xx (o 401 recomendado acima), cada recusa conta como falha: failing_since abre na primeira e, com três dias sem nenhum 2xx, o webhook é suspenso. Nada se perde nesse meio-tempo, porque as entregas recusadas entram na reentrega, mas o conserto é seu: troque o secret no validador (ou rotacione de novo e use o secret da resposta), e depois reative e faça o replay do período, se chegou a suspender. Um validador que já não sabe qual dos dois secrets é o vigente resolve com uma rotação nova: a resposta traz um secret certo, e a partir dela a janela de sobreposição recomeça.
6. O que responder no webhook
| HTTP | Quando devolver | Comportamento IORQ |
|---|---|---|
2xx | Recebido, inclusive um corpo autenticado que você decidiu descartar (duplicata, tipo que não te interessa) | Marca como entregue, não reenvia |
401 | Assinatura inválida (ver acima) | Falha; entra na reentrega como qualquer não-2xx |
3xx | Nunca | Falha; a IORQ não segue redirect |
4xx / 5xx / timeout | Falha no seu lado | Reenvia pela agenda de reentrega: até 10 retentativas, 11 tentativas em cerca de 70 h |
Três pontos que a tabela não cabe:
- Redirect não é seguido. A entrega vai para a URL registrada e para aí: um
3xxé falha e entra na reentrega, como qualquer outro não-2xx. Registre a URL final, não uma que redireciona. 4xxtambém é retentado. Não há distinção entre erro permanente e transitório: um400seu recebe as mesmas 11 tentativas de um503. A política e os intervalos estão em Idempotência e reentrega.2xxpara descartar é só para corpo autenticado. A assinatura inválida é o único caso em que a resposta certa é recusar: o corpo pode não ter vindo da IORQ. Tudo o que passou na assinatura e você não quer processar, responda2xxe descarte do seu lado, senão a IORQ reentrega por três dias e, no fim, suspende o webhook.
A janela é curta: a IORQ espera 10 segundos e depois corta a conexão, contando como falha. Valide a assinatura, coloque o evento numa fila sua e responda 2xx na hora. Processar antes de responder é o jeito mais comum de transformar um evento entregue numa reentrega.
7. Assinatura legada (X-IORQ-Signature)
X-IORQ-Signature)
Legado, mantido até data a ser anunciadaOs headers
X-IORQ-SignatureeX-IORQ-Timestampcontinuam saindo em toda entrega assinada, ao lado dos headers Standard Webhooks e sobre os mesmos bytes, até uma data que será anunciada. Um validador que já os usa segue funcionando; uma integração nova deve validarwebhook-signature(seção 1).
| Header | Conteúdo |
|---|---|
X-IORQ-Signature | t=<unix_ts>,v1=<hex> — o HMAC-SHA256 de "{timestamp}.{corpo_bruto}", em hexadecimal, com a chave = os bytes UTF-8 do signing_secret inteiro. Durante uma rotação de secret vem um segundo v1=: t=<ts>,v1=<novo>,v1=<antigo> |
X-IORQ-Timestamp | O mesmo unix_ts, igual ao webhook-timestamp — o instante desta tentativa, não do evento |
O secret desta assinatura é o signing_secret, devolvido nas mesmas duas respostas que o standard_webhooks_secret. A chave HMAC das duas assinaturas é a mesma; o que muda é o conteúdo assinado (o legado não cobre o webhook-id) e a codificação (hex aqui, base64 lá).
Para validar:
- Extraia
te todos osv1do headerX-IORQ-Signature(um normalmente; dois durante uma rotação de secret) - Concatene:
signed_payload = t + "." + corpo_bruto(use os bytes exatos do body recebido, antes de qualquer parse) - Calcule HMAC-SHA256(
signing_secret,signed_payload) - Compare com cada
v1em tempo constante (hmac.compare_digestem Python ou equivalente), sem parar no primeiro que bate; basta um bater - Rejeite se o header não tiver
tou nenhumv1, se nenhumv1bater, ou setestiver a mais de 5 minutos do relógio do seu servidor
Os dois validadores abaixo calculam o digest sobre os bytes recebidos, sem decodificar e reserializar; devolvem False se faltar t ou v1; e comparam todas as entradas v1= em tempo constante.
# Python
import hmac, hashlib, time
def verify(payload_bytes, signature_header, secret, tolerance=300):
ts, sigs = None, []
for part in signature_header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
ts = value
elif key == "v1":
sigs.append(value) # dois durante uma rotação
if ts is None or not ts.isdigit() or not sigs:
return False
if abs(time.time() - int(ts)) > tolerance:
return False
signed = ts.encode() + b"." + payload_bytes # os bytes recebidos, sem decode
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
ok = False
for sig in sigs: # compara com todos, sem parar no primeiro que bate
ok |= hmac.compare_digest(expected, sig.encode())
return ok// Node.js
const crypto = require('crypto');
function verify(payloadBytes, signatureHeader, secret, tolerance = 300) {
let ts = null;
const sigs = [];
for (const part of signatureHeader.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1);
if (key === 't') ts = value;
else if (key === 'v1') sigs.push(value); // dois durante uma rotação
}
if (ts === null || !/^\d+$/.test(ts) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
const signed = Buffer.concat([Buffer.from(ts + '.'), payloadBytes]); // os bytes recebidos, sem toString
const expected = crypto.createHmac('sha256', secret).update(signed).digest();
let ok = false;
for (const sig of sigs) { // compara com todos, sem parar no primeiro que bate
const given = Buffer.from(sig, 'hex');
const match = given.length === expected.length && crypto.timingSafeEqual(expected, given);
ok = match || ok;
}
return ok;
}payload_bytes / payloadBytes é o corpo cru da requisição, como o seu framework o entrega antes de qualquer parse.
Para migrar um validador legado sem janela de rejeição, aceite durante a troca qualquer uma das duas assinaturas, publique, e depois remova a validação de X-IORQ-Signature. O secret não muda: derive o standard_webhooks_secret do signing_secret que você já tem (seção 1).
8. Próximos passos
Updated 10 days ago
