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: |   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. This webhook is called for both inbound and outbound emails. 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: "" 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: |   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: "" 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: |   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: "" 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: |   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: "" 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: |   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: "" 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: |   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: "" 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: |   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: "" 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: |   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: "" 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: |   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: |   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: |   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: "" 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: "

I need help with my account settings.

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