# Preflight API

Check an ad image against each network’s rules from your own product. Send the image with the placements you plan to buy, then read a verdict for each placement. Results are advisory. The network makes the final decision.

This is the Markdown version of https://hawtads.com/preflight/docs/api, built from the same source.

- Base URL: `https://hawtads.com/api/public/v1`
- Authentication: `Authorization: Bearer <key>`, with a live or test key
- Format: `multipart/form-data` to create a check, JSON in every response
- Reference: [OpenAPI document](https://hawtads.com/preflight/docs/api#openapi) and [this page as Markdown](https://hawtads.com/preflight/docs/api.md)

> Access is turned on per organization. Email [hello@hawtads.com](mailto:hello@hawtads.com) to turn on the API for your organization.

## Contents

- [Quickstart](https://hawtads.com/preflight/docs/api#quickstart)
- [Authentication](https://hawtads.com/preflight/docs/api#authentication)
- [Create a check](https://hawtads.com/preflight/docs/api#create-a-check)
- [The check object](https://hawtads.com/preflight/docs/api#the-check-object)
- [Verdicts](https://hawtads.com/preflight/docs/api#verdicts)
- [What to do with `needs_review`](https://hawtads.com/preflight/docs/api#needs-review)
- [List checks](https://hawtads.com/preflight/docs/api#list-checks)
- [Networks and codes](https://hawtads.com/preflight/docs/api#networks-and-codes)
- [Test mode](https://hawtads.com/preflight/docs/api#test-mode)
- [Idempotency](https://hawtads.com/preflight/docs/api#idempotency)
- [Rate limits](https://hawtads.com/preflight/docs/api#rate-limits)
- [Errors](https://hawtads.com/preflight/docs/api#errors)
- [OpenAPI document](https://hawtads.com/preflight/docs/api#openapi)

## Quickstart

Start with a test key, which an owner or admin creates under [Settings, then API keys](https://hawtads.com/dashboard/settings/api-keys). A test check runs no model and costs nothing, and it returns the outcome you name in `test_scenario`. Begin with `pass`, then try the others in [Test mode](https://hawtads.com/preflight/docs/api#test-mode).

### Send a test check

Save the key as `HAWTADS_API_KEY` and send an image with the placements you plan to buy. This one asks about a Meta feed placement and an ExoClick banner on mainstream traffic.

curl:

```sh
curl https://hawtads.com/api/public/v1/compliance/checks \
  -H "Authorization: Bearer $HAWTADS_API_KEY" \
  -F image=@ad.png \
  -F 'targets=[
    {
      "id": "fb-feed",
      "network": "meta",
      "placement": "meta.facebook_feed_image"
    },
    {
      "network": "exoclick",
      "placement": "exoclick.banner",
      "inventory": "mainstream"
    }
  ]' \
  -F test_scenario=pass
```

Node:

```js
// check.mjs, for Node 18 or later
import { readFile } from 'node:fs/promises'

const API = 'https://hawtads.com/api/public/v1'
const auth = {
  Authorization: `Bearer ${process.env.HAWTADS_API_KEY}`,
}

const image = new Blob([await readFile('ad.png')], {
  type: 'image/png',
})
const form = new FormData()
form.append('image', image, 'ad.png')
form.append(
  'targets',
  JSON.stringify([
    {
      id: 'fb-feed',
      network: 'meta',
      placement: 'meta.facebook_feed_image',
    },
    {
      network: 'exoclick',
      placement: 'exoclick.banner',
      inventory: 'mainstream',
    },
  ]),
)
form.append('test_scenario', 'pass')

let response = await fetch(`${API}/compliance/checks`, {
  method: 'POST',
  headers: auth,
  body: form,
})
let check = await response.json()
if (!response.ok) {
  throw new Error(`${check.error.code}: ${check.error.message}`)
}
```

Python:

```python
# check.py, for Python 3.9 or later with requests installed
import json
import os
import time

import requests

API = "https://hawtads.com/api/public/v1"
AUTH = {
    "Authorization": f"Bearer {os.environ['HAWTADS_API_KEY']}",
}

targets = [
    {
        "id": "fb-feed",
        "network": "meta",
        "placement": "meta.facebook_feed_image",
    },
    {
        "network": "exoclick",
        "placement": "exoclick.banner",
        "inventory": "mainstream",
    },
]

with open("ad.png", "rb") as image:
    response = requests.post(
        f"{API}/compliance/checks",
        headers=AUTH,
        files={"image": ("ad.png", image, "image/png")},
        data={
            "targets": json.dumps(targets),
            "test_scenario": "pass",
        },
        timeout=60,
    )
check = response.json()
if not response.ok:
    error = check["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")
```

### Read the result

The answer is `202 Accepted` with the check. A test check is usually finished already. A live check starts `queued`, so read it again after the seconds in `Retry-After`, until its `status` is `completed` or `failed`. The Node and Python code below carries on from the code above.

curl:

```sh
# The id is in the first response, and the URL in its Location header.
curl https://hawtads.com/api/public/v1/compliance/checks/chk_3f2a9c1b8e7d6a5f4e3d2c1b \
  -H "Authorization: Bearer $HAWTADS_API_KEY"
```

Node:

```js
while (check.status === 'queued' || check.status === 'running') {
  const seconds = Number(response.headers.get('Retry-After') ?? 3)
  await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
  response = await fetch(`${API}/compliance/checks/${check.id}`, {
    headers: auth,
  })
  check = await response.json()
  if (!response.ok) {
    throw new Error(`${check.error.code}: ${check.error.message}`)
  }
}

for (const result of check.results) {
  console.log(result.id, result.status, result.verdict)
}
```

Python:

```python
while check["status"] in ("queued", "running"):
    time.sleep(int(response.headers.get("Retry-After", "3")))
    response = requests.get(
        f"{API}/compliance/checks/{check['id']}",
        headers=AUTH,
        timeout=30,
    )
    check = response.json()
    if not response.ok:
        error = check["error"]
        raise RuntimeError(f"{error['code']}: {error['message']}")

for result in check["results"]:
    print(result["id"], result["status"], result["verdict"])
```

When your integration handles every scenario, switch to a live key and stop sending `test_scenario`, which a live key refuses.

## Authentication

Send an API key as a Bearer token on every request.

```http
Authorization: Bearer hawt_live_...
```

Keys start with `hawt_live_` or `hawt_test_`. The prefix tells you which is which, but the server reads each key’s mode from our records, never from its prefix. A live key runs real checks and spends credits. A test key runs [test mode](https://hawtads.com/preflight/docs/api#test-mode) and costs nothing.

A key has the `checks:write` scope to create checks, `checks:read` to read checks and the reference endpoints, or both. A request outside its key’s scopes gets `403 forbidden`.

### Getting a key

Access is turned on per organization. Email [hello@hawtads.com](mailto:hello@hawtads.com) to turn on the API for your organization.

Once it is on, owners and admins create keys in the dashboard, under [Settings, then API keys](https://hawtads.com/dashboard/settings/api-keys). A key is shown once, so store it as you create it. Revoking a key stops it at once. If the API is turned off for your organization, every key answers `403 api_access_disabled` until it is turned back on, and checks already accepted still finish.

Keep keys on your server. Never put one in a browser, a mobile app, or a code repository.

## Create a check

`POST /compliance/checks` takes one ad image and the placements you plan to run it on, sent as `multipart/form-data`.

### Fields

| Field | Type | Description |
| --- | --- | --- |
| `image` | File, required | The ad, as a still PNG, JPEG, or WebP of at most 4 MB and 50 megapixels. We read the type from the file’s bytes, not its name, and refuse animated images. |
| `targets` | JSON string, required | A JSON array of 1 to 20 targets. See [Targets](https://hawtads.com/preflight/docs/api#targets). |
| `metadata` | JSON string, optional | A JSON object of your own string values, such as a creative id, up to 2 KB. Keys are 1 to 64 characters and values up to 512. We return it on the check and never read it. |
| `test_scenario` | String | Required with a test key and refused with a live one. See [Test mode](https://hawtads.com/preflight/docs/api#test-mode). |

Any other field is refused with `400 invalid_request`. The whole request can be at most 4.4 MB.

### Targets

A target is one placement you plan to buy. A check costs the same whatever its targets, so send every placement for an ad in one request.

| Field | Type | Description |
| --- | --- | --- |
| `network` | String, required | A network id from [`GET /compliance/networks`](https://hawtads.com/preflight/docs/api#networks-and-codes), such as `meta`. |
| `placement` | String, required | One of that network’s placement ids, such as `meta.facebook_feed_image`. It has to be a placement that takes an image. |
| `inventory` | String | Required when the placement runs on more than one inventory, such as ExoClick’s `mainstream` and `adult`, since we never assume one. Refused when the placement has none. |
| `id` | String, optional | Your own name for the target, up to 64 letters, digits, and `_ . : -`, starting with a letter or digit. Without one, we name it from the placement and inventory, such as `exoclick-banner-mainstream`. Each id appears once per check. |

### Headers

| Header | Description |
| --- | --- |
| `Authorization` | `Bearer` and your key. Required. |
| `Idempotency-Key` | Optional, and strongly advised. 8 to 64 letters, digits, underscores, or hyphens. See [Idempotency](https://hawtads.com/preflight/docs/api#idempotency). |
| `Content-Type` | `multipart/form-data`, which your HTTP client sets, boundary included, when you send a form. |

### Response

A new check answers `202 Accepted` with [the check](https://hawtads.com/preflight/docs/api#the-check-object). Its `Location` header is the check’s own URL. While the check is `queued` or `running`, `Retry-After` gives the seconds to wait before you read it.

```http
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /api/public/v1/compliance/checks/chk_3f2a9c1b8e7d6a5f4e3d2c1b
Retry-After: 5
X-Request-Id: req_6e0c2b7d4f1a9e8c3b5d2a10
```

Sending a request again under an `Idempotency-Key` you already used answers `200 OK` with the first check and an `Idempotent-Replayed: true` header.

### Reading a check

`GET /compliance/checks/{id}` returns the check as it stands. Read it until `status` is `completed` or `failed`, waiting the seconds in `Retry-After` between reads. That header is sent only while the check is still running.

A key reads only its own organization’s checks, in its own mode, so a test key cannot read a live check. Any other id answers `404 not_found`, and so does a check someone hid in the dashboard.

### Credits

A live check costs 100 credits, whatever the number of targets. Test checks cost nothing. Every 4xx error and `503 service_unavailable` comes before the charge, so a refused request costs nothing.

Billing is per attempt. Once a check is charged, the charge stands even if a target ends without a verdict.

Owners and admins can give each key a monthly spend cap in credits, which resets on the first of each month, UTC. A check that would go over it answers `402 spend_cap_reached`. An organization without enough credits gets `402 insufficient_credits`.

## The check object

Creating, reading, and listing checks all return the same object. Keys are snake_case throughout, and times are ISO 8601 in UTC.

- [Check fields](https://hawtads.com/preflight/docs/api#check)
- [Target result](https://hawtads.com/preflight/docs/api#target-result)
- [Findings](https://hawtads.com/preflight/docs/api#findings)
- [Requirements and likely labels](https://hawtads.com/preflight/docs/api#requirements-and-labels)

A test check of one Meta placement with `test_scenario` set to `fail_before_after`. Some lists are shortened.

```json
{
  "id": "chk_3f2a9c1b8e7d6a5f4e3d2c1b",
  "mode": "test",
  "status": "completed",
  "verdict": "fail",
  "analysis_version": "2026-10.3",
  "content_rating": {
    "value": "sfw",
    "source": "detected"
  },
  "results": [
    {
      "id": "fb-feed",
      "network": "meta",
      "placement": "meta.facebook_feed_image",
      "inventory": null,
      "status": "completed",
      "policy_version": "meta@2026-10-03.2",
      "verdict": "fail",
      "findings": [
        {
          "disposition": "violated",
          "code": "before_after",
          "rule_id": "meta.weight_change_before_after_unrelated",
          "effect": "prohibits",
          "title": "Before and after weight change images",
          "message": "Meta does not allow side by side before and after images of weight loss or weight gain that are not related to a product or service.",
          "remedy": "Show one current photo instead of a before and after pair.",
          "evidence": "Test mode: no model looked at this image.",
          "policy_url": "https://transparency.meta.com/policies/ad-standards/objectionable-content/suicide-selfinjury-eating-disorders/",
          "policy_quote": "Contain side by side imagery depicting before and after weight loss/gain not related to the use of a product or service.",
          "limitation": "The check sees the image, not the ad text, so it cannot always tell whether the change relates to a product or service."
        }
      ],
      "requirements": [
        {
          "rule_id": "meta.ad_relevance",
          "code": "misleading_claim",
          "effect": "requires",
          "title": "Ad matches the business and landing page",
          "message": "Meta requires every ad to clearly represent the business and what it sells, with all text and images relevant to the offer and the same products on the landing page.",
          "policy_url": "https://transparency.meta.com/policies/ad-standards/",
          "policy_quote": "Ads must clearly represent the company, product, service, or brand that is being advertised."
        }
      ],
      "likely_labels": [],
      "spec_compatible_placements": [
        "meta.facebook_feed_image",
        "meta.instagram_feed_image"
      ],
      "not_evaluated": [
        "landing_page",
        "targeting",
        "ad_copy",
        "account_authorization",
        "licences"
      ]
    }
  ],
  "metadata": {
    "creative_id": "spring-sale-01"
  },
  "created_at": "2026-10-04T09:30:00.000Z",
  "completed_at": "2026-10-04T09:30:00.000Z"
}
```

### Check fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | String | Starts with `chk_`. |
| `mode` | String | `live` or `test`, from the key that sent it. |
| `status` | String | `queued`, `running`, `completed`, or `failed`. A check is `failed` when it could not finish, such as when none of its targets could run, and a `completed` check can still hold a failed target, so read each target’s `status` too. |
| `verdict` | String or null | The most serious target verdict. Null unless every target completed. See [Verdicts](https://hawtads.com/preflight/docs/api#verdicts). |
| `analysis_version` | String | Which version of the check judged it. It is opaque, so compare it only for equality. |
| `content_rating` | Object or null | `value` is `sfw` or `nsfw`, how the check read the ad. `source` is `detected`, or `fallback` when the reading was unsure and the check took the stricter route. Null until the image is rated. |
| `results` | Array | One [target result](https://hawtads.com/preflight/docs/api#target-result) for each target, in the order you sent them. |
| `metadata` | Object | Your `metadata`, as you sent it. |
| `created_at` | String | When the check was accepted. |
| `completed_at` | String or null | When the check finished. Null while it runs, and on a failed check. |

### Target result

| Field | Type | Description |
| --- | --- | --- |
| `id` | String | The target’s `id`, or the one we gave it. |
| `network` | String | As you sent it. |
| `placement` | String | As you sent it. |
| `inventory` | String or null | As you sent it, or the placement’s only inventory when it has one. |
| `status` | String | `queued`, `running`, `completed`, or `failed`. |
| `policy_version` | String or null | The network rules this target was checked against, such as `meta@2026-10-03.2`, fixed when the check was accepted. |
| `verdict` | String or null | This placement’s verdict. Null until it completes. |
| `findings` | Array | What the check found in the image. See [Findings](https://hawtads.com/preflight/docs/api#findings). |
| `requirements` | Array | Rules the image cannot show that may apply to this ad, for you to confirm outside the check, such as a licence or what the landing page says. |
| `likely_labels` | Array | Labels the network would likely put on the ad, and what each does to delivery. Never ones it has already applied. |
| `spec_compatible_placements` | Array | Placements on this network the file fits by size, weight, and type. Technical fit only, never policy approval. |
| `not_evaluated` | Array | What this target did not evaluate, whatever its verdict. Once the target has run, it always lists `landing_page`, `targeting`, `ad_copy`, `account_authorization`, and `licences`, and can add more, such as `asset:icon` for an asset the placement needs that one image cannot supply. Treat a value you do not know as something to confirm. It can be empty while the target is queued or running, and on a target that never ran. |
| `error` | Object, failed targets only | A `code`, `service_unavailable` or `internal_error`, and our `message`. |

### Findings

A finding is a rule the image did not clearly meet.

| Field | Type | Description |
| --- | --- | --- |
| `disposition` | String | `violated` when the check is sure, `uncertain` when a person should look. |
| `code` | String | A code from [`GET /compliance/codes`](https://hawtads.com/preflight/docs/api#networks-and-codes), the same on every network. |
| `rule_id` | String | The network’s rule, such as `meta.weight_change_before_after_unrelated`. |
| `effect` | String | `prohibits`, `requires`, `restricts`, or `classifies`. |
| `title` | String | The rule’s name. |
| `message` | String | What is wrong, in plain words. We write it for every rule, so you can show it to your users. |
| `remedy` | String | What to change. |
| `evidence` | String or null | What the check saw in this image. |
| `limitation` | String, optional | What the check cannot see for a rule it can only partly judge, such as real age when it sees apparent age. |
| `policy_url` | String | The network’s own policy page. |
| `policy_quote` | String, optional | The words on that page the rule rests on. |

### Requirements and likely labels

A requirement has `rule_id`, `code`, `effect`, `title`, `message`, `policy_url`, and `policy_quote`, the same as a finding without a judgment. A likely label has `id`, `title`, `effect`, `rule_id`, `policy_url`, and `policy_quote`, where `effect` says what the label does to delivery. `policy_quote` can be missing on both.

## Verdicts

Each target gets one of four verdicts. From most to least serious:

| Verdict | Meaning | What to do |
| --- | --- | --- |
| `fail` | A confirmed violation of a rule that prohibits or requires something at this placement. | Block the ad. Show each finding’s `message` and `remedy`. |
| `needs_review` | Something material is unresolved, such as a finding the check could not settle from the image. | Hold the ad for a person. See [What to do with `needs_review`](https://hawtads.com/preflight/docs/api#needs-review). |
| `limited` | Conditions come with the ad, such as 18+ targeting, a certification, or a label the network will likely apply. It does not mean the network will accept it. | Run it only once the conditions are met. |
| `pass` | No violation found among the creative rules checked for this placement. | Go ahead, after confirming what `not_evaluated` lists, such as the landing page and targeting. |

A check’s `verdict` is the most serious of its targets’, and null unless every target completed. There is no confidence score. `needs_review` is how the check says it is not sure.

Results are advisory. The network makes the final decision.

## What to do with `needs_review`

> Hold a `needs_review` ad for a person. Never approve it automatically.

`needs_review` means the check found something it could not settle from the image, such as an apparent age close to a network’s limit, or a claim it cannot verify. Treating it as a pass would approve the ads the check was least sure about.

1. Hold the ad, and send it to a person on your side or the advertiser’s.
2. Show them our `message` for each finding, with its `remedy`, and its `evidence` and `limitation` when they are there. We write `message` and `remedy` for every rule in plain words, so they can go straight into your product.
3. Let the person approve the ad, change it, or send a new version as a new check.

## List checks

`GET /compliance/checks` lists your organization’s checks in the key’s mode, newest first. A test key lists only test checks.

| Parameter | Type | Description |
| --- | --- | --- |
| `limit` | Number | How many checks to return, from 1 to 100. The default is 20. |
| `cursor` | String | The `next_cursor` from the page before. |
| `verdict` | String | `pass`, `limited`, `needs_review`, or `fail`. Only checks with that overall verdict. |
| `network` | String | A network id. Only checks with a target on that network. |

```sh
curl "https://hawtads.com/api/public/v1/compliance/checks?verdict=needs_review&limit=50" \
  -H "Authorization: Bearer $HAWTADS_API_KEY"
```

The answer has `data`, an array of [checks](https://hawtads.com/preflight/docs/api#the-check-object), and `next_cursor`, which you send as `cursor` for the next page. It is null on the last page.

## Networks and codes

`GET /compliance/networks` lists every network we check under `networks`. New networks and placements show up here, so read ids from it instead of keeping your own list.

| Field | Type | Description |
| --- | --- | --- |
| `id` | String | The `network` you send in a target. |
| `display_name` | String | The network’s name. |
| `policy_version` | String | The network rules a check sent now is checked against. |
| `policy_home` | String | The network’s own policy home page. |
| `coverage` | String | What these rules do not cover, in plain words. |
| `inventories` | Array | The network’s inventories, such as mainstream and adult traffic, each with an `id`, `title`, and `description`. |
| `placements` | Array | Each placement’s `id`, `title`, and `family`, the `assets` it takes, and its `inventories`. An empty `inventories` means the placement does not depend on one. Two or more mean a target on it needs an `inventory`. |

`GET /compliance/codes` lists the violation codes under `codes`, each with a `code`, `title`, and `description`. They are the same on every network, so you can group findings across networks by `code`.

Both need the `checks:read` scope. For the same networks in plain words, see [Networks and coverage](https://hawtads.com/preflight/docs).

## Test mode

A test key runs no model and charges nothing. Each request names the outcome it wants in `test_scenario` and gets it back in the shape a live check uses. Results come from the target’s real rules, so rule ids, messages, and policy links are real, and every finding’s `evidence` says `Test mode: no model looked at this image.`

A test request without `test_scenario` is refused with `400 test_scenario_required`. An integration pointed at a test key by mistake fails loudly instead of approving every ad.

| Scenario | What you get |
| --- | --- |
| `pass` | Every target passes. |
| `limited` | Every target is `limited`, by a condition from that network’s own rules. |
| `fail_before_after` | Every target fails on its network’s before and after rule, or on another rule that prohibits something where the network has none. |
| `needs_review` | Every target needs review, with one `uncertain` finding. |
| `spec_fail` | Every target fails its file rules for size, weight, and type. Refused with `400 invalid_request` for a placement that has no file rules. |
| `slow` | The check stays `running` for about 20 seconds, then passes the first time you read it after that. |
| `target_failed` | The first target fails to run, with a `service_unavailable` error on it, and the others pass. With one target, the check is `failed`. |
| `insufficient_credits` | Answers `402 insufficient_credits` with `required_credits` of 100 and `available_credits` of 0. |
| `rate_limited` | Answers `429 rate_limited` with `Retry-After` and the `RateLimit-*` headers. |
| `idempotency_conflict` | Answers `409 idempotency_conflict`. |
| `service_unavailable` | Answers `503 service_unavailable` with a `Retry-After` header. |

`insufficient_credits`, `rate_limited`, `idempotency_conflict`, and `service_unavailable` answer with the same error a live key would get, as soon as the fields and the image are valid, and store nothing. The others store a test check, kept apart from live ones, so a test key lists and reads only test checks. Test checks count toward the per-minute rate limits.

## Idempotency

Send an `Idempotency-Key` header with each new check, so a retry after a timeout or a dropped connection never runs or charges the same check twice. Use 8 to 64 letters, digits, underscores, or hyphens, such as your own id for the creative and its version.

- The same key with the same request returns the first check, with `200 OK` and `Idempotent-Replayed: true`.
- The same key with a different request answers `409 idempotency_conflict`. We compare the image’s bytes, the targets as you sent them, `metadata`, and `test_scenario`.
- An idempotency key belongs to your organization and the API key’s mode, not to one API key, so it still works after you rotate your API keys.
- Idempotency keys do not expire. Each one always returns its first check.

Retry with the same `Idempotency-Key` after a network error, a `429`, a `500`, or a `503`. To check the same ad again on purpose, for example after a target failed to run, send it under a new one. That is a new check, and it is charged.

## Rate limits

Each limit counts in fixed one-minute windows, except running checks, which count what is running now.

| Limit | Value |
| --- | --- |
| New checks, per key | 60 a minute |
| New checks, per organization across all its keys | 120 a minute |
| Live checks running at once, per organization | 20 |
| Reads, per key, for checks, networks, codes, and the OpenAPI document | 600 a minute |

Going over answers `429 rate_limited` with these headers:

| Header | Meaning |
| --- | --- |
| `Retry-After` | Seconds to wait before you retry. Always sent. |
| `RateLimit-Limit` | The limit you reached. |
| `RateLimit-Remaining` | Requests left in the window, so `0`. |
| `RateLimit-Reset` | Seconds until the window resets. |

The running checks limit sends `Retry-After` alone. Test checks count toward the per-minute limits too, and the `rate_limited` scenario returns these headers so you can test how you handle them.

A capacity problem on our side is never a `429`. It answers `503 service_unavailable` with `Retry-After`, so you can tell slowing down from trying again later.

## Errors

Every error has the same shape: a stable `code`, our own `message`, a `doc_url` that links to its row below, and the `request_id`.

```json
{
  "error": {
    "code": "insufficient_credits",
    "message": "The organization does not have enough credits for this check.",
    "doc_url": "https://hawtads.com/preflight/docs/api#error-insufficient-credits",
    "required_credits": 100,
    "available_credits": 40
  },
  "request_id": "req_6e0c2b7d4f1a9e8c3b5d2a10"
}
```

- Branch on `code`, never on `message`. A message can be more specific than the one below, such as naming the target that needs an inventory, and it is safe to show to people.
- `insufficient_credits` adds `required_credits` and `available_credits`.
- Every response carries an `X-Request-Id` header. Send it to [hello@hawtads.com](mailto:hello@hawtads.com) when you ask about a request.
- A request over 4.5 MB can be refused by our hosting platform before it reaches the API, as a `413` without this JSON. Treat any `413` as `image_too_large`.

### Error codes

| Code | Status | Message | What to do |
| --- | --- | --- | --- |
| `invalid_request` | 400 | The request is not valid. | Read `message`, which names the field or value. Fix the request before you send it again, since the same request fails the same way. |
| `test_scenario_required` | 400 | Test keys need a test_scenario on every check, so a test key used by mistake fails loudly instead of approving ads. | Add `test_scenario` to the request, or use a live key for real checks. |
| `unauthorized` | 401 | Send a valid API key as a Bearer token. | Send `Authorization: Bearer` with a key that has not been revoked. |
| `insufficient_credits` | 402 | The organization does not have enough credits for this check. | Add credits to your organization. `required_credits` and `available_credits` give the numbers. |
| `spend_cap_reached` | 402 | This API key has reached its monthly spend cap. | An owner or admin can raise the key’s cap under [Settings, then API keys](https://hawtads.com/dashboard/settings/api-keys). Caps reset on the first of each month, UTC. |
| `forbidden` | 403 | This API key does not have the scope this request needs. | Use a key with the scope the request needs: `checks:write` to create checks, `checks:read` for everything else. |
| `api_access_disabled` | 403 | The API is not turned on for this organization. Contact HawtAds to turn it on. | The API is not on for your organization. Email [hello@hawtads.com](mailto:hello@hawtads.com). |
| `not_found` | 404 | Nothing was found here. | Check the id. A key reads only its own organization’s checks, in its own mode. |
| `idempotency_conflict` | 409 | This Idempotency-Key was already used for a different request. Use a new key for a new check. | Send a new check under a new `Idempotency-Key`. Reuse a key only to retry the same request. |
| `image_too_large` | 413 | The image is over 4 MB, or the request is over its size limit. | Export a smaller file. The image can be at most 4 MB, and the whole request at most 4.4 MB. |
| `unsupported_image` | 422 | Send a still PNG, JPEG, or WebP image. | Send a still PNG, JPEG, or WebP of at most 50 megapixels. `message` says what was wrong with this one. |
| `unsupported_network` | 422 | One of the targets names a network we do not support. | Use a network id from [`GET /compliance/networks`](https://hawtads.com/preflight/docs/api#networks-and-codes). |
| `unsupported_placement` | 422 | One of the targets names a placement its network does not have. | Use one of that network’s placements from [`GET /compliance/networks`](https://hawtads.com/preflight/docs/api#networks-and-codes). The placement has to take an image. |
| `inventory_required` | 422 | One of the targets needs an inventory, which its placement does not fix. | Add an `inventory` to the target, from the placement’s `inventories`. |
| `rate_limited` | 429 | Too many requests. Retry after the number of seconds in Retry-After. | Wait the seconds in `Retry-After`, then retry with the same `Idempotency-Key`. |
| `internal_error` | 500 | Something went wrong on our side. Retry with the same Idempotency-Key. | Retry with the same `Idempotency-Key`. If it keeps happening, send us the `request_id` at [hello@hawtads.com](mailto:hello@hawtads.com). |
| `service_unavailable` | 503 | Checks are paused for a moment on our side, and nothing was charged. Retry after the number of seconds in Retry-After. | Wait the seconds in `Retry-After`, then retry with the same `Idempotency-Key`. |

## OpenAPI document

The OpenAPI 3.1 document describes every endpoint, field, and error, for generating a client or loading into an API tool. It is at [`/api/public/v1/openapi.json`](https://hawtads.com/api/public/v1/openapi.json) and, like the other reads, needs a key with `checks:read`.

```sh
curl https://hawtads.com/api/public/v1/openapi.json \
  -H "Authorization: Bearer $HAWTADS_API_KEY" \
  -o preflight-openapi.json
```

This page is also [plain Markdown](https://hawtads.com/preflight/docs/api.md), for coding agents and tools that read text.
