Ads
Run chat card campaigns in ChatGPT from an openaiads connection, connected with an Ads Manager API key, and measure them with the pixel and server-side conversions.
ChatGPT Ads shows a chat card (a title, a short body, an image and a link) inside ChatGPT. You connect it with an API key instead of OAuth, create a campaign in one request, and measure it with a pixel on your site plus server-side conversions. The connection platform is openaiads and the ad endpoints call the network openai.
| Connection platform | openaiads |
platform on ad endpoints |
openai |
| Structure | Campaign, ad group, chat card ad, all created by one call |
| Goals | traffic, awareness, conversions |
| Title | headline, 3 to 50 characters, required |
| Body | Up to 100 characters, required |
| Creative | A static image (imageUrl) and a link (linkUrl), both required |
| Budget | daily or lifetime, at least 1 in the ad account currency |
| Targeting | Country only |
| Changes after launch | Status and budget |
| Measurement | Pixel plus Conversions API |
| Dry run | validateOnly: true |
Only businesses based in the US can use ChatGPT Ads Manager. The ads are shown to people on the ChatGPT Free and Go plans in the United States, Canada, Australia and New Zealand.
#Connect with an API key
Create a key in ChatGPT Ads Manager under Settings. A key belongs to exactly one ad account, so connect each ad account with its own key.
Ads Manager has no read-only key. The key you paste can create and change campaigns, and Pozy uses it to read performance and to manage the campaigns you create through Pozy. Campaigns built in Ads Manager stay editable there.
curl -X POST https://api.pozy.ai/v1/connect/openai-ads/credentials \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiKey": "sk-ads-...", "profileId": "66b2e19d8c3f5a7e9d0b1c2a" }'const res = await fetch("https://api.pozy.ai/v1/connect/openai-ads/credentials", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POZY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ apiKey: process.env.CHATGPT_ADS_KEY!, profileId: "66b2e19d8c3f5a7e9d0b1c2a" }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.accountId, data.adAccountName);import os, requests
resp = requests.post(
"https://api.pozy.ai/v1/connect/openai-ads/credentials",
headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
json={"apiKey": os.environ["CHATGPT_ADS_KEY"], "profileId": "66b2e19d8c3f5a7e9d0b1c2a"},
)
print(resp.json()){ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountName": "Acme US" }Before saving, Pozy tests the key on the ad account. Sending a new key for an ad account you already connected updates that connection rather than adding another. The dashboard can connect it too.
| Response | Meaning |
|---|---|
401 invalid_credentials |
The key cannot read an ad account. Create a new one in Ads Manager. |
402 payment_required |
The workspace is past its free connected accounts with no payment method. reason is free_tier_exceeded and dashboard_url is where to add one. |
#Create a campaign
POST /v1/ads/create builds the campaign, ad group and chat card. This example runs a lifetime budget with a fixed bid until the end date:
curl -X POST https://api.pozy.ai/v1/ads/create \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: planner-launch-chatgpt" \
-d '{
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"adAccountId": "acct_abc123",
"name": "Planner launch",
"goal": "traffic",
"budgetAmount": 25,
"budgetType": "lifetime",
"endDate": "2027-02-28T23:59:00Z",
"bidStrategy": "LOWEST_COST_WITH_BID_CAP",
"bidAmount": 2,
"headline": "Try the new planner",
"body": "Tasks, docs and meetings in one place.",
"imageUrl": "https://cdn.example.com/planner.png",
"linkUrl": "https://example.com/planner",
"countries": ["US"]
}'const res = await fetch("https://api.pozy.ai/v1/ads/create", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POZY_API_KEY}`,
"Idempotency-Key": "planner-launch-chatgpt",
"Content-Type": "application/json",
},
body: JSON.stringify({
accountId: "66b2e19d8c3f5a7e9d0b1c2d",
adAccountId: "acct_abc123",
name: "Planner launch",
goal: "traffic",
budgetAmount: 25,
budgetType: "lifetime",
endDate: "2027-02-28T23:59:00Z",
bidStrategy: "LOWEST_COST_WITH_BID_CAP",
bidAmount: 2,
headline: "Try the new planner",
body: "Tasks, docs and meetings in one place.",
imageUrl: "https://cdn.example.com/planner.png",
linkUrl: "https://example.com/planner",
countries: ["US"],
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.ad.status);resp = requests.post(
"https://api.pozy.ai/v1/ads/create",
headers={
"Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
"Idempotency-Key": "planner-launch-chatgpt",
},
json={
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"adAccountId": "acct_abc123",
"name": "Planner launch",
"goal": "traffic",
"budgetAmount": 25,
"budgetType": "lifetime",
"endDate": "2027-02-28T23:59:00Z",
"bidStrategy": "LOWEST_COST_WITH_BID_CAP",
"bidAmount": 2,
"headline": "Try the new planner",
"body": "Tasks, docs and meetings in one place.",
"imageUrl": "https://cdn.example.com/planner.png",
"linkUrl": "https://example.com/planner",
"countries": ["US"],
},
)
print(resp.json()["ad"]["status"]){
"ad": {
"_id": "66f0a1b2c3d4e5f6a7b8c9d0",
"name": "Planner launch",
"platform": "openai",
"status": "pending_review",
"adType": "standalone",
"goal": "traffic",
"budget": { "amount": 25, "type": "lifetime" }
},
"message": "Ad created"
}budgetAmount and bidAmount are whole units of the ad account currency; the ad's currency field names it.
#Budget
A ChatGPT campaign has exactly one spend cap:
budgetType |
Behavior |
|---|---|
daily |
A daily cap. Required for automatic bidding. |
lifetime |
A total cap. Send endDate to set the window it is spent over. |
The minimum is 1 in the ad account currency (422 below that). Some currencies have a higher daily minimum, which is only checked on a real create; the error names the amount.
#Bidding
bidStrategy |
Bidding | Needs |
|---|---|---|
Omitted or LOWEST_COST_WITHOUT_CAP |
Automatic: most clicks on traffic, most conversions on conversions |
budgetType: "daily" |
LOWEST_COST_WITH_BID_CAP or COST_CAP |
A fixed bid (the cost per action under conversions) |
bidAmount |
Automatic bidding on a lifetime budget or on awareness returns 400 missing_required_field with param: "bidStrategy"; send a fixed bid there. There is no ROAS bidding, so LOWEST_COST_WITH_MIN_ROAS returns 422.
#Status at launch
With status: "PAUSED", only the campaign is paused and its ad group and ad are left on, so a single PUT /v1/ads/campaigns/{campaignId}/status with active starts it all. Add campaignStatus: "ACTIVE" to flip that around: the campaign runs and the ad group holds the pause. Send neither and the ad starts as soon as it passes review.
#Targeting
Countries are the only targeting. Ages, genders, interests and audiences return 400 instead of being ignored. Custom audiences are managed in Ads Manager only.
#Optimize for a conversion
goal: "conversions" needs an optimizable conversion event on the ad account before you create the campaign. Create one with the pixel (defaultEventType, below) or in Ads Manager.
Pick the event with promotedObject.customEventType. Read the candidates from conversionEvents[] on GET /v1/accounts/{accountId}/conversion-destinations:
{
"conversionEvents": [
{ "id": "evs_123", "name": "Purchase", "eventType": "order_created", "customEventName": null, "archived": false, "optimizable": true }
]
}Pass the event's id, its eventType (such as order_created) or its name (such as Purchase):
{ "goal": "conversions", "promotedObject": { "customEventType": "order_created" } }- Only
optimizableevents qualify: active, standard events, not custom ones. - If the event is unknown, archived or custom, you get
400with the events the account can optimize for. If several events share the name, the400asks you to send theid. - Without
customEventType, the campaign optimizes for the most recently created optimizable event. customEventTypeis the onlypromotedObjectfield ChatGPT Ads accepts, and only withconversions.
#Click tracking
Every click adds tracking.urlTags to the end of linkUrl. Pozy leaves ChatGPT's macros {campaign_id}, {ad_group_id}, {ad_id} and {oppref} (the click id) unescaped there, so ChatGPT fills them in on the click. Macros written into linkUrl itself stay as literal text.
{
"tracking": {
"urlTags": [
{ "key": "utm_source", "value": "chatgpt" },
{ "key": "utm_campaign", "value": "{campaign_id}" },
{ "key": "click_id", "value": "{oppref}" }
]
}
}#Check before you spend
Send validateOnly: true to test a request without creating anything. Pozy checks copy lengths, budget, bid strategy and targeting, and looks up the conversion event and countries live.
{
"validateOnly": true,
"results": [
{ "node": "campaign", "status": "validated" },
{ "node": "adSet", "status": "validated" },
{ "node": "ad", "status": "validated" }
],
"message": "..."
}Checks that only run at write time, such as fetching the image, currency minimums and ad review, still happen on the real create. POST /v1/ads/campaigns also takes validateOnly.
#Manage a running campaign
| Change | Call | Notes |
|---|---|---|
| Pause or resume a campaign | PUT /v1/ads/campaigns/{campaignId}/status with { "status": "active", "platform": "openai" } |
Pozy reads the switch first; if it already matches, nothing is written (updated: 0, skipped: 1). |
| Pause or resume one ad | PUT /v1/ads/{adId}/status |
|
| Change the budget | budget: { amount, type } on PUT /v1/ads/{adId} or PUT /v1/ads/campaigns/{campaignId} |
Overwrites the one budget cap. A lifetime budget may become daily, but a daily one cannot become lifetime (422). |
| Rename an ad group or change its bid | PUT /v1/ads/ad-sets/{adSetId} |
|
| Create a campaign on its own | POST /v1/ads/campaigns |
Needs a budget (422 without one). Takes goal and promotedObject.customEventType. |
| Stop | DELETE /v1/ads/{adId} |
There is no delete on ChatGPT Ads. Pozy archives the ad, its ad group and its campaign, and marks the ad cancelled. Archiving is final. |
Targeting and creative cannot change after launch (501 feature_not_available). There is no boost, because ChatGPT has no organic posts, and no video.
GET /v1/ads/tree reports two kinds of status on ChatGPT Ads: status (is it delivering, inherited from parents) and the levels' own switches (platformCampaignStatus on campaigns, configuredStatus on ads, each active/ACTIVE, paused or archived). Objects archived in Ads Manager settle as archived in Pozy.
#Measure results
Pozy syncs impressions, clicks and spend once a day and derives CTR, CPC and CPM. GET /v1/ads/{adId}/analytics defaults to the last 90 days. Every other metric, reach included, reads 0, and demographic breakdowns are not available. History starts 90 days before the connection.
#The pixel
The pixel measures what people do on your site after a chat card. Manage it through the tracking tags endpoints on the openaiads connection.
curl -X POST https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "adAccountId": "acct_abc123", "name": "Acme website", "defaultEventType": "order_created" }'- One call creates the pixel and a Conversions API key for it.
defaultEventTypealso creates the conversion event aconversionscampaign needs. adAccountIdis required by the endpoint but the API key already decides the ad account.- Each pixel has two ids, and per-pixel calls accept either:
tag.id(cds_...) andtag.siteTagId(alsopixelId), the id the site code and the Conversions API send. - Pozy creates one pixel per connection; a second create returns
409. Creating is not idempotent and pixels cannot be deleted, so never retry it automatically. After a502, list the pixels first. GET .../tracking-tags/{tagId}/statsshows the latest events received, up to 50 from the last 15 minutes. Use it to check the pixel fires, not for totals.
Conversion events take name, type and siteEvent, with ChatGPT's fixed 30-day window. type is a standard event (order_created, lead_created, items_added, contents_viewed, checkout_started, registration_completed, subscription_created, trial_started, appointment_scheduled, page_viewed, app_installed, app_opened) or a custom name of 1 to 64 letters, digits, _ or -. To edit or remove an event, or the pixel, use Ads Manager: the API cannot.
Install the pixel on a connected Shopify, WordPress, Wix or Webflow site with one call. See Pixels.
#Conversions API
Server-side events go to POST /v1/ads/conversions with the pixel's site id as destinationId. A pixel must exist first (422 tracking_tag_required otherwise).
curl -X POST https://api.pozy.ai/v1/ads/conversions \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"destinationId": "4Kx9TqLm2VbN7wRz1PsYcD",
"events": [{
"eventName": "Purchase",
"eventTime": 1798804800,
"eventId": "order_12345",
"value": 25.99,
"currency": "USD",
"user": { "email": "customer@example.com" }
}]
}'Purchase, Lead, AddToCart, ViewContent, InitiateCheckout, CompleteRegistration, Subscribe, StartTrial and Schedule map to ChatGPT's own event types (Purchase arrives as order_created). Any other name is sent as a custom event. Events go in chunks of 1,000, and one malformed event fails its chunk. See Conversions.
#Limits
| Limit | Value |
|---|---|
| Title | 3 to 50 characters |
| Body | Up to 100 characters |
| Budget | At least 1 in the ad account currency |
| Events per conversions request | 1,000 per chunk |
| ChatGPT Ads rate limits | 600 requests per minute per endpoint, 1,200 per minute overall |
#Errors you will see
| Status and code | Cause | Fix |
|---|---|---|
401 invalid_credentials |
The key cannot read an ad account | Create a new key in Ads Manager. |
400 on a conversions create |
No optimizable event, or an unknown, archived, custom or ambiguous customEventType |
Create the event, or pass its id. |
400 missing_required_field (bidStrategy) |
Automatic bidding combined with a lifetime budget or the awareness goal |
Switch to a daily budget, or set a fixed bid. |
400 on a targeting field |
Targeting other than countries |
Leave it out. |
422 with param: "budget.amount" |
Budget below 1 | Raise it. |
422 with param: "budget.type" |
A lifetime budget on a campaign with a daily cap | Keep daily, or create a new campaign. |
422 tracking_tag_required |
Conversions sent before a pixel exists | Create the pixel. |
422 feature_not_available |
Pixel management is not switched on for the ad account | Ask your ChatGPT Ads contact to turn it on. |
501 feature_not_available |
Targeting or creative edits after launch | Create a new ad. |
#Endpoints
#Webhook events
| Event | When it fires |
|---|---|
account.ads.initial_sync_completed |
Ads initial sync completed. |
account.ads.sync_failed |
Ads sync failed. |
account.ads.sync_recovered |
Ads sync recovered. |
ad.review.approved |
Ad review approved. |
ad.review.rejected |
Ad review rejected. |