Aditya Sharma

Automation

Publishing to WordPress From a Script: The Complete, Correct Way

On this page, 12 sections

Send this to /wp-json/wp/v2/posts and read what comes back:

Ask to publish 60 seconds out and you get status: future.

The body is four keys: "title": "Test", "content": "hello", "status": "publish" and "date": "2026-09-10T08:00:00".

The response says "status": "future". You asked to publish and WordPress scheduled instead.

Move the date thirty seconds out rather than days and ask for "status": "future", and the response says "status": "publish" with the post already live.

Neither behaviour is in the REST API reference. It lives in wp_insert_post():

if ( 'attachment' !== $post_type ) {
    $now = gmdate( 'Y-m-d H:i:s' );

    if ( 'publish' === $post_status ) {
        if ( strtotime( $post_date_gmt ) - strtotime( $now ) >= MINUTE_IN_SECONDS ) {
            $post_status = 'future';
        }
    } elseif ( 'future' === $post_status ) {
        if ( strtotime( $post_date_gmt ) - strtotime( $now ) < MINUTE_IN_SECONDS ) {
            $post_status = 'publish';
        }
    }
}

Sixty seconds, decided from post_date_gmt, applied whatever you asked for.

Which is fine once you know, and produces very confusing bug reports until you do.

So the whole practice here is: send precise input, then read the response and check that it says what you expected. That is the entire method, and everything below is the specifics.

Authentication

Application Passwords have been in core since WordPress 5.6. Users, your profile, Application Passwords at the bottom of the page, name it after the script so you can revoke it later.

You get one 24-character string with spaces in it, shown once. The spaces are part of the password.

The REST API Authentication handbook page covering cookie authentication and the wp_rest nonce
developer.wordpress.org, REST API Authentication. Screenshot taken 3 September 2026.

Checking that it works

It is HTTP Basic over TLS. Nothing more:

Run curl -s -u "aditya:abcd EFGH ijkl MNOP qrst UVWX" "https://example.com/wp-json/wp/v2/users/me?context=edit" and pipe it through jq for the id, name, roles and the length of the capabilities object.

Two failures that look like a wrong password

If that returns your user with a capabilities object, you are done. Two ways it fails that look like a wrong password and are not:

  • The Authorization header never reaches PHP. Common on Apache with CGI or FastCGI, where the header is dropped before PHP sees it. The symptom is a 200 response containing the public view of a user rather than a 401. Fix it in .htaccess with SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1, or on nginx by passing HTTP_AUTHORIZATION through to PHP.
  • The site is not on HTTPS. Core refuses to offer Application Passwords over plain HTTP outside a local environment, so there is nothing to generate in the first place.

Capabilities, not just credentials

Capabilities matter more than people expect.

WP_REST_Posts_Controller::handle_status_param() returns rest_cannot_publish with a 403 for both publish and future unless the user can publish_posts, and silently downgrades an unrecognised status to draft rather than erroring. Media upload needs upload_files separately.

An Editor has both. A Contributor has neither, and will quietly produce drafts forever.

Timezones, which is where most of the real bugs are

Three date fields, and they do not mean the same thing.

From the posts schema: date is “the date the post was published, in the site’s timezone”, date_gmt is the same moment as GMT. Both are writable.

What decides which one you are actually setting is a regular expression in rest_get_date_with_gmt():

function rest_get_date_with_gmt( $date, $is_utc = false ) {
    $has_timezone = preg_match( '#(Z|[+-]\d{2}(:\d{2})?)$#', $date );

    $date = rest_parse_date( $date );

    if ( false === $date ) {
        return null;
    }

    if ( ! $is_utc && ! $has_timezone ) {
        $local = gmdate( 'Y-m-d H:i:s', $date );
        $utc   = get_gmt_from_date( $local );
    } else {
        $utc   = gmdate( 'Y-m-d H:i:s', $date );
        $local = get_date_from_gmt( $utc );
    }

    return array( $local, $utc );
}
The WordPress REST API Posts reference showing the date and date_gmt fields and their contexts
developer.wordpress.org, the REST API Posts reference showing date and date_gmt. Screenshot taken 3 September 2026.

Three requests, three stored times

