---
title: "Publishing to WordPress From a Script: The Complete, Correct Way"
url: https://adityaarsharma.com/publish-to-wordpress-from-a-script/
date: 2026-09-14
modified: 2026-09-03
author: "Aditya Sharma"
description: "Application Passwords, the posts and media endpoints, and the four things that return HTTP 201 while being wrong."
categories:
  - "Automation"
  - "WordPress"
image: https://adityaarsharma.com/wp-content/uploads/2026/09/08ecaf15-7b7b-4531-90b1-91d59db4f3a1_2912x1632-1024x574.webp
word_count: 2617
---

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

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

![Ask to publish 60 seconds out and you get status: future.](https://adityaarsharma.com/wp-content/uploads/2026/09/08ecaf15-7b7b-4531-90b1-91d59db4f3a1_2912x1632-scaled.png)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.

On this page

- यह नक्षत्र और पद असल में क्या दर्शाते हैं- यह प्लेसमेंट कैसे दिखता है- वह बात जो कम बताई जाती है- यह मंगल कब मजबूत या कमज़ोर होता है- लोग अक्सर गलत समझते हैं- महादशा में यह कब जागता है- तीन परतें एक साथ रखिए- नीचभंग की शर्तें क्या होती हैं- नक्षत्र से दशा-क्रम कैसे तय होता है- अक्सर पूछे जाने वाले सवाल
## 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](https://adityaarsharma.com/wp-content/uploads/2026/09/30e5ff41-946f-4f09-9446-09a5a54c0bb8_2800x1720-scaled.png)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](https://adityaarsharma.com/wp-content/uploads/2026/09/1340af43-a9f8-40e5-b223-d83231d86ece_2800x1720-scaled.png)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 send | Stored local | Stored UTC |
| ------------- | ------------ | ---------- |
| `"date": "2026-09-10T08:00:00"` | 08:00 | 02:30 |
| `"date": "2026-09-10T08:00:00Z"` | 13:30 | 08:00 |
| `"date_gmt": "2026-09-10T08:00:00"` | 13:30 | 08: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](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/), 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.

Newsletter

## Automating the boring half

I publish one researched piece a week on putting agents to work on real sites. What I built, what broke, and the commands to check it yourself.

Email address

Get it weekly

Free. One email a week. Unsubscribe in one click, and I do not send anything else.

## 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](https://adityaarsharma.com/wp-content/uploads/2026/09/56ac6cc1-61da-4242-8d88-562335bd39fd_2800x1720-scaled.png)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](https://adityaarsharma.com/how-to-compress-wordpress-images-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><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><img src="..." alt=""/></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](https://adityaarsharma.com/how-to-add-copy-code-button-to-wordpress-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](https://adityaarsharma.com/bulk-edit-wordpress-rest-api/), and deciding what belongs in a script at all rather than in [a workflow tool like n8n](https://adityaarsharma.com/n8n-wordpress-what-to-automate/).

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](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/) covers the wiring, and [what is arriving in WordPress 7.0](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/) is where this surface is heading next.

## More on wordpress automation
- [Scheduling WordPress Content Properly: Why Posts Miss Their Schedule and How to Fix It for Good](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/)- [Bulk Editing WordPress Content Over the REST API Without Breaking Anything](https://adityaarsharma.com/bulk-edit-wordpress-rest-api/)- [n8n and WordPress: What Is Actually Worth Wiring Together](https://adityaarsharma.com/n8n-wordpress-what-to-automate/)
## Resources
- [Posts endpoint reference](https://developer.wordpress.org/rest-api/reference/posts/), the full field list and which context each field lives in- [Media endpoint reference](https://developer.wordpress.org/rest-api/reference/media/)- [Authentication in the REST API handbook](https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/)- [Application Passwords integration guide on make.wordpress.org](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/)- [Core block reference in the Block Editor handbook](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/), for the exact saved markup of every core block- [wp_insert_post() in the code reference](https://developer.wordpress.org/reference/functions/wp_insert_post/), where the 60 second rule lives