A step-by-step guide for developers to integrate their applications with KaiMail's webhook delivery. Receive emails sent to your custom domain as structured JSON payloads via HTTP POST — no mail server required.
KaiMail is a SaaS email forwarding service. You register your custom domain with KaiMail, point your MX records to KaiMail's mail server, and every email sent to your domain gets forwarded to a destination you choose — another email address, or an HTTP webhook endpoint.
With webhook delivery, incoming emails are converted to JSON and delivered as HTTP POST requests to your application. This lets you programmatically process emails for use cases like:
Before you begin, make sure you have:
Create an account at KaiMail and subscribe to the PLUS plan or above. Webhook delivery requires a paid plan.
In the KaiMail dashboard, go to the routing page and add your custom domain (e.g., yourdomain.com).
Log in to your DNS provider and update the MX records for your domain:
| Type | Host | Value | Priority |
|---|---|---|---|
| MX | yourdomain.com | mail.kaimail.net | 10 |
Remove any existing MX records to avoid delivery conflicts.
Back in the KaiMail dashboard, click the Check MX button next to your domain. KaiMail will query DNS to confirm the MX records point to mail.kaimail.net.
Add a mailbox (e.g., [email protected]) and select Webhook as the route type. Enter your HTTPS endpoint URL (e.g., https://app.example.com/webhooks/email).
Any email sent to [email protected] will now be delivered as a JSON POST to your endpoint.
Note: A unique webhook signing secret is automatically generated for each webhook route. To view it, click Edit on the mailbox — the secret is shown on the edit page. See "Finding Your Webhook Secret" below for more details.
Sender KaiMail Your Application
| | |
|-- SMTP email ---------->| |
| |-- SPF/DKIM/ARC validation |
| |-- Serialize to JSON |
| |-- HMAC-SHA256 signature |
| |-- HTTP POST (JSON) -------------->|
| | |-- Return 200 OK
| |<-- 200 OK -------------------------|
2xx response.KaiMail sends a POST request with Content-Type: application/json and the following custom headers:
| Header | Description |
|---|---|
X-KAI-Webhook-Signature |
HMAC-SHA256 signature of the request body |
X-KAI-Tracking-ID |
Unique identifier for this delivery attempt |
X-KAI-Event |
Event type — always email.received |
User-Agent |
Always KaiMail-Webhook/1.0 |
{
"version": "1.0",
"timestamp": "ISO 8601 UTC timestamp",
"headers": {
"Subject": "string",
"From": "string",
"To": "string",
"...": "All original email headers (multi-value headers are arrays)"
},
"body_text": "string or null",
"body_html": "string or null",
"attachments": [
{
"filename": "string",
"content_type": "string",
"size": 0,
"url": "string (presigned URL)"
}
],
"envelope": {
"sender": "SMTP envelope sender",
"recipient": "SMTP envelope recipient"
},
"account": {
"email": "Your KaiMail account email",
"domain": "Your custom domain",
"mailbox": "The mailbox address that received the email"
},
"metadata": {
"authentication_results": {
"PASS_REV_IP": true,
"PASS_SPF": true,
"PASS_DKIM": true,
"PASS_ARC": true
}
}
}
| Field | Type | Description |
|---|---|---|
version |
string | Payload format version. Currently always "1.0". |
timestamp |
string | ISO 8601 UTC timestamp of when KaiMail processed the email. |
headers |
object | All original email headers. Headers that appear multiple times (e.g., Received) are represented as arrays. |
body_text |
string or null | Plain text body of the email, or null if none. |
body_html |
string or null | HTML body of the email, or null if none. |
attachments |
array | List of attachments. Empty array if none. Each has filename, content_type, size (bytes), and url (presigned download URL). |
envelope.sender |
string | SMTP MAIL FROM address (the actual sender). |
envelope.recipient |
string | SMTP RCPT TO address (the address the email was sent to). |
account.email |
string | Your KaiMail account email address. |
account.domain |
string | The custom domain that received the email. |
account.mailbox |
string | The specific mailbox address that matched. |
metadata.authentication_results |
object | Email authentication check results. See Authentication Results. |
HTTP Request Headers:
POST /webhooks/email HTTP/1.1
Content-Type: application/json
X-KAI-Event: email.received
X-KAI-Tracking-ID: aB3kLm9xQz-7yN2pR4wHtg
X-KAI-Webhook-Signature: sha256=e4a21c38b7d0f65a91c3d4e8f2b6a7c5d0e9f8a1b3c4d5e6f7a8b9c0d1e2f3a4
User-Agent: KaiMail-Webhook/1.0
JSON Payload:
{
"version": "1.0",
"timestamp": "2026-02-26T15:03:17.984266+00:00",
"headers": {
"Authentication-Results": "kaimail.net; iprev=pass policy.iprev=209.85.208.174 (mail-lj1-f174.google.com); spf=pass reason=\"sender SPF authorized\" smtp.helo=mail-lj1-f174.google.com [email protected]; dkim=pass (good signature) header.d=senderdomain.com; arc=pass",
"Received-SPF": "pass (kaimail.net: domain of [email protected] designates 209.85.208.174 as permitted sender)",
"Received": [
"(qmail 100942 invoked from network); 26 Feb 2026 15:03:16 -0000",
"from mail-lj1-f174.google.com (209.85.208.174) by mail.kaimail.net with ESMTPS; 26 Feb 2026 15:03:16 -0000"
],
"X-KAI-Sender-IP": "209.85.208.174",
"X-KAI-Sender-Host": "mail-lj1-f174.google.com",
"X-KAI-Received-Datetime": "2026-02-26 15:03:16",
"X-KAI-DKIM-Status": "pass;d=senderdomain.com",
"DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=senderdomain.com; s=20230601; ...",
"MIME-Version": "1.0",
"From": "John Smith <[email protected]>",
"Date": "Thu, 26 Feb 2026 21:03:01 +0600",
"Message-ID": "<CALo1LC2VYJ9K5YNNBE8Cfu=2rxv6y3ATYSDLCy6_0t=WzSYM9A@mail.senderdomain.com>",
"Subject": "Hello from a customer",
"To": "[email protected]",
"Content-Type": "multipart/alternative; boundary=\"0000000000006df8bb064bbb6b83\""
},
"body_text": "Hello\n",
"body_html": "<div dir=\"auto\">Hello</div>\n",
"attachments": [],
"envelope": {
"sender": "[email protected]",
"recipient": "[email protected]"
},
"account": {
"email": "[email protected]",
"domain": "yourdomain.com",
"mailbox": "[email protected]"
},
"metadata": {
"authentication_results": {
"PASS_REV_IP": true,
"PASS_SPF": true,
"PASS_DKIM": true,
"PASS_ARC": true
}
}
}
Note: Some headers (e.g.,
ARC-Seal,ARC-Message-Signature, Google-internal headers) are preserved in the actual payload but omitted here for brevity.
HTTP Request Headers:
POST /webhooks/email HTTP/1.1
Content-Type: application/json
X-KAI-Event: email.received
X-KAI-Tracking-ID: xK4mNp8qTv-3wR5sY7zJbA
X-KAI-Webhook-Signature: sha256=f8c72d41a9e0b35c82d4f6a1e3b7c9d5f0a2e8b4c6d1f3a5b7c9d0e2f4a6b8c0
User-Agent: KaiMail-Webhook/1.0
JSON Payload:
{
"version": "1.0",
"timestamp": "2026-03-02T14:05:59.147593+00:00",
"headers": {
"Authentication-Results": "kaimail.net; iprev=pass policy.iprev=209.85.208.173 (mail-lj1-f173.google.com); spf=pass reason=\"sender SPF authorized\" smtp.helo=mail-lj1-f173.google.com [email protected]; dkim=fail (bad signature) header.d=senderdomain.com; arc=fail",
"Received-SPF": "pass (kaimail.net: domain of [email protected] designates 209.85.208.173 as permitted sender)",
"Received": [
"(qmail 146414 invoked from network); 2 Mar 2026 14:05:54 -0000",
"from mail-lj1-f173.google.com (209.85.208.173) by mail.kaimail.net with ESMTPS; 2 Mar 2026 14:05:54 -0000"
],
"X-KAI-Sender-IP": "209.85.208.173",
"X-KAI-Sender-Host": "mail-lj1-f173.google.com",
"X-KAI-Received-Datetime": "2026-03-02 14:05:54",
"X-KAI-DKIM-Status": "fail;d=senderdomain.com",
"DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=senderdomain.com; s=20230601; ...",
"MIME-Version": "1.0",
"From": "John Smith <[email protected]>",
"Date": "Mon, 2 Mar 2026 20:05:38 +0600",
"Message-ID": "<CALo1LC3UtaUEiuDJzU+Ce35Rx2Ptea3dkKLJfTOG7-O758FEqQ@mail.senderdomain.com>",
"Subject": "Order receipt with photo",
"To": "[email protected]",
"Content-Type": "multipart/related; boundary=\"000000000000a0584a064c0b1542\""
},
"body_text": "Please find the photo attached.\n",
"body_html": "<div dir=\"auto\">Please find the photo attached.<br><img src=\"cid:ii_19caede5e3f45621bd61\" style=\"max-width: 100%; height: auto;\"><br><br></div>\n",
"attachments": [
{
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size": 362004,
"url": "https://kaimail-attachments.s3.amazonaws.com/xK4mNp8qTv-3wR5sY7zJbA/photo.jpg?response-content-disposition=attachment%3B%20filename%3D%22photo.jpg%22&AWSAccessKeyId=AKIAEXAMPLE123456&Signature=EXAMPLE_SIGNATURE&Expires=1772546759"
}
],
"envelope": {
"sender": "[email protected]",
"recipient": "[email protected]"
},
"account": {
"email": "[email protected]",
"domain": "yourdomain.com",
"mailbox": "[email protected]"
},
"metadata": {
"authentication_results": {
"PASS_REV_IP": true,
"PASS_SPF": true,
"PASS_DKIM": false,
"PASS_ARC": false
}
}
}
Note: In this example, DKIM and ARC checks failed — this can happen when email content is modified in transit (e.g., by mailing list software). The
metadata.authentication_resultsfields let you decide how to handle such cases.
You can simulate a webhook delivery to your endpoint:
curl -X POST https://your-app.example.com/webhooks/email \
-H "Content-Type: application/json" \
-H "X-KAI-Event: email.received" \
-H "X-KAI-Tracking-ID: test-tracking-id-001" \
-H "X-KAI-Webhook-Signature: sha256=your_computed_signature" \
-H "User-Agent: KaiMail-Webhook/1.0" \
-d '{
"version": "1.0",
"timestamp": "2026-03-01T12:00:00.000000+00:00",
"headers": {"Subject": "Test", "From": "[email protected]", "To": "[email protected]"},
"body_text": "Test email body",
"body_html": null,
"attachments": [],
"envelope": {"sender": "[email protected]", "recipient": "[email protected]"},
"account": {"email": "[email protected]", "domain": "yourdomain.com", "mailbox": "[email protected]"},
"metadata": {"authentication_results": {"PASS_REV_IP": true, "PASS_SPF": true, "PASS_DKIM": true, "PASS_ARC": true}}
}'
Every webhook request includes an X-KAI-Webhook-Signature header containing an HMAC-SHA256 signature. Always verify this signature before processing the payload to ensure the request came from KaiMail and was not tampered with.
HMAC-SHA256(your_webhook_secret, raw_request_body).sha256=<hex_digest> in the X-KAI-Webhook-Signature header.Each webhook route has its own unique signing secret (a 64-character hex string), automatically generated when the Mailbox is created.
An example of how to verify the authenticity of the webhook using Python.
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature_header: str, secret: str) -> bool:
"""
Verify the HMAC-SHA256 signature of a KaiMail webhook request.
Args:
payload: Raw request body (bytes).
signature_header: Value of the X-KAI-Webhook-Signature header.
secret: Your webhook secret (from the mailbox edit page in KaiMail).
Returns:
True if the signature is valid.
"""
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
expected_signature = f"sha256={expected}"
return hmac.compare_digest(expected_signature, signature_header)
Security note: Use
hmac.compare_digest()for constant-time comparison to prevent timing attacks. Never use==to compare signatures.
If you're using PHP, below is example code on how you can verify that the webhook really came from KaiMail.
<?php
/**
* Verify the HMAC-SHA256 signature of a KaiMail webhook request.
* * @param string $payload Raw request body (from php://input).
* @param string $signatureHeader Value of the X-KAI-Webhook-Signature header.
* @param string $secret Your webhook secret.
* @return bool True if the signature is valid.
*/
function verify_webhook_signature($payload, $signatureHeader, $secret) {
// 1. Generate the HMAC-SHA256 hash in hex format
$expectedHash = hash_hmac('sha256', $payload, $secret);
// 2. Prefix with 'sha256=' to match the KaiMail header format
$expectedSignature = "sha256=" . $expectedHash;
// 3. Use hash_equals for a timing-attack safe comparison
// This is the PHP equivalent of hmac.compare_digest
return hash_equals($expectedSignature, $signatureHeader);
}
// --- Usage Example ---
// Get the raw body
$payload = file_get_contents('php://input');
// Get the signature from headers (case varies by server/framework)
$signatureHeader = $_SERVER['HTTP_X_KAI_WEBHOOK_SIGNATURE'] ?? '';
$secret = 'your-webhook-secret';
if (verify_webhook_signature($payload, $signatureHeader, $secret)) {
// Signature is valid, process the data
$data = json_decode($payload, true);
$logFile = 'webhook_log_' . date('Y-m-d') . '.json';
file_put_contents($logFile, $payload . PHP_EOL, FILE_APPEND | LOCK_EX);
// Optional: Log the headers too (very helpful for debugging signatures)
$headers = getallheaders();
file_put_contents('headers_log.txt', print_r($headers, true), FILE_APPEND);
http_response_code(200);
} else {
// Invalid signature, reject the request
http_response_code(401);
exit("Invalid signature");
}
Attachments are not embedded in the JSON payload. Instead, each attachment includes a presigned URL pointing to cloud storage (Amazon S3).
filename, content_type, size (in bytes), and url.url includes authentication parameters — no additional headers are needed to download.import requests
import os
def download_attachments(payload: dict, save_dir: str = "./attachments"):
"""Download all attachments from a webhook payload."""
os.makedirs(save_dir, exist_ok=True)
for attachment in payload.get("attachments", []):
filename = attachment["filename"]
url = attachment["url"]
filepath = os.path.join(save_dir, filename)
response = requests.get(url, timeout=30)
response.raise_for_status()
with open(filepath, "wb") as f:
f.write(response.content)
print(f"Downloaded {filename} ({attachment['size']} bytes)")
A complete Flask application that receives, verifies, and processes KaiMail webhooks:
import hmac
import hashlib
import os
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["KAIMAIL_WEBHOOK_SECRET"]
def verify_signature(payload: bytes, signature_header: str) -> bool:
expected = hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature_header)
@app.route("/webhooks/email", methods=["POST"])
def receive_email():
# 1. Verify the webhook signature
signature = request.headers.get("X-KAI-Webhook-Signature", "")
if not verify_signature(request.data, signature):
return jsonify({"error": "Invalid signature"}), 401
# 2. Parse the JSON payload
payload = request.get_json()
sender = payload["envelope"]["sender"]
recipient = payload["envelope"]["recipient"]
subject = payload["headers"].get("Subject", "(no subject)")
body = payload.get("body_text") or ""
print(f"Email from {sender} to {recipient}: {subject}")
# 3. Download attachments (if any)
for attachment in payload.get("attachments", []):
resp = requests.get(attachment["url"], timeout=30)
if resp.ok:
filepath = os.path.join("attachments", attachment["filename"])
os.makedirs("attachments", exist_ok=True)
with open(filepath, "wb") as f:
f.write(resp.content)
print(f" Saved attachment: {attachment['filename']}")
# 4. Return 200 to acknowledge receipt
return jsonify({"status": "ok"}), 200
if __name__ == "__main__":
app.run(port=8888)
Important: Return a
2xxresponse within 30 seconds. If your processing takes longer, return200immediately and handle the email asynchronously (e.g., via a task queue).
If your endpoint fails to respond with a 2xx status, KaiMail retries with exponential backoff:
| Attempt | Delay After Failure |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 10 seconds |
| 3rd retry | 20 seconds |
After 3 failed retries (4 total attempts), the delivery is marked as failed.
| Condition | Retried? |
|---|---|
| 5xx server error | Yes |
| 408 Request Timeout | Yes |
| 429 Too Many Requests | Yes |
| Connection timeout | Yes |
| Connection refused / DNS failure | Yes |
| 4xx client error (except 408, 429) | No |
| SSL/TLS error | No |
X-KAI-Tracking-ID header to detect duplicate deliveries. The same tracking ID is used across retries.The metadata.authentication_results object tells you whether the incoming email passed standard email authentication checks:
| Field | Description |
|---|---|
PASS_REV_IP |
Reverse IP lookup passed — the sending server's IP matches its hostname. |
PASS_SPF |
SPF (Sender Policy Framework) check passed — the sender's domain authorizes the sending server. |
PASS_DKIM |
DKIM (DomainKeys Identified Mail) check passed — the email's cryptographic signature is valid. |
PASS_ARC |
ARC (Authenticated Received Chain) check passed — the forwarding chain's authentication is intact. |
def is_email_authenticated(metadata: dict) -> bool:
"""Check if the email passed basic authentication."""
auth = metadata.get("authentication_results", {})
return auth.get("PASS_SPF", False) and auth.get("PASS_DKIM", False)
Webhook delivery is available on paid plans. Check out the plans on our Pricing Page.
The free BASIC plan supports email forwarding only — not webhooks.
mail.kaimail.net (not kaimail.net).2xx status code within 30 seconds.kaimail-attachments.s3.amazonaws.com (no firewall blocks).4xx error means your endpoint rejected the request — check your application logs.5xx error means your server encountered an internal error — KaiMail will retry up to 3 times.200 immediately and process asynchronously.