Read the branch. A date value with no trailing Z and no offset is treated as site-local.

The same value with a Z on the end is treated as UTC and converted.

So on a site running on Asia/Kolkata, which is UTC plus 5 hours 30, these three requests produce three different publish times:

What you sendStored localStored UTC
"date": "2026-09-10T08:00:00"08:0002:30
"date": "2026-09-10T08:00:00Z"13:3008:00
"date_gmt": "2026-09-10T08:00:00"13:3008:00

Pick one convention and keep it

Pick one convention and never mix them in the same script.

I send date with no suffix, because the schedule I care about is the one a reader sees, and because it survives a change to the site’s timezone setting without me recalculating anything.

Whichever you choose, read date and date_gmt back off the response and confirm the gap between them is the offset you expect. That one check catches the entire class of bug.

Sending null does not mean leave it alone

One more: sending null for date or date_gmt does not mean “leave it alone”. The controller resets both to 0000-00-00 00:00:00. To leave a date alone, omit the key.

Scheduling

Ask for the status you want and give it a date more than a minute away:

  • "title" and "slug", for example how-wp-cron-actually-works.
  • "content", holding block markup such as <!-- wp:paragraph --><p>...</p><!-- /wp:paragraph -->.
  • "status": "future".
  • "date": "2026-09-10T08:00:00", more than a minute away.

Confirm it landed

Then confirm it landed, because a scheduled post is a promise the site has to keep later:

Read it back with curl -s -u "$WP_USER:$WP_APP_PASS" "$WP_SITE/wp-json/wp/v2/posts/1234?context=edit&_fields=id,status,date,date_gmt,slug,link".

Status future and the right pair of dates means WordPress has scheduled a publish_future_post cron event for that ID.

Whether that event ever fires is a different question, and one worth settling before you schedule anything you care about: posts miss their schedule for a specific and fixable reason, and a script that schedules confidently onto a broken WP-Cron just fills a queue nobody drains.

Categories and tags take IDs, not names

categories and tags are arrays of term IDs. There is no name field and no create-on-write. There is also no append: the array you send becomes the complete set of terms for that taxonomy on that post.

  • GET /wp/v2/categories or /wp/v2/tags with search set to the name, per_page=100, context=edit and _fields=id,name,slug.
  • Walk the results and return the id only where item["name"].strip().lower() equals the name you asked for, lowercased and stripped the same way.
  • Only if nothing matches exactly, POST to the same route with {"name": name} and use the id that comes back.

Why the exact match matters

The exact-match loop is not decoration.

search is a partial match, so asking for Automation happily returns Marketing Automation first, and a script that takes items[0]["id"] files posts under the wrong category for months before anyone notices.

Creating terms needs manage_categories, which an Author does not have, so decide deliberately whether your script is allowed to invent taxonomy at all.

Mine is not: it fails loudly on an unknown category name, because a typo should not silently create a new one.

Media, and the two headers that are mandatory

Uploading is a raw POST of the file bytes to /wp/v2/media. WP_REST_Attachments_Controller::upload_from_data() rejects the request before touching the disk if either header is missing, with rest_upload_no_content_type or rest_upload_no_content_disposition. The filename comes from the disposition header and nowhere else.

  • Read the file bytes, and take the MIME type from mimetypes.guess_type(name), falling back to application/octet-stream.
  • POST the raw bytes to /wp-json/wp/v2/media with four headers: Authorization, Content-Type set to that MIME type, Content-Disposition: attachment; filename="...", and Content-MD5 from hashlib.md5(data).hexdigest().
  • Give the upload a generous timeout. I use 180 seconds.
  • Then, if you have alt text, POST {"alt_text": ...} to /wp/v2/media/<id> and return the media id.
The WordPress REST API Media reference showing the schema fields for a media item
developer.wordpress.org, the REST API Media reference. Screenshot taken 3 September 2026.

What Content-MD5 buys you

Content-MD5 is optional and worth sending.

The controller compares it against md5( $data ) and returns a 412 with rest_upload_hash_mismatch if the body arrived truncated, which is how you find out that a proxy is cutting large uploads instead of finding out from a broken image on a live page.

Then attach it. featured_media takes the attachment ID, and the controller calls set_post_thumbnail(), so a bad ID is refused rather than stored:

