openapi: 3.1.0
info:
  title: AhaSend Webhooks
  description: |
    AhaSend webhook events documentation. This specification describes all webhook events that AhaSend sends to your configured webhook URLs.

    ## Overview

    Webhooks are HTTP callbacks that AhaSend sends to your configured URLs when specific events occur. They provide real-time notifications about:

    - **Message Events**: Email delivery status (sent, delivered, bounced, opened, etc.)
    - **Suppression Events**: When email addresses are automatically suppressed
    - **Domain Events**: DNS configuration issues
    - **Route Events**: Inbound email processing

    ## Standard Webhooks Compatibility

    AhaSend webhook deliveries use the Standard Webhooks header names, signed-content format, HMAC-SHA256 algorithm, and signature format. Secret handling is intentionally different from the encoded-secret convention assumed by some Standard Webhooks libraries, so compatibility with stock libraries is not unconditional.

    ### Security Headers

    All webhooks include these security headers for verification:

    - `webhook-id`: Unique identifier for the webhook event (used as idempotency key)
    - `webhook-timestamp`: Unix timestamp when the webhook was sent
    - `webhook-signature`: HMAC-SHA256 signature of the payload using the resource secret

    ### Verification

    The HMAC key is the literal UTF-8 bytes of the `secret` returned when the webhook or route resource is created. Use the complete returned string, including any prefix. Do not Base64-decode it and do not strip a prefix.

    Use the AhaSend SDK verifier, or a verifier that explicitly accepts raw key bytes. A stock Standard Webhooks library is compatible only if it has a raw-secret/raw-key mode that preserves these literal UTF-8 bytes. Constructors that decode an encoded Standard Webhooks secret will derive a different key and reject valid AhaSend deliveries.

    The signed content is the unmodified `webhook-id`, `webhook-timestamp`, and raw request body joined with periods. See the [Standard Webhooks verification algorithm](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md#verifying-webhook-authenticity) for the shared protocol details, subject to the AhaSend secret-handling boundary above.

    ### Retry Policy

    - Failed webhooks are retried **6 times** over **16+ minutes**
    - Only HTTP status codes **200-299** are considered successful
    - After **100 consecutive failures**, the webhook is automatically disabled
    - You'll receive an email notification when a webhook is disabled

    ### Payload Format

    All webhooks follow the Standard Webhooks payload structure:

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

    Message, suppression, and domain webhooks use `webhook_id`. Route
    webhooks use `route_id` instead.

    ## Getting Started

    1. **Configure a webhook** in your AhaSend dashboard
    2. **Choose which events** you want to receive
    3. **Verify webhook signatures** using the literal resource secret as described above
    4. **Handle the events** in your application

    For more information, visit the [AhaSend webhook documentation](https://ahasend.com/help/integrations/webhooks).

  version: "2.0.0"
  contact:
    email: support@ahasend.com
  license:
    name: Proprietary

servers:
  - url: https://your-webhook-endpoint.com
    description: Your webhook endpoint URL (configured in AhaSend dashboard)

webhooks:
  # Message Event Webhooks
  message.reception:
    post:
      summary: Message Reception Event
      description: |
        &nbsp;

        Triggered when an email has been received and queued.
        This is always the first event in a message's lifecycle. The message has been accepted by AhaSend and is queued for delivery to the recipient's mail server.

        **When it's sent**: Immediately after AhaSend receives and queues an email for delivery.
        <Note>
          This webhook is called for both inbound and outbound emails.
        </Note>
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageReceptionWebhookPayload'
            example:
              type: "message.reception"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T09:49:16.687031577Z"
              data:
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                event: "on_reception"
                from: "sender@example.com"
                recipient: "recipient@example.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  message.delivered:
    post:
      summary: Message Delivered Event
      description: |
        &nbsp;

        Triggered when an email has been successfully delivered to the recipient's mail server.

        This indicates that the recipient's mail server has accepted the email. Note that this doesn't guarantee the email reached the recipient's inbox (it could still be filtered to spam).

        **When it's sent**: When the recipient's mail server confirms successful delivery.
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageDeliveredWebhookPayload'
            example:
              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: "sender@example.com"
                recipient: "recipient@example.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
                delivery_attempt:
                  smtp_code: 250
                  enhanced_status_code: "2.0.0"
                  response: "OK: queued"
                  command: "DATA"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  message.transient_error:
    post:
      summary: Message Transient Error Event
      description: |
        &nbsp;

        Triggered when an email delivery is delayed due to a temporary issue with the receiving mail server.

        This is a temporary failure (soft bounce). AhaSend will retry delivery according to its retry policy. Common causes include:
        - Recipient's mailbox is full
        - Temporary server issues
        - Rate limiting by the recipient's server

        **When it's sent**: When a temporary delivery failure occurs.
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageTransientErrorWebhookPayload'
            example:
              type: "message.transient_error"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T09:51:16.687031577Z"
              data:
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                event: "on_transient_error"
                from: "sender@example.com"
                recipient: "recipient@example.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
                delivery_attempt:
                  classification: "QuotaIssues"
                  smtp_code: 452
                  enhanced_status_code: "4.2.2"
                  response: "The recipient's inbox is out of storage space"
                  command: "RCPT TO"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  message.failed:
    post:
      summary: Message Failed Event
      description: |
        &nbsp;

        Triggered when an email exhausts its retry window without being delivered.

        No more delivery attempts will be made. This event reports retry exhaustion rather than one specific SMTP exchange, so `data.delivery_attempt` is omitted. An immediate permanent rejection from a recipient server is reported as `message.bounced` instead.

        **When it's sent**: After all retry attempts have been exhausted.
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageFailedWebhookPayload'
            example:
              type: "message.failed"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T09:55:16.687031577Z"
              data:
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                event: "on_failed"
                from: "sender@example.com"
                recipient: "invalid@nonexistent-domain.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  message.bounced:
    post:
      summary: Message Bounced Event
      description: |
        &nbsp;

        Triggered when a message reaches a bounced outcome. This includes an immediate permanent SMTP rejection or an out-of-band delivery status notification received after the message was accepted.

        A direct SMTP rejection normally includes `data.delivery_attempt`. An out-of-band bounce does not describe one SMTP attempt, so the object is omitted in that case.

        **When it's sent**: When a recipient server permanently rejects the message or AhaSend receives a later bounce notification.
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageBouncedWebhookPayload'
            example:
              type: "message.bounced"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T10:05:16.687031577Z"
              data:
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                event: "on_bounced"
                from: "sender@example.com"
                recipient: "recipient@example.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
                delivery_attempt:
                  classification: "InvalidRecipient"
                  smtp_code: 550
                  enhanced_status_code: "5.1.1"
                  response: "The email account that you tried to reach does not exist"
                  command: "RCPT TO"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  message.suppressed:
    post:
      summary: Message Suppressed Event
      description: |
        &nbsp;

        Triggered when no delivery attempt was made because the recipient is suppressed.

        The email was not sent because the recipient email address is on your suppression list. This prevents sending to addresses that have previously bounced or been marked as problematic.

        **When it's sent**: When an email is submitted for a suppressed recipient.
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageSuppressedWebhookPayload'
            example:
              type: "message.suppressed"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T10:10:16.687031577Z"
              data:
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                event: "on_suppressed"
                from: "sender@example.com"
                recipient: "bounced@example.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  message.opened:
    post:
      summary: Message Opened Event
      description: |
        &nbsp;

        Triggered when the recipient opens your email.

        **Requirements**:
        - Open tracking must be enabled for your account or this specific email
        - The email must contain HTML content
        - The recipient's email client must load images

        **Additional Data**: This webhook includes user agent and IP address information for analytics.

        **Bot Detection**: The `is_bot` field indicates whether the open was detected as automated (email security scanners, etc.).

        **When it's sent**: When the tracking pixel in the email is loaded.
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageOpenedWebhookPayload'
            example:
              type: "message.opened"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T10:15:16.687031577Z"
              data:
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                event: "on_opened"
                from: "sender@example.com"
                recipient: "recipient@example.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
                user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
                ip: "192.168.1.100"
                is_bot: false
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  message.clicked:
    post:
      summary: Message Clicked Event
      description: |
        &nbsp;

        Triggered when the recipient clicks a tracked link in your email.

        **Requirements**:
        - Click tracking must be enabled for your account or this specific email
        - The clicked link must be processed by AhaSend's click tracking system

        **Additional Data**: This webhook includes user agent and IP address information for analytics.

        **When it's sent**: When a tracked link in the email is clicked.
      tags:
        - Message Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageClickedWebhookPayload'
            example:
              type: "message.clicked"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T10:20:16.687031577Z"
              data:
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                event: "on_clicked"
                from: "sender@example.com"
                recipient: "recipient@example.com"
                subject: "Welcome to our service"
                message_id_header: "<message-id-12345@localhost>"
                id: "407926766d2711f09b30960002cafe7c"
                url: "https://example.com/landing-page"
                user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
                ip: "192.168.1.100"
                is_bot: false
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  # Suppression Event Webhooks
  suppression.created:
    post:
      summary: Suppression Created Event
      description: |
        &nbsp;

        Triggered when a suppression is created for an email address.

        **Why suppressions are created**:
        - Multiple hard bounces from the same email address
        - Repeated soft bounces over time
        - Manual suppression through the API or dashboard

        **Auto-expiration**: Suppressions automatically expire after 30 days to allow for temporary issues to be resolved.

        **Impact**: Future emails to this address will be automatically suppressed until the suppression expires or is manually removed.

        **When it's sent**: When a new suppression is added to your account.
      tags:
        - Suppression Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SuppressionWebhookPayload'
            example:
              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: "your-domain.com"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  # Domain Event Webhooks
  domain.dns_error:
    post:
      summary: Domain DNS Error Event
      description: |
        &nbsp;

        AhaSend automatically checks your domain's DNS configuration every 15 to 30 minutes and sends this webhook if any severer issues are detected that will prevent the system from sending emails on your domain's behalf.

        Triggered when DNS configuration issues are detected for any domain in your account.

        **Common DNS issues**:
        - Missing or invalid SPF record
        - Missing or invalid DKIM record
        - Missing or invalid DMARC record
        - DNS propagation delays

        **Impact**: After this event, email sending will be rejected until DNS configuration is fixed.

        **Resolution**: Check your domain's DNS settings in the AhaSend dashboard and update your DNS records as needed.

        **When it's sent**: When AhaSend's automated DNS checker detects configuration problems.
      tags:
        - Domain Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DomainWebhookPayload'
            example:
              type: "domain.dns_error"
              webhook_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T12:59:46.404433272Z"
              data:
                domain: "example.com"
                account_id: "4cdd7bdd-294e-4762-892f-83d40abf5a87"
                spf_valid: false
                dkim_valid: false
                dmarc_valid: false
                dns_last_checked_at: "2024-05-06T12:59:46.404433312Z"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

  # Route Event Webhooks (Inbound Email Processing)
  message.routing:
    post:
      summary: Inbound Message Route Event
      description: |
        &nbsp;

        Triggered when an inbound email is received and processed through a configured route.

        **Purpose**: This webhook delivers complete inbound email data to your application for processing inbound emails (like support tickets, replies, etc.).

        **Difference from message events**:
        - **Route events**: Process *inbound* emails sent TO your domains
        - **Message events**: Track *outbound* emails sent FROM your domains

        **Route configuration**:
        - Configure routes in your AhaSend dashboard
        - Choose whether to include attachments and headers
        - Set recipient filters and processing options

        When attachments are enabled, `attachments` includes conventional attachments,
        inline MIME parts (such as images referenced by `cid:` URLs in `html_body`), and
        filename-bearing MIME parts that omit the `Content-Disposition` header.

        **Use cases**:
        - Support ticket systems
        - Email-to-ticket conversion
        - Automated email processing
        - Reply handling

        **When it's sent**: When an inbound email matches a configured route.
      tags:
        - Route Events
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RouteWebhookPayload'
            example:
              type: "message.routing"
              route_id: "abe11757-2886-4b55-96f1-0e0afc95795a"
              timestamp: "2024-05-06T13:15:46.404433272Z"
              data:
                id: "route-msg-12345"
                from: "customer@gmail.com"
                reply_to: "customer@gmail.com"
                to: "support@yourdomain.com"
                subject: "Help with my account"
                message_id: "<unique-message-id@gmail.com>"
                size: 2048
                spam_score: 0.1
                bounce: false
                cc: ""
                date: "Mon, 06 May 2024 13:15:46 +0000"
                in_reply_to: ""
                references: ""
                auto_submitted: ""
                html_body: "<p>I need help with my account settings.</p>"
                plain_body: "I need help with my account settings."
                reply_from_plain_body: "I need help with my account settings."
                attachments:
                  - filename: "logo.png"
                    content_type: "image/png"
                    content_id: "logo-123"
                    disposition: "inline"
                    data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB..."
                headers:
                  "X-Mailer": "Gmail"
                  "X-Priority": "3"
      responses:
        '200':
          description: Webhook received successfully
        '201':
          description: Webhook received successfully
        '202':
          description: Webhook received successfully
        '204':
          description: Webhook received successfully

components:
  schemas:
    # Webhook payload schemas
    MessageWebhookPayload:
      type: object
      required:
        - type
        - webhook_id
        - timestamp
        - data
      properties:
        type:
          type: string
          enum:
            - message.reception
            - message.delivered
            - message.transient_error
            - message.failed
            - message.bounced
            - message.suppressed
            - message.opened
            - message.clicked
          description: The event type
        webhook_id:
          type: string
          description: The webhook ID (the same ID as returned by the [Create Webhook API](https://ahasend.com/docs/api-reference/webhooks/create-webhook))
          format: uuid
        timestamp:
          type: string
          format: date-time
          description: When the event occurred (ISO 8601 format)
        data:
          $ref: '#/components/schemas/MessageWebhookData'

    MessageReceptionWebhookPayload:
      allOf:
        - $ref: '#/components/schemas/MessageWebhookPayload'
        - type: object
          properties:
            type:
              enum:
                - message.reception
    MessageDeliveredWebhookPayload:
      allOf:
        - $ref: '#/components/schemas/MessageWebhookPayload'
        - type: object
          properties:
            type:
              enum:
                - message.delivered
    MessageTransientErrorWebhookPayload:
      allOf:
        - $ref: '#/components/schemas/MessageWebhookPayload'
        - type: object
          properties:
            type:
              enum:
                - message.transient_error
    MessageFailedWebhookPayload:
      allOf:
        - $ref: '#/components/schemas/MessageWebhookPayload'
        - type: object
          properties:
            type:
              enum:
                - message.failed
    MessageBouncedWebhookPayload:
      allOf:
        - $ref: '#/components/schemas/MessageWebhookPayload'
        - type: object
          properties:
            type:
              enum:
                - message.bounced
    MessageSuppressedWebhookPayload:
      allOf:
        - $ref: '#/components/schemas/MessageWebhookPayload'
        - type: object
          properties:
            type:
              enum:
                - message.suppressed
    MessageOpenedWebhookPayload:
      allOf:
        - $ref: '#/components/schemas/MessageWebhookPayload'
        - type: object
          properties:
            type:
              enum:
                - message.opened
    MessageClickedWebhookPayload:
      type: object
      required:
        - type
        - webhook_id
        - timestamp
        - data
      properties:
        type:
          type: string
          enum:
            - message.clicked
          description: The event type
        webhook_id:
          type: string
          description: The webhook ID (the same ID as returned by the [Create Webhook API](https://ahasend.com/docs/api-reference/webhooks/create-webhook))
          format: uuid
        timestamp:
          type: string
          format: date-time
          description: When the event occurred (ISO 8601 format)
        data:
          $ref: '#/components/schemas/MessageClickedWebhookData'

    MessageWebhookData:
      type: object
      required:
        - account_id
        - event
        - from
        - recipient
        - subject
        - message_id_header
        - id
      properties:
        account_id:
          type: string
          format: uuid
          description: Account ID that sent the message
        event:
          type: string
          enum:
            - on_reception
            - on_delivered
            - on_transient_error
            - on_failed
            - on_bounced
            - on_suppressed
            - on_opened
            - on_clicked
          description: The specific event that occurred
        from:
          type: string
          format: email
          description: Sender email address
        recipient:
          type: string
          format: email
          description: Recipient email address
        subject:
          type: string
          description: Email subject line
        message_id_header:
          type: string
          description: Message ID header (same as returned by the [Create Message API](https://ahasend.com/docs/api-reference/messages/create-message))
        id:
          type: string
          description: Internal AhaSend message ID
        user_agent:
          type: string
          description: User agent (only for opened/clicked events)
        ip:
          type: string
          description: IP address (only for opened/clicked events)
        is_bot:
          type: boolean
          description: |
            Whether the action was performed by a bot.

            Bot detection runs on every open and click and always yields a definite
            true or false. Current deliveries include the field on every message
            event; it is meaningful for `message.opened` and always `false`
            elsewhere. Older deliveries may omit it — absence carries no signal, so
            do not infer `false` from a missing field and never reject a delivery
            because of it.
        delivery_attempt:
          description: |
            Diagnostics for the delivery attempt this event reports on: the
            SMTP status code, the response text, and — on failures — the
            bucket the bounce classifier assigned.

            Optional, and absent more often than present. Today it is
            `message.delivered`, `message.bounced`, and
            `message.transient_error` that carry it, and only when an SMTP
            attempt was actually recorded. These are the cases where no attempt
            is recorded, and so no object is sent:

            - **Retry exhaustion.** A message that runs out of retries arrives
              as `message.failed`, which reports the exhaustion rather than a
              single attempt.
            - **Out-of-band bounces**, where the DSN arrives separately after
              the destination already accepted the message.
            - **Non-SMTP routing**, where the message was handled outside SMTP.
              Sandbox sends are the exception: they synthesize a representative
              attempt rather than omitting one.
            - **Empty responses**, carrying neither a status code nor any text.

            These cases are not closed, and an explicit `null` means the same
            as a missing field. Always read the object with optional chaining
            and never treat its absence as an error. Equally, do not treat its
            presence on another event sharing this data shape as impossible.
            Events with their own data shape — `message.clicked`,
            `message.routing`, `suppression.created`, `domain.dns_error` —
            never carry one.

            On test webhooks and sandbox deliveries the values are
            representative rather than observed. A sandbox simulation can
            replace the sample classification with the classification of the
            selected outcome, so it may not agree with the representative SMTP
            code and response. Do not calibrate a `classification` switch
            against sandbox traffic.
          $ref: '#/components/schemas/DeliveryAttempt'

    MessageClickedWebhookData:
      type: object
      required:
        - account_id
        - event
        - from
        - recipient
        - subject
        - message_id_header
        - url
        - user_agent
        - ip
        - id
      properties:
        account_id:
          type: string
          format: uuid
          description: Account ID that sent the message
        event:
          type: string
          description: The specific event that occurred
        from:
          type: string
          format: email
          description: Sender email address
        recipient:
          type: string
          format: email
          description: Recipient email address
        subject:
          type: string
          description: Email subject line
        message_id_header:
          type: string
          description: Message ID header (same as returned by the [Create Message API](https://ahasend.com/docs/api-reference/messages/create-message))
        url:
          type: string
          description: The URL that was clicked
        user_agent:
          type: string
          description: User agent of the client that clicked the link
        ip:
          type: string
          description: IP address of the client that clicked the link
        id:
          type: string
          description: Internal AhaSend message ID
        is_bot:
          type: boolean
          description: |
            Whether the click was performed by a bot.

            Bot detection runs on every click, so the field is always present with a
            definite true or false. It stays optional in this schema for tolerance:
            if it were ever absent, do not infer `false` and do not reject the
            delivery.

    DeliveryAttempt:
      # `null` is admitted alongside the object deliberately: it carries exactly
      # the same meaning as the field being absent, so a consumer that rejected
      # it would answer 400 to a delivery that told it nothing was recorded, and
      # 100 consecutive failures disable a webhook. Absent, `null`, and an
      # object are the three states a reader must handle.
      type: ["object", "null"]
      description: |
        Diagnostics from a single delivery attempt: the SMTP status code, the
        response text, and — on failures — the bucket the bounce classifier
        assigned.
      required:
        - smtp_code
      properties:
        classification:
          type: string
          description: |
            Bucket the bounce classifier assigned to this attempt. Absent on
            successful deliveries, and absent on a failure the classifier did
            not label.

            Known values: `InvalidRecipient`, `BadDomain`, `InactiveMailbox`, `InvalidSender`, `QuotaIssues`, `NoAnswerFromHost`, `BadConnection`, `DNSFailure`, `RoutingErrors`, `TransientFailure`, `MessageExpired`, `ProtocolErrors`, `AuthenticationFailed`, `PolicyRelated`, `Uncategorized`.

            This is **not** a closed set. New buckets can be introduced at any
            time, and this list describes what is emitted today rather than
            everything that may ever appear. Branch on the values you know and
            keep a fallback branch for everything else; never reject a delivery
            because the value is unfamiliar.

            Note that `TransientFailure` here is a classification, unrelated to
            the `message.transient_error` event type or to the `event` field.
          x-known-values:
            - InvalidRecipient
            - BadDomain
            - InactiveMailbox
            - InvalidSender
            - QuotaIssues
            - NoAnswerFromHost
            - BadConnection
            - DNSFailure
            - RoutingErrors
            - TransientFailure
            - MessageExpired
            - ProtocolErrors
            - AuthenticationFailed
            - PolicyRelated
            - Uncategorized
        smtp_code:
          type: integer
          minimum: 0
          description: |
            SMTP status code recorded for the attempt. Usually this is the code
            returned by the destination. Always present when this object is
            present. `0` means AhaSend recorded response content without an SMTP
            code — an internal error, typically — so do not read `0` as the
            field being absent.
        enhanced_status_code:
          type: string
          description: |
            RFC 3463 enhanced status code in `class.subject.detail` form (for
            example `5.1.1`), when one was recorded for the attempt.
        response:
          type: string
          description: |
            Text describing the outcome, as the MTA recorded it. Present when
            non-empty.

            Two things it is not. It is not reliably code-free: the reply code
            usually lives in `smtp_code` alone, but some destinations leave the
            reply code, the enhanced code, or both at the front of the text, so
            do not assume a bare sentence and do not parse codes back out of
            it. And it is not always the destination's own words: when the
            failure was raised inside AhaSend rather than by the destination,
            this carries AhaSend's description of that failure instead.

            Read it as human-readable diagnostics. Branch on `classification`,
            `smtp_code`, and `enhanced_status_code`.
        description:
          type: string
          description: |
            Human-readable translation of a complex `response`, present only
            when that translation differs from `response` itself.

            This is display prose, not an identifier: its wording changes as
            the translations improve. Never compare it to a literal and never
            parse it — branch on `classification`, `smtp_code`, or
            `enhanced_status_code` instead.
        command:
          type: string
          description: |
            Normalized SMTP command name that was in flight when the attempt was
            recorded (for example `RCPT TO` or `DATA`), when the MTA reported
            one. Command arguments such as envelope addresses are not included.

    SuppressionWebhookPayload:
      type: object
      required:
        - type
        - webhook_id
        - timestamp
        - data
      properties:
        type:
          type: string
          enum: [suppression.created]
          description: The event type
        webhook_id:
          type: string
          description: The webhook ID (the same ID as returned by the [Create Webhook API](https://ahasend.com/docs/api-reference/webhooks/create-webhook))
          format: uuid
        timestamp:
          type: string
          format: date-time
          description: When the event occurred (ISO 8601 format)
        data:
          $ref: '#/components/schemas/SuppressionWebhookData'

    SuppressionWebhookData:
      type: object
      required:
        - account_id
        - recipient
        - created_at
        - expires_at
        - reason
        - sending_domain
      properties:
        account_id:
          type: string
          format: uuid
          description: Account ID
        recipient:
          type: string
          format: email
          description: Suppressed email address
        created_at:
          type: string
          format: date-time
          description: When the suppression was created
        expires_at:
          type: string
          format: date-time
          description: When the suppression expires (typically 30 days from creation)
        reason:
          type: string
          description: Reason for suppression (e.g., "Too many hard bounces")
        sending_domain:
          type: string
          description: Domain from which the failed emails were sent

    DomainWebhookPayload:
      type: object
      required:
        - type
        - webhook_id
        - timestamp
        - data
      properties:
        type:
          type: string
          enum: [domain.dns_error]
          description: The event type
        webhook_id:
          type: string
          description: The webhook ID (the same ID as returned by the [Create Webhook API](https://ahasend.com/docs/api-reference/webhooks/create-webhook))
          format: uuid
        timestamp:
          type: string
          format: date-time
          description: When the event occurred (ISO 8601 format)
        data:
          $ref: '#/components/schemas/DomainWebhookData'

    DomainWebhookData:
      type: object
      required:
        - domain
        - account_id
        - spf_valid
        - dkim_valid
        - dmarc_valid
        - dns_last_checked_at
      properties:
        domain:
          type: string
          description: Domain name with DNS issues
        account_id:
          type: string
          format: uuid
          description: Account ID
        spf_valid:
          type: boolean
          description: Whether SPF record is valid
        dkim_valid:
          type: boolean
          description: Whether DKIM record is valid
        dmarc_valid:
          type: boolean
          description: Whether DMARC record is valid
        dns_last_checked_at:
          type: string
          format: date-time
          description: When DNS was last checked

    RouteWebhookPayload:
      type: object
      required:
        - type
        - route_id
        - timestamp
        - data
      properties:
        type:
          type: string
          enum: [message.routing, route.message]
          x-deprecated-values: [route.message]
          description: The event type. `message.routing` is canonical; legacy `route.message` remains accepted as deprecated input.
        timestamp:
          type: string
          format: date-time
          description: When the event occurred (ISO 8601 format)
        route_id:
          type: string
          description: The route ID (the same ID as returned by the [Create Route API](https://ahasend.com/docs/api-reference/routes/create-route))
          format: uuid
        data:
          $ref: '#/components/schemas/RouteWebhookData'

    RouteWebhookData:
      type: object
      # Mirrors the producer's struct tags: every field except `headers`
      # carries no `omitempty`, so Go emits it on every delivery.
      required:
        - id
        - from
        - reply_to
        - to
        - subject
        - message_id
        - size
        - spam_score
        - bounce
        - cc
        - date
        - in_reply_to
        - references
        - auto_submitted
        - html_body
        - plain_body
        - reply_from_plain_body
        - attachments
      properties:
        id:
          type: string
          description: Unique route message ID
        from:
          type: string
          description: Sender mailbox text
        reply_to:
          type: string
          format: email
          description: Reply-to email address
        to:
          type: string
          format: email
          description: Recipient email address (your route endpoint)
        subject:
          type: string
          description: Email subject line
        message_id:
          type: string
          description: Original Message-ID header
        size:
          type: integer
          description: Email size in bytes
        spam_score:
          type: number
          format: float
          description: Spam score (0.0 to 10.0)
        bounce:
          type: boolean
          description: Whether this is a bounce message
        cc:
          type: string
          description: CC recipients
        date:
          type: string
          description: Date header from the email
        in_reply_to:
          type: string
          description: In-Reply-To header
        references:
          type: string
          description: References header
        auto_submitted:
          type: string
          description: Auto-Submitted header
        html_body:
          type: string
          description: HTML body content
        plain_body:
          type: string
          description: Plain text body content
        reply_from_plain_body:
          type: string
          description: Extracted reply content from plain body
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/RouteAttachment'
          description: |
            MIME attachments, if the route is configured to include them. This includes
            conventional attachments, inline parts, and filename-bearing parts without a
            Content-Disposition header. Use `content_id` to resolve `cid:` references in
            `html_body`.
        headers:
          type: object
          additionalProperties:
            type: string
          description: Email headers (if route is configured to include them)

    RouteAttachment:
      type: object
      required:
        - filename
        - content_type
        - content_id
        - disposition
        - data
      properties:
        filename:
          type: string
          description: Attachment filename
        content_type:
          type: string
          description: MIME content type
        content_id:
          type: string
          description: |
            Content-ID with the angle brackets stripped and the remainder
            percent-decoded, so `<a+b%40x>` arrives as `a b@x`. Empty when the part had
            no Content-ID header — the key is always present.

            This is what distinguishes an embedded part from a real attachment, not
            `disposition`: an image referenced by a `cid:` URL commonly arrives with no
            Content-Disposition header at all.
        disposition:
          type: string
          description: |
            Content-Disposition type of the MIME part, usually `attachment` or `inline`,
            and `""` when the part carried no Content-Disposition header.

            Note that `""` does not mean "not inline". An embedded image sent as a
            `multipart/related` part with a Content-ID and no Content-Disposition — what
            Gmail and Outlook produce — arrives with `disposition: ""` and a populated
            `content_id`. Use `content_id` to separate embedded parts from real
            attachments.

            The key is always present, and the value is deliberately unconstrained: it
            is whatever token the sending mail server wrote, merely lowercased and
            trimmed of parameters, so tokens other than `attachment` and `inline` (for
            example `form-data`) do arrive in practice.

            Do not validate this field against a fixed list of values — an arbitrary
            sender could then make your receiver fail, and repeated delivery failures
            can disable the webhook. Treat an unrecognized value as `attachment`.
        data:
          type: string
          description: Base64-encoded attachment data

tags:
  - name: Message Events
    description: |
      Webhooks for outbound email delivery tracking.

      These events track the lifecycle of emails you send through AhaSend, from initial reception through final delivery or failure.
  - name: Suppression Events
    description: |
      Webhooks for email suppression list changes.

      These events notify you when email addresses are automatically added to your suppression list due to delivery issues.
  - name: Domain Events
    description: |
      Webhooks for domain configuration issues.

      These events alert you to DNS configuration problems that could prevent email delivery.
  - name: Route Events
    description: |
      Webhooks for inbound email processing.

      These events deliver inbound emails to your application for processing (e.g., support tickets, replies).
