Cormorant

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

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
}

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:

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