> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mailbreeze.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Integration

> Process incoming emails in your application via webhooks

When you configure a webhook destination for inbound email, MailBreeze sends a POST request to your endpoint with the full email data. This allows you to parse emails, extract information, trigger workflows, and integrate with your application.

## Webhook Payload

MailBreeze sends a JSON payload with the complete email data:

```json theme={null}
{
  "id": "inb_a1b2c3d4e5f6",
  "event": "inbound.received",
  "timestamp": "2026-01-27T15:30:05.123Z",
  "data": {
    "from": {
      "address": "customer@example.com",
      "name": "Jane Customer"
    },
    "to": [
      {
        "address": "support@yourdomain.com",
        "name": null
      }
    ],
    "cc": [
      {
        "address": "manager@example.com",
        "name": "Manager"
      }
    ],
    "replyTo": {
      "address": "customer@example.com",
      "name": "Jane Customer"
    },
    "subject": "Question about my order #12345",
    "text": "Hi,\n\nI have a question about my recent order...\n\nThanks,\nJane",
    "html": "<p>Hi,</p><p>I have a question about my recent order...</p><p>Thanks,<br>Jane</p>",
    "attachments": [
      {
        "id": "att_xyz789",
        "filename": "receipt.pdf",
        "contentType": "application/pdf",
        "size": 45678,
        "url": "https://api.mailbreeze.com/v1/inbound/inb_a1b2c3d4e5f6/attachments/att_xyz789"
      }
    ],
    "headers": {
      "message-id": "<CADfH8w3abc123@mail.example.com>",
      "date": "Mon, 27 Jan 2026 10:30:00 -0500",
      "in-reply-to": "<previous-message-id@yourdomain.com>",
      "references": "<original-message-id@yourdomain.com>"
    },
    "authentication": {
      "spf": "pass",
      "dkim": "pass",
      "dmarc": "pass"
    },
    "spamScore": 0.1,
    "receivedAt": "2026-01-27T15:30:05.123Z"
  }
}
```

### Payload Fields

<ResponseField name="id" type="string">
  Unique identifier for this inbound email.
</ResponseField>

<ResponseField name="event" type="string">
  Event type. Always `inbound.received` for new emails.
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 timestamp when the webhook was sent.
</ResponseField>

<ResponseField name="data.from" type="object">
  Sender information with `address` and `name` fields.
</ResponseField>

<ResponseField name="data.to" type="array">
  Array of recipient objects matching your domain.
</ResponseField>

<ResponseField name="data.cc" type="array">
  Array of CC recipient objects.
</ResponseField>

<ResponseField name="data.subject" type="string">
  Email subject line.
</ResponseField>

<ResponseField name="data.text" type="string">
  Plain text body of the email.
</ResponseField>

<ResponseField name="data.html" type="string">
  HTML body of the email (if present).
</ResponseField>

<ResponseField name="data.attachments" type="array">
  Array of attachment objects with metadata and download URLs.
</ResponseField>

<ResponseField name="data.headers" type="object">
  Key email headers including Message-ID, Date, In-Reply-To, and References.
</ResponseField>

<ResponseField name="data.authentication" type="object">
  SPF, DKIM, and DMARC check results (`pass`, `fail`, `none`).
</ResponseField>

<ResponseField name="data.spamScore" type="number">
  Spam score from 0.0 (not spam) to 1.0 (definitely spam).
</ResponseField>

## Webhook Requirements

Your endpoint must meet these requirements:

| Requirement      | Details                                      |
| ---------------- | -------------------------------------------- |
| **Protocol**     | HTTPS only (HTTP endpoints will be rejected) |
| **Method**       | Accept POST requests                         |
| **Response**     | Return `200 OK` within 30 seconds            |
| **Content-Type** | Accept `application/json`                    |
| **Availability** | Must be publicly accessible                  |

