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.

HeaderConteúdo
webhook-idIdentificador 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-timestampInstante desta tentativa, em unix time (segundos). Muda a cada reentrega
webhook-signaturev1,<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:

HeaderConteúdo
X-IORQ-Event-IdIdentificador 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-TypeComo 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-IdIdentificador 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-timestamp e event_timestamp são coisas diferentes

O 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_timestamp do 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 assinatura

Webhooks 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-id e webhook-timestamp chegam mesmo assim, e nada mais muda para eles até você agir. Trocar a contract_version nã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_secret da 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

  1. Leia webhook-id, webhook-timestamp e webhook-signature; rejeite se faltar qualquer um
  2. Rejeite se webhook-timestamp nã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
  3. Decodifique em base64 o que vem depois de whsec_ no standard_webhooks_secret: esses bytes são a chave
  4. Concatene: conteudo_assinado = webhook-id + "." + webhook-timestamp + "." + corpo_bruto (use os bytes exatos do body recebido, antes de qualquer parse)
  5. Calcule HMAC-SHA256(chave, conteudo_assinado) e codifique em base64
  6. Separe webhook-signature por espaço e compare o texto depois de v1, 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 matched
import { 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 401

2. Defesa contra replay

  • Tolerância de tempo — recuse entregas cujo webhook-timestamp esteja 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-id deduplica 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 com webhook-id novo, 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, a idempotency_key do envelope, a mesma em qualquer reentrega e em qualquer replay do mesmo fato; num webhook v1, o conteúdo de data (ex.: entity_id + status + installment_code), prefixado pelo iorq_fund_id do 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

POST /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

HTTPQuando devolverComportamento IORQ
2xxRecebido, inclusive um corpo autenticado que você decidiu descartar (duplicata, tipo que não te interessa)Marca como entregue, não reenvia
401Assinatura inválida (ver acima)Falha; entra na reentrega como qualquer não-2xx
3xxNuncaFalha; a IORQ não segue redirect
4xx / 5xx / timeoutFalha no seu ladoReenvia 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.
  • 4xx também é retentado. Não há distinção entre erro permanente e transitório: um 400 seu recebe as mesmas 11 tentativas de um 503. A política e os intervalos estão em Idempotência e reentrega.
  • 2xx para 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, responda 2xx e 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)

🚧

Legado, mantido até data a ser anunciada

Os headers X-IORQ-Signature e X-IORQ-Timestamp continuam 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 validar webhook-signature (seção 1).

HeaderConteúdo
X-IORQ-Signaturet=<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-TimestampO 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:

  1. Extraia t e todos os v1 do header X-IORQ-Signature (um normalmente; dois durante uma rotação de secret)
  2. Concatene: signed_payload = t + "." + corpo_bruto (use os bytes exatos do body recebido, antes de qualquer parse)
  3. Calcule HMAC-SHA256(signing_secret, signed_payload)
  4. Compare com cada v1 em tempo constante (hmac.compare_digest em Python ou equivalente), sem parar no primeiro que bate; basta um bater
  5. Rejeite se o header não tiver t ou nenhum v1, se nenhum v1 bater, ou se t estiver 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


Did this page help you?