> ## Documentation Index
> Fetch the complete documentation index at: https://userepass.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Verificação de assinatura

> Valide a autenticidade dos webhooks com HMAC.

Todo webhook entregue pela Repass é assinado com HMAC-SHA256, usando um segredo
exclusivo do endpoint. A assinatura viaja no header `Repass-Signature` e permite
que você confirme que o corpo veio da Repass e não foi adulterado em trânsito.
Esta página descreve o formato da assinatura, como validá-la passo a passo e
como rotacionar o segredo sem perder entregas.

<Note>
  Para o panorama de webhooks de saída (assinaturas por endpoint, fan-out e
  entregas), veja [Webhooks: visão geral](/docs/webhooks/visao-geral). Para
  reentregas e tentativas, veja [Retries e dead letter](/docs/webhooks/retries-e-dead-letter).
</Note>

## O header `Repass-Signature`

Cada entrega inclui dois headers:

```http theme={null}
content-type: application/json
Repass-Signature: t=1718294400,v1=5257a869e7 ... 8c1f
```

O header `Repass-Signature` é uma lista de pares `chave=valor` separados por
vírgula:

<ParamField header="t" type="integer">
  Timestamp Unix em **segundos** de quando a entrega foi assinada. Use-o para
  proteção contra replay.
</ParamField>

<ParamField header="v1" type="string">
  Assinatura HMAC-SHA256 em hexadecimal. **Pode aparecer mais de uma vez**:
  durante a janela de rotação do segredo, a entrega é assinada com o segredo
  atual e o anterior, gerando dois `v1=`. Considere a assinatura válida se
  **qualquer** `v1` conferir.
</ParamField>

A assinatura é calculada sobre a string concatenada `"{t}.{body}"`, onde `body`
é o corpo HTTP **exato** (bytes brutos) recebido na requisição:

```text theme={null}
assinatura = HMAC_SHA256( segredo, "<t>.<corpo-bruto>" )  →  hex
```

<Warning>
  Assine o corpo **bruto**, antes de qualquer parsing. Se você fizer
  `JSON.parse` e depois `JSON.stringify` o objeto, a reserialização pode
  reordenar chaves ou mudar o espaçamento, e a assinatura nunca vai conferir.
  Capture os bytes crus do request antes de desserializar.
</Warning>

## Passo a passo da validação

<Steps>
  <Step title="Recupere o segredo do endpoint">
    O segredo é gerado pela Repass no formato `rwhs_<base64url>` e devolvido em
    claro **apenas** na resposta de criação do endpoint (`POST /webhook-endpoints`)
    e na rotação (`POST /webhook-endpoints/{endpointId}/rotate-secret`). Em
    qualquer outra resposta ele aparece mascarado como `rwhs_***<4 últimos>`.
    Guarde-o de forma segura no momento em que for emitido.
  </Step>

  <Step title="Leia o corpo bruto e o header">
    Capture o corpo da requisição como string/bytes crus, sem parsing, e leia o
    header `Repass-Signature`. Extraia `t` e todos os valores `v1`.
  </Step>

  <Step title="Recalcule o HMAC">
    Calcule `HMAC-SHA256` com o segredo sobre a string `"{t}.{corpo}"` e
    converta o resultado para hexadecimal.
  </Step>

  <Step title="Compare em tempo constante">
    Compare a assinatura recalculada com cada `v1` recebido usando uma
    comparação de tempo constante (veja o aviso abaixo). Se qualquer uma
    conferir, a assinatura é válida.
  </Step>

  <Step title="Rejeite timestamps antigos (anti-replay)">
    Verifique que `t` está dentro de uma janela de tolerância (sugerimos **5
    minutos**) em relação ao horário atual. Isso impede que um atacante
    reenvie uma entrega válida capturada anteriormente.
  </Step>

  <Step title="Responda 2xx e deduplique">
    Responda com um status HTTP `2xx` o mais rápido possível. As entregas são
    **at-least-once**: o mesmo evento pode chegar mais de uma vez (após
    retentativa ou replay). Deduplique pelo `id` do evento (`evt_...`) no corpo.
  </Step>
</Steps>

<Warning>
  **Sempre compare assinaturas em tempo constante** (por exemplo,
  `crypto.timingSafeEqual` no Node.js ou `hmac.compare_digest` em Python).
  Comparações comuns de string (`===`, `==`) param no primeiro byte diferente e
  vazam, pelo tempo de resposta, quantos bytes iniciais batem, abrindo um canal
  lateral para um atacante forjar a assinatura byte a byte. Garanta também que os
  dois buffers tenham o mesmo tamanho antes de comparar.
</Warning>

