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

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP in the EU, or send-us.ahasend.com as a US connection point forwarding to the EU. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# Email Webhooks: Delivery, Bounce and Open Events

> Receive real-time AhaSend email events (delivered, bounced, deferred, opened, clicked) with webhooks, verify signatures and handle retries.

AhaSend webhooks send email events to your application as HTTP requests; this guide shows how to create an endpoint, choose events and test delivery.

Available on Free, Pro and Max. See [plans and features](/docs/facts#plans-and-features).

<Info>
  **Real-Time Integration:** Unlike polling APIs that require you to repeatedly check for updates, webhooks deliver event data instantly as it happens, making your applications more responsive and efficient.
</Info>

<a id="what-are-webhooks" />

## What Are AhaSend Webhooks?

Webhooks are user-defined HTTP callbacks triggered by specific events in AhaSend. When an event occurs—such as an email being delivered or bouncing—AhaSend automatically sends a POST request to your webhook URL with detailed event data.

This enables you to:

<CardGroup cols={2}>
  <Card title="Automate Workflows" icon="robot">
    Trigger actions in your application when emails are delivered or bounce
  </Card>

  <Card title="Monitor Performance" icon="chart-line">
    Track email engagement in real-time without manual checking
  </Card>

  <Card title="Update User Interfaces" icon="arrows-rotate">
    Show delivery status updates to users immediately
  </Card>

  <Card title="Handle Errors" icon="triangle-exclamation">
    Respond to delivery failures or suppression events automatically
  </Card>
</CardGroup>

### How Webhooks Work in AhaSend

<Steps>
  <Step title="Event Occurs" icon="bolt">
    An event happens in your account (email delivered, bounced, opened, etc.)
  </Step>

  <Step title="Webhook Triggered" icon="webhook">
    AhaSend identifies any webhooks configured for that event type
  </Step>

  <Step title="HTTP Request Sent" icon="paper-plane">
    AhaSend sends a POST request to your webhook URL with event data
  </Step>

  <Step title="Your Application Responds" icon="code">
    Your endpoint processes the event and returns a 2xx status code
  </Step>
</Steps>

<Note>
  **Delivery Guarantee:** AhaSend makes 6 attempts over about 16 minutes, including the first attempt. Only HTTP status codes 200-299 are considered successful.
</Note>

<a id="available-webhook-events" />

## Which Events Can I Receive?

Choose delivery, bounce, deferral, failure, suppression, open, click and domain-DNS events when configuring your webhook. The [event reference](/docs/api-reference/webhooks#event-types) lists every event, its filter flag and its payload. Inbound routes use the separate [`message.routing` event](/docs/api-reference/routes/route-message).

## Understanding Message Lifecycle

The message lifecycle helps you understand when different webhook events occur:

### Successful Delivery Path

```text theme={null}
Reception → Delivered → [Opened] → [Clicked]
```

1. **Reception**: Email accepted and queued for delivery
2. **Delivered**: Successfully sent to recipient's mail server
3. **Opened**: Recipient opens email (requires tracking enabled)
4. **Clicked**: Recipient clicks tracked links (requires tracking enabled)

### Bounce Scenarios

<AccordionGroup>
  <Accordion title="Hard Bounce (Immediate Failure)" icon="x">
    ```text theme={null}
    Reception → Bounced → Suppression Created
    ```

    Occurs when the recipient's server immediately rejects the email (invalid address, domain doesn't exist). The email address is automatically suppressed.
  </Accordion>

  <Accordion title="Soft Bounce with Recovery" icon="arrows-rotate">
    ```text theme={null}
    Reception → Deferred → Deferred → ... → Delivered
    ```

    Temporary issues (full mailbox, server problems) cause deferrals. Message delivery retries continue until the message's expiry time. This is separate from [webhook delivery retries](/docs/api-reference/webhooks/retry-policy).
  </Accordion>

  <Accordion title="Soft Bounce with Final Failure" icon="triangle-exclamation">
    ```text theme={null}
    Reception → Deferred → Deferred → ... → Failed → Suppression Created
    ```

    After the retry window is exhausted, the message fails permanently and the address may be suppressed. Because retry exhaustion is not one SMTP exchange, its terminal event has no `delivery_attempt` object.
  </Accordion>

  <Accordion title="Suppressed Email" icon="ban">
    ```text theme={null}
    Reception → Suppressed
    ```

    No delivery attempt is made because the recipient is on your suppression list from previous failures.
  </Accordion>
</AccordionGroup>

<a id="creating-webhooks" />

## How Do I Create a Webhook?

<Frame>
  <img src="https://ahasend.com/sites/default/files/inline-images/webhooks.png" alt="Screenshot of the webhooks dashboard" className="rounded-md" width="300" />
</Frame>

Configure webhooks in your AhaSend dashboard to start receiving event notifications:

<Steps>
  <Step title="Access Webhooks Dashboard" icon="browser">
    1. **Log in** to your [AhaSend Dashboard](https://dash.ahasend.com)
    2. **Navigate** to the **[Webhooks](https://dash.ahasend.com/account/-/integrations/webhooks)** section from the main menu
    3. **Click** the **"Add Webhook"** button
  </Step>

  <Step title="Configure Webhook URL" icon="link">
    **Enter Your Endpoint URL:**

    * Provide the complete URL where you want to receive webhook events
    * Must be a valid HTTPS URL (HTTP allowed for development)
    * Should respond with 2xx status codes for successful processing

    **Example URLs:**

    ```text theme={null}
    https://api.yourapp.com/webhooks/ahasend
    https://yourapp.com/api/email-events
    ```
  </Step>

  <Step title="Select Events" icon="list-check">
    Choose which events to receive:

    **Option 1: All Events**

    * Select "Send all events to this endpoint URL"
    * Simplest option, receives every event type
    * Good for comprehensive logging or analytics

    **Option 2: Specific Events**

    * Select "I'll choose which events to send"
    * Pick individual event types you need
    * More efficient for targeted use cases
  </Step>

  <Step title="Create and Verify" icon="check">
    1. **Click** "Create Webhook" to save your configuration
    2. **Note the Webhook Secret** displayed on the details page
    3. **Copy and store** the secret securely for signature verification
  </Step>
</Steps>

<a id="testing-your-webhooks" />

## How Do I Test a Webhook Locally?

Use [CLI webhook testing](/docs/cli/webhook-testing) to forward events to localhost and trigger sample events. Use [sandbox sends](/docs/send-api/sandbox) to exercise delivery outcomes without real mail.

Verify your webhook integration works correctly before going live:

<Steps>
  <Step title="Use Test Events" icon="flask">
    1. **Go to** your webhook details page in the dashboard
    2. **Click** "Send Test Event"
    3. **Select** which event types to test
    4. **Click** "Send Test Events"

    Test events arrive within seconds and contain realistic sample data. Test
    `message.delivered`, `message.transient_error`, and `message.bounced` events
    include representative `delivery_attempt` diagnostics; these values are
    synthetic and were not observed from a destination mail server.
  </Step>

  <Step title="Development Tools" icon="wrench">
    **For Quick Testing:**

    * Use [Beeceptor](https://beeceptor.com) to create temporary webhook URLs
    * Inspect incoming requests and verify payload structure
    * Test without writing code first

    **For Development:**

    * Use [ngrok](https://ngrok.com) to expose local development servers
    * Test webhook handling in your actual application code
  </Step>

  <Step title="Verify Response Handling" icon="clipboard-check">
    Ensure your endpoint:

    * **Returns 2xx status codes** for successful processing
    * **Processes requests quickly** (under 5 seconds)
    * **Handles duplicate events** gracefully using webhook-id
    * **Validates webhook signatures** for security

    <Warning>
      **Timeout Considerations:** Webhook requests timeout after 5 seconds. Perform heavy processing asynchronously.
    </Warning>
  </Step>
</Steps>

<a id="security-and-verification" />

## How Do I Verify a Webhook?

AhaSend follows the [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks) for secure webhook delivery:

`webhook-id` is the delivery ID used for deduplication. The payload's `webhook_id` or `route_id` identifies the configured endpoint, not the delivery.

## Security Headers

Every webhook request includes security headers:

```http theme={null}
webhook-id: wh_1234567890abcdef
webhook-timestamp: 1683360000
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
```

<AccordionGroup>
  <Accordion title="webhook-id" icon="fingerprint">
    **Unique event identifier** - Use as an idempotency key to prevent processing duplicate events from retries.
  </Accordion>

  <Accordion title="webhook-timestamp" icon="clock">
    **Unix timestamp** when the webhook was sent - Use to reject old webhook attempts.
  </Accordion>

  <Accordion title="webhook-signature" icon="shield">
    **HMAC signature** of the payload using your webhook secret - Verify this to ensure authenticity.
  </Accordion>
</AccordionGroup>

### Signature Verification

Use Standard Webhooks libraries for easy verification:

<CodeGroup>
  ```javascript Node.js theme={null}
  import { Webhook } from 'standardwebhooks';

  async function readLimitedBody(request) {
    const reader = request.body?.getReader();
    if (!reader) return new Uint8Array();
    const chunks = [];
    let size = 0;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      size += value.byteLength;
      if (size > 30_000_000) { await reader.cancel(); return null; }
      chunks.push(value);
    }
    const body = new Uint8Array(size);
    let offset = 0;
    for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
    return body;
  }

  const webhook = new Webhook(process.env.WEBHOOK_SECRET, { format: 'raw' });

  // In your webhook handler
  export async function handleWebhook(request) {
    const payload = await readLimitedBody(request);
    if (payload === null) return new Response(null, { status: 413 });
    const headers = {
      'webhook-id': request.headers.get('webhook-id'),
      'webhook-timestamp': request.headers.get('webhook-timestamp'),
      'webhook-signature': request.headers.get('webhook-signature')
    };

    try {
      const event = webhook.verify(new TextDecoder('utf-8', { fatal: true }).decode(payload), headers);
      // Process verified event
      console.log('Verified event:', event.type); // Includes unknown signed event types.
      return new Response('OK', { status: 200 });
    } catch (error) {
      return new Response('Invalid signature', { status: 400 });
    }
  }
  ```

  The Python example expects a framework request with a raw `body` attribute. Configure a 30,000,000-byte request limit in that framework or its front proxy before it buffers the body. Supply the application processing callback used below.

  ```python Python theme={null}
  import base64
  import binascii

  def supported_signatures(header):
      # Skip unsupported or malformed entries before the library decodes them.
      supported = []
      for entry in (header or "").split():
          version, separator, encoded = entry.partition(",")
          if version != "v1" or not separator:
              continue
          try:
              signature = base64.b64decode(encoded, validate=True)
          except (binascii.Error, ValueError):
              continue
          if len(signature) == 32:
              supported.append(entry)
      return " ".join(supported)

  from standardwebhooks.webhooks import Webhook
  import os

  webhook = Webhook(os.environ['WEBHOOK_SECRET'].encode('utf-8'))

  def handle_webhook(request):
      headers = {
          'webhook-id': request.headers.get('webhook-id'),
          'webhook-timestamp': request.headers.get('webhook-timestamp'),
          'webhook-signature': supported_signatures(request.headers.get('webhook-signature'))
      }

      try:
          payload = request.body
          if len(payload) > 30_000_000:
              return {'error': 'Request too large'}, 413
          event = webhook.verify(payload, headers)

          # Process verified event
          print(f"Verified event: {event['type']}")
          return {'status': 'success'}, 200
      except Exception as e:
          return {'error': 'Invalid signature'}, 400
  ```

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

  import (
      "io"
      "log"
      "net/http"
      "os"
      standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
  )

  func handleWebhook(w http.ResponseWriter, r *http.Request) {
      webhook, err := standardwebhooks.NewWebhookRaw([]byte(os.Getenv("WEBHOOK_SECRET")))
      if err != nil {
          http.Error(w, "Invalid webhook secret", 500)
          return
      }

      r.Body = http.MaxBytesReader(w, r.Body, 30_000_000)
      payload, err := io.ReadAll(r.Body)
      if err != nil {
          http.Error(w, "Cannot read webhook body", 413)
          return
      }
      headers := r.Header

      if err := webhook.Verify(payload, headers); err != nil {
          http.Error(w, "Invalid signature", 400)
          return
      }

      // Process verified event
      log.Printf("Verified webhook received")
      w.WriteHeader(200)
  }
  ```

  ```php PHP theme={null}
  <?php
  // PHP handler: read the exact body, with a limit, before parsing JSON.
  $body = file_get_contents('php://input', false, null, 0, 30_000_001);
  $secret = getenv('WEBHOOK_SECRET');
  $id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
  $timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
  $signatures = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
  if ($body === false || strlen($body) > 30_000_000) { http_response_code(413); exit; }
  if (!$secret) { http_response_code(500); exit; }
  if (!$id || !ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) {
      http_response_code(400); exit;
  }
  $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $secret, true));
  $valid = false;
  foreach (explode(' ', $signatures) as $candidate) {
      $parts = explode(',', $candidate, 2);
      if (count($parts) === 2 && $parts[0] === 'v1' && hash_equals($expected, $parts[1])) $valid = true;
  }
  if (!$valid) { http_response_code(400); exit; }
  try { $event = json_decode($body, true, 512, JSON_THROW_ON_ERROR); }
  catch (JsonException $e) { http_response_code(400); exit; }
  // Verification-only example: add an atomic durable handoff before business side effects.
  // Unknown event types are valid signed deliveries too.
  http_response_code(204);
  ```
</CodeGroup>

<Info>
  **Standard Webhooks Libraries:** AhaSend webhooks are fully compatible with [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks) available for Python, JavaScript, Go, PHP, Ruby, Java, Rust, C#, and Elixir.
</Info>

## Webhook Payload Structure

<Tip>
  **Complete Payload Documentation:** For detailed event schemas and all payload fields, see the [Webhook Events API Reference](/docs/api-reference/webhooks).
</Tip>

All AhaSend webhooks follow the Standard Webhooks payload format:

```json theme={null}
{
  "type": "event.type",
  "webhook_id": "abe11757-2886-4b55-96f1-0e0afc95795a",
  "timestamp": "2024-05-06T09:49:16.687031577Z",
  "data": {
    // Event-specific data
  }
}
```

### Message Event Example

```json theme={null}
{
  "type": "message.delivered",
  "webhook_id": "abe11757-2886-4b55-96f1-0e0afc95795a",
  "timestamp": "2024-05-06T09:50:16.687031577Z",
  "data": {
    "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87",
    "event": "on_delivered",
    "from": "hello@yourcompany.com",
    "recipient": "customer@example.com",
    "subject": "Welcome to our service",
    "message_id_header": "message-id-12345",
    "id": "ahasend-message-67890",
    "delivery_attempt": {
      "smtp_code": 250,
      "enhanced_status_code": "2.0.0",
      "response": "OK: queued",
      "command": "DATA"
    }
  }
}
```

### Delivery Attempt Diagnostics

`message.delivered`, `message.transient_error`, and `message.bounced` can include
an optional `data.delivery_attempt` object describing the SMTP exchange behind
the event:

* `smtp_code` is always present when the object is present. A value of `0`
  means AhaSend recorded diagnostic text without an SMTP code.
* `enhanced_status_code` is the enhanced status code, such as `5.1.1`, when
  one was recorded.
* `response` contains human-readable diagnostic text. Do not parse identifiers
  from it.
* `description` can contain a clearer translation of a complex response. Its
  wording may change, so use it only for display.
* `classification` is the machine-readable failure category. Treat it as an
  open set and keep a fallback for values your integration does not recognize.
* `command` is the normalized SMTP command name, such as `RCPT TO` or `DATA`.

Do not require this object. A missing or `null` value is normal for retry
exhaustion, out-of-band bounces, non-SMTP routing, and attempts with no recorded
response. In particular, retry exhaustion is reported as `message.failed`,
which has no specific attempt to report.

Test webhooks and sandbox sends use representative diagnostics rather than an
observed SMTP exchange. A sandbox classification may not agree with its sample
SMTP code and response, so test classification-based behavior with production
events or fixtures you control.

### Suppression Event Example

```json theme={null}
{
  "type": "suppression.created",
  "webhook_id": "abe11757-2886-4b55-96f1-0e0afc95795a",
  "timestamp": "2024-05-06T12:57:06.451529527Z",
  "data": {
    "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87",
    "recipient": "bounced@example.com",
    "created_at": "2024-05-06T12:57:06.451529617Z",
    "expires_at": "2024-06-05T12:57:06.451529617Z",
    "reason": "Too many hard bounces",
    "sending_domain": "yourcompany.com"
  }
}
```

## Best Practices

<AccordionGroup>
  <Accordion title="Reliability and Performance" icon="rocket">
    **Handle Retries Gracefully:**

    * Use `webhook-id` as an idempotency key to prevent duplicate processing
    * Store processed webhook IDs to detect and skip duplicates
    * Return 2xx status codes quickly (under 5 seconds)

    **Process Asynchronously:**

    * Acknowledge receipt immediately with 200 OK
    * Queue heavy processing for background workers
    * Avoid database operations that could timeout

    **Monitor Your Endpoints:**

    * Log webhook processing times and errors
    * Set up alerts for failing webhooks
    * Track webhook-related application metrics
  </Accordion>

  <Accordion title="Security Implementation" icon="shield">
    **Always Verify Signatures:**

    * Use Standard Webhooks libraries for verification
    * Reject requests with invalid or missing signatures
    * Check timestamp to prevent replay attacks

    **Secure Your Endpoints:**

    * Use HTTPS URLs for production webhooks
    * Don't expose webhook URLs publicly
    * Consider IP whitelisting if needed

    **Protect Webhook Secrets:**

    * Store webhook secrets in environment variables
    * Never commit secrets to version control
    * Rotate secrets periodically
  </Accordion>

  <Accordion title="Error Handling" icon="triangle-exclamation">
    **Graceful Failure Handling:**

    * Return appropriate HTTP status codes
    * Log webhook processing errors for debugging
    * Implement circuit breakers for external dependencies

    **Event Processing Logic:**

    * Handle unexpected event types gracefully
    * Validate event data before processing
    * Don't assume all fields will always be present

    **Testing and Monitoring:**

    * Test webhook handlers with various event types
    * Monitor webhook delivery success rates in dashboard
    * Set up alerts for consecutive webhook failures
  </Accordion>
</AccordionGroup>

<a id="troubleshooting" />

## Why Did My Webhook Fail?

<AccordionGroup>
  <Accordion title="Webhooks Not Being Received" icon="question">
    **Check Your Configuration:**

    * Verify webhook URL is correct and accessible
    * Ensure your server returns 2xx status codes
    * Check firewall settings aren't blocking AhaSend IPs

    **Test Your Endpoint:**

    * Use "Send Test Event" feature in dashboard
    * Test with tools like curl or Postman
    * Verify your endpoint handles POST requests

    **Review Dashboard Logs:**

    * Check webhook delivery attempts in your dashboard
    * Look for error messages and status codes
    * Monitor retry attempts and failure patterns
  </Accordion>

  <Accordion title="Authentication Errors" icon="key">
    **Signature Verification Issues:**

    * Ensure webhook secret is correctly configured
    * Use raw request body for signature calculation
    * Check Standard Webhooks library implementation

    **Common Mistakes:**

    * Using wrong webhook secret
    * Modifying request body before verification
    * Incorrect header name extraction

    **Debug Steps:**

    * Log incoming headers and payload
    * Verify webhook secret matches dashboard
    * Test with Standard Webhooks library examples
  </Accordion>

  <Accordion title="Performance Issues" icon="gauge">
    **Timeout Problems:**

    * Reduce processing time in webhook handler
    * Move heavy operations to background jobs
    * Return 200 OK before processing starts

    **High Volume Handling:**

    * Implement proper queuing systems
    * Scale webhook processing horizontally
    * Monitor resource usage during peak times

    **Optimization Tips:**

    * Cache frequently accessed data
    * Use database connection pooling
    * Implement proper logging levels
  </Accordion>

  <Accordion title="Webhook Disabled" icon="ban">
    **Automatic Disabling:**

    * AhaSend disables webhooks after more than 100 consecutive failed attempts
    * You'll receive an email notification when this happens
    * Fix underlying issues before re-enabling

    **Re-enabling Process:**

    * Identify and resolve the root cause
    * Test your endpoint is working correctly
    * Re-enable the webhook in your dashboard

    **Prevention:**

    * Monitor webhook success rates regularly
    * Set up alerts for webhook failures
    * Implement proper error handling and logging
  </Accordion>
</AccordionGroup>

## Advanced Integration Patterns

<CardGroup cols={2}>
  <Card title="Event-Driven Architecture" icon="diagram-project">
    Use webhooks to trigger microservices, update user interfaces, and orchestrate complex workflows based on email events.
  </Card>

  <Card title="Real-Time Analytics" icon="chart-line">
    Stream webhook events to analytics platforms for real-time email performance dashboards and reporting.
  </Card>

  <Card title="Customer Support Integration" icon="headset">
    Automatically create support tickets from bounce events and delivery failures to keep your team informed.
  </Card>

  <Card title="Marketing Automation" icon="robot">
    Trigger marketing campaigns, update customer segments, and personalize user experiences based on email engagement.
  </Card>
</CardGroup>

## Related Guides

* [Domain setup](/docs/domains), [API authentication](/docs/api-reference/authentication), [Event reference](/docs/api-reference/webhooks).
* [Webhook CLI](/docs/cli/commands/webhooks), [Local webhook testing](/docs/cli/webhook-testing), [Signature verification](/docs/api-reference/webhooks/security).
* [Retry policy](/docs/api-reference/webhooks/retry-policy), [Node.js SDK](/docs/guides/nodejs-sdk), [Go SDK](/docs/guides/go-sdk).
* [Plans and limits](/docs/facts), [Tracking setup](/docs/tracking/open-tracking), [Sandbox behavior](/docs/send-api/sandbox).
* [Suppression durations](/docs/bounces#suppression-duration).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.