---
title: "Engagement history for several addresses"
method: POST
path: "/v1/activity/batch"
tags: ["Activity"]
---

# Engagement history for several addresses

`POST /v1/activity/batch`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/activity/batch`
- Auth: one of:
  - API key in header `X-Api-Key`
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.kawaa.com/oauth/authorize`, token URL `https://api.kawaa.com/oauth/token`, scopes: `verification:write`)

The same lookup for a list. A POST because the addresses go in the body, but it is NOT free and NOT side-effect-free: every non-empty batch debits `0.25 credits per address` BEFORE the lookup runs, whatever it finds. Note the rate: the single-address form costs 0.5, so a hundred addresses here is 25 credits, not 50. Send `Idempotency-Key` and reuse it on a retry; without one, a retry after a timeout buys the batch again.

## Headers

- `Idempotency-Key` string

## Request body

- object: `emails` is the only field. Any other top-level key is refused with `400 Unknown request field` before anything is charged, as on every other paid route.
  - `emails` string[], required: Up to 100 addresses, each at most 254 raw characters. Syntax is checked after trimming surrounding whitespace, but the submitted spelling is not normalized. The API refuses the request with 400 before any lookup if the list is longer, or if ANY address is malformed or over-long: one bad entry rejects the whole batch rather than being skipped. Every address in the list is charged for, including one with no recorded activity: the charge is for the lookup, not for a hit.

## Response `200`

One row per address, in the order they were sent.

- object
  - `success` unknown, required
  - `data` object, required
    - `results` object[], required: One entry per address submitted, in order, so a caller can zip it back to its input.
      - `email` string, required: The address exactly as it was sent, including surrounding whitespace. The API trims it only to validate it and look it up, so this response deliberately has no `format: email`: a paid successful response can contain a whitespace-padded value that a standard format validator would reject.
      - `email_hash` string, required: SHA-256 of the lower-cased, trimmed address. The key the data is stored under.
      - `activity` ActivityData, required
        - `level` 'active' | 'inactive' | 'dormant' | 'unknown', required: How recently this address was seen active. `inactive` sits between the active and dormant windows; `unknown` means there was nothing to judge from, not that the address is inactive.
        - `score` number, required
        - `last_activity_at` string, date-time, nullable, required
        - `engagement` object, required
          - `open_count` integer, required
          - `click_count` integer, required
          - `reply_count` integer, required: Counts replies recorded by Kawaa email warmup campaigns only. It is not a measurement of replies to your own mail, which Kawaa cannot see. Warmup is switched off, so no new replies are recorded, and new opens and clicks come only from your own tracking pixels and links.
        - `deliverability` object, required
          - `bounce_count` integer, nullable, required
          - `complaint_count` integer, nullable, required
        - `confidence` number, required: 0 means your account has recorded no activity for this address, not that it is inactive.
        - `data_points` integer, required
        - `message` string: Present only when no activity data is available for a single-address lookup.
    - `total` integer, required: `results.length`.
    - `credits_used` number, required: 0.25 per address submitted, charged whether or not a row was found. **On a replay this is 0.** Repeating the request under the same `Idempotency-Key` returns the earlier result with `credits_used: 0` and `idempotent_replay: true`; the per-address rate describes a NEW lookup only.
    - `credits_remaining` number
    - `idempotent_replay` boolean: Present and true when this replayed an earlier call under the same `Idempotency-Key` and charged nothing.

## Other responses

- `400`: The request is malformed, or names a field this endpoint does not accept. Retrying it unchanged will fail identically.
- `401`: No credential, or one that is not accepted. `code` distinguishes `API_KEY_SUSPENDED` (subscription ended; restartable) from `API_KEY_REVOKED`, `SESSION_EXPIRED` and `OAUTH_TOKEN_EXPIRED` (a connected app's one-hour access token lapsed: refresh it and retry, or sign in again if no refresh token was issued or the refresh is refused).
- `402`: The account cannot pay for this request. The message carries the numbers and where to top up. Nothing was charged.
- `403`: Refused, and **the code says which of three problems it is**, and they need different remedies: - `INSUFFICIENT_SCOPE`: the credential is valid but not scoped for this route. With an API key, issue one that carries the scope named in the message. With an OAuth access token, connect the app again asking for that scope as well as the ones it has, unless the message says a connected app can never be given it; then use an API key with every scope the message names, or have the account owner do it in the dashboard. - `PLAN_UPGRADE_REQUIRED`: the account's plan does not include the feature. **No key of any scope reaches it**, so issuing another is wasted work; the remedy is a plan change. Returned by the verification routes for `options.enrich`, and by the finder, the monitors, the audit log, auto-clean and the white-label routes. - `PLAN_LIMIT_EXCEEDED`: the plan includes the feature and the account is at its ceiling (webhook endpoints, team seats, integrations, auto-clean lists). Delete one or upgrade. - `FORBIDDEN`: refused for a reason none of the above covers. `ErrorCode` is an open set, so branch on the code with a default rather than exhaustively.
- `409`: The `Idempotency-Key` was used before for a different request on this route. No credit moved. Repeat the original request exactly, or use a new key.
- `429`: Too many requests. When the server knows how long to wait, `Retry-After` says so and `error.retry_after_seconds` carries the same number; the `X-RateLimit-*` headers say where you stand.
- `500`: The lookup was charged for and then failed, or its result could not be stored. The refund is attempted and can itself fail, leaving the charge unsettled, which is what makes this materially different from the 503 beside it, where nothing was charged. Check `GET /v1/usage` before paying for another attempt.
- `503`: The retry protection for this `Idempotency-Key` could not be resolved, or a concurrent attempt was refunded. Nothing was charged. **Retry with the SAME key**: a new one buys the lookup again.
- `default`: Any status this operation does not list. Kawaa's own errors carry the error envelope. The gateway in front of the API can answer any operation with its own bare body (`GatewayError`): a `503` after about 29 seconds, when the request may still have run and been charged, a `413` for a body over about 6 MB, or a `400` for a path with malformed percent-encoding.

---

[API](https://skmtc.dev/kawaa/apis/kawaa-api.md) · [All operations](https://skmtc.dev/kawaa/apis/kawaa-api/llms.txt) · [OpenAPI document](https://skmtc.dev/kawaa/apis/kawaa-api/revisions/d27c9c33f6ba?raw)
