Pular para o conteúdo principal

Verificação de Assinatura

Toda entrega de webhook de um webhook com segredo leva o header X-Webhook-Signature. Ele garante que a requisição saiu do TapSign e que o corpo não foi alterado no caminho.

Como funciona​

  1. O TapSign calcula o HMAC-SHA256 do corpo da requisição usando o secret do webhook
  2. O resultado vai em hexadecimal minúsculo no header X-Webhook-Signature
  3. Sua aplicação recalcula o HMAC sobre o corpo cru recebido e compara com o header
Sempre valide a assinatura

Não processe um webhook sem verificar a assinatura. Sem essa validação, qualquer pessoa que descubra sua URL pode enviar eventos falsos.

Header de assinatura​

X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c592ae7f6ce0c8b3e6b1f0a4e3c2d1b0a9f8

O valor é só o hash em hexadecimal, sem prefixo (não há sha256= na frente).

Use o corpo exatamente como chegou

Calcule o HMAC sobre os bytes recebidos. Se você fizer o parse do JSON e serializar de novo, espaços e ordem dos campos mudam e a assinatura não bate.

Se o webhook não tiver segredo configurado (hasSecret: false), a entrega sai sem esse header.

Exemplos de código​

Node.js (Express)​

const crypto = require('crypto');

app.post('/webhooks/tapsign', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.TAPSIGN_WEBHOOK_SECRET)
.update(req.body) // Buffer com o corpo cru
.digest('hex');
const received = req.get('X-Webhook-Signature') || '';

const valid = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) {
return res.status(401).send('Assinatura inválida');
}

const event = JSON.parse(req.body.toString('utf8'));
console.log('Evento recebido:', event.event_type, event.token);

res.sendStatus(200); // responda rápido e processe depois
});

Kotlin (Spring Boot)​

import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

fun isValidSignature(rawBody: ByteArray, received: String, secret: String): Boolean {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
val expected = mac.doFinal(rawBody).joinToString("") { "%02x".format(it) }
return MessageDigest.isEqual(expected.toByteArray(), received.toByteArray())
}

@RestController
class TapSignWebhookController(
@Value("\${tapsign.webhook.secret}") private val webhookSecret: String,
private val objectMapper: ObjectMapper,
) {
@PostMapping("/webhooks/tapsign")
fun handle(
@RequestBody rawBody: ByteArray,
@RequestHeader("X-Webhook-Signature", required = false) signature: String?,
): ResponseEntity<Void> {
if (signature == null || !isValidSignature(rawBody, signature, webhookSecret)) {
return ResponseEntity.status(401).build()
}
val event = objectMapper.readTree(rawBody)
println("Evento recebido: ${event["event_type"].asText()}")
return ResponseEntity.ok().build()
}
}

Python (Flask)​

import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/tapsign")
def tapsign_webhook():
raw = request.get_data() # bytes crus
expected = hmac.new(WEBHOOK_SECRET.encode("utf-8"), raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Webhook-Signature", "")

if not hmac.compare_digest(received, expected):
abort(401)

event = request.get_json()
print(f"Evento recebido: {event['event_type']}")
return "", 200
Use comparação em tempo constante

Compare com timingSafeEqual, MessageDigest.isEqual ou hmac.compare_digest para evitar ataques de timing.


Política de retentativas​

Uma entrega é considerada falha quando seu servidor responde com status fora de 2xx, demora mais que o timeout (5 s para conectar, 15 s para ler) ou a conexão falha. Nesse caso o TapSign tenta de novo, até 5 tentativas no total:

TentativaQuando
1ªNo momento do evento
2ªCerca de 30 segundos depois da 1ª
3ªCerca de 2 minutos depois da 2ª
4ªCerca de 4 minutos depois da 3ª
5ªCerca de 8 minutos depois da 4ª

As retentativas são processadas por uma rotina que roda a cada minuto, então cada intervalo pode se estender em até cerca de 1 minuto.

Tentativas esgotadas

Se a 5ª tentativa falhar, a entrega fica com status DEAD_LETTER e não é mais retentada automaticamente. Você pode reenviá-la pela rota de reenvio de entrega.

Boas práticas​

1. Responda rapidamente​

Retorne 2xx assim que validar a assinatura e processe o evento de forma assíncrona (fila, worker). Respostas que passam de 15 segundos contam como falha.

app.post('/webhooks/tapsign', express.raw({ type: 'application/json' }), (req, res) => {
// valide a assinatura antes (veja o exemplo acima)
res.sendStatus(200);
enqueue(JSON.parse(req.body.toString('utf8'))); // fila, worker etc.
});

2. Seja idempotente​

O mesmo evento pode chegar mais de uma vez por causa das retentativas. O payload não tem um ID de evento, então use a combinação token + event_type como chave de deduplicação. Para doc_viewed, que pode se repetir legitimamente, inclua o e-mail de signer_who_signed.

async function processEvent(event) {
const key = `${event.token}:${event.event_type}`;
if (await db.processedWebhooks.exists(key)) return; // já processado

await db.processedWebhooks.insert({ key, processedAt: new Date() });
// processar evento...
}

3. Sempre valide a assinatura​

Verifique o header X-Webhook-Signature antes de qualquer processamento.

4. Use HTTPS​

A URL do webhook precisa usar HTTPS em produção. URLs http:// são recusadas no cadastro.

5. Registre tudo em log​

Guarde pelo menos event_type, token, external_id, o horário de recebimento e o resultado do processamento. Do lado do TapSign, os logs de entrega mostram o que foi enviado e a resposta do seu servidor.