All documentation

Account

WordPress drafts

Send a finished article into a connected WordPress site as a new draft, never a publish, with duplicate, unknown-outcome and interrupted-send handling.

WordPress settings before a site is connected: the site address, username and application password fields, and the Connect WordPress button

A new draft, never a publish

WordPress sends one finished article into a WordPress site as a new draft post. Each accepted send creates a new draft; nothing is ever updated, and nothing here ever publishes, edits or deletes a post in WordPress, or touches anything already there. The only write this feature makes is the single POST that creates a post with status: 'draft'; no other request DiscoveredBy sends to WordPress can change anything (api/services/cms/wordpress.py#draft_payload, api/services/cms/wordpress_client.py#request).

Only a finished article, one that has reached ready status with a written draft, can be sent. A page recommendation from Optimizations is a different kind of record and stays a Markdown or PDF task packet; it is never sent to WordPress (api/services/cms/wordpress.py#send_draft).

Connect a WordPress account

Open WordPress under project settings. Only the project owner can connect, replace or disconnect the project's WordPress account; an editor or a viewer sees a message saying so instead of the form, and nobody outside the project can reach this screen at all (api/services/cms/wordpress.py#connect, #disconnect, api/dependencies.py#get_project_for_user).

Connecting takes a site address, a WordPress username, and a WordPress Application Password (a per-app credential WordPress issues from a user's own profile, separate from their login password). The address is checked before anything is sent to WordPress:

  • It must be https://, with no query string, no URL fragment, no embedded username or password, no port other than the default, and no host that is obviously private on its face, such as a literal internal IP address (api/services/cms/wordpress_client.py#validate_site_url).
  • Its registrable domain must equal the project's own domain. A subdomain such as https://blog.example.com is accepted for a project on example.com, because both reduce to the same registrable domain; a different domain entirely is refused (api/services/urls.py#extract_domain).

DiscoveredBy then signs in as that account and asks WordPress who it is. It tries the pretty permalink form of the REST API first (https://your-site/wp-json/wp/v2/...); only when that specific address answers with a 404 or a response DiscoveredBy cannot parse does it retry with the ?rest_route= form instead. Any other failure there, including a wrong username or an application password WordPress refuses, is reported immediately, with no second attempt (api/services/cms/wordpress.py#connect, api/services/cms/wordpress_client.py#api_url). Whichever form answers is the one every later send from this connection uses.

The account must be able to create posts. WordPress reports this back as a capability named edit_posts, and a connection attempt is refused with "This WordPress account cannot create posts" when it is missing (api/services/cms/wordpress.py#connect). We recommend connecting a Contributor account rather than an Administrator or Editor one: a Contributor can create drafts but cannot publish, so WordPress itself enforces the draft-only rule as a second line of defense. When the account DiscoveredBy connects can publish, the settings screen says so as information; it changes nothing about what gets sent (frontend/src/routes/(app)/settings/wordpress/+page.svelte#connection).

The Application Password itself is stored encrypted. It is never returned by the API, never logged, and never written into an audit row; reading the connection back exposes the site address, the WordPress username, the account's display name, when it was last verified, and whether that account can publish, but never the password itself (api/schemas/wordpress.py#WordpressConnectionRead, api/services/cms/wordpress.py#read).

Every actual request this feature sends, at connect time (the identity check above) or at send time, re-resolves the site's address live and refuses to proceed if that address turns out to be private or internal, even when the address already passed the syntax check above; this defends against a public hostname that later resolves somewhere internal, and a refusal here is reported as unreachable (api/services/cms/wordpress_client.py#request). No such request ever follows a redirect either: a site that answers with a redirect, the bare domain bouncing to www for one, fails outright with a message asking for the address it redirects to; the Application Password is never sent to whatever address a redirect points at (api/services/cms/wordpress_client.py#_perform). An http:// address can never reach this point in the first place: the syntax check above only accepts https://.

What is sent

Each click sends exactly one WordPress post, built from the finished article:

  • Title: the article's title, cut to 500 characters and then sent as plain text: <, > and & are sent as HTML entities, so WordPress shows them as those characters instead of reading them as markup (api/services/cms/wordpress.py#draft_payload).
  • Content: the article's Markdown, rendered to HTML. A leading heading that just repeats the article's own title is stripped first, since the title above already carries it (api/services/cms/article_html.py#_without_title_heading). The Markdown is model output built from scraped and cited pages, so it is not trusted as markup. Raw HTML written inside it, such as a <script> or an <iframe> tag, is escaped: it reaches WordPress as visible text, not as a tag. A link whose target is not http://, https://, mailto:, or a relative link, a javascript: link among them, is not created at all: the surrounding link markdown fails to parse as a link, and its raw text appears in the article as plain, visible text instead (api/services/cms/article_html.py#render, #_safe_link). A link that is created keeps only its address; a hover title written for it is dropped (api/services/cms/article_html.py#_link_open).
  • Excerpt: the article's meta description, when it has one, sent as plain text the same way as the title (api/services/cms/wordpress.py#draft_payload).
  • Slug: the slug suggested on the article's publish checklist, only when it is present and already matches a check DiscoveredBy applies before sending, not a WordPress rule: lowercase letters, digits and hyphens only, at most 80 characters. Otherwise no slug is sent, and WordPress assigns its own (api/services/cms/wordpress.py#draft_payload, #SLUG).

WordPress expands any text in square brackets that names one of its shortcodes, such as a contact form or an embed. So that no text in the article can do that, every [ and ] in the rendered content, code included, is sent as the HTML entity &#91; or &#93;, which displays as the same bracket; a bracket inside a link's address is percent-encoded instead (api/services/cms/article_html.py#_encode_brackets, #_link_open).

Images are not sent. An image in the Markdown becomes its alt text, as plain text, and the draft contains no <img> tag at all, so nothing loads from the image's address when the draft is previewed (api/services/cms/article_html.py#_image_as_alt_text). Nothing is uploaded to WordPress's media library either; this feature never makes a media-upload request. Nothing else new reaches WordPress: no categories, no tags, no JSON-LD, and no scheduling: the post is always created with status: draft, and nothing sets a publish date (api/services/cms/wordpress.py#draft_payload). A very long article, one whose Markdown or rendered HTML passes an internal size ceiling, is refused outright rather than sent partially (api/services/cms/wordpress.py#send_draft, api/services/cms/article_html.py#MAX_MARKDOWN_CHARS, #MAX_HTML_BYTES).

These protections describe the draft as DiscoveredBy creates it. Entities such as &lt; and &#91; are stored in the post as sent, but WordPress's own editor can decode some of them when someone opens the draft there and saves it again, so read the draft in WordPress before you publish it.

WordPress answers a send with the status it actually gave the new post. When that is anything other than draft (a plugin on the site can change it, for example), the send's line under Recent sends says "WordPress reports this post as" followed by that status. DiscoveredBy records the status exactly as WordPress reported it and never tries to correct it: it makes no second request to change the post (api/services/cms/wordpress.py#send_draft, frontend/src/routes/(app)/articles/[id]/+page.svelte#wpDrafts).

Duplicates and "Send another copy"

Sending the same finished draft twice does not normally create two WordPress posts. Before sending, DiscoveredBy fingerprints the exact payload above together with the connected site's address, then looks at its most recent earlier send of this article with that same fingerprint and site that either created a draft or ended with an unknown outcome (see Outcome unknown below):

  • When it created a draft, the new send is refused with a message naming the earlier post's number.
  • When its outcome is unknown, the new send is refused with "An earlier send of this version may already be in WordPress. Check WordPress, then use Send another copy if it is not there."

A send that failed in a way that means nothing was created, a refused password for example, never blocks a resend. This check never asks WordPress: it only looks at DiscoveredBy's own record of that earlier send, so a post that was since trashed or deleted directly in WordPress still counts, and the refusal happens regardless. Changing the article's content, its title, its meta description, or its slug changes the fingerprint, so it is never treated as a duplicate of an older send. When either refusal appears, the screen offers a second button, Send another copy, which explicitly asks to create a second draft anyway (api/services/cms/wordpress.py#send_draft, #UNCERTAIN_KINDS, frontend/src/routes/(app)/articles/[id]/+page.server.js#sendDraft).

Spacing, and a send stuck in flight

Two sends for the same project must be at least ten seconds apart, whether or not they are for the same article; a second send inside that window is refused with a message asking to wait (api/services/cms/wordpress.py#SEND_INTERVAL).

While a send is waiting on WordPress, its record reads Sending, and a second send for the same project is refused outright rather than allowed to race it, so clicking a second time while the first request is still waiting cannot create a second draft. That guard clears once the first send finishes one way or the other, or after two minutes, whichever comes first. A record still reading Sending past that two-minute mark is shown as Interrupted instead: WordPress may or may not have actually created the post, and DiscoveredBy has no way to tell which, so it is never edited or retried automatically (api/services/cms/wordpress.py#SENDING_TIMEOUT, #_state).

When a send or a connection fails

Once DiscoveredBy has passed its own checks and is actually talking to WordPress, whether that is the identity check at connect time or an actual send, any failure there carries one of eleven kinds, each with its own message:

  • auth_refused: WordPress refused this username and Application Password.
  • forbidden: WordPress says this account is not allowed to do that.
  • not_found: No WordPress REST API answered at this address.
  • redirected: WordPress answered with a redirect. Enter the address it redirects to.
  • rejected: WordPress rejected the request.
  • unreachable: WordPress could not be reached. On a send, this kind only covers failures before anything reached WordPress: the address could not be resolved or connected to, the secure connection or its certificate failed, or the live public-address check described above refused it. At connect time it covers every network failure other than a timeout.
  • timeout: WordPress did not answer in time. The draft may or may not have been created; check WordPress before sending again. On a send, any other network failure after the connection was made, such as the answer being cut off, is reported as this kind too, because WordPress may already have received the request.
  • server_error: WordPress answered with a server error (an HTTP 5xx status). The draft may or may not have been created; check WordPress before sending again.
  • unconfirmed: WordPress accepted the request but its answer could not be read. The draft was probably created; check WordPress before sending again. This kind only happens on a send: at connect time an unreadable answer is bad_response.
  • bad_response: WordPress sent a response DiscoveredBy could not read.
  • interrupted: shown only on a send stuck past the two-minute mark above, never on a connection attempt, and never stored as its own status (api/models/wordpress.py#ERROR_KINDS, api/services/cms/wordpress_client.py#MESSAGES, api/services/cms/wordpress.py#_state).

A connection check creates nothing, so at connect time timeout and server_error use plainer messages, "WordPress did not answer in time." and "WordPress answered with a server error. Try again later." (api/services/cms/wordpress.py#connect).

None of the earlier local refusals on this page carry one of these eleven kinds: a missing plan, the wrong role, an article too long, sending too soon, an unresolved duplicate, and a site address that fails the syntax check are each their own one-off message, decided before DiscoveredBy ever talks to WordPress.

Outcome unknown

A send that fails as timeout, server_error or unconfirmed may have created a post in WordPress anyway: each can happen after WordPress received the request. Its line under Recent sends reads Outcome unknown with that kind's message, instead of Failed, and it blocks a plain resend of the same version the way a created draft does (see Duplicates and "Send another copy" above). Check WordPress for the post, then use Send another copy only if it is not there (api/services/cms/wordpress.py#UNCERTAIN_KINDS, #_state). After any failed send, the page reloads its list, so the new line appears straight away (frontend/src/routes/(app)/articles/[id]/+page.svelte#wpDrafts).

Roles and who sees what

Reading the connection's status and reading the send history are open to any current project member, always: neither check restricts by role beyond ordinary project membership, and neither one checks the plan (api/services/cms/wordpress.py#read, #list_drafts). On an article's own page, Recent sends and any past send already in it stay visible whether or not the project is on a qualifying plan, whether or not WordPress is even still connected, and regardless of the article's own status; only the Send buttons above that list are shown or hidden by connection, plan, role and article state (frontend/src/routes/(app)/articles/[id]/+page.svelte#wpDrafts).

Everything that changes state is narrower. Sending a draft is open to owners and editors; a viewer's attempt is refused the same way any other viewer write is (api/dependencies.py#require_write_access). Connecting a new account, replacing one, and disconnecting are all owner-only (api/services/cms/wordpress.py#connect, #disconnect). On an article's page, an editor or viewer whose project has no connection, or whose owner's plan lacks this feature, is told that the project owner manages it, without a link to a settings screen they cannot use (frontend/src/routes/(app)/articles/[id]/+page.svelte#wpConnection).

On Settings → WordPress, a downgraded owner is not locked out of an existing connection: the card showing the connected site, the account and Disconnect keeps showing as long as a connection exists, because that part of the screen runs off whether a connection exists, not off the plan. Only Replace connection, and a fresh connect form when nothing is connected yet, are hidden once the plan no longer includes this feature (frontend/src/routes/(app)/settings/wordpress/+page.svelte#connection).

Plan

Connecting a new account, replacing one, and sending a draft each require the project owner's persisted cms_drafts entitlement. Standard Starter, Growth and Pro presets enable it; Free and Trial do not (api/services/cms/wordpress.py#owner_can_use, api/services/entitlements.py#get_entitlements, api/services/plan.py#get_user_plan). It is read from the project owner's plan, not the plan of whichever member is signed in, the same rule every other owner-plan entitlement in this product follows; see Plans and limits for the general rule.

Disconnecting is the one write this entitlement never gates: an owner can disconnect an existing connection on any plan, downgraded or not (api/services/cms/wordpress.py#disconnect). Reading the connection's status and its send history never check the entitlement either, on either screen; see Roles and who sees what above for exactly what stays visible on a plan without this feature.

Disconnecting

Disconnecting deletes the stored connection outright, unlike a Google integration on this product, which only marks its connection revoked and keeps the row. Drafts already sent to WordPress are not affected either way: nothing about disconnecting reaches into WordPress, and the sent history on this project's articles stays exactly as it was (api/services/cms/wordpress.py#disconnect). Reconnecting afterward is a fresh connection, entered again from scratch; there is no previous username or address to reuse, because the row that held them is gone. Disconnecting here only deletes DiscoveredBy's own stored copy of the Application Password; WordPress does not know a disconnect happened, so the password itself stays valid there until you revoke it from the account's own WordPress profile too.

  • Articles: how a finished draft gets to "ready" in the first place
  • Optimizations: the other kind of recommendation, which stays a task packet and is never sent to WordPress
  • Plans and limits: how an owner-plan entitlement like this one is read and enforced
  • Team: what owner, editor and viewer mean elsewhere in the product

Last verified 2026-09-27

Start monitoring your AI visibility.

See how AI search engines talk about your brand.

Free to start. No credit card required.