<Warning>
  If your endpoint returns a non-2xx status code or times out, MailBreeze will retry the webhook. See [Retry Behavior](#retry-behavior) below.
</Warning>

## Verifying Webhooks

To ensure webhooks are genuinely from MailBreeze, verify the signature header.

### Signature Header

Every webhook includes an `X-MailBreeze-Signature` header:

```
X-MailBreeze-Signature: t=1706369405,v1=abc123def456...
```

The header contains:

* `t` — Unix timestamp when the signature was generated
* `v1` — HMAC-SHA256 signature of the payload

### Verification Steps

<Steps>
  <Step title="Extract the timestamp and signature">
    Parse the header to get `t` (timestamp) and `v1` (signature).

    ```javascript theme={null}
    const sigHeader = req.headers['x-mailbreeze-signature'];
    const [tPart, v1Part] = sigHeader.split(',');
    const timestamp = tPart.split('=')[1];
    const signature = v1Part.split('=')[1];
    ```
  </Step>

  <Step title="Prepare the signed payload">
    Concatenate the timestamp and raw request body with a period.

    ```javascript theme={null}
    const signedPayload = `${timestamp}.${rawBody}`;
    ```
  </Step>

  <Step title="Compute expected signature">
    Calculate HMAC-SHA256 using your webhook secret.

    ```javascript theme={null}
    const crypto = require('crypto');
    const expectedSignature = crypto
      .createHmac('sha256', webhookSecret)
      .update(signedPayload)
      .digest('hex');
    ```
  </Step>

  <Step title="Compare signatures">
    Use timing-safe comparison to prevent timing attacks.

    ```javascript theme={null}
    const isValid = crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
    ```
  </Step>

  <Step title="Validate timestamp">
    Reject requests older than 5 minutes to prevent replay attacks.

    ```javascript theme={null}
    const tolerance = 5 * 60 * 1000; // 5 minutes
    const isRecent = Date.now() - (timestamp * 1000) < tolerance;
    ```
  </Step>
</Steps>

### Complete Verification Example

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  const crypto = require('crypto');
  const express = require('express');
  const app = express();

  // Must use raw body for signature verification
  app.use('/webhook', express.raw({ type: 'application/json' }));

  app.post('/webhook/inbound', (req, res) => {
    const signature = req.headers['x-mailbreeze-signature'];
    const webhookSecret = process.env.MAILBREEZE_WEBHOOK_SECRET;

    if (!verifySignature(signature, req.body, webhookSecret)) {
      return res.status(401).send('Invalid signature');
    }

    const payload = JSON.parse(req.body);

    // Process the email
    console.log('Received email from:', payload.data.from.address);

    res.status(200).send('OK');
  });

  function verifySignature(sigHeader, body, secret) {
    const [tPart, v1Part] = sigHeader.split(',');
    const timestamp = tPart.split('=')[1];
    const signature = v1Part.split('=')[1];

    // Check timestamp (5 minute tolerance)
    const age = Date.now() - (parseInt(timestamp) * 1000);
    if (age > 5 * 60 * 1000) return false;

    // Compute expected signature
    const signedPayload = `${timestamp}.${body}`;
    const expected = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');

    // Timing-safe comparison
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }
  ```

  ```python Python (Flask) theme={null}
  import hmac
  import hashlib
  import time
  from flask import Flask, request, abort

  app = Flask(__name__)
  WEBHOOK_SECRET = os.environ['MAILBREEZE_WEBHOOK_SECRET']

  @app.route('/webhook/inbound', methods=['POST'])
  def handle_inbound():
      signature = request.headers.get('X-MailBreeze-Signature')

      if not verify_signature(signature, request.data, WEBHOOK_SECRET):
          abort(401, 'Invalid signature')

      payload = request.json

      # Process the email
      print(f"Received email from: {payload['data']['from']['address']}")

      return 'OK', 200

  def verify_signature(sig_header, body, secret):
      parts = dict(p.split('=') for p in sig_header.split(','))
      timestamp = parts['t']
      signature = parts['v1']

      # Check timestamp (5 minute tolerance)
      age = time.time() - int(timestamp)
      if age > 5 * 60:
          return False

      # Compute expected signature
      signed_payload = f"{timestamp}.{body.decode('utf-8')}"
      expected = hmac.new(
          secret.encode(),
          signed_payload.encode(),
          hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(signature, expected)
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	body, _ := io.ReadAll(r.Body)
  	signature := r.Header.Get("X-MailBreeze-Signature")
  	secret := os.Getenv("MAILBREEZE_WEBHOOK_SECRET")

  	if !verifySignature(signature, body, secret) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}

  	// Process the email
  	// ...

  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte("OK"))
  }

  func verifySignature(sigHeader string, body []byte, secret string) bool {
  	parts := strings.Split(sigHeader, ",")
  	timestamp := strings.Split(parts[0], "=")[1]
  	signature := strings.Split(parts[1], "=")[1]

  	// Check timestamp (5 minute tolerance)
  	ts, _ := strconv.ParseInt(timestamp, 10, 64)
  	if time.Now().Unix()-ts > 300 {
  		return false
  	}

  	// Compute expected signature
  	signedPayload := timestamp + "." + string(body)
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(signedPayload))
  	expected := hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(signature), []byte(expected))
  }
  ```

  ```php PHP theme={null}
  <?php
  $webhookSecret = getenv('MAILBREEZE_WEBHOOK_SECRET');
  $signature = $_SERVER['HTTP_X_MAILBREEZE_SIGNATURE'];
  $body = file_get_contents('php://input');

  if (!verifySignature($signature, $body, $webhookSecret)) {
      http_response_code(401);
      exit('Invalid signature');
  }

  $payload = json_decode($body, true);

  // Process the email
  error_log("Received email from: " . $payload['data']['from']['address']);

  http_response_code(200);
  echo 'OK';

  function verifySignature($sigHeader, $body, $secret) {
      $parts = [];
      foreach (explode(',', $sigHeader) as $part) {
          list($key, $value) = explode('=', $part, 2);
          $parts[$key] = $value;
      }

      $timestamp = $parts['t'];
      $signature = $parts['v1'];

      // Check timestamp (5 minute tolerance)
      if (time() - intval($timestamp) > 300) {
          return false;
      }

      // Compute expected signature
      $signedPayload = $timestamp . '.' . $body;
      $expected = hash_hmac('sha256', $signedPayload, $secret);

      return hash_equals($signature, $expected);
  }
  ?>
  ```
</CodeGroup>

## Retry Behavior

If your endpoint fails to respond with `200 OK`, MailBreeze retries with exponential backoff:

| Attempt | Delay After Failure |
| ------- | ------------------- |
| 1       | Immediate           |
| 2       | 1 minute            |
| 3       | 5 minutes           |
| 4       | 30 minutes          |
| 5       | 2 hours             |
| 6       | 8 hours             |
| 7       | 24 hours            |

After 7 failed attempts, the webhook is marked as failed. Failed webhooks are visible in **Domains** > **Inbound** > **Delivery Logs**.

<Note>
  Your endpoint should handle duplicate deliveries gracefully. Use the `id` field to deduplicate emails in case a webhook is retried despite successful processing.
</Note>

## Downloading Attachments

Attachment URLs require authentication. Include your API key when downloading:

```bash theme={null}
curl -o receipt.pdf \
  -H "x-api-key: sk_live_xxx" \
  "https://api.mailbreeze.com/v1/inbound/inb_a1b2c3d4e5f6/attachments/att_xyz789"
