Skip to content
couryo

Developer guideEvents

Webhooks

Get your email events by webhook, signed with Standard Webhooks and retried for up to 3 days. Verification examples in Node, PHP and Python.

View as Markdown

Webhooks tell your system when something happens to an email: delivered, deferred, bounced, complained, opened, clicked. Every call is signed with the open Standard Webhooks specification.

Create a webhook#

cURL
curl https://api.couryo.com/v1/webhooks \
  -H "Authorization: Bearer $COURYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/couryo", "events": ["delivered", "bounced", "complained"] }'
Response
{
  "id": "whk_5n1c8v2z7r3k6m9p",
  "url": "https://example.com/webhooks/couryo",
  "events": ["delivered", "bounced", "complained"],
  "enabled": true,
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
  "created_at": "2026-10-07T18:30:00Z"
}

The secret is shown only in this response. Store it with your other credentials.

  • The URL must start with https:// and be public: internal network addresses are refused (invalid_field).
  • Creating, changing and deleting webhooks needs the admin scope; listing and reading, read.
  • PATCH /v1/webhooks/{id} changes url, events or enabled (to pause without deleting). DELETE /v1/webhooks/{id} deletes it.

What you receive#

A JSON POST with three headers:

Headers
webhook-id: evt_7d3k9s0a2m5n8b1c
webhook-timestamp: 1791484264
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Body
{
  "type": "bounced",
  "timestamp": "2026-10-07T18:31:04Z",
  "data": {
    "email_id": "msg_9w2k7c1x0d4e5f6g",
    "event_id": "evt_7d3k9s0a2m5n8b1c",
    "recipient": "maria@company.com",
    "provider": "other",
    "mx": "mx.company.com",
    "smtp_code": "550 5.1.1",
    "smtp_response": "550 5.1.1 <maria@company.com>: Recipient address rejected: User unknown",
    "explanation": "The mailbox does not exist. The address was added to the suppression list.",
    "tags": { "type": "order" },
    "metadata": { "order_id": "1042" }
  }
}

webhook-id is the event ID and stays the same on retries: use it to ignore duplicates.

Verify the signature#

Always verify before trusting the payload. The signature is an HMAC-SHA256 of webhook-id.webhook-timestamp.body, keyed with the Base64-decoded secret (without the whsec_ prefix). Also reject messages older than 5 minutes, to prevent replays.

Use the raw body, exactly as received. If your framework already parsed it into JSON, the signature will not match.

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); // answer fast; process later (queue, 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")

The official Standard Webhooks libraries (for Node, PHP, Python, Go, Ruby and more) also work with Couryo's secret.

Retries#

  • Reply with any 2xx status to acknowledge. Reply fast and process later, in a queue.
  • Without a 2xx (or with no answer within 15 seconds), Couryo retries after 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 12 h, 16 h and 24 h: 12 attempts over about 3 days.
  • The dashboard shows each webhook's last 100 attempts, with your server's status and response, and has a button to resend right away.
  • Missed events? List everything at GET /v1/events.

Available events#

accepted, queued, sent, delivered, deferred, bounced, complained, opened, clicked, suppressed and failed. What each one means is in Events and timeline.