Reddit Ads
Create image, video, carousel and text ads, promote existing Reddit posts, target subreddits and conversation keywords, and send Conversions API events on a redditads account.
Create a campaign with POST /v1/ads/create, or promote a Reddit post that already exists with POST /v1/ads/boost, on a redditads account (ad platform value reddit, alias redditads). Reddit Ads is a standalone connection: it has its own OAuth and its own accountId, separate from the organic Reddit account you post with.
Quick reference
| Property | Value |
|---|---|
| Hierarchy | Campaign > Ad Group > Ad, created in one call |
Goals (goal) | traffic, awareness, conversions, video_views, lead_generation |
| Formats | Image, video, carousel (2 to 10 cards), text, and promoting an existing Reddit post |
| Headline | Required, up to 300 characters |
| Body | Optional on image, video and carousel ads, required on text ads |
| Call to action | 17 values (Call to action) |
| Budget | daily or lifetime (a lifetime budget needs endDate), set on the ad group |
| Bidding | Automatic on create; manual bid or cost cap afterwards on the ad group |
| Pixel | Required on every ad group; conversions and lead_generation also need the pixel to have received the event |
| Targeting | Locations (with exclusions), gender, languages, interests, custom audiences, subreddits and conversation keywords (each with exclusions) |
| Edits after creation | Status, name, budget, bid and targeting; not creative |
| Delete | Archives on Reddit |
| Performance sync | Daily impressions, clicks and spend |
| Conversions API | Yes, to a Reddit pixel |
Dry run (validateOnly) | No (501) |
Before you start
You need a Reddit account with a role on at least one Reddit ad account, created in Reddit Ads Manager. Two things in that ad account must exist before you create an ad, because Reddit requires them and Zernio cannot create them:
- A pixel. Reddit attaches a pixel to every ad group, whatever the goal. Without one, every create returns
422. Create it in Reddit Ads Manager under Events Manager. - A profile. A Reddit ad is a post made by a profile of the ad account. Zernio posts new ads from the ad account's first profile.
Connect
Call GET /v1/connect/redditads/ads with the profileId the connection belongs to (Connect ads). No posting account is involved, so there is no accountId on this call.
import Zernio from '@zernio/node';
const zernio = new Zernio();
const { data: start } = await zernio.connect.connectAds({
path: { platform: 'redditads' },
query: { profileId: '66a1f0c2a4b9d3e8f1a2b3c4', redirect_url: 'https://your-app.com/cb' }
});
console.log(start.authUrl ?? start.accountId);Response (200) for a profile with no working Reddit Ads connection:
{
"authUrl": "https://www.reddit.com/api/v1/authorize?client_id=...&scope=adsread+adsedit+adsconversions&duration=permanent&...",
"state": "..."
}Send the user to authUrl. Reddit asks for three permissions:
| Scope | Used for |
|---|---|
adsread | Reading ad accounts, campaigns, ad groups, ads, pixels and metrics |
adsedit | Creating and changing campaigns, ad groups, ads and the posts behind them |
adsconversions | Sending events to a pixel through the Conversions API |
adsread and adsedit are required: if the user unticks either, the browser comes back with error=reddit_ads_scope_not_granted. adsconversions is not, so a connection can exist without it and only the Conversions API refuses to work.
After consent, Zernio syncs the Reddit ad accounts the user can access. In the Zernio dashboard the user then picks which of them to sync. Through the API, pass adAccountId (or adAccountIds, repeated or comma-separated) on the connect call to limit sync to those ad accounts; the ids have the shape a2_<id> (or t2_<id>), and a malformed one returns 400. Without either parameter, every accessible ad account syncs.
On success the browser lands on your redirect_url unchanged. Call the connect endpoint again to read the connection: it now answers without a new consent screen.
{
"alreadyConnected": true,
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"platform": "redditads",
"username": "acme_marketing",
"displayName": "Reddit Ads (acme_marketing)"
}That accountId is the redditads account every sample below uses. On failure the browser lands on your redirect_url with platform=redditads and an error such as reddit_ads_auth_failed (the user declined), reddit_ads_scope_not_granted, reddit_ads_no_ad_accounts (the Reddit user has no ad account) or reddit_ads_callback_error.
Reconnect
When the connection's token stops working, or it lacks one of the three permissions (for example a connection made before adsconversions was requested), the same connect call returns an authUrl again instead of alreadyConnected. Send the user through it. The callback reconnects the same accountId, so synced ads, campaign history and your stored ids stay as they are.
Never disconnect a Reddit Ads account to fix a permission. Reconnect it with the connect call. Disconnecting first creates a new connection with a new accountId.
Pass force=true to get an authUrl even when the connection is healthy.
Create an ad
POST /v1/ads/create creates the post, the campaign, the ad group and the ad in one call. The shape of the creative picks the format: carouselCards makes a carousel, video a video ad, imageUrl an image ad, and none of them a text ad.
const { data: created } = await zernio.adcampaigns.createStandaloneAd({
body: {
accountId: '66b2e19d8c3f5a7e9d0b1c2d',
adAccountId: 'a2_gu5tcd9ah5se',
name: 'Planner launch',
goal: 'traffic',
budgetAmount: 50,
budgetType: 'daily',
headline: 'The planner your whole team will actually open',
body: 'Tasks, docs and meetings in one place. Free for teams of 5.',
imageUrl: 'https://cdn.example.com/planner.png',
linkUrl: 'https://example.com/planner',
callToAction: 'SIGN_UP',
countries: ['US', 'CA'],
targeting: {
communities: ['productivity', 'r/projectmanagement'],
conversationKeywords: ['task manager']
}
}
});
console.log(created.ad.id);Response (201):
{
"ad": {
"id": "66f0a1b2c3d4e5f6a7b8c9d0",
"name": "Planner launch",
"platform": "reddit",
"status": "pending_review",
"adType": "standalone",
"goal": "traffic",
"budget": { "amount": 50, "type": "daily" },
"platformAdId": "2160485120873925373",
"platformCampaignId": "2160485120262584829",
"platformAdSetId": "2160485120597863293",
"platformAdAccountId": "a2_gu5tcd9ah5se"
},
"message": "Ad created and submitted for review"
}budgetAmount is in whole units of the ad account's currency, so 50 is $50.00 on a USD account. platformAdSetId is the Reddit ad group. Campaign and ad group names default to <name> - Campaign and <name> - Ad Group; send campaignName and adSetName to set them. Every sample below reuses the zernio and client constructors from this one.
If Reddit rejects a later step, Zernio archives the campaign and ad group it already created, so a failed create does not leave an empty campaign behind.
Formats
| Format | Send | Notes |
|---|---|---|
| Image | headline, imageUrl, linkUrl, optional body and callToAction | |
| Video | headline, video.url, video.thumbnailUrl (or imageUrl as the thumbnail), linkUrl, optional body and callToAction | video.id is not accepted: send a URL |
| Carousel | headline, carouselCards (2 to 10), optional body | Each card takes imageUrl, linkUrl (falls back to the top-level linkUrl), callToAction and a caption from its headline or description |
| Text | headline and body only | A text ad cannot link out: linkUrl returns 400. callToAction does not apply |
| Existing post | POST /v1/ads/boost | Promote an existing post |
headline is required on every format and takes up to 300 characters; a longer one returns 400. Zernio publishes the creative as a post from the ad account's profile, with comments open, and the ad promotes that post.
Call to action
callToAction takes one of LEARN_MORE, SHOP_NOW, SIGN_UP, CONTACT_US, DOWNLOAD, GET_QUOTE, SUBSCRIBE, APPLY_NOW, BOOK_NOW, BUY_TICKETS, DONATE_NOW, GET_DIRECTIONS, GET_SHOWTIMES, LISTEN_NOW, ORDER_NOW, SEE_MENU or INSTALL_MOBILE_APP (shown as "Install"). Any other value returns 400 listing these. Omit it to leave the button off.
Goals
goal | Reddit objective | Delivery | Requires |
|---|---|---|---|
traffic | Clicks | Billed per click | |
awareness | Brand awareness | Billed per thousand impressions | |
video_views | Video views | Billed per 6-second view (optimizationGoal: "VIDEO_VIEW_6S", the default) or 15-second view (VIDEO_VIEW_15S) | A video creative; anything else returns 422 |
conversions | Conversions | Optimizes toward one pixel event | promotedObject.customEventType, and a pixel that has received that event |
lead_generation | Lead generation | Sends people to linkUrl and optimizes toward the pixel's LEAD event | A pixel that has received LEAD |
Any other goal returns 422 listing these 5. conversions and lead_generation ad groups use Reddit's 7-day click, 1-day view attribution window.
Pixel
Every Reddit ad group carries a pixel. Zernio uses the pixel you name in promotedObject.pixelId (or tracking.pixelId), and otherwise the ad account's first pixel. A pixel id that is not a pixel of adAccountId returns 400 with param: "promotedObject.pixelId". List an ad account's pixels with List conversion destinations.
On conversions, promotedObject.customEventType picks the event the campaign optimizes toward (case-insensitive):
customEventType | Reddit optimization goal |
|---|---|
PURCHASE | Purchase |
LEAD | Lead |
SIGN_UP, COMPLETE_REGISTRATION | Sign up |
ADD_TO_CART | Add to cart |
PAGE_VISIT, VIEW_CONTENT, CONTENT_VIEW | Page visit |
Omitting it returns 400 with code missing_required_field.
{
"goal": "conversions",
"promotedObject": { "pixelId": "a2_gu5tcd9ah5se", "customEventType": "PURCHASE" }
}Reddit only optimizes toward an event the pixel has already received. Before a conversions or lead_generation create, Zernio reads the pixel's last-fired times, and if it never received the event the request returns 422 with code invalid_resource_state. Fire the event from your site, or send it once through the Conversions API, then retry.
Budget, schedule and bidding
The budget sits on the ad group. budgetType: "daily" spends up to budgetAmount a day; budgetType: "lifetime" spends it over the whole flight and needs endDate, otherwise the request returns 400 with param: "endDate".
startDate defaults to now. A startDate or endDate without a time zone offset is read in the ad account's time zone, and a date-only endDate runs to 23:59:59 that day. Send an offset (2027-01-04T09:00:00Z) to pin an exact instant.
New ad groups always bid automatically. To set a manual bid or a cost cap, update the ad group after creation (Manage campaigns and ads).
status: "PAUSED" creates everything paused so you can review it before it spends; campaignStatus overrides the switch for the campaign alone. Omitted, the ad goes live after Reddit's review.
Targeting fields
| Field | Reddit targeting | Values from |
|---|---|---|
countries | Locations | ISO country codes. With no location at all, the ad targets US |
regions, metros, cities, zips | Locations | { "key": "..." } from targeting search, dimension=geo |
excludedLocations | Excluded locations | countries, regions, cities and zips only; other shapes return 400 |
gender | Gender | all, male or female |
languages | Languages | Language codes such as en or de |
targeting.interests | Interests | { "id": "..." } from targeting search, dimension=interest |
audienceInclude, audienceExclude | Custom audiences | Reddit custom audience ids, created in Reddit Ads Manager |
targeting.communities, targeting.excludedCommunities | Subreddits | Subreddit names, with or without r/; from targeting search, dimension=community |
targeting.conversationKeywords, targeting.excludedConversationKeywords | Conversation keywords | Free text; suggestions from targeting search, dimension=conversationKeyword |
Age, income, behaviors, radius and every other targeting field return 400 naming the field, because Reddit has no slot for them.
Find targeting values
GET /v1/ads/targeting/search with the redditads accountId searches Reddit's own catalog (Search ad targeting). It takes dimension set to geo, interest, community or conversationKeyword; any other dimension returns 400. On geo, geoType is city (the default), zip, region, metro_area, country or all, and countryCode narrows the search to one country.
const { data: search } = await zernio.adtargeting.searchAdTargeting({
query: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', q: 'gaming', dimension: 'community' }
});
const subreddits = search.results.map((result) => result.id);A community result's id is the subreddit name to send in targeting.communities, with the subscriber count in audienceSize. Geo results carry the Reddit location id as id (send it as key), and interest results the interest id with its category in path.
Promote an existing post
POST /v1/ads/boost promotes a Reddit post that already exists, keeping its votes and comments. Pass platformPostId (the Reddit post id, with or without the t3_ prefix), or a Zernio postId published by a Reddit account connected to the same profile.
const { data: boosted } = await zernio.adcampaigns.boostPost({
body: {
accountId: '66b2e19d8c3f5a7e9d0b1c2d',
adAccountId: 'a2_gu5tcd9ah5se',
platformPostId: 't3_1o4x9qz',
name: 'Boost launch thread',
goal: 'traffic',
budget: { amount: 30, type: 'daily' },
linkUrl: 'https://example.com/planner',
targeting: { countries: ['US'], communities: ['productivity'] }
}
});
console.log(boosted.ad.id);Response (201):
{
"ad": {
"id": "66f0a1b2c3d4e5f6a7b8c9d1",
"name": "Boost launch thread",
"platform": "reddit",
"status": "pending_review",
"adType": "boost",
"goal": "traffic",
"budget": { "amount": 30, "type": "daily" }
},
"message": "Ad created and submitted for review"
}Reddit only promotes posts made by a profile of the ad account: a post by any other Reddit user returns 422 with param: "platformPostId". Link the author to the ad account in Reddit Ads Manager first. Goals, pixel, budget, schedule and targeting work as on a create, with targeting inside the targeting object; video_views needs a video post. linkUrl is optional and sets the ad's click URL.
Boosts are not idempotent. Send an Idempotency-Key header to make a retry replay the original 201 instead of creating a second ad (idempotency).
Manage campaigns and ads
Reddit keeps an on/off switch at each level, and Zernio writes to the level you call:
| Change | Call | Reddit behaviour |
|---|---|---|
| Pause or resume an ad | PUT /v1/ads/{adId}/status, or PUT /v1/ads/{adId} with status | Sets the ad's own switch |
| Pause or resume a campaign or ad group | PUT /v1/ads/campaigns/{campaignId}/status, PUT /v1/ads/ad-sets/{adSetId}/status | Sets that level's switch |
| Rename | name on PUT /v1/ads/{adId}, PUT /v1/ads/campaigns/{campaignId} or PUT /v1/ads/ad-sets/{adSetId} | |
| Budget | budget: { amount, type } on PUT /v1/ads/{adId} or PUT /v1/ads/ad-sets/{adSetId} | Changes the amount; the type must stay the same |
| Bid | bidStrategy and bidAmount on PUT /v1/ads/ad-sets/{adSetId} | See below |
| Targeting | targeting on PUT /v1/ads/{adId} | Replaces the ad group's targeting with the fields you send |
| Delete | DELETE /v1/ads/{adId}, DELETE /v1/ads/campaigns/{campaignId}, DELETE /v1/ads/ad-sets/{adSetId} | Archives |
bidStrategy on an ad group takes LOWEST_COST_WITHOUT_CAP (automatic bidding), LOWEST_COST_WITH_BID_CAP (a manual bid of bidAmount) or COST_CAP (Reddit's maximize-volume bidding capped at bidAmount). bidAmount is in whole currency units. Any other strategy returns 422.
Reddit cannot switch a published budget between daily and lifetime, so sending the other type returns 422: keep the type, or create a new ad. A targeting update replaces the whole set, so a field you leave out is cleared on the ad group (locations fall back to US). Creative edits are not available: creative on PUT /v1/ads/{adId} returns 501 with code feature_not_available; create a new ad instead.
POST /v1/ads/campaigns creates a campaign on its own (Create a standalone campaign). With a budget it becomes a campaign-budget campaign: send a daily budget (a lifetime one returns 400, as this call takes no end date), and the pixel rules above apply. Under a campaign budget, change the budget with PUT /v1/ads/campaigns/{campaignId}: an ad group budget update answers with an error that points there.
Deleting archives. Zernio sets the campaign, ad group or ad to ARCHIVED on Reddit and marks it cancelled in Zernio. Reddit only lets an archived entity be deleted for good after it has been archived for 3 hours, and Zernio never performs that final delete.
Reddit's listings stop returning archived and deleted entities, so Zernio re-reads by id any stored campaign, ad group or ad the listing no longer returns. One that was archived or deleted in Reddit Ads Manager settles as cancelled, and one Reddit no longer finds is marked deleted.
Analytics
Call GET /v1/ads/{adId}/analytics with the ad's id (Get ad analytics). Zernio syncs impressions, clicks and spend per ad and day from Reddit, in the ad account's time zone, and derives CTR, CPC and CPM from them. Ads created in Reddit Ads Manager sync too, so GET /v1/ads?platform=reddit lists every ad in the synced ad accounts.
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": "Planner launch", "platform": "reddit", "status": "active", "currency": "USD" },
"analytics": {
"summary": { "spend": 318.4, "impressions": 142600, "clicks": 1870, "ctr": 1.31, "cpc": 0.17, "cpm": 2.23 },
"daily": [ { "date": "2027-01-04", "spend": 45.2, "impressions": 20100, "clicks": 262 } ]
}
}Those 3 metrics are the whole sync: reach, video and conversion metrics stay 0 on Reddit, and the demographic breakdowns parameter is Meta and TikTok only. GET /v1/ads/tree rolls the same metrics up per campaign and ad group (Get campaign tree).
Conversions API
POST /v1/ads/conversions sends server-side events to a Reddit pixel. It needs the adsconversions permission on the connection (Reconnect if it is missing).
destinationId is the pixel id. List the pixels of every synced ad account with GET /v1/accounts/{accountId}/conversion-destinations:
{
"platform": "redditads",
"destinations": [
{ "id": "a2_gu5tcd9ah5se", "name": "Acme website", "status": "active", "adAccountId": "a2_gu5tcd9ah5se" }
]
}const { data: sent } = await zernio.conversions.sendConversions({
body: {
accountId: '66b2e19d8c3f5a7e9d0b1c2d',
destinationId: 'a2_gu5tcd9ah5se',
events: [{
eventName: 'Purchase',
eventTime: 1798804800,
eventId: 'order_12345',
sourceUrl: 'https://shop.example.com/checkout/confirmation',
value: 25.99,
currency: 'USD',
user: {
email: 'customer@example.com',
ipAddress: '203.0.113.7',
userAgent: 'Mozilla/5.0 ...',
clickIds: { rdt_cid: '1234567890abcdef' }
}
}]
}
});
console.log(sent.eventsReceived);Response (200):
{
"platform": "redditads",
"eventsReceived": 1,
"eventsFailed": 0,
"failures": []
}How Zernio maps each event:
| Zernio field | Reddit field | Notes |
|---|---|---|
eventName | type.tracking_type | Purchase, Lead, CompleteRegistration (as SIGN_UP), AddToCart, AddToWishlist, ViewContent, Search and PageView (as PAGE_VISIT) map to Reddit's standard events; any other name is sent as a custom event with the name kept |
eventTime | event_at | Unix seconds. Reddit accepts events up to 7 days old |
eventId | metadata.conversion_id | Deduplicates against the same event fired by the pixel |
value, currency, items | metadata | items become products and item_count |
user.clickIds.rdt_cid | click_id | The Reddit click id from the landing URL |
user.clickIds.rdt_uuid | user.uuid | The pixel's first-party id (_rdt_uuid cookie) |
user.email, user.phone, user.externalId | user | Hashed with SHA-256 by Zernio: send plain values |
user.ipAddress, user.userAgent | user | Sent as is |
actionSource | action_source | web (the default) is WEBSITE, app is APP, anything else OTHER |
Every event needs at least one match key: rdt_cid, rdt_uuid, an email, a phone, an external id, or the IP address together with the user agent. consent.adUserData: "DENIED" drops the user identifiers, leaving only the click id. Pass testCode to send the batch with Reddit's test_id, so you can check it in Reddit's Events Manager.
Larger submissions are sent in chunks of 1,000 events. An event older than 7 days, timed more than an hour in the future (usually a millisecond timestamp) or with no match key is reported in failures[] with code INVALID_EVENT and the rest still go. A chunk Reddit rejects lands in failures[] with code REDDIT_REJECTED and Reddit's message.
| Status | Cause | Fix |
|---|---|---|
403 with code reconnect_required | The connection lacks adsconversions | Reconnect Reddit Ads. |
403 with code insufficient_permissions | The connected Reddit user has no role on the ad account that owns the pixel | Grant the user a role on that ad account in Reddit Ads Manager. |
401 with code reconnect_required | Reddit rejected the token | Reconnect. |
400 with code invalid_field_value | destinationId is not a Reddit pixel id | Use an id from conversion-destinations. |
Rate limits
Reddit limits requests per connected Reddit user and per group of endpoints, and says when the window resets. When Reddit answers 429, Zernio waits for the reset and retries automatically, up to twice, as long as the wait is under a minute. If Reddit still refuses, or asks for a longer wait, the call returns 429 with code rate_limited and a Retry-After header with the seconds to wait. See rate limits.
What you cannot do
Reddit Ads through Zernio does not support:
- App install campaigns and catalog (dynamic product) ads.
- Reddit's native lead forms. Reddit retired them on September 30, 2026, so
lead_generationsends people tolinkUrland optimizes toward the pixel'sLEADevent, andleadGenFormIdreturns400. - Pixel management. Create pixels in Reddit Ads Manager; the tracking tags API answers
400for aredditadsaccount. Server-side events go through the Conversions API. - Creating or uploading custom audiences (
POST /v1/ads/audiencesreturns422). Target audiences built in Reddit Ads Manager by id. - Adding an ad to an existing campaign or ad group (
existingCampaignIdandadSetIdreturn400), andPOST /v1/ads/ad-sets. - Creative edits after creation, and
validateOnly(501). - Age, income, behavior and radius targeting.
Common errors
| Error | Cause | Fix |
|---|---|---|
422 with code feature_not_available, "has no pixel" | The ad account has no pixel | Create one in Reddit Ads Manager, then retry. |
422 with code feature_not_available, "has no profile" | The ad account has no profile to post the ad from | Add a profile in Reddit Ads Manager. |
422 with code invalid_resource_state | The pixel never received the event a conversions or lead_generation campaign optimizes toward | Fire the event or send it through the Conversions API, then retry. |
400 with param: "promotedObject.customEventType" | goal: "conversions" without a supported event | Send one of the events in Pixel. |
400 with param: "promotedObject.pixelId" | The pixel is not in adAccountId | Use a pixel of that ad account. |
400 with param: "linkUrl" | A text ad with a link | Remove linkUrl, or add an image, video or carousel. |
400 with param: "headline" | Missing, or over 300 characters | Send a headline of 300 characters or fewer. |
400 with param: "leadGenFormId" | Reddit retired native lead forms | Remove leadGenFormId. |
422 with param: "goal" | A goal outside the 5 Reddit values | Use a supported goal. |
422 with param: "platformPostId" | A boost of a post not made by a profile of the ad account, or video_views on a non-video post | Boost a post from the ad account's profiles. |
422 with param: "budget" | A budget type change, or an ad group budget under a campaign budget | Keep the type, or update the campaign budget. |
429 with code rate_limited | Reddit's rate limit for the connected user | Wait Retry-After seconds and retry. |
502 with code platform_api_error | Reddit rejected the request | Read the message for Reddit's reason. |
Error handling covers the envelope and the stable codes.
Related
- Reddit: organic posting, a separate connection.
- Connect ads: every parameter of the connect call.
- Create standalone ad and Boost post: every field of both requests.
- Update ad, Update a campaign and Get campaign tree.
- Search ad targeting: locations, interests, subreddits and keywords.
- Get ad analytics: metrics, date ranges and rollups.
- Send conversions and List conversion destinations.
- Rate limits: how Zernio and platform limits combine.
OpenAI Ads
Run ChatGPT ads on an openaiads account, from connecting an API key to creating chat card campaigns, pixels and server-side conversions.
Shopify
Connect a Shopify store to manage its blog articles through the Blogs API, manage its catalog, stock and discounts through the Commerce API and install ad platform pixels; a store publishes no social posts.