> ## 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. 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.

# Get Email Statistics via the API

> Get stored email counts from the AhaSend API for any period of your account's history, by hour, day, week or month, for the whole account or per domain.

The AhaSend API returns the stored email counts of the account, or of each of its domains, for any period of the account's history. Unlike the transactional reports, it is not limited to the account's [message metadata retention](https://ahasend.com/docs/retention.md#message-data-vs-metadata) period. The counts cover transactional, campaign and inbound mail.

A key with `statistics:read:all` can query every domain. A
domain-scoped key reads only the domains in its scopes and must be able
to read every comma-separated name supplied in `sender_domain`.

**Query Parameters:**

* `from_time`: Start of the range, included (RFC3339 format). Defaults to 30 days before `to_time`
* `to_time`: End of the range, excluded (RFC3339 format). Defaults to now
* `group_by`: Group by time period (hour, day, week, month). Defaults to day
* `sender_domain`: Filter by sender domain (comma-separated, deleted domains included)
* `breakdown`: `domain` returns the counts of each domain, paged by domain
* `limit` and `after`: Page through the domains of a breakdown

## Related Guides

* [API authentication](https://ahasend.com/docs/api-reference/authentication.md), [Permission scopes](https://ahasend.com/docs/api-reference/scopes.md), [Statistics CLI](https://ahasend.com/docs/cli/commands/stats.md).
* [Delivery statistics API](https://ahasend.com/docs/api-reference/reports/get-deliverability-report.md), [API rate limits](https://ahasend.com/docs/api-reference/rate-limits.md), [API errors](https://ahasend.com/docs/api-reference/errors.md).
* [Retention rules](https://ahasend.com/docs/retention.md).


## OpenAPI

````yaml openapi.yaml GET /v2/accounts/{account_id}/statistics/emails
openapi: 3.1.0
info:
  title: AhaSend API v2
  description: >
    The AhaSend API v2 allows you to send transactional emails, manage domains,
    webhooks, routes, API keys, and view statistics.


    ## Authentication

    All API requests must be authenticated using a Bearer token in the
    Authorization header:

    ```

    Authorization: Bearer aha-sk-64-CHARACTER-RANDOM-STRING

    ```


    ## Scopes

    API keys have specific scopes that control access to different resources and
    actions:


    ### Message Scopes

    - `messages:send:all` - Send messages from any domain in the account

    - `messages:send:{domain}` - Send messages from a specific domain

    - `messages:cancel:all` - Cancel messages from any domain

    - `messages:cancel:{domain}` - Cancel messages from a specific domain

    - `messages:read:all` - Read messages from any domain

    - `messages:read:{domain}` - Read messages from a specific domain


    ### Domain Scopes

    - `domains:read` - Read all domains

    - `domains:write` - Create and update domains

    - `domains:delete:all` - Delete any domain

    - `domains:delete:{domain}` - Delete a specific domain


    ### Account Scopes

    - `accounts:read` - Read account information

    - `accounts:write` - Update account settings

    - `accounts:billing` - Access billing information

    - `accounts:members:read` - Read account members

    - `accounts:members:add` - Add account members

    - `accounts:members:update` - Update account members

    - `accounts:members:remove` - Remove account members


    ### Webhook Scopes

    - `webhooks:read:all` - Read all webhooks

    - `webhooks:read:{domain}` - Read webhooks for a specific domain

    - `webhooks:write:all` - Create and update webhooks

    - `webhooks:write:{domain}` - Create and update webhooks for a specific
    domain

    - `webhooks:delete:all` - Delete any webhook

    - `webhooks:delete:{domain}` - Delete webhooks for a specific domain


    ### Route Scopes

    - `routes:read:all` - Read all routes

    - `routes:read:{domain}` - Read routes for a specific domain

    - `routes:write:all` - Create and update routes

    - `routes:write:{domain}` - Create and update routes for a specific domain

    - `routes:delete:all` - Delete any route

    - `routes:delete:{domain}` - Delete routes for a specific domain


    ### Suppression Scopes

    - `suppressions:read` - Read suppressions

    - `suppressions:write` - Create suppressions

    - `suppressions:delete` - Delete suppressions

    - `suppressions:wipe` - Delete the account's suppressions except protected
    ones (dangerous)


    ### Template Scopes

    - `templates:read` - Read transactional templates, their drafts and their
    published versions, and campaign templates

    - `templates:write` - Create and update transactional templates, and
    publish, discard and restore their drafts; create and update campaign
    templates

    - `templates:delete` - Delete transactional templates and campaign templates


    ### Campaign Scopes

    - `campaigns:read` - Read campaigns, their stats and their recipients

    - `campaigns:write` - Create, update and duplicate campaigns

    - `campaigns:delete` - Delete draft campaigns

    - `campaigns:send` - Send, unschedule and test-send campaigns


    ### Contact Scopes

    - `contacts:read` - Read contacts

    - `contacts:write` - Create and update contacts

    - `contacts:delete` - Delete contacts


    ### Contact lifecycle and attributes

    Contacts are account-global. A contact created through this API joins no

    list on its own; campaigns target lists, so add it to one with the Lists

    endpoints before a campaign can reach it.

    Contact subscription state and suppression records are independent:

    creating, updating, unsubscribing, resubscribing, or deleting a contact

    never creates, changes, or removes complaint, permanent, or protected

    suppressions. Prefer unsubscribe over delete when the goal is only to stop

    marketing mail, because unsubscribe is reversible and preserves contact

    history.


    Writable attribute keys and types come from the account's contact-attribute

    definitions managed in the dashboard; this API does not expose property

    definition CRUD. Writes accept only canonical values for those definitions.

    Reads can return any JSON value, including nested arrays and objects, so a

    value read back need not match its definition.


    An email used as `{id_or_email}` is a raw SDK argument and must be

    percent-encoded exactly once as a URL path segment by an HTTP client. For

    example, do not pre-encode `user+tag@example.com` before passing it to an

    SDK that encodes path parameters.


    ### List Scopes

    - `lists:read` - Read lists and list membership

    - `lists:write` - Create and update lists, and add or remove their contacts

    - `lists:delete` - Delete lists


    ### Lists and membership

    A list is a static, private, single opt-in container of contacts, and it is

    what a campaign targets. A membership carries its own `subscription_status`

    (`unconfirmed`, `confirmed`, `unsubscribed`, or `complained`), which is

    per-list consent and is independent of the contact's account-wide

    `unsubscribed` flag. A membership created without a status named is

    `confirmed`, because that is what a campaign reaches. `complained` is the

    mailbox provider's verdict, never writable, and a membership holding it is

    frozen.


    Deleting a list keeps its memberships, so an unsubscribe survives it.

    Removing a contact from a list deletes the membership row and discards that

    record with it, which is why unsubscribing is the reversible way to stop

    marketing mail on one list. Expanding a list's members with

    `include_contacts=true` requires `contacts:read` in addition to

    `lists:read`.


    ### Segment Scopes

    - `segments:read` - Read segments, their members and a contact's segments,
    and preview a definition

    - `segments:write` - Create and update segments

    - `segments:delete` - Delete segments


    ### Segments

    A segment is a saved set of rules over the account's contacts: their

    fields, their list memberships, and the campaigns they received, opened

    or clicked. AhaSend keeps its members up to date as that data changes,

    usually within a minute; rules about campaigns follow within an hour of a

    send, and relative times such as `30 days ago` are read again on a

    schedule. Membership is the rule match only, whatever the contact's

    consent: a campaign to a segment reaches the members who are enabled, not

    unsubscribed, not found invalid, and either confirmed on at least one list

    or on no list at all. A contact who unsubscribed from or complained on

    every list they were on, or has not confirmed a double opt-in, is not

    reached. A contact who unsubscribes from a segment send, or reports it as

    spam, gets no more marketing email from the account.


    ### SMTP Credentials Scopes

    - `smtp-credentials:read:all` - Read all SMTP credentials

    - `smtp-credentials:read:{domain}` - Read SMTP credentials for a specific
    domain

    - `smtp-credentials:write:all` - Create SMTP credentials

    - `smtp-credentials:write:{domain}` - Create SMTP credentials for a specific
    domain

    - `smtp-credentials:delete:all` - Delete any SMTP credentials

    - `smtp-credentials:delete:{domain}` - Delete SMTP credentials for a
    specific domain


    ### Statistics Scopes

    - `statistics:read:all` - Read all transactional statistics, and the email
    statistics of every domain, which also count campaign and inbound mail

    - `statistics:read:{domain}` - Read transactional statistics for a specific
    domain, and its email statistics, which also count campaign and inbound mail


    The API also accepts `statistics-transactional:read:all` and
    `statistics-transactional:read:{domain}`, which give the same access, and
    returns a scope as it was saved.


    ### API Key Scopes

    - `api-keys:read` - Read API keys

    - `api-keys:write` - Create and update API keys

    - `api-keys:delete` - Delete API keys


    ### Sub-Account Scopes

    - `sub-accounts:read` - List and read sub accounts under the parent

    - `sub-accounts:write` - Create and update sub accounts

    - `sub-accounts:delete` - Soft-delete sub accounts

    - `sub-accounts:suspend` - Pause and resume sub accounts, and unpause their
    domains

    - `sub-accounts:usage` - Read per-sub-account usage and allocated cost

    - `sub-account-api-keys:read` - List and read API keys owned by sub accounts

    - `sub-account-api-keys:write` - Create and update API keys owned by sub
    accounts

    - `sub-account-api-keys:delete` - Delete API keys owned by sub accounts


    ## Rate Limiting

    - General API endpoints: 100 requests per second, 200 burst

    - Transactional statistics endpoints (`/statistics/transactional/*`): 1
    request per second, 1 burst


    A request over the limit gets HTTP 429 with a `Retry-After` header: the

    number of seconds to wait before retrying.


    ## Pagination

    List endpoints use cursor-based pagination with the following parameters:

    - `limit`: Maximum number of items to return (default: 100, max: 100)

    - `after`: Pass `pagination.next_cursor` to fetch the next page

    - `before`: Pass `pagination.previous_cursor` to fetch the previous page


    `after` and `before` are mutually exclusive.


    ## Time Formats

    All timestamps must be in RFC3339 format, e.g., `2023-12-25T10:30:00Z`


    ## Idempotency

    The operations that explicitly declare the optional `Idempotency-Key`

    header support idempotency. Other POST operations do not gain idempotency

    implicitly. When the header is provided:

    - A fresh execution does not return `Idempotent-Replayed`.

    - A completed stored response is replayed with its original status and body
      and `Idempotent-Replayed: true`.
    - An unexpired in-progress execution returns HTTP 409 with
      `Idempotent-Replayed: false` and a positive integer `Retry-After` header.
    - Reusing a key with a different HTTP method, resolved request path, or
      request body returns HTTP 422. That response has neither
      `Idempotent-Replayed` nor `Retry-After`.
    - For ordinary idempotent operations, deterministic 2xx and 4xx outcomes
      produced after the idempotency middleware are stored and replayed.
      Authentication and IP-allow-list failures happen before idempotency and
      are therefore not stored.
    - The two secret-bearing API-key create operations use manual encrypted
      completion. They store and replay a successful 201, including the same
      one-time `secret_key`; a controller-produced 4xx is not stored and leaves
      the key in progress until its execution lease expires.
    - Create Segment and Test Campaign store and replay a 2xx only. A 4xx is
      not stored: it releases the key, so a retry with the same key runs
      again.
    - Server errors (5xx), handler failures, and panics are not stored. The key
      is released so retrying the same request with the same key re-executes it.
      The v2 API does not use an HTTP 412 failed-key tombstone.
    - Idempotency keys for non-secret responses expire after 24 hours.

    - Encrypted API-key creation replay responses expire after 5 minutes.


    ## IP Allow Lists

    Each API key can carry an `ip_allow_list`: a set of source IPs that restrict
    where the key may authenticate from.

    - Entries are CIDR blocks (e.g. `203.0.113.0/24`) or bare IPv4/IPv6
    addresses (stored as a `/32` or `/128`). They are canonicalized and
    de-duplicated; the allow-all prefixes `0.0.0.0/0` and `::/0` are rejected,
    and at most 100 entries are allowed after de-duplication (a longer list is
    rejected with HTTP 400).

    - An empty list (the default) places no restriction.

    - When the list is non-empty, any authenticated request whose client IP is
    not covered by an entry is rejected with HTTP 403 on every v2 endpoint,
    regardless of the key's scopes.

    - Manage the list with the `ip_allow_list` field on the API-key create and
    update endpoints (including the sub-account API-key endpoints).

    - When a key updates its own `ip_allow_list` to a value that excludes the
    caller's current IP, the update is rejected with HTTP 409 to prevent
    self-lockout. Updating a sub-account key from a parent key has no such
    guard.
  version: 2.0.0
  contact:
    email: support@ahasend.com
  license:
    name: MIT
    identifier: MIT
servers:
  - url: https://api.ahasend.com
    description: Production server
security:
  - BearerAuth: []
tags:
  - name: Utility
    description: Utility endpoints for health checks and diagnostics
  - name: API Keys
    description: Manage API keys for authentication and access control
  - name: Domains
    description: Manage sending domains
  - name: Messages
    description: Send and manage transactional messages
  - name: Accounts
    description: Manage account settings and members
  - name: Contacts
    description: Read, create, update, batch upsert, and delete account-global contacts
  - name: Templates
    description: >-
      Create, change, publish and delete the account's transactional templates,
      and read them with their drafts, their published versions and the
      variables a send must supply
  - name: Campaign Templates
    description: >-
      Create, change, read and delete the account's campaign templates, the
      designs a campaign copies when it is made from one
  - name: Campaigns
    description: >-
      Create, change, read, duplicate, delete, test and send the account's
      marketing campaigns, and read their stats and recipients
  - name: Lists
    description: Manage contact lists and the contacts on them
  - name: Segments
    description: >-
      Manage segments, the saved rules that keep a set of contacts up to date,
      and read their members
  - name: Suppressions
    description: Manage email suppressions
  - name: Routes
    description: Manage inbound email routing
  - name: Webhooks
    description: Manage webhook notifications
  - name: SMTP Credentials
    description: Manage SMTP authentication credentials
  - name: Statistics
    description: >-
      Access transactional email statistics, and email statistics that also
      count campaign and inbound mail
  - name: Message Events
    description: Webhooks for outbound message delivery events
  - name: Suppression Events
    description: Webhooks for suppression list changes
  - name: Domain Events
    description: Webhooks for domain configuration issues
  - name: Route Events
    description: Webhooks for inbound email routing
paths:
  /v2/accounts/{account_id}/statistics/emails:
    get:
      tags:
        - Statistics
      summary: Get Email Statistics
      description: >
        Returns the email counts of the account, or of each of its domains, in
        hourly, daily, weekly or monthly periods, for any time in the account's
        history. It is not limited to the account's message metadata retention
        period, as the transactional statistics are.


        **What is counted:**

        The counts cover transactional, campaign and inbound mail. Inbound mail
        is counted under the domain that received it, so `sender_domain` names
        the account's domain in both directions. Sandbox mail is not counted. A
        counter is placed in the period in which its event happened.


        `deferred_count`, `opened_count` and `clicked_count` count events, and
        one message can add more than one. The other counters count a message at
        most once. Get Deliverability Statistics counts each message once, by
        its current status, so the two endpoints do not return the same numbers
        for the same range.


        Opens and clicks are not fully accurate. AhaSend makes a reasonable
        effort to filter out bots and scanners.


        A bounce report can arrive after the message was counted as delivered.
        AhaSend then takes one delivery away and adds one bounce, both in the
        period in which a transactional message was accepted or a campaign
        message was delivered. That period can differ from the one in which the
        delivery was counted, so a period can lose more deliveries than it
        holds. Every counter in the response is at least 0: a counter of a row
        or of `totals` that comes out below 0 is returned as 0.


        **Domains:**

        A domain is identified by its name. Deleted domains are included. When a
        domain was deleted and added again with the same name, the counts of
        both are added together.


        **Access:**

        The API key needs `statistics:read:all` or at least one
        `statistics:read:{domain}` scope. A key with `statistics:read:all` reads
        every domain of the account. A key with domain scopes reads the domains
        named in its scopes, a deleted domain included. Without `sender_domain`,
        the request selects every domain the key can read. The key must be able
        to read each name in `sender_domain`, or the request gets a 403. The
        account must have, or have had, a domain with each name, or the request
        gets a 400. The scope is checked first, then the parameters and the
        range, then `sender_domain`.


        **Range and periods:**

        Periods are in UTC, and weeks start on Monday. A range includes
        `from_time` and excludes `to_time`. A missing `to_time`, or one after
        now, is now. A missing `from_time` is 30 days before `to_time`.
        `from_time` must be before `to_time`.


        The range can be at most 31 days for `group_by=hour`, 366 days for `day`
        and `week`, and 5 calendar years for `month`. The range is checked
        before it is rounded. A longer range gets a 400 that advises a larger
        `group_by` or a shorter range, or, for `month`, more than one request.


        Then `from_time` is rounded down to the start of its period, and
        `to_time` up to the start of the next period unless it is already at the
        start of a period. The response gives the rounded range. For example, a
        request with no parameters at 14:00 UTC returns 31 daily periods: today
        and the 30 days before it.


        **Rows:**

        Without a breakdown, `data` has one row for each period of the range, in
        time order. A period without counts has all counters at 0. With
        `breakdown=domain`, `data` has a row for a domain and a period only when
        AhaSend has counts for that domain in that period. The rows are ordered
        by domain name, then by time. `to_timestamp` of a row is the start of
        the next period.


        **Paging of a breakdown:**

        A page holds up to `limit` domains, each with all its periods in the
        range. Only domains with counts in the range are listed, ordered by name
        in byte order. When `pagination.has_more` is true, pass
        `pagination.next_cursor` as `after` to fetch the next page. Paging is
        forward only: `before` is not accepted, and `previous_cursor` is never
        returned. `limit` and `after` are accepted only with `breakdown=domain`.


        **Totals:**

        `totals` covers the whole request on every page: the whole rounded range
        and every domain the request selects. When the key reads every domain
        and the request has no `sender_domain`, `totals` and the rows without a
        breakdown are the counts of the account. Otherwise, and for the rows of
        a breakdown, they are the sums of the counts of the domains. AhaSend
        keeps the counts of the account and of its domains apart, and the two
        can differ by a few events, so the rows of a breakdown can add up to a
        few events more or less than `totals`. `totals` adds the counts before
        any counter is raised to 0, so it is not always the sum of the rows.
      operationId: getEmailStatistics
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: from_time
          in: query
          description: >-
            Start of the range, included. It is rounded down to the start of its
            period. Defaults to 30 days before `to_time`. It cannot be before
            2000-01-01.
          schema:
            type: string
            format: date-time
        - name: to_time
          in: query
          description: >-
            End of the range, excluded. It is rounded up to the start of the
            next period unless it is already at the start of a period. Defaults
            to now; a time after now is now.
          schema:
            type: string
            format: date-time
        - name: group_by
          in: query
          description: >-
            Length of each period, in UTC. Weeks start on Monday. The range can
            be at most 31 days for `hour`, 366 days for `day` and `week`, and 5
            calendar years for `month`.
          schema:
            type: string
            enum:
              - hour
              - day
              - week
              - month
            default: day
        - name: sender_domain
          in: query
          description: >-
            Comma-separated domain names of the account, at most 50, deleted
            domains included; the key must be able to read every name. Defaults
            to every domain the key can read.
          schema:
            type: string
          example: example.com,example.org
        - name: breakdown
          in: query
          description: >-
            `domain` returns the rows of each domain, paged by domain. Without
            it, each row covers all the domains the request selects.
          schema:
            type: string
            enum:
              - domain
        - name: limit
          in: query
          description: >-
            Maximum number of domains on a page of a breakdown (1-50). Only with
            `breakdown=domain`.
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 25
        - name: after
          in: query
          description: >-
            Pagination cursor for the next page of a breakdown. Provide the
            value from `next_cursor` in the response. Only with
            `breakdown=domain`.
          schema:
            type: string
      responses:
        '200':
          description: Email statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailStatisticsResponse'
              example:
                object: email_statistics
                from_time: '2026-08-01T00:00:00Z'
                to_time: '2026-11-01T00:00:00Z'
                group_by: month
                breakdown: domain
                totals:
                  reception_count: 184200
                  delivered_count: 181050
                  bounced_count: 1900
                  suppressed_count: 1250
                  complained_count: 12
                  deferred_count: 2310
                  opened_count: 60210
                  clicked_count: 8400
                data:
                  - from_timestamp: '2026-08-01T00:00:00Z'
                    to_timestamp: '2026-09-01T00:00:00Z'
                    sender_domain: example.com
                    reception_count: 61000
                    delivered_count: 60100
                    bounced_count: 610
                    suppressed_count: 290
                    complained_count: 4
                    deferred_count: 800
                    opened_count: 20100
                    clicked_count: 2800
                pagination:
                  has_more: false
        '400':
          description: >
            A parameter that is not valid; `limit` or `after` without
            `breakdown=domain`; `before`; a range that is empty, longer than the
            maximum of its `group_by`, or that starts before 2000-01-01; more
            than 50 names in `sender_domain`; or a `sender_domain` name that is
            empty or that the account has never had as a domain. The message
            names the valid values or advises how to change the request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: >-
                  the range is longer than 31 days, the maximum for
                  group_by=hour; use group_by=day or a shorter range
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            The API key has neither `statistics:read:all` nor a
            `statistics:read:{domain}` scope, or it cannot read a domain in
            `sender_domain`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - BearerAuth:
            - statistics:read:all
        - BearerAuth:
            - statistics:read:{domain}
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"
              "time"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              fromTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
              toTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

              response, _, err := client.StatisticsAPI.GetEmailStatistics(ctx, accountID, requests.GetEmailStatisticsParams{
                FromTime:  &fromTime,
                ToTime:    &toTime,
                GroupBy:   ahasend.String("month"),
                Breakdown: ahasend.String("domain"),
              })
              if err != nil {
                log.Fatalf("Error getting email statistics: %v", err)
              }
              fmt.Printf("Accepted %d messages in 2025\n", response.Totals.ReceptionCount)
              for _, row := range response.Data {
                fmt.Printf("%s %s: %d accepted, %d delivered\n", *row.SenderDomain, row.FromTimestamp.Format("2006-01"), row.ReceptionCount, row.DeliveredCount)
              }
              if response.Pagination.HasMore {
                fmt.Printf("Next page of domains: After = %q\n", *response.Pagination.NextCursor)
              }
            }
        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const statistics = await client.statistics.emails({
              from_time: "2026-01-01T00:00:00Z",
              to_time: "2026-04-01T00:00:00Z",
              group_by: "month",
            });
            console.log("Email statistics loaded.", {
              periods: statistics.data.length,
              delivered: statistics.totals.delivered_count,
            });
components:
  schemas:
    EmailStatisticsResponse:
      type: object
      required:
        - object
        - from_time
        - to_time
        - group_by
        - breakdown
        - totals
        - data
        - pagination
      properties:
        object:
          type: string
          enum:
            - email_statistics
          description: Object type identifier
        from_time:
          type: string
          format: date-time
          description: Start of the rounded range, included
        to_time:
          type: string
          format: date-time
          description: End of the rounded range, excluded
        group_by:
          type: string
          enum:
            - hour
            - day
            - week
            - month
          description: Length of each period
        breakdown:
          type:
            - string
            - 'null'
          enum:
            - domain
            - null
          description: '`domain` for a breakdown by domain. Null without a breakdown.'
        totals:
          $ref: '#/components/schemas/EmailStatisticsTotals'
          description: >-
            The counts of the whole request: the whole rounded range and every
            domain the request selects, on every page of a breakdown
        data:
          type: array
          items:
            $ref: '#/components/schemas/EmailStatistics'
          description: >-
            Without a breakdown, one row for each period, in time order. With a
            breakdown, the rows of the domains of this page, by domain name,
            then by time.
        pagination:
          $ref: '#/components/schemas/PaginationInfo'
          description: >-
            Pages the domains of a breakdown. `next_cursor` is present when
            `has_more` is true; `previous_cursor` is never present. Without a
            breakdown, `has_more` is false.
    ErrorResponse:
      type: object
      description: |
        Human-readable API error. The server currently sends no stable machine
        error code. In particular, clients must not parse `message` to
        distinguish IP-allow-list, scope, ownership, plan, self-lockout,
        suppression-duplicate, or other errors that share an HTTP status.
      additionalProperties: false
      required:
        - message
      properties:
        message:
          type: string
          description: Error description
      example:
        message: Error message
    EmailStatisticsTotals:
      type: object
      description: >-
        The email counts of one period, or of the whole request. Every counter
        is at least 0.
      required:
        - reception_count
        - delivered_count
        - bounced_count
        - suppressed_count
        - complained_count
        - deferred_count
        - opened_count
        - clicked_count
      properties:
        reception_count:
          type: integer
          format: int64
          minimum: 0
          description: >-
            Messages that AhaSend accepted, inbound mail included. Billing
            counts the same messages.
        delivered_count:
          type: integer
          format: int64
          minimum: 0
          description: Messages delivered
        bounced_count:
          type: integer
          format: int64
          minimum: 0
          description: Messages bounced
        suppressed_count:
          type: integer
          format: int64
          minimum: 0
          description: Messages not sent because the recipient was suppressed
        complained_count:
          type: integer
          format: int64
          minimum: 0
          description: Spam complaints
        deferred_count:
          type: integer
          format: int64
          minimum: 0
          description: >-
            Temporary delivery failures. This counts events, and one message can
            add more than one, so it differs from `deferred_count` of Get
            Deliverability Statistics, which counts messages.
        opened_count:
          type: integer
          format: int64
          minimum: 0
          description: >-
            Opens. This counts events, and one message can add more than one, so
            it differs from `opened_count` of Get Deliverability Statistics,
            which counts messages. Opens are not fully accurate; AhaSend makes a
            reasonable effort to filter out bots and scanners.
        clicked_count:
          type: integer
          format: int64
          minimum: 0
          description: >-
            Clicks. This counts events, and one message can add more than one,
            so it differs from `clicked_count` of Get Deliverability Statistics,
            which counts messages. Clicks are not fully accurate; AhaSend makes
            a reasonable effort to filter out bots and scanners.
    EmailStatistics:
      description: The email counts of one period, and of one domain in a breakdown.
      allOf:
        - type: object
          required:
            - from_timestamp
            - to_timestamp
            - sender_domain
          properties:
            from_timestamp:
              type: string
              format: date-time
              description: Start of the period
            to_timestamp:
              type: string
              format: date-time
              description: >-
                End of the period, excluded, which is the start of the next
                period
            sender_domain:
              type:
                - string
                - 'null'
              description: >-
                The domain name of the row in a breakdown. Null without a
                breakdown.
        - $ref: '#/components/schemas/EmailStatisticsTotals'
    PaginationInfo:
      type: object
      required:
        - has_more
      properties:
        has_more:
          type: boolean
          description: Whether more items exist after the last item of this page
        next_cursor:
          type: string
          description: Pass as `after` to fetch the next page
        previous_cursor:
          type: string
          description: >-
            Pass as `before` to fetch the previous page; absent on a `before`
            page when the start of the list has been reached
      example:
        has_more: true
        next_cursor: eyJpZCI6MTIzNH0=
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: aha-sk-64-CHARACTER-RANDOM-STRING
      description: |
        API key for authentication. Non-empty Security Requirement values are
        AhaSend API-key roles. Roles listed within one requirement object are
        jointly required; separate requirement objects are alternatives.

````

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