POST {"featured_media": media_id} to /wp/v2/posts/1234 and the controller does the rest.

Two operational notes

Two operational notes.

Uploading generates every registered image size on the server during your request, so a 6 MB photograph on a small box can take longer than a default 30 second client timeout; set the timeout generously and resize before upload rather than after.

And if the library is already full of unoptimised files, that is a separate job to do at the filesystem level, not through this endpoint: compressing a WordPress image library in bulk is the faster route.

A sideload parameter arriving in 7.1

WordPress trunk adds a url parameter to the media endpoint, marked @since 7.1.0, which sideloads a remote image on the server instead of you sending the bytes.

It only accepts URLs whose extension maps to an image MIME type. Useful when it reaches your sites; until then, upload the bytes.

Slug collisions

Send a slug that already exists and the request succeeds. wp_unique_post_slug() appends -2, then -3, and keeps counting.

You get a 201, your script logs a success, and the URL is not the one you put in your links or your sitemap.

Check before, and check after

So check first, and check again after:

  • GET /wp/v2/posts with slug set to the one you want.
  • Set status explicitly to publish,draft,pending,private,future.
  • Add context=edit, per_page=1 and _fields=id,slug,status,link.
  • Return the first item if there is one, otherwise nothing.

The default status list misses your own draft

Note the explicit status list.

The default is publish, so a check that omits it will not see the draft that is already holding your slug, and you will collide with your own earlier run.

That is also the cheapest form of idempotency: if slug_owner() returns a post, update it instead of creating a second one.

A script that crashes halfway and gets rerun should not produce duplicates.

After the create, compare what you asked for against what you got:

Compare created["slug"] against the slug you asked for and stop the run if they differ, naming both and the link you actually got.

Block markup versus raw HTML

post_content is a string either way, and both work. The difference is what the editor does with it afterwards.

Send plain HTML and WordPress stores plain HTML.

Open that post in the block editor and everything outside a block delimiter becomes one Classic block, because the parser has no <!-- wp: --> markers to work with.

The post renders correctly on the front end and is unpleasant to edit: no block toolbar, no reusable structure, and converting it later reflows things you did not want reflowed.

What block markup looks like

Block markup is HTML comments wrapped around ordinary HTML:

  • <!-- wp:paragraph --><p>A paragraph.</p><!-- /wp:paragraph -->
  • <!-- wp:heading {"level":2} --><h2>A heading</h2><!-- /wp:heading -->
  • <!-- wp:list --><ul class="wp-block-list"><li>One</li><li>Two</li></ul><!-- /wp:list -->
  • <!-- wp:code --> wrapped around a pre carrying the block class, closed by <!-- /wp:code -->
  • <!-- wp:image {"id":42,"sizeSlug":"large"} --><figure class="wp-block-image size-large"><img src="..." alt="" class="wp-image-42"/></figure><!-- /wp:image -->

Two rules that are easy to break from a generator.

The class names in the inner HTML are part of the block’s saved output, so wp-block-list on the ul and wp-image-42 on the img are not optional decoration; drop them and the editor reports invalid content.

And the attribute object is JSON, so {"level":2} with double quotes, never single.

If you are generating code blocks, escape &, < and > inside them before you send, or the first angle bracket in your sample code becomes a tag.

The same escaping question turns up on the display side when you add a copy button to Gutenberg code blocks.

The one-request way to confirm it stored

There is a one-request way to confirm the markup survived, and almost nobody uses it. The posts controller exposes content.block_version, computed by block_version( $post->post_content ), which is has_blocks(), which is str_contains( $post, '<!-- wp:' ). So:

GET the post with context=edit and _fields=id,slug,status,date,date_gmt,content.block_version, then assert that content.block_version is 1. Anything else means the content was stored without block markup.

A zero means your delimiters did not survive, and there is one common reason they did not.

KSES, if the account is not an administrator

