Research competitor ads
Find the ads that a company runs on Facebook, in one normalized shape.
Research finds the ads that a company runs on Facebook. Use the records for competitor analysis. Use them also as evidence for your next creative.
Research is a paid job with its own read paths. Quote it, confirm it, poll the status, then read the result. A live fetch costs 1 credit. A fresh cache hit costs 0 credits.
Two research capabilities are live:
research.facebook-company-ads@1reads one page of up to 30 ads for one Facebook Page ID.research.facebook-ad-search@1reads one page of up to 30 ads for one keyword.
roasOS declares seven more capabilities and blocks each one. A request that names a blocked capability answers 400 validation_error.
Research uses its own status words
A research job carries seven statuses:
quoted, waiting_cache, queued, processing, succeeded, failed, unknown.
Two of them differ from the job vocabulary. Read the five published job statuses on Jobs, then note these two rules:
- The terminal success of a research job is
succeeded. It is nevercompleted. unknownbelongs to research alone. It says that roasOS cannot yet state the outcome of the credit. The envelope carriesreconciliation_required: true. roasOS settles the reservation itself, and the job then reports its real terminal status. Do not send the request again.
A research job has its own resource, so it is absent from the job list. GET /v1/jobs?kind=research answers an empty page. Read a research job on GET /v1/research/jobs/{id}.
1. Quote
Name the Facebook Page ID of the company and your filters. Set quote_only to true to read the price. A quote is free.
Send an Idempotency-Key header on the quote and on the confirm. Use the same value for both.
curl -X POST https://api.roasos.com/v1/research/jobs \
-H "Authorization: Bearer roasos_sk_..." \
-H "Idempotency-Key: nike-research-001" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "research.facebook-company-ads@1",
"target": { "kind": "page_id", "page_id": "15087023444" },
"filters": { "country": "ALL", "status": "ACTIVE", "media_type": "ALL", "sort_by": "relevancy_monthly_grouped" },
"page": { "max_pages": 1, "max_results": 30 },
"freshness": { "max_age_seconds": 21600, "allow_stale": true },
"quote_only": true
}'{
"data": {
"schema_version": "research-quote@1",
"kind": "research_quote",
"status": "quoted",
"quote_id": "dddddddd-...",
"capability_id": "research.facebook-company-ads@1",
"cache_disposition": "live_fetch",
"reserved_credits": 0,
"maximum_credits": 1,
"expires_at": "2026-08-22T12:10:00.000Z",
"authorization": { "status": "auto_approved" },
"confirmation": {
"max_pages": 1,
"max_results": 30,
"max_age_seconds": 21600,
"allow_stale": true
}
}
}Read two fields before you confirm:
maximum_creditsis the ceiling of the run. It is1on a live fetch and0on a fresh cache hit.cache_dispositionsays which one you get. Its values arecache_hit,live_fetchandcache_join.
The envelope also carries a pricing_version. Research prices on its own version string, and not on the model pricing version. Refer to Quotes and credits.
The request fields
| Field | Rule |
|---|---|
capability_id | research.facebook-company-ads@1 |
target.kind | page_id |
target.page_id | The numeric Facebook Page ID of the company |
filters.country | An ISO country code, or ALL |
filters.status | ACTIVE |
filters.media_type | ALL |
filters.language | Two capital letters, for example EN |
filters.sort_by | relevancy_monthly_grouped |
filters.start_date, filters.end_date | YYYY-MM-DD. The end date must not precede the start date |
page.max_pages | 1 |
page.max_results | 1 to 30 |
freshness.max_age_seconds | 0 to 21600, which is six hours |
freshness.allow_stale | true or false |
Every filter field is optional. The body needs capability_id, target, filters, page and freshness. It takes no other field.
2. Confirm
Send the same body with the quote_id in place of quote_only. Use the same idempotency key.
This step reserves your credits.
curl -X POST https://api.roasos.com/v1/research/jobs \
-H "Authorization: Bearer roasos_sk_..." \
-H "Idempotency-Key: nike-research-001" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "research.facebook-company-ads@1",
"target": { "kind": "page_id", "page_id": "15087023444" },
"filters": { "country": "ALL", "status": "ACTIVE", "media_type": "ALL", "sort_by": "relevancy_monthly_grouped" },
"page": { "max_pages": 1, "max_results": 30 },
"freshness": { "max_age_seconds": 21600, "allow_stale": true },
"quote_id": "dddddddd-..."
}'{
"data": {
"schema_version": "research-job@1",
"kind": "research_job",
"id": "eeeeeeee-...",
"capability_id": "research.facebook-company-ads@1",
"status": "queued",
"cache_disposition": "live_fetch",
"reserved_credits": 1,
"maximum_credits": 1,
"created_at": "2026-08-22T12:00:00.000Z",
"updated_at": "2026-08-22T12:00:00.000Z"
}
}quote_only: true and quote_id together answer 400. Send one of the two.
If the price is above the approval threshold of your key, the quote answers authorization.status: approval_required with an authorize_url. The response also carries the header X-RoasOS-Approval-Required: true. A confirm without that approval answers 403 spend_approval_required.
3. Poll the status
Research sends no webhook. Poll the status every few seconds.
curl https://api.roasos.com/v1/research/jobs/eeeeeeee-.../status \
-H "Authorization: Bearer roasos_sk_..."The status moves from queued to processing. It then becomes succeeded, failed or unknown. A succeeded job carries result_ready: true and completed_at.
A job that waits on another caller's identical fetch reports waiting_cache with cache_disposition: cache_join. It reserves nothing.
GET /v1/research/jobs/{id} returns the same envelope. Use /status for the poll loop.
4. Read the result
curl https://api.roasos.com/v1/research/jobs/eeeeeeee-.../result \
-H "Authorization: Bearer roasos_sk_..."/result answers 409 research_result_not_ready while the status is not succeeded. This is not a defect in your code. Poll /status first.
{
"data": {
"schema_version": "research-result@1",
"kind": "research_result",
"job_id": "eeeeeeee-...",
"capability_id": "research.facebook-company-ads@1",
"status": "succeeded",
"cache_disposition": "live_fetch",
"reserved_credits": 1,
"maximum_credits": 1,
"settled_credits": 1,
"provenance": {
"source": "facebook",
"native_id": "15087023444",
"fetched_at": "2026-08-22T11:00:00.000Z",
"fresh_through": "2026-08-22T17:00:00.000Z"
},
"result": {
"kind": "facebook_company_ads",
"item_count": 1,
"items": [
{
"id": "1212560900568826",
"source": "facebook_ad",
"advertiser": { "id": "15087023444", "name": "Nike", "avatar_url": "https://cdn.example.com/nike.jpg" },
"media": {
"type": "image",
"slides": [{ "type": "image", "thumbnail_url": "https://cdn.example.com/ad.jpg", "video_url": null }]
},
"copy": "Winter creative",
"title": "Look activated",
"description": "Public description",
"domain": "nike.com",
"cta": "Shop now",
"destination_url": "https://www.nike.com/winter",
"run_start_date": "2025-06-17",
"run_end_date": null,
"is_active": true,
"platform_link": "https://www.facebook.com/ads/library?id=1212560900568826"
}
]
}
}
}Each record keeps the observed data separate from its source. provenance gives the source, the native id, the fetch time and the freshness horizon. The result carries no raw provider payload and no billing record.
Cache and freshness
roasOS holds one cache entry for each identical request.
max_age_secondssets the oldest data you accept. The ceiling is 21600 seconds. A cache entry inside that age is a fresh hit, and roasOS returns it at once for 0 credits.allow_stalepermits older cached data. Set it tofalseto force a live fetch.- Two identical misses at the same time join one paid fetch. The second job reports
cache_joinand reserves nothing.
When roasOS charges nothing
Research reserves at most 1 credit, and it settles only a useful live fetch.
| Case | cache_disposition | Credits |
|---|---|---|
| A fresh cache hit | cache_hit | 0 |
| A join onto another live fetch | cache_join | 0 |
| A live fetch that returns ads | live_fetch | 1 |
| A live fetch that returns no ads | live_fetch | 0 |
| A failed job | live_fetch | 0 |
A job that costs you nothing carries settled_credits: 0. roasOS refunds the reservation in full.
An empty live fetch and a failed live fetch both hold a reservation for a short time. The status is unknown while roasOS returns the credit. It becomes succeeded or failed when the refund lands.
Errors
| Code | Cause |
|---|---|
400 validation_error | The body broke a field rule of the table above |
400 research_quote_required | The confirm carried no quote_id |
402 insufficient_credits | The workspace balance is below the ceiling |
403 spend_approval_required | The price is above the approval threshold of the key |
404 research_job_not_found | The id belongs to another workspace, or to another key |
409 research_result_not_ready | The status is not succeeded yet |
503 research_unavailable | roasOS cannot read the job or the result now |
503 research_quote_store_unavailable | roasOS cannot issue a quote now |
A failed job also carries an error code of its own: research_failed or normalization_failed. Refer to Errors.
Search the Meta Ad Library by keyword
The keyword search reads live ads by words, and not by company. Use it when you do not know the Page ID, or when you want the ads of a whole category.
The steps are the same: quote, confirm, poll, then read the result. A live fetch costs 1 credit. A fresh cache hit costs 0 credits.
curl -X POST https://api.roasos.com/v1/research/jobs \
-H "Authorization: Bearer roasos_sk_..." \
-H "Idempotency-Key: betting-search-001" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "research.facebook-ad-search@1",
"target": { "kind": "query", "query": "sports betting app" },
"filters": { "country": "US", "media_type": "VIDEO", "sort_by": "total_impressions" },
"page": { "max_pages": 1, "max_results": 12 },
"freshness": { "max_age_seconds": 21600, "allow_stale": false },
"quote_only": true
}'Confirm with the same body. Replace quote_only with the quote_id. Send the same idempotency key.
The request fields
| Field | Rule |
|---|---|
capability_id | research.facebook-ad-search@1 |
target.kind | query |
target.query | 2 to 200 characters. One line, single spaces, no leading or trailing space, no control character |
filters.country | An ISO country code, or ALL |
filters.status | ACTIVE |
filters.media_type | ALL, IMAGE or VIDEO |
filters.language | Two capital letters, for example EN |
filters.sort_by | total_impressions or relevancy_monthly_grouped |
filters.start_date, filters.end_date | YYYY-MM-DD. The end date must not precede the start date |
page.max_pages | 1 |
page.max_results | 1 to 30 |
freshness.max_age_seconds | 0 to 21600, which is six hours |
freshness.allow_stale | true or false |
Every filter field is optional. roasOS fills the defaults country: ALL, status: ACTIVE, media_type: ALL and sort_by: total_impressions. The filled body is the cache identity, so an omitted default and an explicit default name the same cache row.
The result
The result carries result.kind: "facebook_ad_search". Each item holds the same normalized ad shape the company capability returns. The ads come from many advertisers, so provenance.native_id holds your query instead of a Page ID.
{
"data": {
"capability_id": "research.facebook-ad-search@1",
"status": "succeeded",
"provenance": {
"source": "facebook",
"native_id": "sports betting app",
"fetched_at": "2026-09-05T11:00:00.000Z",
"fresh_through": "2026-09-05T17:00:00.000Z"
},
"result": { "kind": "facebook_ad_search", "item_count": 12, "items": ["..."] }
}
}From an agent
Four MCP tools cover research. The quote rule is the same, and the paid tool never approves spend for you.
| Tool | Path | Mark |
|---|---|---|
roasos_run_research | POST /v1/research/jobs | paid |
roasos_get_research_job | GET /v1/research/jobs/{id} | read |
roasos_get_research_status | GET /v1/research/jobs/{id}/status | read |
roasos_get_research_result | GET /v1/research/jobs/{id}/result | read |
Refer to Connect an AI agent (MCP).
Next
- Break one competitor ad into its shots and its insights: Analyze an ad.
- Rebuild the same structure with your product: Remix an ad.
- Read how roasOS prices a paid call: Quotes and credits.