Designing a publishing API that lets AI write safely into any CMS
Generating an article is the easy half. The hard half is putting it into someone else's website: in the right category, under a real author, with the SEO title in the field their SEO plugin actually reads, exactly once, and published only when they meant it to be.
On Jorge Castro, an AI SEO agent platform where I'm the main contributor, content goes out three ways: a WordPress.org plugin, a Shopify app, and a custom integration contract for every other stack, which I designed and documented. This post walks through the decisions behind that layer, and the rules I'd apply to any system that lets AI write into production sites.
One contract, many adapters
The core decision is that the AI side of the system should never need to know which CMS it's talking to. It produces a post, a product or a set of SEO fields in one internal shape, and an adapter per target translates.
The custom integration is that shape made public. It's an OpenAPI spec that the customer's site implements, so any stack (a headless CMS, a Laravel app, a framework site with its own database) can receive content by exposing a handful of endpoints:
| Endpoint | Purpose |
|---|---|
POST /handshake | Confirm the URL and key work before anything is published |
/posts, /posts/{id} | Create a post, then update it by the site's own ID |
/blog-categories, /authors | Let the platform choose from what already exists |
/products, /product-categories | The same, for catalogue content |
PUT /seo | Write SEO metadata separately from the content |
Three choices in there are worth explaining.
The handshake fails early. A wrong URL or a stale key should surface while the user is on the connect screen, not hours later inside a background job.
Lookups instead of invention. A model will happily make up a category called "Tips & Insights" if you let it. Reading the site's real categories and authors first means content lands where the site owner expects it.
SEO is its own call. Every platform keeps SEO metadata somewhere different. In WordPress, Yoast and Rank Math each store the title and description in their own fields, which is why the WordPress plugin writes Yoast and Rank Math SEO fields directly. Shopify stores a resource's SEO title and description as metafields in the global namespace, title_tag and description_tag. A separate endpoint lets each target map the fields its own way, and lets SEO be updated without resending the body.
The other two targets follow the same shape. The WordPress plugin exposes REST endpoints for posts, pages, WooCommerce products, categories and authors, and the Shopify app plays the same role for Shopify stores. The code that decides what to publish doesn't change between them.
As a simplified illustration (not the exact spec), a create request in a contract like this looks something like:
{ "idempotencyKey": "pub_7f3c2a91", "title": "How to choose a standing desk for a small office", "slug": "choose-standing-desk-small-office", "html": "<p>...</p>", "status": "draft", "categoryIds": [12], "authorId": 3 }
Authentication: simple, strict, constant-time
A plugin installed by non-developers needs a connection flow they can finish. With the WordPress plugin, the site owner pastes an API key from the platform into the plugin settings. Every request to the plugin's endpoints is then authorised by comparing the key it carries with the connected key in constant time (in PHP, that's what hash_equals is for), so response timing can't reveal how much of a guessed key was right. WordPress makes this hard to forget: the REST API handbook says every custom route must register a permission_callback, and it raises a notice when one is missing.
The Shopify app authenticates with an API key plus a shop-domain header, so each request says which store it targets as well as carrying a credential.
For custom integrations, the receiving site has to do the same work. In Node:
import { createHash, timingSafeEqual } from "node:crypto"; export function isValidKey(provided: string | undefined, expected: string): boolean { if (!provided) return false; // Hash both values so the buffers always have the same length. const a = createHash("sha256").update(provided).digest(); const b = createHash("sha256").update(expected).digest(); return timingSafeEqual(a, b); }
Hashing first matters because Node's crypto.timingSafeEqual throws when the two buffers differ in length. Add the basics on top: HTTPS only, one key per site, and a way to rotate it.
Assume every request will arrive twice
Long-running AI work in Jorge Castro runs on a Redis job queue (Bull) with retries, exponential backoff and stalled-job detection. That's the right setup for flaky networks and slow model calls, and it guarantees something the publishing layer has to handle: the same publish can run more than once. A request times out after the CMS has already created the post. A worker dies mid-job, the job is detected as stalled, and another worker picks it up.
So every write has to be safe to repeat. The rules I'd follow:
- Create the idempotency key with the job, not per attempt. Every retry has to carry the same key, or nothing is idempotent.
- Have the target remember it. If a create arrives with a key it has already processed, it returns the existing post instead of making a second one. Stripe's idempotency docs are a good model: the same key returns the saved result, and the same key with different parameters is an error.
- Switch to update after the first success. Store the ID the target returns. Later attempts and edits go to
/posts/{id}, not/posts. - Deduplicate when enqueuing too. Bull won't add a job whose
jobIdalready exists, so a double-clicked Publish button doesn't become two jobs.
await publishQueue.add( { siteId, contentId, revision }, { jobId: `publish:${siteId}:${contentId}:${revision}`, attempts: 5, backoff: { type: "exponential", delay: 30_000 }, } );
The revision is in the ID on purpose. If completed jobs are kept around, an ID without it would silently block a deliberate re-publish after an edit.
Drafts by default, publishing on purpose
An AI system that can publish straight to a live site should still default to drafts. Put the status in the contract and make "publish" an explicit choice by the user, per site or per piece. A draft that needs fixing costs a minute. A published page with a wrong price or an invented claim costs trust, and it may already be indexed.
Two related rules:
- Send only the fields the AI owns. Updating a product description shouldn't resend price, stock or variants. Partial updates keep the AI away from data it never generated.
- Treat generated HTML as untrusted input. Sanitise it before sending, and expect the target to sanitise it again on arrival.
Classify failures before retrying
Retrying everything is nearly as bad as retrying nothing. Backoff helps with a server that's briefly down. It does nothing for a revoked key except hit the site five more times. Sort responses into groups:
| Response | Likely cause | What to do |
|---|---|---|
| Timeout, 502, 503 | Target briefly unavailable | Retry with backoff |
| 429 | Rate limited | Retry, respecting Retry-After if present |
| 401, 403 | Key revoked or plugin disconnected | Stop, mark the connection broken, tell the user |
| 404 on update | Post deleted on the site | Stop, and don't silently recreate it |
| 400, 422 | Payload the target rejects | Stop and show the target's message |
For the "stop" rows, Bull's job.discard() makes sure a job isn't retried even if it has attempts left. Whatever the outcome, the final state should be visible in the dashboard with the target's real error message, not a generic "publish failed". Most publishing problems are fixed by the site owner (a reconnected plugin, a restored category), and they can only fix what they can see.
Conclusion
The model is the least predictable part of an AI publishing system, so everything around it should be as predictable as possible. One documented contract keeps targets interchangeable. Constant-time key checks keep connections honest. Idempotency keys make retries harmless, drafts keep a person in the loop, and classified errors tell people what to fix instead of retrying blindly. None of this is new, and that's the point: the publishing layer is where ordinary, well-understood engineering does the most good.