# 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:

```bash cURL
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"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/twitter/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&accountId=66b2e19d8c3f5a7e9d0b1c2b&redirect_url=https:%2F%2Fapp.example.com%2Fads%2Fconnected", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.authUrl ?? data.accountId);
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/connect/twitter/ads",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={
        "profileId": "66b2e19d8c3f5a7e9d0b1c2a",
        "accountId": "66b2e19d8c3f5a7e9d0b1c2b",
        "redirect_url": "https://app.example.com/ads/connected",
    },
)
print(resp.json())
```

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.

```bash cURL
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"]
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/ads/create", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Idempotency-Key": "q2-platform-launch",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    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"],
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.ad.platformAdSetId); // the X line item
```
```python Python
resp = requests.post(
    "https://api.pozy.ai/v1/ads/create",
    headers={
        "Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
        "Idempotency-Key": "q2-platform-launch",
    },
    json={
        "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"],
    },
)
print(resp.json()["ad"]["status"])
```

```json
{
  "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`.

```bash
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](https://pozy.ai/docs/ads/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](#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.

```bash
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"
```

```json
{ "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](https://pozy.ai/docs/ads/targeting).

### Tailored Audiences

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

```bash
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](https://pozy.ai/docs/ads/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`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform |
| [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts |
| [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad |
| [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad |
| [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree |
| [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details |
| [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad |
| [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad |
| [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set |
| [`PUT /v1/ads/ad-sets/{adSetId}/status`](https://pozy.ai/docs/api/updateAdSetStatus) | Pause or resume a single ad set |
| [`PUT /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/updateAdCampaign) | Update a campaign |
| [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign |
| [`DELETE /v1/ads/{adId}`](https://pozy.ai/docs/api/deleteAd) | Cancel an ad |
| [`DELETE /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/deleteAdCampaign) | Delete a campaign |
| [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics |
| [`GET /v1/ads/targeting/search`](https://pozy.ai/docs/api/searchAdTargeting) | Search targeting options |
| [`POST /v1/ads/targeting/reach-estimate`](https://pozy.ai/docs/api/estimateAdReach) | Estimate audience reach |
| [`POST /v1/ads/audiences`](https://pozy.ai/docs/api/createAdAudience) | Create custom audience |
| [`POST /v1/ads/audiences/{audienceId}/users`](https://pozy.ai/docs/api/addUsersToAdAudience) | Add users to audience |
| [`GET /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/listTrackingTags) | List tracking tags |
| [`POST /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/createTrackingTag) | Create a tracking tag |
| [`PATCH /v1/accounts/{accountId}/tracking-tags/{tagId}`](https://pozy.ai/docs/api/updateTrackingTag) | Update a tracking tag |
| [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/events`](https://pozy.ai/docs/api/createTrackingTagEvent) | Create a conversion event |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. |
| [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. |
| [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. |
| [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. |
| [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. |
| [`ad_account.status_changed`](https://pozy.ai/docs/webhooks/events#ad-account-status-changed) | Ad account status changed. |
