Webhooks
Receive signed Hyo Mail events at your HTTPS endpoint — delivery, engagement, contact, conversion and store events — verify the signature, and handle retries.
On this page
Webhooks let HyoNova tell your systems when something happens, instead of your systems asking repeatedly. They are part of Hyo Mail: they report events for messages sent through the Mail API, for campaign recipients, for contacts, and for orders from a connected store.
Who can set them up#
Creating, editing and deleting webhooks needs Mail administration — by default Owners and Admins.
Create a webhook#
- Open the webhooks. Go to Mail › Automations and open the Webhooks tab.
- Add an endpoint. Choose New webhook. Enter the URL of your endpoint and an optional description.
- Choose the events. Tick the Events you want. An endpoint receives only the events it subscribed to.
- Copy the secret. After saving, Webhook secret is shown once. Store it in your secret manager; you need it to verify signatures.
The endpoint must use https:// and resolve to a public internet address. Private, loopback and internal hosts are refused with WEBHOOK_HOST_NOT_ALLOWED when you save, and checked again before every delivery.
Edit webhook changes the URL, the events or Enabled. Deliveries lists recent deliveries with the event, Status, Attempts and the HTTP status your endpoint returned.
Rotate the secret#
Rotate secret issues a new secret and shows it once. For the next 24 hours, every delivery is signed with both the new and the previous secret — the header then carries two v1 values — so you can switch your endpoint over without losing events. After 24 hours, only the new secret signs.
Events#
| Event | Sent when | data fields |
|---|---|---|
sent | The email service accepted a message | Transactional: messageId, email, occurredAt. Campaign: campaignId, recipientId, contactId, email, occurredAt |
delivered | The recipient's mail server accepted it | As for sent |
bounced | It bounced permanently; the address is suppressed | As for sent |
complaint | The recipient reported it as spam; the address is suppressed | As for sent |
failed | It was not sent, or the email service refused it | As for sent, plus error for transactional messages |
opened | A campaign recipient opened the email for the first time | Campaign fields |
clicked | A campaign recipient clicked a tracked link for the first time | Campaign fields, plus linkId |
unsubscribed | A campaign recipient unsubscribed | Campaign fields |
message.accepted | The email service accepted a transactional message | messageId, email, providerMessageId, occurredAt |
campaign.sent | A campaign finished sending | campaignId, accepted, failed, suppressed, skipped, unknown, occurredAt |
campaign.click | Every click on a tracked campaign link, not only the first | Campaign fields, plus linkId and suspect (true when the click looks automated) |
contact.updated | A contact was created or changed through the API or a store connection | contactId, email, status, source, change (created, updated or consent), occurredAt |
conversion.recorded | A conversion was recorded or adjusted | conversion; a refund also has adjustment and netRevenue |
order.event | A connected store sent any order event | integrationId, provider, order |
order.created | A connected store reported a new order | As for order.event |
order.paid | A connected store reported a paid order | As for order.event |
The request#
HyoNova sends each event as an HTTP POST with a JSON body:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: HyoNova-Mail-Webhooks/1.0
HyoNova-Signature: t=1792270000,v1=5d41402abc4b2a76b9719d911017c592...
HyoNova-Delivery: 0f8e7d6c-5b4a-4321-9876-0123456789ab
HyoNova-Attempt: 1{
"id": "0f8e7d6c-5b4a-4321-9876-0123456789ab",
"event": "delivered",
"occurredAt": "2026-10-18T09:30:05.000Z",
"data": {
"messageId": "6f1c2d4e-9a7b-4c1d-8e2f-3a4b5c6d7e8f",
"email": "name@example.com",
"occurredAt": "2026-10-18T09:30:05.000Z"
}
}idis the delivery ID. It is also in theHyoNova-Deliveryheader and stays the same when a delivery is retried, so use it to ignore duplicates.HyoNova-Attemptis the attempt number, starting at 1.
Verify the signature#
HyoNova-Signature has the form t=<unix seconds>,v1=<signature>. The signature is the hex HMAC-SHA256 of the string <t>.<raw request body>, keyed with your webhook secret. During a secret rotation the header has two v1 values; accept the request if either one matches.
To verify a request:
- Read the raw body exactly as received, before any JSON parsing.
- Compute HMAC-SHA256 of
t + "." + bodywith your secret, as hex. - Compare it with each
v1value in constant time. Reject the request if none matches. - Reject requests whose
tis more than five minutes away from your clock, to block replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
export function verifyHyoMailSignature(rawBody: string, header: string, secret: string, toleranceSeconds = 300): boolean {
const parts = header.split(',').map((p) => p.trim());
const t = parts.find((p) => p.startsWith('t='))?.slice(2) ?? '';
const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!/^\d+$/.test(t) || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return signatures.some((s) => {
const given = Buffer.from(s, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
const header = String(req.headers['hyonova-signature'] ?? '');
if (!verifyHyoMailSignature(raw, header, process.env.HYO_MAIL_WEBHOOK_SECRET!)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(raw); // { id, event, occurredAt, data }
// Ignore event.id if you have already handled it, then queue the work.
res.writeHead(200).end();
});
}).listen(3000);<?php
function hyo_mail_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = '';
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't') {
$timestamp = $pair[1];
} elseif ($pair[0] === 'v1') {
$signatures[] = $pair[1];
}
}
if (!ctype_digit($timestamp) || !$signatures || abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_HYONOVA_SIGNATURE'] ?? '';
if (!hyo_mail_verify($raw, $header, getenv('HYO_MAIL_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true); // ['id' => ..., 'event' => ..., 'data' => ...]
// Ignore $event['id'] if you have already handled it, then queue the work.
http_response_code(200);# BODY is the raw request body, T is the t= value from HyoNova-Signature
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$HYO_MAIL_WEBHOOK_SECRET" -hex
# Compare the result with the v1= value(s)Respond and retries#
- Answer with any
2xxstatus within 10 seconds. Do slow work after you respond. - Redirects are not followed; point the webhook at the final URL.
- Any other answer, a timeout or a network error counts as a failed attempt. HyoNova makes up to 5 attempts in total, waiting about 1 minute, 5 minutes, 15 minutes and 1 hour between them.
- After the fifth failed attempt, the delivery moves to the dead-letter state (
dead_letter) and is not retried automatically. HyoNova keeps it for a manual retry, but the Deliveries list does not offer a retry button yet. Fix your endpoint; new events are delivered as usual. - Deliveries can arrive more than once and not always in order. Make your handling idempotent with the delivery
id, and useoccurredAtto order events. - HyoNova records only the HTTP status your endpoint returned, never the response body.
Status#
| Feature | Status | Notes |
|---|---|---|
| Signed webhooks | AvailableWorks today for workspaces whose plan includes it. | Receive signed Hyo Mail delivery, engagement, contact and store events at your HTTPS endpoint, with automatic and manual retries. |
Was this page helpful?
Thanks. Page ratings are not collected yet, so nothing was sent. If something is missing or wrong, see the Get help page.