# Verificar assinaturas de webhook

> Confirme que as requisições de webhook vêm do Emailit verificando a assinatura HMAC-SHA256 e o timestamp, com código para Node.js, Python, PHP, Ruby, Go e Laravel.

Qualquer pessoa que conheça a URL do seu webhook pode enviar requisições para ela. Toda requisição do Emailit é assinada com o segredo do seu webhook, então verificar a assinatura prova que a requisição veio do Emailit e não foi alterada no caminho. Verificar o timestamp também impede que requisições antigas sejam reenviadas (ataque de replay). Faça as duas verificações antes de agir sobre qualquer evento.

## Como a assinatura funciona

Cada webhook tem o próprio segredo: `whsec_` seguido de 64 caracteres hexadecimais. Você o vê uma única vez, ao criar o webhook ou redefinir o segredo, e [Obter um webhook](/pt/docs/api-reference/webhooks/get/) o retorna.

Para cada requisição, o Emailit:

1. Pega o horário Unix atual em segundos, por exemplo `1790846045`, e o envia em `X-Emailit-Timestamp`.
2. Monta a string `{timestamp}.{raw body}`: o timestamp, um ponto e os bytes exatos do corpo da requisição.
3. Calcula o HMAC-SHA256 dessa string, usando como chave o segredo completo, incluindo `whsec_`.
4. Envia o resultado, codificado em hexadecimal minúsculo, em `X-Emailit-Signature`.

Para verificar, o seu endpoint repete os passos 2 e 3 com o corpo bruto que recebeu e compara o resultado com o cabeçalho usando uma comparação em tempo constante. Depois, confirma que o timestamp é recente. Recomendamos uma tolerância de 5 minutos (300 segundos). Cada nova tentativa é assinada de novo com um timestamp novo, então uma janela de 5 minutos não rejeita novas tentativas.

## Verificar as requisições

Cada exemplo lê o corpo bruto, rejeita com `401` as requisições com uma assinatura ausente, antiga ou incorreta e só então interpreta o array JSON.

**Node.js**

```javascript title="server.js"
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // full value, including whsec_
const TOLERANCE_SECONDS = 300;

export function verifyEmailitSignature(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!(age <= TOLERANCE_SECONDS)) return false; // also rejects non-numeric timestamps

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.`)
    .update(rawBody) // a Buffer with the exact bytes received
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw() keeps the body as a Buffer. Don't run express.json() on this route.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = verifyEmailitSignature(
    req.body,
    req.get('X-Emailit-Signature'),
    req.get('X-Emailit-Timestamp'),
  );
  if (!valid) return res.status(401).send('Invalid signature');

  const events = JSON.parse(req.body.toString('utf8'));
  // Queue the events for processing, then acknowledge.
  res.sendStatus(204);
});

app.listen(3000);
```

**Python**

```python title="app.py"
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode()  # full value, including whsec_
TOLERANCE_SECONDS = 300

