Ads

X Ads

Run promoted posts on X from an xads connection, with website cards, Tailored Audiences, bid control and the X Pixel.

X ads are promoted posts. Pozy can write a new post that only shows as an ad, or promote a post you already published so it keeps its replies and likes. Both run on an xads connection, and the ad endpoints refer to X as twitter.

Connection platform xads (created from a connected X account)
platform on ad endpoints twitter
Structure Campaign, line item, promoted post, all created by one call
Goals engagement, traffic, awareness, video_views, app_promotion
Ad text Up to 280 characters
Changes after launch Status, budget and bid
Audiences Tailored Audiences (customer lists, matched on email)
Measurement X Pixel. Server-side conversions are not available for X.

Pozy signs every X Ads request for you. You authenticate with your Pozy key like on any other network.

#Connect

X Ads uses its own authorization, separate from posting, and it always starts from an X account that is already connected to the profile. There is no ads-only X connection.

Payment method

X bills every API call, so connecting the first X account to a workspace needs a payment method on file. Without one the posting connection returns 402 payment_required, and there is nothing to build the ads connection from.

Call the connect endpoint with the profile and the X posting account:

curl "https://api.pozy.ai/v1/connect/twitter/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&accountId=66b2e19d8c3f5a7e9d0b1c2b&redirect_url=https://app.example.com/ads/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"

The first call always returns an authUrl, because X Ads keeps its own token. Send the person there. After consent they land on your redirect_url, and calling the endpoint again returns alreadyConnected: true with the accountId of the new xads connection.

If consent fails, the redirect carries platform=xads and an error such as x_ads_denied, x_ads_auth_failed or x_ads_token_failed.

adAccountId (or adAccountIds, repeated or comma separated) on the connect call limits ad sync to those X ad accounts. Leaving both out keeps the scope you stored before. Narrowing the scope removes synced ads from the accounts you drop.

X ad account ids are short base36 strings such as 18ce54d4x5t. List them with GET /v1/ads/accounts?accountId=....

Either account id works

On ad calls, accountId can be the X posting account or the xads connection. Pozy resolves one from the other. If the profile has no X Ads connection, the call returns 422 ads_connection_required with param: "accountId".

#Create a campaign

POST /v1/ads/create publishes a promoted-only post from the ad account's promotable user, then builds the campaign and line item around it. The post never shows on the organic timeline.

curl -X POST https://api.pozy.ai/v1/ads/create \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: q2-platform-launch" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "18ce54d4x5t",
    "name": "Q2 platform launch",
    "goal": "traffic",
    "budgetAmount": 500,
    "budgetType": "lifetime",
    "startDate": "2027-01-04T00:00:00+01:00",
    "endDate": "2027-01-31T23:59:59+01:00",
    "body": "Ship faster with a platform your whole team can use.",
    "imageUrl": "https://example.com/images/platform-card.png",
    "headline": "Platform engineering, done right",
    "linkUrl": "https://example.com/platform",
    "countries": ["US"],
    "languages": ["en"]
  }'
Response (201)
{
  "ad": {
    "_id": "66f0a1b2c3d4e5f6a7b8c9d0",
    "name": "Q2 platform launch",
    "platform": "twitter",
    "status": "pending_review",
    "adType": "standalone",
    "goal": "traffic",
    "budget": { "amount": 500, "type": "lifetime" },
    "platformCampaignId": "f4x8j",
    "platformAdSetId": "9r2mv",
    "platformAdId": "5gzhb"
  },
  "message": "Ad created"
}

#Text and website cards

The creative is the post text plus, optionally, a website card.

You send You get
body only A text post. Required on every create, up to 280 characters.
body + linkUrl The link is added to the text. X shortens it, and it uses about 24 of the 280 characters.
body + imageUrl + headline + linkUrl A website card: the image, the headline as its title (up to 70 characters) and the link under the post. The text keeps all 280 characters.

imageUrl or headline without the other two returns 400. The image must be a public URL; Pozy downloads it and uploads it to X.

description, video and callToAction have no place on an X ad and return 400. To run a video ad, publish the video as a normal X post first and promote it.

