How delivery works
EuroMail POSTs a JSON payload to your endpoint URL for every event your webhook is subscribed to. Each attempt:
- Uses
POSTwithContent-Type: application/json. - Times out after 10 seconds.
- Carries these headers, exactly as cased below:
| Header | Value |
|---|---|
X-Euromail-Signature | t=<unix_timestamp>,v1=<hex hmac-sha256> — see Signature verification |
X-Euromail-Event | The event type, e.g. delivered |
X-Euromail-Attempt | The attempt number, starting at 1 |
Any 2xx response counts as delivered. A 5xx response or a timeout is treated
as transient and retried (see Retries). Any
other response — 4xx, or anything else that isn't 2xx/5xx — is treated as
permanent and is not retried, since it means your endpoint understood the
request and rejected it.
EuroMail does not currently publish a stable, dedicated outbound IP range for webhook delivery — unlike SMTP sending, which does use a small set of fixed, warmed IPs (see IP Warmup) — so IP allowlisting isn't a supported way to authenticate requests. Verify the signature instead.
Signature verification
Every payload is signed with HMAC-SHA256 using your webhook's signing secret
(shown once at creation, and viewable again from the webhook's detail page in
the dashboard). The signature travels in X-Euromail-Signature as
t=<unix_timestamp>,v1=<hex_signature> — the same shape Stripe uses.
To verify:
- Split the header on
,to gett(a Unix timestamp) andv1(a hex string). - Reject the request if
abs(now - t)exceeds a tolerance window. 5 minutes (300 seconds) is the default in the PHP SDK's verifier and the value the snippets below use. This bounds how long a captured request stays replayable. - Recompute the signature as
hex(hmac_sha256(secret, "{t}.{raw_request_body}"))— the raw bytes of the body exactly as received, not a re-serialized copy. - Compare your computed signature to
v1using a constant-time comparison (hmac.compare_digest,crypto.timingSafeEqual,hash_equals, and so on). A plain==leaks timing information an attacker can use to forge a signature byte by byte.
The header always carries exactly one t= and one v1=. Rotating the secret
from the dashboard generates a new one and signs every later delivery with it.
A delivery your endpoint rejects with a 4xx is not retried, so update your
verifier right after rotating, or accept both secrets for a short window.
Node.js
const crypto = require("crypto");
function verifyWebhookSignature(payload, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim()))
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false;
}
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hmac
import hashlib
import time
def verify_webhook_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed_payload = f"{timestamp}.{payload.decode()}"
expected = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Go
func verifyWebhookSignature(payload []byte, header, secret string, tolerance time.Duration) bool {
var timestamp, signature string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
timestamp = kv[1]
case "v1":
signature = kv[1]
}
}
if timestamp == "" || signature == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if age := time.Since(time.Unix(ts, 0)); age > tolerance || -age > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(expected), []byte(signature)) == 1
}Rust
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
fn verify_webhook_signature(payload: &[u8], header: &str, secret: &str, tolerance_secs: i64) -> bool {
let mut timestamp = None;
let mut signature = None;
for part in header.split(',') {
let mut kv = part.trim().splitn(2, '=');
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
match k {
"t" => timestamp = Some(v),
"v1" => signature = Some(v),
_ => {}
}
}
}
let (Some(timestamp), Some(signature)) = (timestamp, signature) else {
return false;
};
let Ok(ts) = timestamp.parse::<i64>() else {
return false;
};
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > tolerance_secs {
return false;
}
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("valid HMAC key");
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(payload);
let expected = hex::encode(mac.finalize().into_bytes());
let (Ok(sig_bytes), Ok(expected_bytes)) = (hex::decode(signature), hex::decode(&expected)) else {
return false;
};
expected_bytes.ct_eq(&sig_bytes).into()
}PHP
The PHP SDK ships this verifier — EuroMail\Webhooks\WebhookSignature::verify() —
so most integrators never need to hand-roll it:
use EuroMail\Webhooks\WebhookSignature;
$payload = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_EUROMAIL_SIGNATURE'] ?? '';
$secret = getenv('EUROMAIL_WEBHOOK_SECRET');
if (!WebhookSignature::verify($payload, $signatureHeader, $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
Without the SDK, the equivalent is:
function verify_webhook_signature(string $payload, string $header, string $secret, int $tolerance = 300): bool {
$timestamp = null;
$signature = null;
foreach (explode(',', $header) as $part) {
[$key, $value] = array_map('trim', explode('=', $part, 2));
if ($key === 't') { $timestamp = $value; }
if ($key === 'v1') { $signature = $value; }
}
if ($timestamp === null || $signature === null) {
return false;
}
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
return hash_equals($expected, $signature);
}Check your implementation
Run your verifier against this fixed test vector — a real HMAC-SHA256 computed
with openssl, not a made-up value — and confirm it produces the same v1:
secret: whsec_test_secret_do_not_use
timestamp: 1735689600
body: {"event":"delivered","email_id":"018f2c3a-7b1e-7c3e-8b1a-2f6e9d4c5a01","account_id":"018f2c3a-7b1e-7c3e-8b1a-2f6e9d4c5a02","timestamp":"2025-01-01T00:00:00Z"}
expected v1: d571fbef13b9e524d460f6f2c88f8d8dc7df3c50ff7aabdedd8a3656abb96dd0
Reproduce it yourself with:
echo -n '1735689600.{"event":"delivered","email_id":"018f2c3a-7b1e-7c3e-8b1a-2f6e9d4c5a01","account_id":"018f2c3a-7b1e-7c3e-8b1a-2f6e9d4c5a02","timestamp":"2025-01-01T00:00:00Z"}' \
| openssl dgst -sha256 -hmac whsec_test_secret_do_not_use
If your function returns a different hex string, the signed input is wrong
before you touch timestamps or comparisons — check that you're hashing
"{t}.{raw_body}" with the raw request body bytes, not a JSON-decoded and
re-encoded copy (re-encoding can reorder keys or change whitespace, which
changes the hash).
Event catalogue
Every event shares four common fields — event, account_id, and a
timestamp (RFC 3339) — plus payload-specific fields below. IDs are UUIDs,
not the em_.../acc_... prefixed style used elsewhere in these docs.
Payloads are additive: new fields may appear over time (an authentication
object on inbound events, for example). Parse defensively and ignore unknown
fields.
sent
Fired once the SMTP session accepts the message. Same shape as delivered
and bounced below, with smtp_response and ip_address still null since
no receiving server has responded yet.
delivered
Fired when a receiving server accepts the message with a 2xx SMTP response.
{
"event": "delivered",
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"account_id": "660e8400-e29b-41d4-a716-446655440001",
"timestamp": "2026-09-03T14:32:01Z",
"from": "[email protected]",
"to": "[email protected]",
"subject": "Your order has shipped",
"domain": "yourdomain.com",
"smtp_response": "250 2.0.0 OK",
"ip_address": "198.51.100.34"
}bounced
Fired on a hard bounce (permanent rejection) or a soft bounce that exhausted
its retry attempts. Same fields as delivered; smtp_response carries the
receiving server's rejection text.
deferred
Fired each time a send is temporarily held back — recipient-domain throttling, a provider backoff, or a transient SMTP failure that will be retried. Carries two extra fields beyond the common ones:
{
"event": "deferred",
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"account_id": "660e8400-e29b-41d4-a716-446655440001",
"timestamp": "2026-09-03T14:32:01Z",
"reason": "domain_throttled: gmail.com throttle_pct=40%",
"next_retry_at": "2026-09-03T14:47:01Z"
}
deferred does not carry from/to/subject/domain/smtp_response/
ip_address — look the email up by email_id if you need those.
opened
Fired the first time a recipient's mail client loads the open-tracking pixel (deduplicated to once per 5 minutes; known scanner/proxy user agents, including Gmail's image proxy, are filtered out so a prefetch doesn't count as an open).
{
"event": "opened",
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"account_id": "660e8400-e29b-41d4-a716-446655440001",
"timestamp": "2026-09-03T14:35:12Z"
}clicked
Fired when a recipient follows a tracked link (deduplicated per link, per 30 seconds). Carries one extra field:
{
"event": "clicked",
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"account_id": "660e8400-e29b-41d4-a716-446655440001",
"timestamp": "2026-09-03T14:36:40Z",
"link_url": "https://yourdomain.com/orders/12345"
}complained
Sent when an ISP feedback loop (FBL/ARF) tells us a recipient marked the message as spam. We only send it when we can tie the report to an email you sent, using either the feedback address on that email or its Message-ID. If we can't match a report, we archive it internally and it never triggers a webhook or a suppression.
You get this event at most once per recipient of an email. We ignore repeat reports.
to_address is the email's recipient, the same as on every other event.
complainer is the person who complained, and we add that address to your
suppression list. The complainer can be a CC or BCC recipient. Each
recipient's copy carries its own feedback address, so the report tells us who
sent it. If an email had several recipients and we can't trace a report about
it to one of them, we keep the report for review and don't send this event.
Every recipient who complains counts toward the complaint rate.
original_message_id is the email's Message-ID exactly as the API returns
it.
{
"event": "complained",
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"account_id": "660e8400-e29b-41d4-a716-446655440001",
"to_address": "[email protected]",
"complainer": "[email protected]",
"feedback_type": "abuse",
"original_message_id": "<[email protected]>",
"timestamp": "2026-09-03T15:02:11Z"
}account.auto_paused
Sent when sending on your account is paused. We pause an account automatically when its bounce or complaint rate crosses our abuse thresholds, or when our content check flags a message as possible phishing. Our team can also pause an account after a review. The account owner gets an email explaining the reason, and this event arrives within about 30 seconds of the pause. source tells you what paused the account: reputation, scam_classifier, registration_burst or admin. See Automated Abuse Protection.
{
"event": "account.auto_paused",
"account_id": "660e8400-e29b-41d4-a716-446655440001",
"source": "reputation",
"reason": "bounce_rate=12.4% over last 500 sends (threshold 10%)",
"timestamp": "2026-09-03T16:00:00Z"
}email.inbound
Fired when a message arrives on a domain with inbound routing configured and the matching route has a webhook URL attached (see Inbound Email).
{
"event": "email.inbound",
"inbound_email_id": "550e8400-e29b-41d4-a716-446655440000",
"account_id": "660e8400-e29b-41d4-a716-446655440001",
"domain": "yourdomain.com",
"from": "[email protected]",
"to": "[email protected]",
"subject": "Question about my order",
"text_body": "...",
"html_body": "...",
"attachments": [],
"message_id": "<[email protected]>",
"authentication": { "spf": "pass" },
"source_ip": "203.0.113.10",
"timestamp": "2026-09-03T16:05:00Z"
}mailbox.message.received
Fired when a message arrives in an agent mailbox.
{
"event": "mailbox.message.received",
"mailbox_id": "550e8400-e29b-41d4-a716-446655440000",
"message": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"from": "[email protected]",
"reply_to": null,
"subject": "Question about my order",
"text_body": "...",
"text_body_stripped": "...",
"html_body": "...",
"attachments": [],
"message_id": "<[email protected]>",
"in_reply_to": null,
"references": null,
"thread_id": "770e8400-e29b-41d4-a716-446655440002",
"classification": { "label": "support_request", "confidence": 0.94 },
"received_at": "2026-09-03T16:05:00Z"
},
"authentication": { "spf": "pass" },
"source_ip": "203.0.113.10",
"timestamp": "2026-09-03T16:05:00Z"
}
text_body_stripped is only present when quoted reply text was detected and
removed; classification is only present when the mailbox has automatic
classification enabled.
Idempotency
Webhook deliveries are not deduplicated on your end automatically — a retry
after a timeout, or (rarely) a duplicate delivery, can arrive twice. Use the
event's identifying field — email_id for send-lifecycle events,
inbound_email_id for email.inbound, or message.id for
mailbox.message.received — together with event as your dedup key.
Automatic retry with backoff
If your endpoint returns a 5xx response or the request times out, EuroMail retries with exponential backoff: 15 minutes, 30 minutes, 1 hour, then 2 hours between attempts (base 15 minutes, doubling each time, plus a few seconds of random jitter to avoid a thundering herd). A 4xx response, or any other non-5xx failure, is treated as permanent and is not retried at all.
After 5 total attempts for a given event, delivery is given up on and the event is logged as undeliverable in the dashboard's delivery history for that webhook — there is currently no automatic replay of an individual failed event from the dashboard.
Separately, a webhook that racks up 10 consecutive delivery failures (across all events, not just one) is automatically deactivated — no further attempts are made until you edit the webhook (even without changing anything) to reactivate it, which also resets the failure count. A webhook you paused yourself, rather than one the system paused, stays paused until you turn it back on the same way.
Configuration
Webhook URLs can be configured per account. Use the API or dashboard to set up endpoints:
curl -X POST https://api.euromail.dev/v1/webhooks \
-H "X-Euromail-Api-Key: em_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/euromail",
"events": ["sent", "delivered", "bounced", "opened", "clicked", "complained",
"deferred", "account.auto_paused", "email.inbound",
"mailbox.message.received"]
}'
The URL must use HTTPS. The response includes the signing secret — store it, it's shown in full only at creation (and can be revealed again, or rotated, from the webhook's detail page in the dashboard).
Dashboard Testing and Event Log
The dashboard's "Send test" button and the POST /v1/webhooks/{id}/test API
endpoint both go through the same delivery pipeline as real events: the test
event is signed the same way, POSTed to your endpoint, and recorded in the
delivery history, so verifying against a test send is a true test of your
signature check. A few things are deliberately different for a test send:
- It carries
"event": "test"and a fixed payload ({"data": {"message": "This is a test webhook event from EuroMail."}}) rather than real event data. - It does not count toward the 10-failure auto-deactivation threshold — a test against an endpoint you're still building is expected to fail sometimes, and shouldn't disable the webhook.
- It is not retried on failure — you get one request per press.
- It's rate-limited to 5 test sends per minute per webhook, shared across the dashboard button and the API endpoint.
The event log on the webhook's detail page shows every delivery attempt — success or failure, status code, response time, and (expandable) the request and response bodies — for both real and test events.