def verify_emailit_signature(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
    if not signature or not timestamp:
        return False
    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/webhooks/emailit")
def emailit_webhook():
    raw_body = request.get_data()  # raw bytes; don't use request.json first
    if not verify_emailit_signature(
        raw_body,
        request.headers.get("X-Emailit-Signature"),
        request.headers.get("X-Emailit-Timestamp"),
    ):
        abort(401)

    events = json.loads(raw_body)
    # Queue the events for processing, then acknowledge.
    return "", 204
```

**PHP**

```php title="webhook.php"
<?php
const TOLERANCE_SECONDS = 300;

function verifyEmailitSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool
{
    if ($signature === '' || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';
$secret = (string) getenv('EMAILIT_WEBHOOK_SECRET'); // full value, including whsec_

if (!verifyEmailitSignature($rawBody, $signature, $timestamp, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

$events = json_decode($rawBody, true);
// Queue the events for processing, then acknowledge.
http_response_code(204);
```

**Ruby**

```ruby title="app.rb"
require 'sinatra'
require 'openssl'
require 'json'
require 'rack/utils'

SECRET = ENV.fetch('EMAILIT_WEBHOOK_SECRET') # full value, including whsec_
TOLERANCE_SECONDS = 300

def verify_emailit_signature(raw_body, signature, timestamp)
  return false if signature.to_s.empty? || timestamp.to_s !~ /\A\d+\z/
  return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(expected, signature)
end

post '/webhooks/emailit' do
  raw_body = request.body.read # in Rails, use request.raw_post
  valid = verify_emailit_signature(
    raw_body,
    request.env['HTTP_X_EMAILIT_SIGNATURE'],
    request.env['HTTP_X_EMAILIT_TIMESTAMP']
  )
  halt 401, 'Invalid signature' unless valid

  events = JSON.parse(raw_body)
  # Queue the events for processing, then acknowledge.
  status 204
end
```

**Go**

```go title="main.go"
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

const toleranceSeconds = 300

func verifyEmailitSignature(rawBody []byte, signature, timestamp, secret string) bool {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || signature == "" {
		return false
	}
	age := time.Now().Unix() - ts
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

type Event struct {
	EventID string          `json:"event_id"`
	Type    string          `json:"type"`
	Data    json.RawMessage `json:"data"`
}

func emailitWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "cannot read body", http.StatusBadRequest)
		return
	}
	secret := os.Getenv("EMAILIT_WEBHOOK_SECRET") // full value, including whsec_
	if !verifyEmailitSignature(rawBody, r.Header.Get("X-Emailit-Signature"), r.Header.Get("X-Emailit-Timestamp"), secret) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	var events []Event
	if err := json.Unmarshal(rawBody, &events); err != nil {
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	// Queue the events for processing, then acknowledge.
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	http.HandleFunc("/webhooks/emailit", emailitWebhook)
	http.ListenAndServe(":8080", nil)
}
```

**Laravel**

```php title="app/Http/Controllers/EmailitWebhookController.php"
<?php
// config/services.php: 'emailit' => ['webhook_secret' => env('EMAILIT_WEBHOOK_SECRET')],
// routes/api.php:      Route::post('/webhooks/emailit', EmailitWebhookController::class);

namespace App\Http\Controllers;

use App\Jobs\ProcessEmailitEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class EmailitWebhookController extends Controller
{
    private const TOLERANCE_SECONDS = 300;

    public function __invoke(Request $request): Response
    {
        $rawBody = $request->getContent();
        $signature = (string) $request->header('X-Emailit-Signature', '');
        $timestamp = (string) $request->header('X-Emailit-Timestamp', '');
        $secret = (string) config('services.emailit.webhook_secret'); // full value, including whsec_

        $fresh = ctype_digit($timestamp)
            && abs(time() - (int) $timestamp) <= self::TOLERANCE_SECONDS;
        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

        abort_unless($fresh && hash_equals($expected, $signature), 401, 'Invalid signature');

        foreach (json_decode($rawBody, true) as $event) {
            ProcessEmailitEvent::dispatch($event); // a queued job
        }

        return response()->noContent();
    }
}
```

No Laravel, as rotas em `routes/api.php` não passam pela verificação de tokens CSRF e recebem o prefixo `/api`, então a URL acima é `https://acme.com/api/webhooks/emailit`. Se você registrar a rota em `routes/web.php`, exclua o caminho da verificação de CSRF; caso contrário, o Laravel rejeita as requisições do Emailit com `419`.

## Testar a sua verificação

Teste a sua função com este exemplo conhecido antes de fazer deploy. Chame diretamente a parte do HMAC, porque o timestamp está no passado e não passa na verificação de 5 minutos.

| Entrada | Valor |
| --- | --- |
| Segredo | `whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f` |
| Timestamp | `1790846045` |
| Assinatura esperada | `7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3` |

Corpo bruto, exatamente como mostrado, sem quebra de linha no final:

```json
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]
```

Depois, use **Send test** no webhook para confirmar que o seu endpoint, já com o deploy feito, aceita uma requisição assinada real. Consulte [Configurar um webhook](/pt/docs/webhooks/set-up/#send-a-test-event).

## Erros comuns

- **Interpretar antes de verificar.** Frameworks que interpretam o JSON automaticamente (`express.json()`, `request.json` no Flask, os params do Rails) entregam um objeto, não os bytes que o Emailit assinou. Leia o corpo bruto na rota do webhook.
- **Serializar o JSON de novo.** `JSON.stringify(req.body)` ou `json.dumps(data)` produzem espaços em branco, ordem de chaves ou escapes diferentes, e o HMAC não confere. Sempre calcule o hash do corpo exatamente como foi recebido.
- **Alterar o segredo.** Use o valor inteiro, incluindo `whsec_`, como texto simples. Não remova o prefixo nem o decodifique de hexadecimal ou Base64.
- **Assinar a string errada.** A entrada é o timestamp, um ponto e depois o corpo. Assinar só o corpo, ou juntar as partes com outro separador, falha.
- **Usar `==` para comparar.** Use uma função de tempo constante, como `crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, `Rack::Utils.secure_compare` ou `hmac.Equal`.
- **Relógio do servidor errado.** Se o relógio do seu servidor se desviar, requisições válidas ficam fora da tolerância. Mantenha-o sincronizado com NTP.
- **Segredo antigo ou trocado.** Depois de **Reset**, o segredo antigo deixa de funcionar imediatamente, inclusive para as novas tentativas. Cada webhook tem o próprio segredo, então dois endpoints não podem compartilhar um.
- **Middleware que reescreve o corpo.** Proxies, gateways de API ou a descompressão do corpo que alteram os bytes antes de o seu código vê-los quebram a assinatura. Verifique o mais perto possível da requisição bruta.

As requisições que falham na verificação recebem `401` do seu endpoint e ganham [novas tentativas](/pt/docs/webhooks/retries-and-failures/) como qualquer outra falha, então corrigir um segredo mal configurado em poucas horas não causa nenhuma perda.

## Veja também

  - [Requisições de webhook](/pt/docs/webhooks/webhook-requests/)
  - [Novas tentativas e falhas](/pt/docs/webhooks/retries-and-failures/)

---
Fonte: https://emailit.com/pt/docs/webhooks/request-signature/
