KaiMail Webhook Integration Guide


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.

What Is KaiMail?

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:

  • Customer support ticket creation
  • Order confirmation handling
  • Automated document processing
  • Inbound email parsing for SaaS applications

Prerequisites

Before you begin, make sure you have:

  1. A KaiMail account on the PLUS plan or above. Webhook delivery is not available on the free BASIC plan. Sign up at kaimail.net.
  2. A custom domain you control. You need DNS access to update MX records.
  3. An HTTPS endpoint to receive webhooks. In production, KaiMail requires HTTPS with a valid TLS certificate.

Quick Start

Step 1 — Sign Up and Choose a Plan

Create an account at KaiMail and subscribe to the PLUS plan or above. Webhook delivery requires a paid plan.

Step 2 — Add Your Custom Domain

In the KaiMail dashboard, go to the routing page and add your custom domain (e.g., yourdomain.com).

Step 3 — Configure MX Records

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.

Step 4 — Verify MX Records

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.

Step 5 — Create a Webhook Route

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.

How It Works

Sender                    KaiMail                         Your Application
  |                         |                                    |
  |-- SMTP email ---------->|                                    |
  |                         |-- SPF/DKIM/ARC validation          |
  |                         |-- Serialize to JSON                |
  |                         |-- HMAC-SHA256 signature            |
  |                         |-- HTTP POST (JSON) -------------->|
  |                         |                                    |-- Return 200 OK
  |                         |<-- 200 OK -------------------------|
  1. A sender delivers an email to your address via SMTP.
  2. KaiMail receives the email and runs authentication checks (SPF, DKIM, ARC).
  3. The email is serialized to a JSON payload. Attachments are uploaded to cloud storage.
  4. KaiMail signs the payload with HMAC-SHA256 and sends an HTTP POST to your endpoint.
  5. Your application processes the payload and returns a 2xx response.

Webhook Request Format

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

Payload Schema Reference

{
  "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 Descriptions

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.

Real-World Examples

Example 1 — Simple Text Email (No Attachments)

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.

Example 2 — Email with Image Attachment

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_results fields let you decide how to handle such cases.

Testing with curl

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}}
  }'

Verifying Webhook Signatures

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.

How It Works

  1. KaiMail computes HMAC-SHA256(your_webhook_secret, raw_request_body).
  2. The result is sent as sha256=<hex_digest> in the X-KAI-Webhook-Signature header.
  3. Your application computes the same HMAC using the shared secret and compares it to the header value.

Finding Your Webhook Secret

Each webhook route has its own unique signing secret (a 64-character hex string), automatically generated when the Mailbox is created.

  • In the dashboard: Go to the Mailbox edit page for your webhook route. The secret is password-masked by default — click Show to reveal it, or Copy to copy it to your clipboard.
  • Regenerating: Click Regenerate Secret at the bottom of the mailbox edit page. This invalidates the old secret immediately, so update your application before regenerating.

Python Verification Example

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.

PHP Verification Example

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");
}

Handling Attachments

Attachments are not embedded in the JSON payload. Instead, each attachment includes a presigned URL pointing to cloud storage (Amazon S3).

Key Details

  • URLs expire after 24 hours — download attachments promptly after receiving the webhook.
  • Each attachment includes filename, content_type, size (in bytes), and url.
  • The url includes authentication parameters — no additional headers are needed to download.

Python Download Example

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)")

Sample Webhook Receiver (Flask)

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 2xx response within 30 seconds. If your processing takes longer, return 200 immediately and handle the email asynchronously (e.g., via a task queue).

Retry Behavior

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.

What Triggers a Retry

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

Best Practices

  • Return 200 quickly. Acknowledge the webhook, then process the email asynchronously.
  • Implement idempotency. Use the X-KAI-Tracking-ID header to detect duplicate deliveries. The same tracking ID is used across retries.
  • Log the tracking ID. This helps when debugging delivery issues in the KaiMail dashboard.

Authentication Results

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.

How to Use These

  • All pass: High confidence the email is legitimate.
  • SPF passes, DKIM fails: May happen with mailing lists or forwarding services that modify the message body. Usually still legitimate.
  • All fail: Treat with caution — the email may be spoofed. Consider flagging for manual review.
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)

Plan Limits Reference

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.

Troubleshooting

MX records not verified

  • DNS changes can take up to 48 hours to propagate. Wait, then click Check MX again.
  • Make sure you removed all other MX records for the domain.
  • Verify the MX value is exactly mail.kaimail.net (not kaimail.net).

Webhook not being delivered

  • Check that your endpoint URL uses HTTPS (not HTTP) in production.
  • Confirm your server returns a 2xx status code within 30 seconds.
  • Check the delivery logs in the KaiMail dashboard for error details.

Signature verification fails

  • Ensure you are verifying against the raw request body (bytes), not a parsed/re-serialized JSON string.
  • Confirm you are using the correct webhook secret from the mailbox edit page in the KaiMail dashboard. Each webhook route has its own secret.
  • Make sure your framework is not modifying the request body before you read it.

Attachments not downloading

  • Presigned URLs expire after 24 hours. Download attachments immediately when you receive the webhook.
  • Check that your server can reach kaimail-attachments.s3.amazonaws.com (no firewall blocks).

Emails received but webhook shows errors

  • A 4xx error means your endpoint rejected the request — check your application logs.
  • A 5xx error means your server encountered an internal error — KaiMail will retry up to 3 times.
  • A timeout means your endpoint took longer than 30 seconds to respond — return 200 immediately and process asynchronously.