ChatGPT Ads
Create and manage ads inside ChatGPT with the same API key you already use for publishing. Upload-Post talks to the OpenAI Advertiser API on your behalf, so you connect your ad account once and then drive campaigns, ad groups, ads, creatives and reporting through api.upload-post.com.
An OpenAI Ads Manager account with API access. Issue a key at ads.openai.com → Settings → API keys. Each OpenAI key is scoped to exactly one ad account; connect one key per ad account you want to manage.
Spend runs on your own OpenAI billing. Upload-Post never charges for ad spend and never bills through your ad account — it only relays the calls.
How ChatGPT Ads are structured
Campaign budget, schedule, location and audience targeting
└── Ad group bid (max CPM or max CPC)
└── Ad creative: title, body, image, destination URL
An ad only serves when all three objects are active and the ad has passed OpenAI's review (review_status: "approved", usually a few minutes).
Every create endpoint defaults to status: "paused" so nothing starts spending by accident. Send "status": "active" explicitly, or flip the objects later with the /activate actions.
Connect an ad account
Endpoint
POST /api/uploadposts/ads/chatgpt/connect
Body Parameters (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
api_key | String | Yes | Your OpenAI Ads API key. Validated live against OpenAI before it is stored. |
name | String | No | Friendly label. Defaults to the ad account name reported by OpenAI. |
default | Boolean | No | Make this the account used when a request omits ad_account_id. |
Example Request
curl -X POST https://api.upload-post.com/api/uploadposts/ads/chatgpt/connect \
-H 'Authorization: Apikey your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{"api_key": "your-openai-ads-api-key"}'
Successful Response (200 OK)
{
"success": true,
"message": "ChatGPT Ads account connected successfully",
"account": {
"ad_account_id": "adacct_123",
"name": "Acme Ads",
"currency_code": "USD",
"timezone": "UTC",
"status": "active",
"review_status": "approved",
"default": true,
"connected_at": "2026-09-06T10:12:00+00:00"
}
}
The key is encrypted at rest and is never returned by any endpoint. Re-posting the same account replaces the stored key — that is how you rotate it.
Managing connected accounts
| Method | Endpoint | Description |
|---|---|---|
GET | /api/uploadposts/ads/chatgpt/accounts | Connected ad accounts (no credentials). |
GET | /api/uploadposts/ads/chatgpt/account | Live metadata for the selected account, straight from OpenAI. |
DELETE | /api/uploadposts/ads/chatgpt/accounts/{ad_account_id} | Forget the stored key. Campaigns keep running in Ads Manager. |
Choosing the ad account
With one connected account everything just works. With several, pick one per request in any of these ways (first match wins):
?ad_account_id=adacct_123query parameterX-Ads-Account-Id: adacct_123header"ad_account_id": "adacct_123"in the JSON body
Otherwise the account flagged default is used; if there is no default and more than one account, the request returns 400.
Launch an ad in one call
promote creates the campaign, ad group and ad — and uploads the creative image — in a single request. Use it to promote a post you just published: pass the post's image as image_url and its permalink (or your landing page) as target_url.
Endpoint
POST /api/uploadposts/ads/chatgpt/promote
Body Parameters (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
title | String | Yes | Ad headline, 3–50 characters. |
body | String | Yes | Ad copy, up to 100 characters. |
target_url | String | Yes | Destination URL (http(s)). |
image_url | String | Yes* | Creative image URL. OpenAI fetches it. |
file_id | String | Yes* | Already-uploaded creative id. Alternative to image_url. |
budget | Number | Yes** | Lifetime budget in your ad account currency. Minimum 1. |
budget_micros | Integer | Yes** | Same value in micros (millionths). 25000000 = 25.00. Alternative to budget. |
max_bid | Number | Yes*** | Max bid per billing event in account currency (e.g. 0.06 for a $60 CPM). |
max_bid_micros | Integer | Yes*** | Same value in micros. Alternative to max_bid. |
name | String | No | Base name for the three objects. Defaults to title. |
status | String | No | paused (default) or active. |
bidding_type | String | No | impressions (default), clicks or conversions. |
start_time | Integer/String | No | Unix timestamp or ISO-8601 datetime. Starts immediately when omitted. |
end_time | Integer/String | No | Unix timestamp or ISO-8601 datetime. |
locations | String[] | No | OpenAI location ids (country, region or DMA). All locations when omitted. |
context_hints | String[] | No | Free-form hints about when your ad is useful, e.g. ["productivity", "team collaboration"]. |
ad_group_name | String | No | Override the ad group name. |
ad_name | String | No | Override the ad name. |
* One of image_url or file_id. ** One of budget or budget_micros. *** One of max_bid or max_bid_micros.
Example Request
curl -X POST https://api.upload-post.com/api/uploadposts/ads/chatgpt/promote \
-H 'Authorization: Apikey your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{
"name": "Spring launch",
"title": "Try the workspace planner",
"body": "Coordinate tasks, docs and meetings in one place.",
"target_url": "https://acme.example/planner",
"image_url": "https://acme.example/assets/planner-card.png",
"budget": 25,
"max_bid": 0.06,
"context_hints": ["productivity", "team collaboration"],
"status": "active"
}'
Successful Response (201 Created)
{
"success": true,
"campaign": { "id": "cmpn_101", "status": "active" },
"ad_group": { "id": "adgrp_301", "status": "active" },
"ad": { "id": "ad_501", "status": "active", "review_status": "in_review" },
"status": "active",
"message": "Ad created. It starts serving once OpenAI's review approves it (check 'review_status')."
}
Everything is built paused and only activated once all three objects exist. If a later step fails, the response carries what was already created so you can finish or archive it:
{
"success": false,
"message": "bid above the account maximum",
"details": { "created": { "campaign_id": "cmpn_101" } }
}
Campaigns
| Method | Endpoint | Description |
|---|---|---|
GET | /api/uploadposts/ads/chatgpt/campaigns | List campaigns. |
POST | /api/uploadposts/ads/chatgpt/campaigns | Create a campaign. |
GET | /api/uploadposts/ads/chatgpt/campaigns/{campaign_id} | Retrieve one campaign. |
POST | /api/uploadposts/ads/chatgpt/campaigns/{campaign_id} | Update a campaign. |
POST | /api/uploadposts/ads/chatgpt/campaigns/{campaign_id}/activate | /pause | /archive | Change state. |
List endpoints accept limit (1–500, default 20), after, before and order (asc/desc).
Create Body Parameters (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
name | String | Yes | 3–1000 characters. |
budget / budget_micros | Number/Integer | Yes | Lifetime spend limit. Minimum 1 (1000000 micros). |
status | String | No | paused (default) or active. |
description | String | No | Free text. |
start_time / end_time | Integer/String | No | Unix timestamp or ISO-8601 datetime. |
bidding_type | String | No | impressions (default), clicks or conversions. Cannot be changed later. |
conversion_event_setting_ids | String[] | No | Exactly one active standard event setting, required for conversions bidding. |
locations | String[] | No | Shorthand for targeting.locations.include. |
custom_audience_ids | String[] | No | Shorthand for targeting.custom_audiences.ids. |
excluded_custom_audience_ids | String[] | No | Shorthand for targeting.excluded_custom_audiences.ids. |
targeting | Object | No | Full OpenAI targeting object. Overrides the shorthands above. |
curl -X POST https://api.upload-post.com/api/uploadposts/ads/chatgpt/campaigns \
-H 'Authorization: Apikey your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{
"name": "Spring launch",
"budget": 25,
"locations": ["2000043"],
"status": "paused"
}'
{
"success": true,
"campaign": {
"id": "cmpn_101",
"name": "Spring launch",
"status": "paused",
"bidding_type": "impressions",
"budget": { "lifetime_spend_limit_micros": 25000000 }
}
}
On update, every field is optional and status also accepts archived. Archiving is irreversible.
Ad groups
| Method | Endpoint | Description |
|---|---|---|
GET | /api/uploadposts/ads/chatgpt/adgroups?campaign_id=... | List ad groups (campaign_id is required). |
POST | /api/uploadposts/ads/chatgpt/adgroups | Create an ad group. |
GET | /api/uploadposts/ads/chatgpt/adgroups/{ad_group_id} | Retrieve one ad group. |
POST | /api/uploadposts/ads/chatgpt/adgroups/{ad_group_id} | Update an ad group. |
POST | /api/uploadposts/ads/chatgpt/adgroups/{ad_group_id}/activate | /pause | /archive | Change state. |
Create Body Parameters (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
campaign_id | String | Yes | Parent campaign. |
name | String | Yes | 3–1000 characters. |
max_bid / max_bid_micros | Number/Integer | Yes | Max bid per billing event. 0.06 = a $60 CPM on impression billing. |
billing_event_type | String | No | impression (default) for impression campaigns, click for click/conversion. |
status | String | No | paused (default) or active. |
context_hints | String[] | No | When your product or service is useful, in your own words. |
description | String | No | Free text. |
bidding_config | Object | No | Full OpenAI bidding config. Overrides max_bid / billing_event_type. |
product_set | Object | No | Product filters for product-feed campaigns. |
curl -X POST https://api.upload-post.com/api/uploadposts/ads/chatgpt/adgroups \
-H 'Authorization: Apikey your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{
"campaign_id": "cmpn_101",
"name": "US English",
"max_bid": 0.06,
"context_hints": ["productivity", "team collaboration"]
}'
Creatives
Upload the image your ad uses and reuse the returned file_id.
POST /api/uploadposts/ads/chatgpt/creatives
Send either JSON with an image_url, or multipart/form-data with a binary file (up to 10 MB).
curl -X POST https://api.upload-post.com/api/uploadposts/ads/chatgpt/creatives \
-H 'Authorization: Apikey your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{"image_url": "https://acme.example/assets/planner-card.png"}'
{ "success": true, "creative": { "file_id": "file_901" } }
Ads
| Method | Endpoint | Description |
|---|---|---|
GET | /api/uploadposts/ads/chatgpt/ads?ad_group_id=... | List ads (ad_group_id is required). |
POST | /api/uploadposts/ads/chatgpt/ads | Create an ad. |
GET | /api/uploadposts/ads/chatgpt/ads/{ad_id} | Retrieve one ad. |
POST | /api/uploadposts/ads/chatgpt/ads/{ad_id} | Update an ad. |
POST | /api/uploadposts/ads/chatgpt/ads/{ad_id}/preview | Temporary iframe preview (expires in 24 h). |
POST | /api/uploadposts/ads/chatgpt/ads/{ad_id}/activate | /pause | /archive | Change state. |
Create Body Parameters (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
ad_group_id | String | Yes | Parent ad group. |
name | String | Yes | Internal name, 3–1000 characters. Not shown to users. |
title | String | Yes | Headline, 3–50 characters. |
body | String | Yes | Copy, up to 100 characters. |
target_url | String | Yes | Destination URL (http(s)). |
file_id | String | Yes | Creative id from /creatives. |
status | String | No | paused (default) or active. |
creative | Object | No | Full OpenAI creative object. Use it for product_ad_template ads. |
curl -X POST https://api.upload-post.com/api/uploadposts/ads/chatgpt/ads \
-H 'Authorization: Apikey your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{
"ad_group_id": "adgrp_301",
"name": "Planner launch card",
"title": "Try the workspace planner",
"body": "Coordinate tasks, docs and meetings in one place.",
"target_url": "https://acme.example/planner",
"file_id": "file_901",
"status": "active"
}'
{
"success": true,
"ad": {
"id": "ad_501",
"name": "Planner launch card",
"status": "active",
"review_status": "in_review",
"creative": {
"type": "chat_card",
"title": "Try the workspace planner",
"body": "Coordinate tasks, docs and meetings in one place.",
"image_url": "https://cdn.openai.com/ads/file_901.png",
"target_url": "https://acme.example/planner"
}
}
}
review_status is in_review, approved or rejected. A rejected ad breaks OpenAI's ads policies; edit it and it goes back into review.
Insights
| Endpoint | Scope |
|---|---|
GET /api/uploadposts/ads/chatgpt/insights | Ad account |
GET /api/uploadposts/ads/chatgpt/campaigns/{campaign_id}/insights | Campaign |
GET /api/uploadposts/ads/chatgpt/adgroups/{ad_group_id}/insights | Ad group |
GET /api/uploadposts/ads/chatgpt/ads/{ad_id}/insights | Ad |
Query Parameters
| Name | Description |
|---|---|
since / until | YYYY-MM-DD shorthand for a date range in the ad account timezone. |
time_granularity | hourly, daily (default), monthly or none. |
aggregation_level | ad_account, campaign, ad_group or ad — the row entity inside the endpoint's scope. |
fields | Repeatable. Metrics and metadata to project: impressions, clicks, spend, ctr, cpc, cpm, campaign.name, … |
segments | One of product, country, device. |
filters / sort | Repeatable JSON-encoded objects, passed through to OpenAI. |
limit | 1–2000, default 20. Page with after / before. |
time_ranges | Repeatable JSON-encoded range objects, for windows since/until can't express. |
Repeatable parameters accept either spelling — fields=clicks&fields=spend or
fields[]=clicks&fields[]=spend — and are always forwarded to OpenAI in the
[] form it expects.
curl -G https://api.upload-post.com/api/uploadposts/ads/chatgpt/insights \
-H 'Authorization: Apikey your-api-key-here' \
--data-urlencode 'aggregation_level=campaign' \
--data-urlencode 'time_granularity=daily' \
--data-urlencode 'since=2026-09-01' \
--data-urlencode 'until=2026-09-05' \
--data-urlencode 'fields=impressions' \
--data-urlencode 'fields=clicks' \
--data-urlencode 'fields=spend'
{
"success": true,
"insights": [
{
"campaign_id": "cmpn_101",
"readable_time": "2026-09-01",
"timezone": "UTC",
"impressions": 15548,
"clicks": 312,
"spend": 42.75
}
],
"count": 1,
"has_more": false,
"first_id": "start=1756684800:end=1756771200:entity_id=cmpn_101",
"last_id": "start=1756684800:end=1756771200:entity_id=cmpn_101"
}
Page forward by sending the previous response's last_id as after.
Amounts and micros
OpenAI expresses money in micros — millionths of your ad account's currency unit. Upload-Post accepts either form:
| You send | OpenAI receives | Meaning |
|---|---|---|
"budget": 25 | 25000000 | 25.00 lifetime budget |
"budget_micros": 25000000 | 25000000 | same |
"max_bid": 0.06 | 60000 | $60 CPM (per impression) |
"max_bid_micros": 60000 | 60000 | same |
All amounts are in the ad account's own currency (currency_code on the connect response).
Errors
| Status | Meaning |
|---|---|
400 | Validation failed, or OpenAI rejected the request. message carries the upstream reason. |
401 | Missing Upload-Post credentials, or the stored OpenAI key was revoked — reconnect the account. |
404 | No ChatGPT Ads account connected, or the campaign/ad group/ad does not exist. |
413 | Creative file larger than 10 MB. |
429 | Rate limited (by Upload-Post or by OpenAI). Retry after a short pause. |
502 | OpenAI Ads is unreachable or returned an unexpected response. |
504 | OpenAI Ads did not respond in time. |
{
"success": false,
"message": "ChatGPT Ads rejected the stored API key. Issue a new one in Ads Manager (https://ads.openai.com → Settings → API keys) and reconnect the account."
}
Related
- User Profiles — connect the social accounts you publish with
- Get Analytics — organic performance for your published posts
- OpenAI Ads documentation — the upstream API and its policies