---
title: "Cancel a running batch"
method: POST
path: "/v1/jobs/{id}/cancel"
tags: ["Jobs"]
---

# Cancel a running batch

`POST /v1/jobs/{id}/cancel`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/jobs/{id}/cancel`
- 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`)

Stop a running batch. Unprocessed addresses are not verified, and the credits held for them are refunded.

**The stop is immediate; the refund is not.** The job is `cancelled` the moment this returns, and no message still queued for it will be verified. The refund waits for the addresses already in a worker's hands to finish (the longest an in-flight check can take, plus a margin), because paying back an address that was in fact checked would be wrong in the customer's favour and would leave the job's counters behind its own results. So expect `billing_status: "charged"` on a `cancelled` job for a few minutes, then `"refunded"`. Nothing is required of you in between: reading the job, or the platform's own reconciliation sweep, completes it.

**`charged` does not always become `refunded`.** A cancel that arrives just after the last address was checked owes nothing, and the job keeps `charged` permanently, and correctly, because those credits were spent. Do not wait on the billing status; wait for `completed_at`. A cancel writes the status immediately and leaves `completed_at` null, and every path that closes the books (the refund, and the settle for a job that owed nothing) sets it. While it is null a refund is still on its way; once it is set the books are closed however they closed. The counters agree in the ordinary case but not always, and a job the platform reconciled without moving them would keep a counter-based poller waiting for ever.

**This is about the money only.** The verdicts are final the moment the job reads `cancelled`: every result is written together with the job's progress counter in one conditional write that requires the job to still be running, so once the cancel lands nothing further can be recorded. Read the results, and the export, straight away. A refund that could not be applied ends at `refund_failed`, which is also final and needs support rather than patience.

**What the job keeps.** Every address checked before the cancel stays, and stays billed. `GET /v1/jobs/{id}` returns those results immediately. `GET /v1/jobs/{id}/download` exports them without waiting for the refund: the worker writes each result together with the job's progress counter in one conditional write that requires the job to still be running, so the cancel closes the result set as it lands. Index propagation is a separate, much shorter wait: the results index is eventually consistent, so a first download can still answer `409` (`retryable: true`) or carry `complete: false`. Retry it. `total_emails` collapses onto the addresses actually checked and billed, so `requested_emails` is what you submitted. Read progress as `processed_emails` of `requested_emails`.

**Repeating the request succeeds.** Cancelling an already-cancelled job returns `200` with the job and tries any outstanding refund again; that does not guarantee it lands. `completed_at` says when it has, and `refund_failed` is final and needs support. A job that has already `completed` or `failed` answers `409`: there is nothing left to stop, and its results are unaffected.

Batch and file jobs only. A single verification answers `400`: it finishes in seconds and its credit settles when the result lands.

A cancel and a completion cannot both commit: the worker finishes a job on a condition over the running statuses, so whichever of the two writes first makes the other impossible.

## Path parameters

- `id` string, uuid, required

## Response `200`

The job, now cancelled. `billing_status` is `refunded` once the remainder has been paid back, `charged` while in-flight addresses are still draining, and also `charged` for good when the cancel arrived after the last address, so nothing was owed. Compare `total_emails` with `processed_emails` to tell those two apart.