kses_init() calls kses_init_filters() for any user without the unfiltered_html capability, and that adds wp_filter_post_kses to content_save_pre. Your content is filtered on the way in, before it is stored. Two consequences for generated posts:

  • <script>, <iframe>, <style> and <form> are removed. A custom HTML block containing an embed becomes an empty custom HTML block.
  • Block delimiters do survive, because wp_kses_split2() handles HTML comments separately, but it runs the inside of every comment through wp_kses() and then collapses runs of dashes with preg_replace( '/--+/', '-', $content ). So a block attribute value containing a double hyphen is rewritten, quietly, in the stored markup.

On multisite nobody has it

On multisite, nobody has unfiltered_html by default, super admins included. Which means the same script, the same payload, different site, different stored content. Test with the exact account the script will use.

The whole thing

Putting it together. This is the shape I use: one call() helper, explicit term lookup, media first, post second, and a verification read that compares the response against the intent rather than trusting a 201.

#!/usr/bin/env python3
"""Publish one post with a featured image, on a schedule."""
import base64, hashlib, json, mimetypes, os, sys
import urllib.request, urllib.error, urllib.parse

SITE = os.environ["WP_SITE"].rstrip("/")
AUTH = "Basic " + base64.b64encode(
    (os.environ["WP_USER"] + ":" + os.environ["WP_APP_PASS"]).encode()).decode()


def call(method, path, params=None, body=None):
    url = SITE + "/wp-json" + path
    if params:
        url += "?" + urllib.parse.urlencode(params, doseq=True)
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("Authorization", AUTH)
    req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return json.loads(r.read().decode()), dict(r.headers)
    except urllib.error.HTTPError as e:
        raise SystemExit("%s %s -> %s %s"
                         % (method, url, e.code, e.read().decode()[:500]))


def publish(post):
    existing = slug_owner(post["slug"])
    if existing:
        raise SystemExit("slug %s already used by post %s (%s)"
                         % (post["slug"], existing["id"], existing["status"]))

    media_id = upload_media(post["image"], post["image_alt"]) if post.get("image") else 0

    payload = {
        "title": post["title"],
        "slug": post["slug"],
        "content": post["content"],
        "excerpt": post["excerpt"],
        "status": "future",
        "date": post["date_local"],          # no Z, no offset: site timezone
        "categories": [find_or_create_term("categories", c)
                       for c in post["categories"]],
        "comment_status": "open",
    }
    if media_id:
        payload["featured_media"] = media_id

    created, _ = call("POST", "/wp/v2/posts", body=payload)

    check, _ = call("GET", "/wp/v2/posts/%d" % created["id"], {
        "context": "edit",
        "_fields": "id,slug,status,date,date_gmt,link,featured_media,content.block_version",
    })

    problems = []
    if check["slug"] != post["slug"]:
        problems.append("slug became %s" % check["slug"])
    if check["status"] != "future":
        problems.append("status became %s" % check["status"])
    if check["date"] != post["date_local"]:
        problems.append("date became %s" % check["date"])
    if check["content"]["block_version"] != 1:
        problems.append("content stored without block markup")
    if media_id and check["featured_media"] != media_id:
        problems.append("featured image not attached")

    if problems:
        raise SystemExit("post %d created but wrong: %s\n%s"
                         % (check["id"], "; ".join(problems), check["link"]))

    print("%s scheduled for %s (%s UTC)\n%s"
          % (check["id"], check["date"], check["date_gmt"], check["link"]))
    return check["id"]

Every one of those five checks corresponds to a failure described above,

and each one is a thing that returns HTTP 201 while being wrong.

That asymmetry is the reason the verification read exists: the create response tells you the request was accepted, and the read tells you what the site now holds.

One thing to run now

Take a post you published from a script and read it back with ?context=edit&_fields=slug,date,date_gmt,content.block_version. If block_version is 0, everything you have published from that script is sitting in a Classic block.

If the gap between date and date_gmt is not your site’s UTC offset, you have been publishing at the wrong hour.

Both take one request to find out and neither shows up anywhere in the admin.

From here, the two adjacent problems are changing many posts at once, which needs a dry run and a plan file rather than a loop, and deciding what belongs in a script at all rather than in a workflow tool like n8n.

If you would rather drive all of this from a terminal with an agent doing the typing, the WP-CLI and REST setup I use covers the wiring, and what is arriving in WordPress 7.0 is where this surface is heading next.

More on wordpress automation

Resources

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.