#Budget and schedule

  • budgetAmount is whole units of the ad account currency. budgetType is daily or lifetime, and it lands on the line item.
  • A lifetime budget needs endDate. startDate defaults to now.
  • Send startDate and endDate with an offset to pin exact times. A value without an offset, including a plain date, is read in the ad account's time zone, and a date-only endDate runs to 23:59:59 that day.

#Promote an existing post

POST /v1/ads/boost promotes a post that is already on X, with its media, video included. Pass the Pozy postId of a post published to X, or the X post id as platformPostId.

cURL
curl -X POST https://api.pozy.ai/v1/ads/boost \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: boost-launch-post" \
  -d '{
    "postId": "65f1c0a9e2b5af0012ab34cd",
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "18ce54d4x5t",
    "name": "Boost launch post",
    "goal": "engagement",
    "budget": { "amount": 50, "type": "daily" },
    "schedule": { "startDate": "2027-01-04", "endDate": "2027-01-11" }
  }'

The goals are the same five as on a create. See Boost posts for how boosting works across networks.

#Targeting

Targeting goes on the create and becomes the line item's targeting criteria.

Field Values
countries ISO country codes
regions, metros, cities, zips { "key": "..." } from targeting search with dimension=geo
interests { "id": "..." } from targeting search with dimension=interest
audienceInclude, audienceExclude X ids of Tailored Audiences
ageMin, ageMax Rounded out to an X age range (see below)
gender male or female. all sends no gender filter.
languages Codes such as en. A region suffix (en_GB) is dropped.

X targets one age range from a fixed list. Ranges start at 13, 18, 21, 25, 35 or 50 and end at 24, 34, 49 or 54, or stay open. Pozy picks the narrowest range that covers your numbers, so ageMin: 20, ageMax: 40 becomes 18 to 49.

incomeTier, customLocations and behaviors return 400. Keyword targeting and follower look-alikes are not available.

#Find geo and interest values

Geo keys and interest ids are X's own. A key from another network does not work.

cURL
curl "https://api.pozy.ai/v1/ads/targeting/search?accountId=66b2e19d8c3f5a7e9d0b1c2d&q=Austin&dimension=geo&geoType=city&countryCode=US" \
  -H "Authorization: Bearer $POZY_API_KEY"
Response
{ "results": [{ "id": "3b77caf94bfc81fe", "name": "Austin", "type": "city", "countryCode": "US" }] }

Send a geo result's id as key. geoType is country, region, metro_area, city or zip. Interest results carry their parent categories in path. More in Targeting.

#Tailored Audiences

A Tailored Audience is a customer list. Create it with type: "customer_list" (the only type X supports), then upload emails.

cURL
curl -X POST https://api.pozy.ai/v1/ads/audiences/66e5b2c3d4f5a6b7c8d9e0f1/users \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "users": [{ "email": "jane@example.com" }, { "email": "sam@example.com" }] }'
  • X matches on email and ignores phone. Pozy hashes every value with SHA-256 before sending.
  • One upload takes up to 10,000 users.
  • X lets you target a list once it matches at least 100 recently active users. Until then its size reads 0.

#Manage a running campaign

After launch only status, budget and bid change. Targeting and creative stay as created; sending them to PUT /v1/ads/{adId} returns 501 feature_not_available.

#Pause and resume

X has no on/off switch per promoted post. PUT /v1/ads/{adId}/status sets the switch of the ad's line item, which every promoted post in that line item shares, and configuredStatus comes back null.

Level Call
Campaign PUT /v1/ads/campaigns/{campaignId}/status with { "status": "paused", "platform": "twitter" }
Line item PUT /v1/ads/ad-sets/{adSetId}/status
Ad PUT /v1/ads/{adId}/status (writes the line item)

Pausing a campaign stops everything in it. Resuming it restarts a line item only if that line item's own switch is on.

#Budget and bid

  • budget: { amount, type } on PUT /v1/ads/{adId}: Pozy finds where X holds the budget (line item or campaign) and writes it there.
  • PUT /v1/ads/ad-sets/{adSetId} with platform: "twitter" changes a line item's budget, name or bid.
  • PUT /v1/ads/campaigns/{campaignId} changes a campaign budget or renames it. If X holds the budget on the line item, a campaign budget returns 409.
bidStrategy X bidding Needs
LOWEST_COST_WITHOUT_CAP Automatic
LOWEST_COST_WITH_BID_CAP Maximum bid bidAmount
COST_CAP Target cost bidAmount

