X Ads
Create campaigns, promote posts you already published and upload Tailored Audiences on an xads account, with OAuth 1.0a signing handled server-side.
Create a campaign with POST /v1/ads/create, or promote a post you already published with POST /v1/ads/boost, on an xads account for X (platform value twitter). Zernio signs every X Ads call with OAuth 1.0a on its own servers, so your integration sends $ZERNIO_API_KEY the same way it does for every other platform.
Quick reference
| Property | Value |
|---|---|
| Hierarchy | Campaign > Line Item > Promoted Tweet, created in one call |
Goals (goal) | engagement, traffic, awareness, video_views, app_promotion |
| Ad text | body, 280 characters including the shortened link |
| Rejected creative fields | headline, description, imageUrl, video, callToAction (return 400) |
| Media | None on a create (the ad is a text post); a boost carries the media of the post it promotes |
| Targeting | Country, region, metro, city, postal code, interests, custom audiences, age (rounded out to X's fixed ranges), gender, language |
| Audiences | Tailored Audiences (create, member upload, read) |
| Analytics | Yes |
| Edits after creation | Status and budget only |
| X Pixel | Create, read, first-party cookie setting, conversion events, stats and Shopify / WordPress install (X Pixel) |
Before you start
X Ads is a separate OAuth from X posting, so the profile needs both: a connected X posting account to author the ads, and the xads account created from it. There is no ads-only mode. You also need the X Ads account id, a base36 string such as 18ce54d4x5t, from List ad accounts.
accountId takes either shape. Pass the X posting account id or the xads account id and Zernio resolves the sibling internally. When the resolved profile has no X Ads connection, the call returns 422 with code ads_connection_required.
Connect
Call GET /v1/connect/twitter/ads with profileId and the accountId of the X posting account the ads are authored by (Connect ads). accountId is required here: X ads carry a real author, so there is no ads-only path.
curl "https://zernio.com/api/v1/connect/twitter/ads?profileId=66a1f0c2a4b9d3e8f1a2b3c4&accountId=66b2e19d8c3f5a7e9d0b1c2d" \
-H "Authorization: Bearer $ZERNIO_API_KEY"Response (200):
{
"authUrl": "https://api.x.com/oauth/authorize?oauth_token=...",
"state": "..."
}Send the user to authUrl, as in the connecting accounts guide. X Ads keeps its own token, so this round trip always happens the first time; afterwards the same call returns alreadyConnected: true with the xads accountId. On failure the browser lands on your redirect_url with platform=xads and an error slug such as x_ads_denied, x_ads_auth_failed or x_ads_token_failed. Pass adAccountId on the connect call to scope ad sync to one X Ads account, or adAccountIds (repeated or comma-separated) for several. Sending neither leaves whatever scope is already stored; sending one removes ads already synced from the accounts it drops.
Create a campaign
Call POST /v1/ads/create. On X, body carries the ad text, at most 280 characters, and X counts a linkUrl as about 24 of them because it shortens the link. headline, description, imageUrl, video and callToAction have no slot on X and return 400: the ad X publishes is a text post built from body and linkUrl. To run an X ad with an image or a video, promote a post that already carries it, within X's media limits.
import Zernio from '@zernio/node';
const zernio = new Zernio();
const { data: created } = await zernio.adcampaigns.createStandaloneAd({
body: {
accountId: '66b2e19d8c3f5a7e9d0b1c2d',
adAccountId: '18ce54d4x5t',
name: 'Q2 product awareness',
goal: 'awareness',
budgetAmount: 75,
budgetType: 'daily',
body: 'Ship faster with platform engineering done right.',
linkUrl: 'https://example.com/platform',
countries: ['US']
}
});
console.log(created.ad.platformAdSetId);Response (201):
{
"ad": {
"_id": "66f0a1b2c3d4e5f6a7b8c9d0",
"name": "Q2 product awareness",
"platform": "twitter",
"status": "pending_review",
"adType": "standalone",
"goal": "awareness",
"budget": { "amount": 75, "type": "daily" },
"platformAdId": "5gzhb",
"platformCampaignId": "f4x8j",
"platformAdSetId": "9r2mv"
},
"message": "Ad created"
}budgetAmount is in whole currency units of the ad account, so 75 is $75.00 on a USD account. platformAdSetId is the X line item. Every sample below reuses the zernio and client constructors from this one.
Targeting fields
countries, regions, cities, metros, zips and interests apply on X. Take the geo keys and interest ids from GET /v1/ads/targeting/search with dimension=geo or dimension=interest on the X connection: they are X's own targeting values, so a key from another platform does not work. incomeTier, customLocations and behaviors are rejected (X removed behavior targeting from its Ads API). audienceInclude and audienceExclude take the X custom audience id of a Tailored Audience.
Promote an existing post
POST /v1/ads/boost promotes a post that is already published, keeping its engagement.
const { data: boosted } = await zernio.adcampaigns.boostPost({
body: {
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' }
}
});
console.log(boosted.ad._id);Response (201):
{
"ad": {
"_id": "66f0a1b2c3d4e5f6a7b8c9d1",
"name": "Boost launch post",
"platform": "twitter",
"status": "pending_review",
"adType": "boost",
"goal": "engagement",
"budget": { "amount": 50, "type": "daily" }
},
"message": "Ad created"
}Boosts are not idempotent. Send an Idempotency-Key header to make a retry replay the original 201 instead of creating a second ad (idempotency).
Tailored Audiences
Create the audience with POST /v1/ads/audiences (type: "customer_list"), then upload members with POST /v1/ads/audiences/{audienceId}/users. X matches on email, ignores phone, and takes at most 10,000 users per request; Zernio hashes every value with SHA-256 before it leaves.
const { data: audience } = await zernio.adaudiences.createAdAudience({
body: {
accountId: '66b2e19d8c3f5a7e9d0b1c2d',
adAccountId: '18ce54d4x5t',
type: 'customer_list',
name: 'Trial signups'
}
});
const { data: upload } = await zernio.adaudiences.addUsersToAdAudience({
path: { audienceId: audience.audience.id },
body: { users: [{ email: 'jane@example.com' }, { email: 'sam@example.com' }] }
});
console.log(upload.numReceived);Response (201):
{
"audience": { "id": "66e5b2c3d4f5a6b7c8d9e0f1", "name": "Trial signups", "type": "customer_list", "platform": "twitter", "status": "pending" },
"message": "Audience created"
}Response (200):
{ "message": "Users added", "numReceived": 2, "numInvalid": 0 }An audience must match at least 100 recently active users before X allows targeting against it, so its size reads as 0 until then.
X Pixel
Create and read an ad account's X Pixel, manage its conversion events through the platform-neutral tracking tags API on your xads account, check whether each event is tracking, and install the pixel on a connected Shopify store or WordPress site.
The X Pixel is the ad account's universal website tag, and X allows one per ad account, so:
| Field | Value |
|---|---|
tagId | The X ad account id (for example 18ce54d4x5t) |
siteTagId | The pixel id (the universal website tag's id), what twq('config') loads |
events | The account's conversion events (web event tags; the ones X auto-creates with the pixel are left out), each with type (PURCHASE, ADD_TO_CART, ...), status, clickWindowDays, viewWindowDays and siteEventId = tw-<pixel>-<event>, the id twq('event') and the Conversions API send |
const { data } = await zernio.trackingtags.getTrackingTag({
path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', tagId: '18ce54d4x5t' }
});Response (200):
{
"platform": "xads",
"tag": {
"id": "18ce54d4x5t",
"siteTagId": "o8z1q",
"name": "X Pixel o8z1q",
"platform": "xads",
"kind": "pixel",
"status": "active",
"code": "<!-- X conversion tracking base code -->\n<script>...twq('config','o8z1q');</script>",
"lastFiredTime": 1804150800,
"installed": true,
"ownerAdAccountId": "18ce54d4x5t",
"events": [
{ "id": "14cgjq", "siteEventId": "tw-o8z1q-rg1cq", "name": "Purchase", "type": "PURCHASE", "status": "TRACKING", "siteEvent": "purchase", "clickWindowDays": 30, "viewWindowDays": 1 }
]
}
}status is active when any of the account's web event tags is TRACKING, and lastFiredTime is the most recent last_tracked_at across them. A per-tag call on an ad account without a pixel answers 400: create one first.
| Operation | Behavior |
|---|---|
| List | One pixel per ad account on the connection; adAccountId scopes it to one. |
Stats (GET .../stats) | One row per web event tag with eventId, name, type, autoCreated, status and lastTrackedTime. X exposes no per-pixel or per-event counts, only campaign WEB_CONVERSION metrics. |
Create (POST .../tracking-tags) | Creates the ad account's pixel (adAccountId = the X ad account id) with first-party cookies on. name is not stored, since X pixels have no name. An account that already has a pixel answers 409 without writing: X allows one and cannot delete it. |
Update (PATCH .../tracking-tags/{tagId}) | firstPartyCookieStatus only, first_party_cookie_enabled or first_party_cookie_disabled (the pixel's allow_1p_cookie). |
Conversion events (.../events) | List, create, update and delete. Fields: name, type (X's event type, such as PURCHASE, SIGN_UP, CUSTOM) or siteEvent, clickWindowDays (1, 7, 14, 30, 60 or 90) and viewWindowDays (0, 1, 7, 14, 30, 60 or 90, no longer than the click window). A create defaults to 30 and 1 days with retargeting on, as Events Manager does, and is not idempotent. Delete is permanent. The events X auto-created with the pixel cannot be changed or deleted (400). |
| Share | 501: an X Pixel belongs to exactly one ad account, and X has no share endpoint. |
| Diagnostics, users and partners | 501. |
| Delete | Not offered: DELETE on a website tag answers 405 at X. |
| Install | Shopify and WordPress. WordPress gets X's uwt.js base code, whose twq('config') is the page view. Shopify sends the page view and each event that has a matching conversion event (the most recently tracked one of a type wins) to analytics.twitter.com/1/i/adsct in uwt.js's own request shape (reverse-engineered: X documents only the legacy image pixel), with conversion_id = the event id so X deduplicates against the Conversions API, and the twclid click id kept first-party. |
Analytics
Call GET /v1/ads/{adId}/analytics with the ad's _id for spend, impressions, clicks, CTR, CPC and CPM over a date range (Get ad analytics).
curl "https://zernio.com/api/v1/ads/66f0a1b2c3d4e5f6a7b8c9d0/analytics?fromDate=2027-01-04&toDate=2027-01-11" \
-H "Authorization: Bearer $ZERNIO_API_KEY"Response (200), trimmed:
{
"ad": { "id": "66f0a1b2c3d4e5f6a7b8c9d0", "name": "Q2 product awareness", "platform": "twitter", "status": "active", "currency": "USD" },
"analytics": {
"summary": { "spend": 421.7, "impressions": 203800, "clicks": 3140, "ctr": 1.54, "cpc": 0.13, "cpm": 2.07, "engagement": 5820 },
"daily": [ { "date": "2027-01-04", "spend": 59.8, "impressions": 29100, "clicks": 448 } ]
}
}X reports reach as 0, because Zernio does not sync it, and the demographic breakdowns parameter is Meta and TikTok only. GET /v1/ads/tree rolls the same metrics up per campaign and line item (Get campaign tree).
What you cannot do
X Ads through Zernio does not support:
- Targeting or creative edits after creation.
PUT /v1/ads/{adId}takes status and budget;targetingorcreativereturns501with codeunsupported_platform_operation. - Campaign duplication.
POST /v1/ads/campaigns/{campaignId}/duplicatereturns501on X. - An ads-only connection. Every X ad is authored by a connected posting account.
- Keyword targeting and follower look-alike targeting.
- Uploading creative with an ad. A create publishes text; media comes from the post a boost promotes.
- Income tier, radius and behavior targeting.
Common errors
| Error | Cause | Fix |
|---|---|---|
422 with code ads_connection_required | The profile has no xads account | Run the connect flow with the X posting account id. |
400 with code missing_required_field | body or linkUrl is absent on a create | Send the ad text and its destination. |
400 with code invalid_field_value | A goal outside the 5 X values, or ad text over 280 characters | Use a supported goal and trim the text, counting about 24 characters for the link. |
403 with code ads_allowance_exceeded | The team has no payment method on file and has reached 500 live ads | Add a card to resume creating ads. |
502 with code platform_api_error | X rejected the request | Read platformError for X's own payload. |
A call against a profile with no X Ads connection returns:
{
"error": "X Ads is not connected for this profile",
"type": "invalid_request_error",
"code": "ads_connection_required",
"param": "accountId"
}Error handling covers the envelope and the stable codes.
Related
- X: the posting account that authors the ads.
- Connecting accounts: the OAuth flow behind
authUrl. - Create standalone ad and Boost post: every field of both requests.
- Create ad audience and Add users to ad audience.
- Get ad analytics: metrics, date ranges and rollups.
- Pixels and tracking tags: the pixel API across platforms.
Pinterest Ads
Create Promoted Pin campaigns, promote Pins you already published, upload customer list audiences and read ad metrics on a pinterestads account.
OpenAI Ads
Run ChatGPT ads on an openaiads account, from connecting an API key to creating chat card campaigns, pixels and server-side conversions.