Webhooks
Verificar assinatura
Como confirmar que um webhook veio da Koku e não foi alterado, com exemplos em Node.js, Python e PHP.
Toda entrega traz o cabeçalho x-koku-signature:
x-koku-signature: t=<unix>,v1=<hex>té o momento da assinatura em segundos (Unix).v1é o HMAC-SHA256, em hexadecimal, calculado com o segredo do seu endpoint sobre o texto<t>.<corpo>: o valor det, um ponto e o corpo exatamente como chegou.
Como verificar
- Leia o corpo bruto da requisição, antes de qualquer conversão de JSON. Reformatar o JSON (espaços, ordem das chaves) muda a assinatura.
- Extraia
tev1do cabeçalho. - Recuse se
testiver a mais de 5 minutos do seu relógio (proteção contra reenvio por terceiros). - Calcule
HMAC-SHA256(segredo, t + "." + corpo)em hexadecimal. - Compare com
v1usando comparação em tempo constante. Diferente: responda400e não processe.
Verificar a assinatura
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Confere a assinatura de um webhook da Koku.
* rawBody: o corpo EXATO recebido (string), antes de qualquer JSON.parse.
* signatureHeader: valor do cabeçalho x-koku-signature ("t=<unix>,v1=<hex>").
*/
export function verifyKokuSignature({ rawBody, signatureHeader, secret, toleranceSeconds = 300, nowSeconds = Math.floor(Date.now() / 1000) }) {
const header = String(signatureHeader ?? '');
const t = /(?:^|,)\s*t=(\d+)/.exec(header);
const v1 = /(?:^|,)\s*v1=([0-9a-f]{64})/.exec(header);
if (!t || !v1) return false;
const timestamp = Number(t[1]);
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(v1[1], 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
// Exemplo com Express: use o corpo bruto (express.raw), nunca o JSON já convertido.
//
// app.post('/webhooks/koku', express.raw({ type: 'application/json' }), (req, res) => {
// const rawBody = req.body.toString('utf8');
// const ok = verifyKokuSignature({
// rawBody,
// signatureHeader: req.get('x-koku-signature'),
// secret: process.env.KOKU_WEBHOOK_SECRET,
// });
// if (!ok) return res.status(400).send('assinatura inválida');
// res.status(200).send('ok'); // responda rápido; processe o evento em segundo plano
// enfileirar(JSON.parse(rawBody));
// });import hashlib
import hmac
import re
import time
def verify_koku_signature(raw_body, signature_header, secret, tolerance_seconds=300, now_seconds=None):
"""Confere a assinatura de um webhook da Koku.
raw_body: o corpo EXATO recebido (bytes ou str), antes de qualquer json.loads.
signature_header: valor do cabeçalho x-koku-signature ("t=<unix>,v1=<hex>").
"""
header = signature_header or ""
t = re.search(r"(?:^|,)\s*t=(\d+)", header)
v1 = re.search(r"(?:^|,)\s*v1=([0-9a-f]{64})", header)
if not t or not v1:
return False
timestamp = int(t.group(1))
now = int(time.time()) if now_seconds is None else now_seconds
if abs(now - timestamp) > tolerance_seconds:
return False
body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1.group(1))
# Exemplo com Flask: use request.get_data() (corpo bruto), nunca request.json.
#
# @app.post("/webhooks/koku")
# def koku_webhook():
# raw_body = request.get_data()
# if not verify_koku_signature(raw_body, request.headers.get("x-koku-signature"), os.environ["KOKU_WEBHOOK_SECRET"]):
# return "assinatura inválida", 400
# enfileirar(json.loads(raw_body)) # processe em segundo plano
# return "ok", 200<?php
/**
* Confere a assinatura de um webhook da Koku.
* $rawBody: o corpo EXATO recebido (file_get_contents('php://input')), antes de json_decode.
* $signatureHeader: valor do cabeçalho x-koku-signature ("t=<unix>,v1=<hex>").
*/
function verify_koku_signature(string $rawBody, ?string $signatureHeader, string $secret, int $toleranceSeconds = 300, ?int $nowSeconds = null): bool
{
$header = $signatureHeader ?? '';
if (!preg_match('/(?:^|,)\s*t=(\d+)/', $header, $t) || !preg_match('/(?:^|,)\s*v1=([0-9a-f]{64})/', $header, $v1)) {
return false;
}
$timestamp = (int) $t[1];
$now = $nowSeconds ?? time();
if (abs($now - $timestamp) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $v1[1]);
}
// Exemplo de uso no endpoint:
//
// $rawBody = file_get_contents('php://input');
// if (!verify_koku_signature($rawBody, $_SERVER['HTTP_X_KOKU_SIGNATURE'] ?? null, getenv('KOKU_WEBHOOK_SECRET'))) {
// http_response_code(400);
// exit('assinatura inválida');
// }
// http_response_code(200);
// echo 'ok';
// enfileirar(json_decode($rawBody, true)); // processe em segundo planoEstes trechos são executados pelos testes automáticos da documentação contra entregas reais da API.
Exemplo para conferir a sua implementação
Com o segredo whsec_Zq4m8Tn2Vb7Lk1Xc9Rd5Hs3Wf6Jp0Ga2Ye8Ui4Ko7 (fictício), a entrega abaixo tem assinatura válida no instante t mostrado. Use-a num teste unitário passando o "agora" igual a t:
Entrega recebida pelo seu endpoint
POST /webhooks/koku HTTP/1.1
content-type: application/json
user-agent: Koku-Webhooks/1
x-koku-signature: t=1791473531,v1=a735379a545698a02021820ddff8136ca623148267e128cf0ab6107e94dc3d96
x-koku-event-id: evt_2f6b1c9e8a4d4e7f9b3c5a1d0e6f8b27
x-koku-event-type: charge.paid
x-koku-event-version: v1
{"id":"evt_2f6b1c9e8a4d4e7f9b3c5a1d0e6f8b27","type":"charge.paid","version":"v1","created_at":"2026-10-07T15:32:11.000Z","environment":"production","data":{"charge_id":"8d3f6a12-4b9e-4c71-a2d5-6e0f1b3c9a47","order_reference":"PEDIDO-1001","amount_cents":"15990","fee_cents":"320","net_cents":"15670","reserve_cents":"0","paid_at":"2026-10-07T15:32:10.000Z","end_to_end_id":"E12345678202610071532a1B2c3D4e5F","late_payment":false,"case_id":null}}Cuidados
- Corpo bruto: em Express use
express.raw({ type: 'application/json' })no endpoint do webhook; em Flask,request.get_data(); em PHP,file_get_contents('php://input'). - Segredo: guarde em variável de ambiente ou cofre. Se vazar, cadastre um endpoint novo (com segredo novo) e desabilite o antigo.
- Tolerância de tempo: mantenha o relógio do servidor sincronizado (NTP).
- Mais de um endpoint: cada endpoint tem o seu segredo; use o segredo do endpoint que recebeu a chamada.