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.
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 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"] }'{
"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
adminscope; listing and reading,read. PATCH /v1/webhooks/{id}changesurl,eventsorenabled(to pause without deleting).DELETE /v1/webhooks/{id}deletes it.
What you receive#
A JSON POST with three headers:
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@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.
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);
});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")The official Standard Webhooks libraries (for Node, PHP, Python, Go, Ruby and more) also work with Couryo's secret.
Retries#
- Reply with any
2xxstatus 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.