- object
  - `success` unknown, required
  - `data` Job, required
    - `job_id` string, uuid, required
    - `status` 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'enqueue_unknown', required: The job's state. `enqueue_unknown` means some sends could not be confirmed and credits are held pending reconciliation. Poll until it resolves. **`cancelled` is what `POST /v1/jobs/{id}/cancel` produces.** Stopping a batch leaves the addresses already checked in place, billed, and readable; the credits held for the rest are refunded. Because the refund waits for in-flight addresses to finish, a job can read `cancelled` with `billing_status: "charged"` for a few minutes before it reads `refunded`. That is the refund draining, not a failure, and nothing is required of you. **Cancelled or not, handle it as terminal, and do so immediately.** A cancelled job's verdicts are final the moment the status says so: every result is written together with the job's progress counter in one conditional write requiring the job to still be running, so the cancel closes the result set as it lands. What is NOT final yet is the bill: the refund for the addresses it never checked waits for the in-flight ones to time out. That is what `completed_at` marks, and the Node.js and Python SDKs expose it as `isSettled` / `is_settled`. Read it if you are reconciling the charge; do not make your results wait on it. What must not happen is treating `cancelled` as still-running and polling for ever. Terminal does NOT mean empty: such a job can carry a **partial** result set, and `GET /v1/jobs/{id}?include_results=true` returns it: the API gates on `processed_emails > 0`, not on status, so the addresses it did check come back. Read `processed_emails` against `total_emails` to see how far it got, and do not discard those results. `GET /v1/jobs/{id}/download` exports exactly those addresses too. Read progress as `processed_emails` of `requested_emails`: a job that stopped early has `total_emails` collapsed onto what it actually checked and billed. **A cancel is a brake, not a substitute for sizing the batch.** The submission-time limits still apply and still refuse first: the plan's per-batch ceiling (`413 BATCH_SIZE_EXCEEDED`), the 10,000 global cap, and the credit balance (`402`). Cancelling after the fact returns the unprocessed credits, never the ones already spent.
    - `total_emails` integer
    - `cache_discount_pending` boolean: Present, and only ever `true`, when this read tried to apply the per-address cache discount and could not, almost always because the results index had not caught up, so the cached count could not be trusted yet. The next read retries it. It exists because nothing else on the job tells "checked, nothing owed" apart from "could not check": both leave a `completed` or `cancelled` job's credits unchanged. If you are reconciling the charge, you have one more read to do while this is true.
    - `requested_emails` integer: The list size you submitted. Present only when some of it will never have a result: a job that was cancelled or stopped early, or one where some addresses could not be queued when you submitted it or never came back from the queue. `total_emails` is then lowered to the addresses actually checked and billed, and the rest are refunded. That is how the platform records that the job owes nothing more for them. Read progress as `processed_emails` of this. The same figure as `requested_emails` on the `job.completed` webhook.
    - `partial` boolean: Present, and only ever `true`, exactly when `requested_emails` is: `requested_emails` minus `total_emails` addresses have no result and never will, and their credits were refunded. A `completed` job can carry it. The same signal as `partial` on the `job.completed` webhook, so polling this endpoint after a missed delivery tells you what the delivery would have. Absent while `total_emails` still counts every address you submitted: a cancelled job gains it when the refund for the addresses it never checked is applied, which `completed_at` marks.
    - `file_counts` object: What the uploaded file held, as `POST /v1/verify/file` counted it when it created the job: the same four figures its acceptance response reported, under the same names. Present on the single job, as `GET /v1/jobs/{id}` and `POST /v1/jobs/{id}/cancel` return it, when the job was created from a file; never on the listing, and absent on a job submitted as a list and on a file job recorded before these counts were stored. `total_emails` is only the addresses verified, so this is the one place the rows that were skipped or merged are still accounted for. `total_submitted` = `unique_emails` + `duplicates_removed` + `invalid_emails_skipped`.
      - `total_submitted` integer, required: Rows read from the file, including the invalid ones. A header row is not counted.
      - `invalid_emails_skipped` integer, required: Rows that were not addresses. Skipped, not charged for, and not in the job.
      - `duplicates_removed` integer, required: Repeats of an earlier address (compared case-insensitively). Removed before charging.
      - `unique_emails` integer, required: Distinct addresses the file yielded: what the job was created to verify.
    - `processed_emails` integer
    - `progress_percent` integer
    - `summary` JobSummary: Four buckets, and they are BUCKETS rather than verdicts. The whole rule: `valid` is exactly `valid`; `invalid` is `invalid` AND `disposable`; `risky` is `risky`, `catch_all`, `role` and `spam_trap`; `unknown` is `unknown` and any row with no stored status. A per-address verdict is in the result row, not here, and `catch_all` inside `risky` is the one worth remembering, because it is inconclusive rather than bad.
      - `valid` integer: Exactly `valid`.
      - `invalid` integer: Rows whose verdict is `invalid` OR `disposable`. Building a suppression list from this bucket therefore removes disposable addresses too, which is usually intended.
      - `risky` integer: Rows whose verdict is `risky`, `catch_all`, `role` or `spam_trap`. Mixed: `catch_all` is inconclusive, not bad.
      - `unknown` integer: Rows whose verdict is `unknown`, plus any row with no stored status at all.
    - `results` VerificationResult[]
      - `job_id` string, uuid
      - `batch_job_id` string, uuid: The batch or file job this result belongs to, when it belongs to one: on a batch row of `GET /v1/jobs/{id}` it is the same id as `job_id`. Omitted from a single verification (`POST /v1/verify`, and its job read back through `GET /v1/jobs/{id}`), which belongs to no batch.
      - `email` string, email, required
      - `status` 'valid' | 'invalid' | 'risky' | 'unknown' | 'catch_all' | 'disposable' | 'role' | 'spam_trap', required: The verdict. `catch_all` means the domain accepts mail for every recipient, so this particular mailbox could not be checked: it is neither a yes nor a no. `unknown` means the check could not conclude.
      - `sub_status` string: Why the verdict came out as it did. New values can be added without a version change; treat an unfamiliar one as informational. Deliberately NOT a closed enum: the list above is what this build emits, and a new reason ships without a version bump. A generated validator built from a closed set would reject an otherwise valid verification result the day one is added, which is the opposite of the forward compatibility this field promises.
      - `quality_score` integer, required
      - `verification` object, required
        - `syntax_valid` boolean, required
        - `domain_exists` boolean, required
        - `mx_found` boolean, required
        - `mx_records` string[]
        - `smtp_check` boolean
        - `catch_all` boolean: The domain accepts mail for any address, so an accepted mailbox proves nothing on its own. Omitted when the catch-all probe did not measure it: the check ended before any probe (a disposable or spam-trap verdict, a domain with no usable mail server), the mail server refused or never answered for the mailbox so the probe never ran, or the probe could not settle it (that result is `risky`). Also `true` beside `provider_accept_all`, where the mail provider accepts addresses it does not have. Absent is not `false`.
        - `greylisted` boolean, required
        - `smtp_code` integer, nullable
        - `smtp_message` string, nullable
        - `mailbox_exists` boolean: `true` when the mail server accepted the mailbox at RCPT TO, on a domain that does not accept every address. `false` when the server answered RCPT TO without accepting it: read it as "not confirmed", not "confirmed absent". It is usually a refusal of the mailbox (`status: "invalid"`), but it is also `false` when the answer was temporary (`status: "unknown"`, `sub_status` `greylisted` or `temporary_failure`) or the server refused the sender rather than the mailbox (`sub_status: "policy_block"`), so read it with `status` and `sub_status`. Omitted when no server answered RCPT TO (no probe ran, the connection failed, or the conversation was refused before RCPT, for example at the greeting), when an acceptance proves nothing (`catch_all`, `provider_accept_all` or a `risky` result), and when the server said the mailbox is full. Absent is not `false`.
        - `provider_accept_all` boolean: The PROVIDER accepts recipient probes too broadly for an individual mailbox to be confirmed. Not the same as `catch_all`, which is the domain's own configuration. Present when the worker could tell. SMTP acceptance here is not evidence the mailbox exists, and a client that treats it as such publishes a deliverability claim nothing measured.
      - `flags` object, required: Independent of the verdict: an address can be valid and also role_account.
        - `disposable` boolean, required
        - `role_account` boolean, required
        - `free_provider` boolean, required
        - `spam_trap` boolean, required
        - `abuse_email` boolean, required: The local part matched an abuse, complaint-handling, feedback-loop, legal or blocklist-removal name or pattern, at any domain, such as abuse@, postmaster@, feedback@, legal@, spam-alerts@ or abuse1@. Every address with complainer set has it too. With AI analysis on, the model can also raise it for a local part containing abuse, spam or complaint. A match on the address itself, not a finding about what the mailbox is used for or anything its owner has done.
        - `honeypot` boolean, required
        - `complainer` boolean, required: A narrower abuse_email: the address is a complaint-handling mailbox, such as abuse@, postmaster@, fbl@ or feedback@, at a reviewed anti-spam, feedback-loop, sender-reputation or consumer-protection organization, such as spamhaus.org, spamcop.net or ftc.gov. The same local part at any other domain, and any other mailbox at those organizations, does not raise it. A match on the address itself, not complaint history: verification does not derive it from any record of who has marked mail as spam.
        - `bot_generated` boolean, required
      - `domain_info` object: The domain's provider type and email-authentication posture. The SPF, DKIM and DMARC fields (`auth_score`, `auth_grade`, `has_spf`, `spf_policy`, `has_dmarc`, `dmarc_policy`, `has_dkim`) are each omitted when their lookup failed or did not run. The lookup runs only when `options.include_ai` is on (the default); with it off, they are present only when a recent check of the same domain already measured them.
        - `domain` string
        - `provider_type` string
        - `auth_score` integer
        - `auth_grade` string
        - `has_spf` boolean
        - `spf_policy` string, nullable
        - `has_dmarc` boolean
        - `dmarc_policy` string, nullable
        - `has_dkim` boolean
      - `ai` object: Present when the worker has something to report: it is built when there is a typo suggestion or a catch-all confidence, and omitted otherwise. That holds whether or not `include_ai` is set: without it the built-in rules produce both; with it, AI analysis is tried first and the rules are the fallback. The four fields below are every field it can carry: it never includes a `fraud_score` or `patterns` field.
        - `typo_suggestion` string, nullable: A corrected address when one looks likely; omitted when there is none. The same value as the top-level `suggestion`. This is the field name the API uses; there is no `suggested_email`.
        - `typo_confidence` number, nullable: How confident the correction is, when one was made.
        - `typo_method` string, nullable: How the suggestion was arrived at, as the detector names it. Present whenever `typo_suggestion` is. Treat it as an open set: it is the detector's own label, and a new detector adds a new one.
        - `catch_all_confidence` number, nullable: 0-100, and only for a domain that accepts everything: how likely this particular mailbox is real despite the catch-all. It is the one piece of evidence that separates two `catch_all` addresses, and it is absent on every other result.
      - `suggestion` string: The corrected address when the domain looks like a misspelling of a well-known one, such as `jane@gmail.com` for `jane@gmial.com`. The same value as `ai.typo_suggestion`, and present exactly when that is. It is advice only: `status` and every check describe the address that was sent, not the correction. The typo check runs whether or not `include_ai` is set; with it, AI analysis can also catch misspellings the built-in list does not. Omitted (never `null`) when there is no correction.
      - `credits_used` number
      - `credits_remaining` number
      - `processing_time_ms` integer
      - `verified_at` string, date-time
      - `from_cache` boolean
      - `request_id` string
      - `idempotent_replay` boolean: `POST /v1/verify` only. True when this request, sent without an `Idempotency-Key`, joined an identical request whose verification was still in flight and was answered from it: `credits_used` is `0` and nothing was charged. Absent when this request paid for the verdict.
      - `activity` ActivityData
        - `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.
      - `domain_reputation` object, nullable: Reserved for a stored numeric domain reputation score when `include_activity` is true. Kawaa does not compute one today, so current responses omit this field.
      - `enrichment` object, nullable: Attached when the request set `options.enrich` and the plan includes data enrichment. Null when nothing is known, which is not a judgement about the address.
        - `first_name` string, nullable
        - `last_name` string, nullable
        - `full_name` string, nullable
        - `gender` string, nullable
        - `country` string, nullable
        - `country_code` string, nullable
      - `cache_age_seconds` integer: How old the cached result is, in seconds. Present on every cache hit, beside `from_cache: true`. Without it a result reused from days ago reads exactly like one measured this second.
      - `classifier_version` integer: Which verdict classifier produced this result. Stamped by the worker onto every newly produced result. When the classifier changes a cached result from an older version is never reused (the address is verified afresh), so two results with different values here were not decided by the same rules.
    - `next_key` string, nullable: Cursor for the next page of results.
    - `has_more` boolean
    - `credits_used` number
    - `credits_refunded` number
    - `billing_status` string
    - `created_at` string, date-time
    - `updated_at` string, date-time
    - `completed_at` string, date-time, nullable: When the job's books closed for the CANCELLATION refund. Null while the job is running and (on a `cancelled` one) until the refund for the unprocessed addresses commits, or the platform settles a job that owed nothing. It says nothing about the results, which are final as soon as the status is terminal. It is not the whole bill either: an ordinary `completed` batch has this set and can still owe its per-address cache discount, and so can a `cancelled` one (its discount on the addresses it did check is applied right after this refund), which `cache_discount_pending` reports. Reconciling a charge means reading both.
    - `cancelled_at` string, date-time, nullable: When `POST /v1/jobs/{id}/cancel` stopped the job. Absent on every other job.
    - `error_message` string, nullable: Why a terminal job ended as it did, in Kawaa's own customer-safe words, present on failures and on reconciliation outcomes. Without it, `status: "failed"` is the whole story a client can tell.

## 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).
- `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.
- `404`: No such resource on this account.
- `409`: The job cannot be cancelled: it has already `completed` or `failed`, or it has not started yet. The second case carries `retryable: true`.
- `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.
- `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)
