Pular para o conteúdo
couryo

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.

Ver em Markdown

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
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"] }'
Resposta
{
  "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} muda url, events ou enabled (para pausar sem apagar). DELETE /v1/webhooks/{id} apaga.

O que chega#

Um POST com JSON e três cabeçalhos:

Cabeçalhos
webhook-id: evt_7d3k9s0a2m5n8b1c
webhook-timestamp: 1791484264
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Corpo
{
  "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.

Node.js (Express)
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);
});
PHP
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);
Python
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 2xx para 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.