Cormorant partner API
For agencies and AI ad platforms that run campaigns on Cormorant as a channel, next to Google Performance Max or Meta Advantage+. One API covers accounts, campaigns, asset-based creatives, conversion tracking, reporting and billing.
It is served by the live bidder process (cormorant bidder serve) at
https://<bidder host>/api/partner/v1, so a campaign change takes effect on the
next bid request.
What is real and what is not
- Bids go only to exchanges configured in the bidder, and only with
live: true. In shadow mode everything below works, but the bidder never answers with a bid. - Every money figure is USD. Conversion values in other currencies are rejected.
- Conversions are attributed by Cormorant (last click within the campaign's
window). Cormorant cannot see your other channels, so deduplicate across
channels on your side with the
cmclidclick id. - The automated creative review is a policy screen (landing domain, restricted terms, blocked categories). Exchanges still run their own audits.
- The AdCP task endpoint uses AdCP task names and envelopes. Its fields are this API's own and have not been validated against the official AdCP schemas.
Getting access
An operator creates your agency and gives you its API key, which is shown once:
curl -X POST https://bid.yagni.co/admin/partner/agencies \
-H "Authorization: Bearer $CORMORANT_ADMIN_TOKEN" -d '{"name": "Adsmith"}'
Send the key as Authorization: Bearer cmk_... on every call. The key sees only
your agency's advertisers. The operator can rotate it, which revokes the old
key (POST /admin/partner/agencies/{id}/rotate_key).
POSTs that create something (/advertisers, /campaigns) accept an
Idempotency-Key header. Retrying with the same key returns the first response
with Idempotent-Replay: true. Errors look like
{"error": "...", "field": "targeting.countries[0]"}, with status 4xx.
Advertisers and money
POST /advertisers
{"name": "StickyGolf", "domain": "stickygolf.example", "external_id": "your-id",
"daily_spend_cap_usd": 200}
Advertisers start prepaid: they spend only while payments received exceed
billed charges. The operator records payments (POST /admin/payments with
counterparty = advertiser id) and can grant invoice terms with a credit
limit (PATCH /admin/partner/advertisers/{id}). GET /advertisers/{id} shows
billed, paid, credits, balance and any spend block.
Monthly statement: GET /advertisers/{id}/statements/2026-11 lists line items
per campaign and event type, plus credits and payments.
Campaigns
POST /campaigns
{
"advertiser_id": "adv_...",
"name": "Golf mats US",
"external_id": "your-campaign-id",
"goal": {"type": "cpa", "target": 25.0, "conversion_events": ["purchase"]},
"billing": {"model": "outcome"},
"budget": {"total_usd": 1000, "daily_usd": 100},
"flight": {"start": "2026-11-01T00:00:00Z", "end": "2026-11-30T00:00:00Z"},
"max_bid_cpm": 20,
"targeting": {
"countries": ["USA"], "regions": ["CA", "NY"], "languages": ["en"],
"device_types": ["phone", "tablet"], "os": ["iOS", "Android"],
"inventory": ["site", "app"], "iab_categories": ["IAB17"],
"include_domains": [], "exclude_domains": ["example-mfa.com"],
"keywords": ["golf", "putting green"], "exclude_keywords": ["tragedy"],
"frequency_cap": {"impressions": 3, "hours": 24},
"dayparting_utc": {"0": [12, 13, 14, 15, 16, 17, 18, 19, 20]}
},
"creative_rotation": "optimize",
"attribution": {"click_window_days": 7}
}
| field | values |
|---|---|
goal.type |
cpc, cpm, cpa, roas (target = return multiple, e.g. 4.0, and expected_conversion_value_usd is required as the prior) |
billing.model |
outcome (default): billed per click, per 1000 impressions, per attributed conversion (CPA), or conversion value / target (ROAS). cost_plus: billed media cost x (1 + markup) per impression, while still bidding toward the goal |
countries |
ISO 3166-1 alpha-3 (USA, DEU), the code OpenRTB uses. Alpha-2 is rejected |
languages |
ISO 639-1, matched against the device language, or the page language when the device sends none |
keywords |
matched against the page URL, site/app keywords and the content title; multi-word keywords need all words |
creative_rotation |
optimize (Thompson sampling on performance, on conversions for CPA/ROAS) or even (uniform, for clean A/B reads) |
budget.pacing |
traffic (default): each hour gets the day's remaining budget in proportion to the campaign's learned eligible traffic in that hour, recomputed every few seconds so shortfalls catch up. even: a flat 1/24 of the day per hour (x hourly_cap_multiplier) |
PATCH /campaigns/{id} replaces each top-level section you send (send the
whole targeting object). POST /campaigns/{id}/pause, /resume and
DELETE /campaigns/{id} (archive) take effect immediately.
delivery_status says why a campaign is or isn't bidding: delivering,
awaiting_exchange_approval, paused, scheduled, ended,
budget_exhausted, daily_budget_reached, no_approved_creatives,
insufficient_funds, credit_limit_reached, advertiser_daily_cap_reached,
advertiser_suspended.
Budgets are billed amounts. Charges are clipped at budget.total_usd, at
budget.daily_usd (for the day the click or conversion happens) and at the
advertiser's daily_spend_cap_usd, so late clicks or conversions never bill
past them.
The bidder's own hard caps (safety.max_total_spend_usd, safety.max_bid_cpm)
also apply to every partner campaign.
Creatives: asset groups
Send assets and Cormorant builds the creatives, as Performance Max does:
PUT /campaigns/{id}/asset_groups/spring
{
"headlines": [{"id": "h1", "text": "Smarter golf practice"}, {"id": "h2", "text": "Track every putt"}],
"descriptions": [{"id": "d1", "text": "The practice mat that shows your line."}],
"images": [{"id": "sq", "url": "https://cdn.../1200x627.png", "w": 1200, "h": 627},
{"id": "mrec", "url": "https://cdn.../300x250.png", "w": 300, "h": 250}],
"logos": [{"id": "logo", "url": "https://cdn.../logo.png", "w": 128, "h": 128}],
"videos": [{"id": "v15", "mp4_url": "https://cdn.../15s.mp4", "w": 1280, "h": 720, "duration_seconds": 15}],
"call_to_action": "Shop now", "brand_name": "StickyGolf",
"click_url": "https://www.stickygolf.example/mat", "advertiser_domain": "stickygolf.example",
"max_native_variants": 12
}
- Banner: one creative per image at a standard IAB size (
GET /formats). - Native (OpenRTB Native 1.2): headline x description x image combinations,
ordered so every asset appears at least once, up to
max_native_variants. Images fill native slots whose size or aspect ratio they match. - Video: inline VAST 3.0 with your MP4, for players that accept VAST 3 or 4.
The asset ids are yours, and reports can break results down by them.
Re-sending unchanged assets keeps creative ids and their learned performance.
A changed asset creates a new creative, which is reviewed again.
GET /campaigns/{id}/creatives shows each creative's review result,
per-exchange approval and performance counters.
Conversion tracking
Every click on a Cormorant ad lands on your click_url with cmclid=<click id>
appended. Store it, as you would a gclid, and send it back with the conversion.
Server to server (recommended, and required for billing CPA/ROAS by default):
POST /advertisers/{id}/conversions
{"events": [{"event_id": "order-1001", "event_name": "purchase", "click_id": "bid_...",
"value": 59.00, "currency": "USD", "event_time": "2026-11-03T18:22:00Z"}]}
Up to 1000 events per call. event_id deduplicates per advertiser, so
retries are safe. Each result has a status: attributed,
attributed_other_event (an event not in the campaign's conversion_events,
stored but not optimized or billed), duplicate, unattributed_no_click,
unattributed_outside_window, unattributed_wrong_advertiser,
rejected_invalid_click, rejected_before_click, rejected_too_fast,
rejected_future_event_time, rejected_invalid.
Pixel: <img src="https://bid.yagni.co/conv/{advertiser_id}/pixel.gif?eid=order-1001&ev=purchase&cmclid=...&value=59.00&cur=USD">.
It always returns a GIF; the outcome is in the X-Cormorant-Conversion header.
Anyone can fire a pixel, so pixel conversions train bidding and appear in
reports but are not billed, unless the operator sets allow_pixel_billing
for the advertiser.
Clicks are validated before they count or bill. Invalid clicks are reported
as invalid_clicks and never billed: bot user agents, datacenter and
non-routable IPs, a duplicate click on the same impression, a click with no
billed impression, a click faster than possible after the bid, or more than N
clicks per IP per hour. Conversions from invalid clicks are rejected.
Reports
GET /reports?start=2026-11-01&end=2026-11-08&group_by=day,campaign_id,creative_id
Dimensions: day or hour, campaign_id, creative_id, exchange,
country, device, asset. Metrics: impressions, clicks, invalid_clicks,
conversions, conversion_value_usd, spend_usd (what Cormorant bills), ctr, cvr,
cpc_usd, cpa_usd, cpm_usd, roas. group_by=asset returns one row per
(asset_role, asset_id). Each creative's results count toward every asset
it uses, so asset rows overlap by design. Data is hourly, and the last minute
may not be flushed yet.
Log-level data
GET /logs/impressions?start=...&end=...&limit=5000[&after=<cursor>][&campaign_id=...]
GET /logs/clicks?...
GET /logs/conversions?...
One row per billed impression, per click (valid and invalid, with the IVT
reason) and per conversion received (with its attribution status). Pages are
in time order; pass next back as after until it is null. Money columns are
USD; impressions carry inventory (site domain or app bundle), seller_id
(first schain node), country, region, device, os, user_match and
what was billed for that impression. An impression's bid_id is the
click_id (cmclid) of its click, so the three logs join with each other and
with your own landing and order records.
Match report
GET /match?start=...&end=... says how well the two sides' ids join:
impressions.user_match_rate: share of impressions where the exchange sent our synced user id (buyeruid), the basis for frequency caps and reach;clicks.click_id_landing_rate: valid clicks whosecmclidcame back in a landing event (landing.js, or the pixel withev=landing). Low means the click id is lost in your redirects, and conversions cannot be attributed;conversions.match_rate: conversions you sent (excluding duplicates and landing events) that were attributed to one of our clicks, with counts by status, plus the click-to-conversion lag (p50, p90).
AdCP tasks
POST /adcp/{task} with the same API key: get_adcp_capabilities,
get_products, create_media_buy (campaign body, plus buyer_ref and
idempotency_key), update_media_buy (media_buy_id plus a campaign patch
or active), sync_creatives (media_buy_id, asset_groups: [{group_id, ...}]),
get_media_buy_delivery (daily rows) and provide_performance_feedback
(advertiser_id, events, the same as the conversions endpoint).
Python client
from cormorant.partner.client import PartnerClient
c = PartnerClient("https://bid.yagni.co", api_key="cmk_...")
adv = c.create_advertiser({"name": "StickyGolf", "domain": "stickygolf.example"})
cmp = c.create_campaign({...})
c.put_asset_group(cmp["id"], "spring", {...})
c.send_conversions(adv["id"], [{"event_id": "order-1001", "click_id": cmclid, "value": 59.0}])
c.report(group_by=["day", "creative_id"])
Operator reference
Enable the API with a partner object in the bidder config. {} means
defaults, and campaigns may then be empty:
"partner": {
"post_bid_audit_exchanges": [],
"max_clicks_per_ip_per_hour": 5,
"min_click_delay_seconds": 0.5,
"min_conversion_delay_seconds": 2.0,
"hourly_cap_multiplier": 2.0
}
post_bid_audit_exchanges lists exchanges that audit creatives after serving.
Creatives that pass review are marked approved there right away. Everywhere
else they wait in the creative-approval registry for the exchange's audit
decision.
Operator routes (admin token): POST /admin/partner/agencies,
.../agencies/{id}/rotate_key, PATCH /admin/partner/advertisers/{id}
(credit terms, status), POST /admin/partner/credits (makegoods, e.g. after
an IVT finding), POST /admin/partner/creatives/{id}/review (manual
approve/reject), GET /admin/partner/report (adds media_cost_usd).
State lives in the bidder's state_dir: partner.sqlite3 (accounts,
campaigns, creatives, clicks, conversions, hourly delivery) and
partner_models.json (creative rotation and CVR learning). Money events go
to the same money journal as the rest of the bidder, which stays the record
of what is billed.
Exchanges that encrypt ${AUCTION_PRICE} the Google Authorized Buyers way are
supported with "price_encryption": "google" and
"price_keys_env": {"encryption": "ENV_VAR", "integrity": "ENV_VAR"}.