{"slug": "the-pain-of-integration-with-the-reddit-api", "title": "The pain of integration with the Reddit API", "summary": "Growomat's integration with the Reddit Ads API took two weeks instead of the estimated one day, due to undocumented requirements, contradictory documentation, and strict field semantics. The team found that the Reddit developer portal blocked automated documentation retrieval, forcing them to use the OpenAPI spec locally, and that error messages often pointed to unset fields, with null and absent values meaning different things. The experience highlights the need to rely on vendor examples over reference docs and to handle explicit nulls for inheritance.", "body_md": "# Why the Reddit Ads API was harder than it looks\n\nWe budgeted a day to integrate Reddit Ads and it took two weeks. Here are the refusals we hit, why our tests never caught them, and how it ends.\n\n**We assumed integrating Reddit ads would be a walk in the park. Boy, were we wrong.**\n\nAfter successfully setting up Google Ads and Microsoft Advertising with their stable interfaces, Reddit was the obvious next step for Growomat - especially since both our dev team and our users love the platform.\n\nThings looked promising at first. We spent a week building the platform adapter and mock services, estimating that hooking up the live APIs would only take about a day.\n\nTwo weeks later, and a lot more hair and sleep loss, we realised it was more complicated than it looked.\n\nIf your team is integrating with the Reddit API, here are some lessons you can learn from.\n\n## The Reddit API is a new interface\n\nClaude and Codex know well-established APIs like Google Ads inside out. Reddit's they don't - there simply isn't much Reddit API material in their training data.\n\nNo problem - they should just pull the latest API documentation from the Reddit developer portal so they know what to build?\n\nOops - next problem, the Reddit developer portal blocked our automated tooling from retrieving the documentation.\n\nWe resolved this by downloading the full Reddit OpenAPI spec file and using that file locally for integration. This is a good learning to speed things up instead of a ton of online calls.\n\nOnce we got these basic interface issues sorted, we got to the next set of issues - weird error messages.\n\n## Weird error messages\n\nBecause we care about our users' ad budgets and controlling their spending, we\nset budget optimization on (`is_campaign_budget_optimization = true`\n\n).\n\nHowever, this gave an error when creating the campaign:\n\n```\nCap is required for Cost Cap campaigns\n```\n\nCampaigns with budget optimization turned on have to send `bid_strategy`\n\nand\n`bid_type`\n\n. Ours sent neither, because the user hadn't configured any bidding at\nall. What came back wasn't \"bid_strategy is required\".\n\nSo we thought - let's just set the automatic bidding strategy,\n`MAXIMIZE_VOLUME`\n\n. However, it isn't; it *is* the Cost Cap product, and it wants\na cap. The automatic one is `BIDLESS`\n\n, which is what Reddit uses in every\ntemplate in its own campaign setup guide.\n\nThe OpenAPI description, meanwhile, says the cap is \"Optional when bid_strategy is MAXIMIZE_VOLUME\", contradicting both the guide and the live API. Believing it cost us a lot of debugging.\n\nSo when a platform complains about a field you never set, go looking for a default you never chose. And when the reference disagrees with the vendor's own worked examples, back the examples; they usually describe the system that's actually running.\n\n## Absent, null and \"inherit\" are three different things\n\nCampaign accepted. We moved down a level, and Reddit refused the ad group:\n\n```\nmissing required attributes: bid_strategy, bid_type\n```\n\nUnder budget optimization the money lives on the campaign, so why does the ad group need bidding fields at all?\n\nBecause `bid_strategy`\n\nand `bid_value`\n\nhave to go up as explicit nulls, and that\nnull is doing real work. It's how an ad group says \"inherit from the campaign\".\nLeaving the keys out isn't the same as sending them empty. Our `compact()`\n\nhelper strips nulls, so we had to attach these fields after it ran.\n\nThen the same distinction bit us from the other direction:\n\n```\nAd group field optimization_goal must match the campaign value.\n```\n\nWe were computing that field in two places under two different rules, so a clicks campaign went up with no goal at all while its ad group confidently asserted one.\n\nThe fix wasn't a better default. It was to send nothing at all in the normal case. Campaigns already on Reddit were created before we sent that field and hold whatever Reddit derived for them, so anything we invent might disagree. Sending nothing can't disagree with anything, and the ad group inherits.\n\nLesson: find out whether your API distinguishes \"key absent\" from \"key present, value null\". Your JSON serializer already has an opinion, and you probably haven't read it.\n\n## One payload for create and update\n\nWe compiled a single payload and used it for both verbs. Two bugs came out of that, and both stayed invisible until something got pushed a second time.\n\nThe first: our update carried create-only fields. Reddit accepts `objective`\n\nand\n`is_campaign_budget_optimization`\n\nwhen you create a campaign, and rejects the\nentire PATCH if either one comes back. Every update of a linked campaign 400'd.\n\nThe second is nastier. Our model has no start date, so `start_time`\n\nwas\nsynthesized as \"now\" during translation, which is correct exactly once. Every\nlater PATCH synthesized a fresher \"now\" and marched the campaign's start past ad\ngroups created minutes or days earlier:\n\n```\nCampaign start time cannot be after the ad group start time.\n```\n\nSo a second push of any Reddit campaign without an explicit start date could never succeed. And guess what counts as a second push? Enabling a campaign - quite important.\n\nLesson: anything that's only true at creation time belongs at the create boundary, not in the code that assembles the payload.\n\n## Deleting is a ladder, and it has a timer on it\n\nYou can't delete a Reddit campaign:\n\n```\nA campaign must be archived before it can be deleted.\n```\n\nFine. Archive it, then delete it:\n\n```\nA campaign cannot be deleted until three hours after it was archived.\n```\n\nThree hours. That second refusal is unavoidable on a fresh teardown, so no removal could ever finish.\n\nThen it got worse. We rethrew the error, so the local cleanup never ran, the tombstone sat there, and the removal fired again on the next sync. Those failures counted toward our circuit breaker, and five of them switched off a user's entire sync schedule - every platform, every campaign - over one deleted Reddit campaign.\n\nThe fix was realising that archiving is already enough. ARCHIVED maps to our canonical removed state, so removal completes there, and the delete itself is a best-effort reap we ignore if it fails.\n\nThere's a matching trap on reads. Reddit has no hard delete, so a removed\ncampaign keeps turning up in list responses as `configured_status: DELETED`\n\nforever. Once a removal finished, that tombstone was just an unmatched remote\nobject, and we cheerfully imported it back as a brand new campaign.\n\nLesson: find out what a platform's terminal states really are before you model deletion as a verb, and go and look at what a deleted object does in a list response.\n\n## Asynchronous creatives change more than the client\n\nReddit creatives aren't fields on the ad. They're a job you submit to\n`/profiles/{profile_id}/structured_posts/jobs`\n\nand then poll.\n\nWe sent our own flat internal shape for weeks, and every ad this product ever tried to publish on Reddit failed at that job, on every sync, with the same refusal:\n\n```\n400 data/creative: \"'creative' is a required property\"\n```\n\nThe real schema nests a `creative`\n\nobject, and it speaks a different vocabulary\nfor each type. A TEXT creative has no destination at all, because a text ad's\nclick comes from the ad's own `click_url`\n\n. An IMAGE creative carries a typed\ndestination object holding the call to action, and CAROUSEL holds an array of\nthose. `profile_id`\n\nis read-only in the body, since the path already carries it.\n\n## A 200 doesn't mean anything is running\n\nThis is the one I'd most want somebody else to avoid.\n\n`funding_instrument_id`\n\nwas written nowhere in our codebase. Not a bug we'd\nintroduced - a field we had never implemented at all. Reddit accepted every\ncampaign we created, returned success, and then quietly parked each one at:\n\n```\ndelivery_status: [\"CAMPAIGN_NOT_FUNDED\"]\n```\n\nForever. Every campaign we had ever created on Reddit was unservable, and nothing in any response we looked at said so.\n\nThe prerequisite check meant to catch this asked whether the *account* had a\nservable funding instrument. It did, the entire time it was broken. Account\nbilling and campaign binding are different facts, and only the second one\ndelivers ads.\n\nLesson: a 200 means the platform accepted your request. It doesn't mean anything is running. If a platform publishes a delivery or status field, read it, and treat \"we reported success\" and \"we verified it works\" as different claims.\n\n## The real problem: our mock agreed with us\n\nRead back over those sections and the same sentence is hiding in all of them. Every one of those shipped through a test suite that was green.\n\nHere's what our mock was doing:\n\n- For reporting, it echoed back\n`conversions`\n\nand`conversion_value`\n\n, two field names our own request had invented. Neither exists on Reddit. The mock was validating our assumption instead of Reddit's contract, so it could never fail. - It overrode the create methods outright and never reached the real transport, so every test passed against a body Reddit rejects.\n- It overrode creative handling wholesale, so the actual wire body didn't exist in any test.\n- It accepted any optimization goal, so every test exported a tree that a real account refuses at the first ad group.\n- It hard-deleted rows that Reddit only tombstones.\n\nWe also had a recorded payload snapshot asserting a value a real account refuses. That baseline wasn't stale; it was wrong on the day it was recorded, and it had been passing happily ever since.\n\nWhich is the actual point of all this. A mock you wrote from the documentation is a machine-readable copy of your assumptions, and it will go on agreeing with your code for exactly as long as your assumptions are the broken thing.\n\nSo we stopped relying on it. We now run a live harness against a real ad account that drives the whole stack - public API, planner, queued orchestrator, real adapter, real platform - and verifies every write by reading the platform back instead of trusting our own success report. Everything it creates is paused and never enabled, every object carries a greppable run stamp, and teardown checks itself by re-reading the account.\n\nThat distinction earned itself on the first proper run. One Reddit sync had been\nreporting `success, errors: 0, counts.ads: 1`\n\nwhile changing nothing at all. The\nharness found three defects a fully green suite had never seen, and two of them\nwere in the sync orchestrator rather than in the adapter, so an adapter-level\ntest would have missed them too.\n\n## Where we landed\n\nWorth saying how this ends, because the fortnight bought something.\n\nCreating and shipping a Reddit campaign is now one conversation. Growomat exposes its whole API as tools, so this is the entire flow:\n\n```\n> Build a Reddit campaign for /without-an-agency and push it live.\n\ncreate_campaign    → campaign created\ncreate_ad_group    → ad group + community targeting\ncreate_ad          → 3 free-form text ads\nvalidate_export    → valid · 0 blocking findings\ntrigger_sync       → platforms: [\"reddit-ads\"]\nget_sync_status    → completed · errors: 0\n```\n\nEverything lands on Reddit paused. You look at it, then you enable it. We enabled that one, and in its first five days it served 8,828 impressions and took 57 clicks.\n\nNone of the refusals above happen any more. They're all encoded now - in the adapter, in a conformance suite that checks every method, path and envelope against Reddit's published spec, and in a mock that refuses in Reddit's own words when we get it wrong.\n\nThe client took a week. The fortnight afterwards, where a real account told us everything we'd got wrong, was the actual integration. That part never shows up in anybody's getting started guide.\n\n## Run all four platforms from one place.\n\nBuild, optimize and report on Google, Microsoft, Reddit and Meta campaigns without an agency retainer.\n\n[See what it costs](/pricing)\n\nRead next\n\n[Why your ad platforms disagree about last week](/blog/why-your-ad-platforms-disagree-about-last-week)\n\nGoogle, Microsoft, Reddit and Meta each close the day and count a conversion on their own terms. Here is exactly where the numbers split, and what to do about it.", "url": "https://wpnews.pro/news/the-pain-of-integration-with-the-reddit-api", "canonical_source": "https://growomat.com/blog/reddit-ads-api-integration-lessons", "published_at": "2026-08-11 07:15:44+00:00", "updated_at": "2026-08-11 07:41:10.258635+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Growomat", "Reddit", "Google Ads", "Microsoft Advertising", "Claude", "Codex"], "alternates": {"html": "https://wpnews.pro/news/the-pain-of-integration-with-the-reddit-api", "markdown": "https://wpnews.pro/news/the-pain-of-integration-with-the-reddit-api.md", "text": "https://wpnews.pro/news/the-pain-of-integration-with-the-reddit-api.txt", "jsonld": "https://wpnews.pro/news/the-pain-of-integration-with-the-reddit-api.jsonld"}}