← Back to docs

Real-time Webhooks

Get instant delivery notifications via signed webhooks

How delivery works

EuroMail POSTs a JSON payload to your endpoint URL for every event your webhook is subscribed to. Each attempt:

  • Uses POST with Content-Type: application/json.
  • Times out after 10 seconds.
  • Carries these headers, exactly as cased below:
HeaderValue
X-Euromail-Signaturet=<unix_timestamp>,v1=<hex hmac-sha256> — see Signature verification
X-Euromail-EventThe event type, e.g. delivered
X-Euromail-AttemptThe 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:

  1. Split the header on , to get t (a Unix timestamp) and v1 (a hex string).
  2. 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.
  3. 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.
  4. Compare your computed signature to v1 using 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.

Send your first email in about 90 seconds

The free tier includes 3,000 emails a month. All data stays in Finland | GDPR compliance without the paperwork.

Create free account See live delivery data