Guia do desenvolvedorEventos
Webhooks
Receba os eventos dos seus e-mails por webhook, assinados no padrão Standard Webhooks, com reenvio por até 3 dias. Exemplos de verificação em Node, PHP e Python.
Webhooks avisam o seu sistema quando algo acontece com um e-mail: entregue, adiado, devolvido, reclamação, aberto, clicado. Cada chamada é assinada no padrão aberto Standard Webhooks.
Criar um webhook#
curl https://api.couryo.com/v1/webhooks \
-H "Authorization: Bearer $COURYO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://exemplo.com.br/webhooks/couryo", "events": ["delivered", "bounced", "complained"] }'{
"id": "whk_5n1c8v2z7r3k6m9p",
"url": "https://exemplo.com.br/webhooks/couryo",
"events": ["delivered", "bounced", "complained"],
"enabled": true,
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
"created_at": "2026-10-07T18:30:00Z"
}O secret aparece só nesta resposta. Guarde junto das suas outras credenciais.
- A URL precisa começar com
https://e ser pública: endereços de rede interna são recusados (invalid_field). - Criar, mudar e apagar webhooks exige escopo
admin; listar e consultar,read. PATCH /v1/webhooks/{id}mudaurl,eventsouenabled(para pausar sem apagar).DELETE /v1/webhooks/{id}apaga.
O que chega#
Um POST com JSON e três cabeçalhos:
webhook-id: evt_7d3k9s0a2m5n8b1c
webhook-timestamp: 1791484264
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4={
"type": "bounced",
"timestamp": "2026-10-07T18:31:04Z",
"data": {
"email_id": "msg_9w2k7c1x0d4e5f6g",
"event_id": "evt_7d3k9s0a2m5n8b1c",
"recipient": "maria@empresa.com.br",
"provider": "other",
"mx": "mx.empresa.com.br",
"smtp_code": "550 5.1.1",
"smtp_response": "550 5.1.1 <maria@empresa.com.br>: Recipient address rejected: User unknown",
"explanation": "A caixa de destino não existe. O endereço foi para a lista de supressão.",
"tags": { "tipo": "pedido" },
"metadata": { "pedido_id": "1042" }
}
}O webhook-id é o ID do evento e se repete nos reenvios: use para ignorar duplicatas.
Verificar a assinatura#
Sempre verifique antes de confiar no conteúdo. A assinatura é um HMAC-SHA256 de webhook-id.webhook-timestamp.corpo, com o segredo decodificado de Base64 (sem o prefixo whsec_). Recuse também mensagens com mais de 5 minutos, para evitar repetição.
Use o corpo cru, exatamente como chegou. Se o seu framework já converteu para JSON, a assinatura não vai bater.
import crypto from "node:crypto";
import express from "express";
function verifyWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) throw new Error("missing headers");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("timestamp too old");
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
const valid = signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
return (
version === "v1" &&
signature?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
});
if (!valid) throw new Error("invalid signature");
return JSON.parse(rawBody);
}
const app = express();
app.post("/webhooks/couryo", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body.toString("utf8"), req.headers, process.env.COURYO_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
res.sendStatus(200); // responda logo; processe depois (fila, job)
console.log(event.type, event.data.email_id);
});function verify_webhook(string $body, array $headers, string $secret): array
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $signatures === '') {
throw new RuntimeException('missing headers');
}
if (abs(time() - (int) $timestamp) > 300) {
throw new RuntimeException('timestamp too old');
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
foreach (explode(' ', $signatures) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
return json_decode($body, true);
}
}
throw new RuntimeException('invalid signature');
}
$body = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$event = verify_webhook($body, $headers, getenv('COURYO_WEBHOOK_SECRET'));
} catch (RuntimeException $e) {
http_response_code(400);
exit;
}
http_response_code(200);import base64
import hashlib
import hmac
import json
import time
def verify_webhook(body: bytes, headers, secret: str) -> dict:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
raise ValueError("missing headers")
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("timestamp too old")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return json.loads(body)
raise ValueError("invalid signature")As bibliotecas oficiais do Standard Webhooks (para Node, PHP, Python, Go, Ruby e outras) também funcionam com o segredo do Couryo.
Reenvio#
- Responda com qualquer status
2xxpara confirmar. Responda rápido e processe depois, numa fila. - Sem
2xx(ou sem resposta em 15 segundos), o Couryo tenta de novo depois de 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 12 h, 16 h e 24 h: 12 tentativas em cerca de 3 dias. - O painel mostra as últimas 100 tentativas de cada webhook, com o status e a resposta do seu servidor, e tem um botão para reenviar na hora.
- Perdeu eventos? Liste tudo em
GET /v1/events.
Eventos disponíveis#
accepted, queued, sent, delivered, deferred, bounced, complained, opened, clicked, suppressed e failed. O significado de cada um está em Eventos e linha do tempo.