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.

Access is turned on per organization. Email hello@hawtads.com to turn on the API for your organization.

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

Results are advisory. The network makes the final decision.

Quickstart

Start with a test key, which an owner or admin creates under Settings, then 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.

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

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.

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

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

imageFile, 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.
targetsJSON string, required
A JSON array of 1 to 20 targets. See Targets.
metadataJSON 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_scenarioString
Required with a test key and refused with a live one. See 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.

networkString, required
A network id from GET /compliance/networks, such as meta.
placementString, required
One of that network’s placement ids, such as meta.facebook_feed_image. It has to be a placement that takes an image.
inventoryString
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.
idString, 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

Authorization
Bearer and your key. Required.
Idempotency-Key
Optional, and strongly advised. 8 to 64 letters, digits, underscores, or hyphens. See 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. 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.

Example check response
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"
}
A test check of one Meta placement with test_scenario set to fail_before_after. Some lists are shortened.

Check fields

idString
Starts with chk_.
modeString
live or test, from the key that sent it.
statusString
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.
verdictString or null
The most serious target verdict. Null unless every target completed. See Verdicts.
analysis_versionString
Which version of the check judged it. It is opaque, so compare it only for equality.
content_ratingObject 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.
resultsArray
One target result for each target, in the order you sent them.
metadataObject
Your metadata, as you sent it.
created_atString
When the check was accepted.
completed_atString or null
When the check finished. Null while it runs, and on a failed check.

Target result

idString
The target’s id, or the one we gave it.
networkString
As you sent it.
placementString
As you sent it.
inventoryString or null
As you sent it, or the placement’s only inventory when it has one.
statusString
queued, running, completed, or failed.
policy_versionString or null
The network rules this target was checked against, such as meta@2026-10-03.2, fixed when the check was accepted.
verdictString or null
This placement’s verdict. Null until it completes.
findingsArray
What the check found in the image. See Findings.
requirementsArray
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_labelsArray
Labels the network would likely put on the ad, and what each does to delivery. Never ones it has already applied.
spec_compatible_placementsArray
Placements on this network the file fits by size, weight, and type. Technical fit only, never policy approval.
not_evaluatedArray
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.
errorObject, 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.

dispositionString
violated when the check is sure, uncertain when a person should look.
codeString
A code from GET /compliance/codes, the same on every network.
rule_idString
The network’s rule, such as meta.weight_change_before_after_unrelated.
effectString
prohibits, requires, restricts, or classifies.
titleString
The rule’s name.
messageString
What is wrong, in plain words. We write it for every rule, so you can show it to your users.
remedyString
What to change.
evidenceString or null
What the check saw in this image.
limitationString, optional
What the check cannot see for a rule it can only partly judge, such as real age when it sees apparent age.
policy_urlString
The network’s own policy page.
policy_quoteString, 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:

fail
A confirmed violation of a rule that prohibits or requires something at this placement.What to do. 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.What to do. Hold the ad for a person. See What to do with 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.What to do. Run it only once the conditions are met.
pass
No violation found among the creative rules checked for this placement.What to do. 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.

limitNumber
How many checks to return, from 1 to 100. The default is 20.
cursorString
The next_cursor from the page before.
verdictString
pass, limited, needs_review, or fail. Only checks with that overall verdict.
networkString
A network id. Only checks with a target on that network.
curl
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, 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.

idString
The network you send in a target.
display_nameString
The network’s name.
policy_versionString
The network rules a check sent now is checked against.
policy_homeString
The network’s own policy home page.
coverageString
What these rules do not cover, in plain words.
inventoriesArray
The network’s inventories, such as mainstream and adult traffic, each with an id, title, and description.
placementsArray
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.

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.

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.

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:

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

invalid_requestStatus 400
The request is not valid.What to do. 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_requiredStatus 400
Test keys need a test_scenario on every check, so a test key used by mistake fails loudly instead of approving ads.What to do. Add test_scenario to the request, or use a live key for real checks.
unauthorizedStatus 401
Send a valid API key as a Bearer token.What to do. Send Authorization: Bearer with a key that has not been revoked.
insufficient_creditsStatus 402
The organization does not have enough credits for this check.What to do. Add credits to your organization. required_credits and available_credits give the numbers.
spend_cap_reachedStatus 402
This API key has reached its monthly spend cap.What to do. An owner or admin can raise the key’s cap under Settings, then API keys. Caps reset on the first of each month, UTC.
forbiddenStatus 403
This API key does not have the scope this request needs.What to do. Use a key with the scope the request needs: checks:write to create checks, checks:read for everything else.
api_access_disabledStatus 403
The API is not turned on for this organization. Contact HawtAds to turn it on.What to do. The API is not on for your organization. Email hello@hawtads.com.
not_foundStatus 404
Nothing was found here.What to do. Check the id. A key reads only its own organization’s checks, in its own mode.
idempotency_conflictStatus 409
This Idempotency-Key was already used for a different request. Use a new key for a new check.What to do. Send a new check under a new Idempotency-Key. Reuse a key only to retry the same request.
image_too_largeStatus 413
The image is over 4 MB, or the request is over its size limit.What to do. Export a smaller file. The image can be at most 4 MB, and the whole request at most 4.4 MB.
unsupported_imageStatus 422
Send a still PNG, JPEG, or WebP image.What to do. Send a still PNG, JPEG, or WebP of at most 50 megapixels. message says what was wrong with this one.
unsupported_networkStatus 422
One of the targets names a network we do not support.What to do. Use a network id from GET /compliance/networks.
unsupported_placementStatus 422
One of the targets names a placement its network does not have.What to do. Use one of that network’s placements from GET /compliance/networks. The placement has to take an image.
inventory_requiredStatus 422
One of the targets needs an inventory, which its placement does not fix.What to do. Add an inventory to the target, from the placement’s inventories.
rate_limitedStatus 429
Too many requests. Retry after the number of seconds in Retry-After.What to do. Wait the seconds in Retry-After, then retry with the same Idempotency-Key.
internal_errorStatus 500
Something went wrong on our side. Retry with the same Idempotency-Key.What to do. Retry with the same Idempotency-Key. If it keeps happening, send us the request_id at hello@hawtads.com.
service_unavailableStatus 503
Checks are paused for a moment on our side, and nothing was charged. Retry after the number of seconds in Retry-After.What to do. 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 and, like the other reads, needs a key with checks:read.

curl
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, for coding agents and tools that read text.