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-datato create a check, JSON in every response- Reference
- OpenAPI document and this page as Markdown
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// 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}`)
}# 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.
# 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"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)
}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.
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 asmeta. 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
mainstreamandadult, 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 asexoclick-banner-mainstream. Each id appears once per check.
Headers
AuthorizationBearerand your key. Required.Idempotency-Key- Optional, and strongly advised. 8 to 64 letters, digits, underscores, or hyphens. See Idempotency.
Content-Typemultipart/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/1.1 202 Accepted
Content-Type: application/json
Location: /api/public/v1/compliance/checks/chk_3f2a9c1b8e7d6a5f4e3d2c1b
Retry-After: 5
X-Request-Id: req_6e0c2b7d4f1a9e8c3b5d2a10Sending 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
{
"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"
}test_scenario set to fail_before_after. Some lists are shortened.Check fields
idString- Starts with
chk_. modeStringliveortest, from the key that sent it.statusStringqueued,running,completed, orfailed. A check isfailedwhen it could not finish, such as when none of its targets could run, and acompletedcheck can still hold a failed target, so read each target’sstatustoo.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 nullvalueissfwornsfw, how the check read the ad.sourceisdetected, orfallbackwhen 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.
statusStringqueued,running,completed, orfailed.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, andlicences, and can add more, such asasset:iconfor 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_unavailableorinternal_error, and ourmessage.
Findings
A finding is a rule the image did not clearly meet.
dispositionStringviolatedwhen the check is sure,uncertainwhen 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. effectStringprohibits,requires,restricts, orclassifies.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
messageandremedy. 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_evaluatedlists, 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.
- Hold the ad, and send it to a person on your side or the advertiser’s.
- Show them our
messagefor each finding, with itsremedy, and itsevidenceandlimitationwhen they are there. We writemessageandremedyfor every rule in plain words, so they can go straight into your product. - 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_cursorfrom the page before. verdictStringpass,limited,needs_review, orfail. Only checks with that overall verdict.networkString- A network id. Only checks with a target on that network.
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
networkyou 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, anddescription. placementsArray- Each placement’s
id,title, andfamily, theassetsit takes, and itsinventories. An emptyinventoriesmeans the placement does not depend on one. Two or more mean a target on it needs aninventory.
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
uncertainfinding. spec_fail- Every target fails its file rules for size, weight, and type. Refused with
400 invalid_requestfor a placement that has no file rules. slow- The check stays
runningfor about 20 seconds, then passes the first time you read it after that. target_failed- The first target fails to run, with a
service_unavailableerror on it, and the others pass. With one target, the check isfailed. insufficient_credits- Answers
402 insufficient_creditswithrequired_creditsof 100 andavailable_creditsof 0. rate_limited- Answers
429 rate_limitedwithRetry-Afterand theRateLimit-*headers. idempotency_conflict- Answers
409 idempotency_conflict. service_unavailable- Answers
503 service_unavailablewith aRetry-Afterheader.
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 OKandIdempotent-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, andtest_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.
{
"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 onmessage. 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_creditsaddsrequired_creditsandavailable_credits.- Every response carries an
X-Request-Idheader. 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
413without this JSON. Treat any413asimage_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_scenarioto the request, or use a live key for real checks. insufficient_creditsStatus 402- The organization does not have enough credits for this check.What to do. Add credits to your organization.
required_creditsandavailable_creditsgive 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:writeto create checks,checks:readfor 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.
messagesays 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
inventoryto the target, from the placement’sinventories. 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 sameIdempotency-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 therequest_idat hello@hawtads.com.
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 https://hawtads.com/api/public/v1/openapi.json \
-H "Authorization: Bearer $HAWTADS_API_KEY" \
-o preflight-openapi.jsonThis page is also plain Markdown, for coding agents and tools that read text.