```

<Warning>
  Attachment URLs expire after 7 days. Download attachments promptly if you need to retain them.
</Warning>

## Testing Webhooks

### Local Development

Use a tunneling service to expose your local server:

```bash ngrok theme={null}
ngrok http 3000
# Use the generated URL (e.g., https://abc123.ngrok.io/webhook/inbound)
```

### Test Endpoint

Send a test webhook from the MailBreeze dashboard:

1. Go to **Domains** > your domain > **Inbound Settings** > **Routes**
2. Select your webhook route
3. Click **Send Test Webhook**

This sends a sample payload to verify your endpoint is working.

### Manual Testing

Send an email to an address on your domain and monitor your endpoint logs.

## Error Handling

Return appropriate HTTP status codes:

| Code    | Meaning                                  | MailBreeze Behavior      |
| ------- | ---------------------------------------- | ------------------------ |
| `200`   | Success                                  | Mark as delivered        |
| `4xx`   | Client error (bad request, unauthorized) | Retry (may be transient) |
| `5xx`   | Server error                             | Retry with backoff       |
| Timeout | No response in 30s                       | Retry with backoff       |

<Tip>
  If you need more time to process an email, return `200 OK` immediately and process asynchronously. Use a message queue (Redis, SQS, etc.) for heavy processing.
</Tip>

## Common Patterns

### Parse Reply-To Thread

Extract the thread ID from reply-to addresses:

```javascript theme={null}
// Your app sends from: reply+order_123@inbound.yourapp.com
// Customer replies to that address

app.post('/webhook/inbound', (req, res) => {
  const { to } = req.body.data;
  const replyAddress = to[0].address;

  // Extract order ID from address
  const match = replyAddress.match(/reply\+(.+)@/);
  if (match) {
    const orderId = match[1]; // "order_123"
    // Associate this reply with the order
  }

  res.status(200).send('OK');
});
```

### Filter Spam

Use the spam score and authentication results:

```javascript theme={null}
app.post('/webhook/inbound', (req, res) => {
  const { spamScore, authentication } = req.body.data;

  // Reject likely spam
  if (spamScore > 0.7) {
    console.log('Rejected spam email');
    return res.status(200).send('OK'); // Still return 200 to prevent retries
  }

  // Flag suspicious emails
  if (authentication.dmarc !== 'pass') {
    console.log('Warning: DMARC check failed');
  }

  // Process legitimate email
  // ...

  res.status(200).send('OK');
});
```

### Extract Thread Context

Use headers for conversation threading:

```javascript theme={null}
app.post('/webhook/inbound', (req, res) => {
  const { headers } = req.body.data;

  const inReplyTo = headers['in-reply-to'];
  const references = headers['references'];

  if (inReplyTo) {
    // This is a reply to a previous message
    const originalMessageId = inReplyTo.replace(/[<>]/g, '');
    // Look up the original conversation
  }

  res.status(200).send('OK');
});
```

## Next Steps

<CardGroup cols={2}>
  <Card title="Configure Routes" icon="route" href="/inbound/routes">
    Set up address-specific routing rules
  </Card>

  <Card title="Inbound API" icon="code" href="/api-reference/inbound/list">
    Retrieve inbound emails via API
  </Card>
</CardGroup>