X decides which strategy an objective allows: target cost works on reach, followers and website clicks only, and maximum bid not on reach or followers. Minimum-ROAS bidding is not available on X, and any other strategy returns 422.

#Delete

DELETE /v1/ads/{adId} removes the promoted post on X and marks Pozy's copy cancelled, keeping its history. Its line item and campaign are removed too, but only when Pozy created them and nothing else is left in them. DELETE /v1/ads/campaigns/{campaignId} removes a whole campaign. Duplicating an X campaign is not available (501).

#Measure results

GET /v1/ads/{adId}/analytics returns spend, impressions, clicks, CTR, CPC, CPM and engagement for a range. GET /v1/ads/tree rolls them up per campaign and line item.

  • reach reads 0 because it is not synced from X.
  • On connect, Pozy imports the last 90 days of metrics.
  • Demographic breakdowns are not available on X.
  • Conversions show up per ad and campaign as web conversion metrics. X does not report counts per pixel or per event.

#X Pixel

The X Pixel is the ad account's website tag, managed through the tracking tags endpoints on the xads connection. X allows one per ad account.

  • POST /v1/accounts/{accountId}/tracking-tags with the X ad account id as adAccountId creates it with first-party cookies on. A second create returns 409, and X has no pixel delete.
  • tagId is the X ad account id. siteTagId is the pixel id the site loads.
  • PATCH changes only firstPartyCookieStatus (first_party_cookie_enabled or first_party_cookie_disabled).
  • Conversion events take a type such as PURCHASE, SIGN_UP or CUSTOM, a clickWindowDays of 1, 7, 14, 30, 60 or 90 and a viewWindowDays of 0, 1, 7, 14, 30, 60 or 90 (never longer than the click window). New events default to 30 and 1 days. Creating one is not idempotent; deleting one is permanent.
  • Events X creates with the pixel are hidden from the list and cannot be changed.

Install it on a connected store or site with one call. See Pixels.

#Errors you will see

Status and code Cause Fix
422 ads_connection_required The profile has no xads connection Run the connect flow with the X posting account id.
400 missing_required_field No body, or a website card missing imageUrl, headline or linkUrl Send the text, and all three card fields or none.
400 invalid_field_value A goal outside the five, text over 280, a headline over 70, or description, video, callToAction Fix the field the message names.
409 A campaign budget when X holds it on the line item Change the budget through the ad or line item.
403 ads_allowance_exceeded No payment method and the free live-ads allowance is used up Add a payment method.
501 feature_not_available Targeting or creative edits, or a campaign duplicate Create a new ad or campaign.
502 platform_api_error X rejected the request Read platformError in the body.

#Endpoints

Endpoint What it does
GET /v1/connect/{platform}/ads Connect ads for a platform
GET /v1/ads/accounts List ad accounts
POST /v1/ads/create Create standalone ad
POST /v1/ads/boost Boost post as ad
GET /v1/ads/tree Get campaign tree
GET /v1/ads/{adId} Get ad details
PUT /v1/ads/{adId} Update ad
PUT /v1/ads/{adId}/status Pause or resume a single ad
PUT /v1/ads/ad-sets/{adSetId} Update an ad set
PUT /v1/ads/ad-sets/{adSetId}/status Pause or resume a single ad set
PUT /v1/ads/campaigns/{campaignId} Update a campaign
PUT /v1/ads/campaigns/{campaignId}/status Pause or resume a campaign
DELETE /v1/ads/{adId} Cancel an ad
DELETE /v1/ads/campaigns/{campaignId} Delete a campaign
GET /v1/ads/{adId}/analytics Get ad analytics
GET /v1/ads/targeting/search Search targeting options
POST /v1/ads/targeting/reach-estimate Estimate audience reach
POST /v1/ads/audiences Create custom audience
POST /v1/ads/audiences/{audienceId}/users Add users to audience
GET /v1/accounts/{accountId}/tracking-tags List tracking tags
POST /v1/accounts/{accountId}/tracking-tags Create a tracking tag
PATCH /v1/accounts/{accountId}/tracking-tags/{tagId} Update a tracking tag
POST /v1/accounts/{accountId}/tracking-tags/{tagId}/events Create a conversion event

#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.
ad_account.status_changed Ad account status changed.