## Exemplos de código

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  const TOLERANCE_SECONDS = 5 * 60;

  /**
   * @param {string} payload  Corpo HTTP bruto (string), antes de qualquer JSON.parse.
   * @param {string} header   Valor do header "Repass-Signature".
   * @param {string} secret   Segredo do endpoint (rwhs_...).
   */
  export function verifyRepassSignature(payload, header, secret) {
    // 1) Parseia "t=...,v1=...,v1=..." em t e na lista de assinaturas.
    const parts = Object.create(null);
    const signatures = [];
    for (const segment of header.split(",")) {
      const [key, value] = segment.split("=", 2);
      if (key === "v1") signatures.push(value);
      else parts[key] = value;
    }

    const timestamp = Number(parts.t);
    if (!Number.isFinite(timestamp)) {
      throw new Error("timestamp ausente no header");
    }

    // 2) Anti-replay: rejeita entregas fora da janela de tolerância.
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
      throw new Error("timestamp fora da janela de tolerância");
    }

    // 3) Recalcula o HMAC-SHA256 sobre "{t}.{body}".
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${payload}`)
      .digest("hex");
    const expectedBuf = Buffer.from(expected, "hex");

    // 4) Compara em tempo constante contra cada v1 (pode haver 2 na rotação).
    const valid = signatures.some((signature) => {
      const received = Buffer.from(signature, "hex");
      return (
        received.length === expectedBuf.length &&
        crypto.timingSafeEqual(received, expectedBuf)
      );
    });

    if (!valid) throw new Error("assinatura inválida");
    return true;
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 5 * 60


  def verify_repass_signature(payload: bytes, header: str, secret: str) -> bool:
      """payload: corpo HTTP bruto (bytes), antes de qualquer json.loads."""
      # 1) Parseia "t=...,v1=...,v1=..." em t e na lista de assinaturas.
      timestamp = None
      signatures = []
      for segment in header.split(","):
          key, _, value = segment.partition("=")
          if key == "v1":
              signatures.append(value)
          elif key == "t":
              timestamp = value

      if timestamp is None:
          raise ValueError("timestamp ausente no header")

      # 2) Anti-replay: rejeita entregas fora da janela de tolerância.
      if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
          raise ValueError("timestamp fora da janela de tolerância")

      # 3) Recalcula o HMAC-SHA256 sobre "{t}.{body}".
      signed = f"{timestamp}.".encode() + payload
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

      # 4) Compara em tempo constante contra cada v1 (pode haver 2 na rotação).
      if any(hmac.compare_digest(expected, sig) for sig in signatures):
          return True

      raise ValueError("assinatura inválida")
  ```

  ```php PHP theme={null}
  <?php
  const REPASS_TOLERANCE_SECONDS = 300;

  /**
   * @param string $payload Corpo HTTP bruto (file_get_contents('php://input')).
   * @param string $header  Valor do header "Repass-Signature".
   * @param string $secret  Segredo do endpoint (rwhs_...).
   */
  function verify_repass_signature(string $payload, string $header, string $secret): bool {
      // 1) Parseia "t=...,v1=...,v1=..." em t e na lista de assinaturas.
      $timestamp = null;
      $signatures = [];
      foreach (explode(',', $header) as $segment) {
          [$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
          if ($key === 'v1') {
              $signatures[] = $value;
          } elseif ($key === 't') {
              $timestamp = $value;
          }
      }

      if ($timestamp === null) {
          throw new Exception('timestamp ausente no header');
      }

      // 2) Anti-replay: rejeita entregas fora da janela de tolerância.
      if (abs(time() - (int) $timestamp) > REPASS_TOLERANCE_SECONDS) {
          throw new Exception('timestamp fora da janela de tolerância');
      }

      // 3) Recalcula o HMAC-SHA256 sobre "{t}.{body}".
      $expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);

      // 4) Compara em tempo constante contra cada v1 (pode haver 2 na rotação).
      foreach ($signatures as $signature) {
          if (hash_equals($expected, $signature)) {
              return true;
          }
      }

      throw new Exception('assinatura inválida');
  }
  ```
</CodeGroup>

## Segredo do endpoint

O segredo de assinatura pertence ao endpoint (recurso `whep_...`), não à
organização inteira: cada endpoint tem o seu. Ele é aleatório (24 bytes), com o
prefixo `rwhs_`, e fica disponível em claro somente nestes dois momentos:

* na resposta de `POST /webhook-endpoints` (criação do endpoint);
* na resposta de `POST /webhook-endpoints/{endpointId}/rotate-secret` (rotação).

Em qualquer outra resposta ou evento, o segredo aparece mascarado
(`rwhs_***<4 últimos>`). Se você perder o valor em claro, a única forma de obter
um novo é rotacionar.

```bash Criar endpoint e capturar o segredo theme={null}
curl -X POST https://api.userepass.com/webhook-endpoints \
  -H "Authorization: Bearer rstr_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://exemplo.com.br/webhooks/repass",
    "subscribedEvents": ["conversion.created", "commission.paid"]
  }'
```

```json Resposta (201), única vez com o secret em claro theme={null}
{
  "id": "whep_01HZ...",
  "url": "https://exemplo.com.br/webhooks/repass",
  "subscribedEvents": ["conversion.created", "commission.paid"],
  "status": "active",
  "maskedSecret": "rwhs_***g0Qf",
  "secret": "rwhs_8sR2v...g0Qf"
}
```

## Rotação de segredo

Rotacione o segredo periodicamente ou imediatamente se suspeitar de vazamento.
A Repass mantém o segredo anterior ativo por uma **janela de graça de 24 horas**:
durante esse período, cada entrega é assinada com os **dois** segredos (dois
`v1=` no header, o novo primeiro). Depois de 24 horas, apenas o novo segredo
assina. Isso permite atualizar o segredo no seu sistema sem perder nenhuma
entrega.

```bash Rotacionar o segredo theme={null}
curl -X POST https://api.userepass.com/webhook-endpoints/whep_01HZ.../rotate-secret \
  -H "Authorization: Bearer rstr_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

```json Resposta (200), novo secret em claro theme={null}
{
  "id": "whep_01HZ...",
  "url": "https://exemplo.com.br/webhooks/repass",
  "subscribedEvents": ["conversion.created", "commission.paid"],
  "status": "active",
  "maskedSecret": "rwhs_***xW7c",
  "previousSecretExpiresAt": "2026-06-14T12:00:00.000Z",
  "secret": "rwhs_n4Lp...xW7c"
}
```

O campo `secret` traz o **novo** segredo em claro (única vez). O
`maskedSecret` é o mesmo segredo mascarado, e `previousSecretExpiresAt` marca
o fim da janela de graça: até esse instante, as entregas são assinadas com o
novo e o anterior.

Fluxo recomendado de rotação sem downtime:

```mermaid theme={null}
sequenceDiagram
    participant Você
    participant Repass
    participant Endpoint as Seu endpoint
    Você->>Repass: POST /rotate-secret
    Repass-->>Você: novo secret (em claro, única vez)
    Note over Você,Endpoint: Configure o endpoint para aceitar AMBOS<br/>(novo + anterior) durante a graça
    Repass->>Endpoint: entrega assinada com 2x v1= (novo + anterior)
    Note over Endpoint: valida com qualquer um dos dois
    Note over Repass: após 24h: graça expira
    Repass->>Endpoint: entrega assinada só com o novo (1x v1=)
    Note over Você,Endpoint: remova o segredo anterior do seu sistema
```

<Tip>
  Aceitar **qualquer** `v1` que confira (como nos exemplos acima) já cobre a
  rotação automaticamente: durante a graça, basta ter os dois segredos
  configurados no seu validador; assim que a graça expira, apenas o novo será
  enviado e validado.
</Tip>

## Boas práticas

<AccordionGroup>
  <Accordion title="Sempre verifique a assinatura antes de processar">
    Trate qualquer entrega cuja assinatura não confira como não confiável:
    responda `400`/`401` e não processe o corpo. Não confie em IPs de origem nem
    apenas na URL secreta.
  </Accordion>

  <Accordion title="Deduplique pelo id do evento">
    As entregas são at-least-once. Guarde os `id` de evento (`evt_...`) já
    processados e ignore repetições: retentativas e replays manuais são
    esperados.
  </Accordion>

  <Accordion title="Responda rápido e processe de forma assíncrona">
    O transporte da Repass aborta a conexão após **10 segundos**. Confirme o
    recebimento com um `2xx` imediato e empurre o trabalho pesado para uma fila;
    respostas lentas contam como falha e disparam retentativa.
  </Accordion>

  <Accordion title="Mantenha a tolerância de replay curta">
    Uma janela de 5 minutos para o `t` equilibra robustez contra clock skew e
    proteção contra replay. Ajuste conforme a precisão do relógio dos seus
    servidores.
  </Accordion>
</AccordionGroup>

## Próximos passos

<CardGroup cols={2}>
  <Card title="Webhooks: visão geral" icon="webhook" href="/docs/webhooks/visao-geral">
    Como funcionam as assinaturas por endpoint, o fan-out e o envelope entregue.
  </Card>

  <Card title="Retries e dead letter" icon="rotate" href="/docs/webhooks/retries-e-dead-letter">
    Cronograma de retentativas, auto-disable por falha e replay manual.
  </Card>

  <Card title="Catálogo de eventos" icon="list" href="/docs/webhooks/catalogo-de-eventos">
    Todos os tipos de evento que você pode assinar.
  </Card>

  <Card title="Event store" icon="database" href="/docs/conceitos/event-store">
    O histórico de eventos que alimenta os webhooks.
  </Card>
</CardGroup>
