Engineering
The WordPress REST API Without a Plugin: Reading, Creating and Updating Content

Part 2 of the thread The WordPress toolbox
- Since WordPress 5.6, an application password is all you need to use the REST API. No JWT plugin required.
- Reads are
GET, creates arePOSTto the collection, and updates arePOSTto the item. Deletes go to the trash unless you addforce=true. - Lists cap at 100 items per page. The
X-WP-TotalPagesheader tells you how many pages to fetch. - Upload media as the raw file with a
Content-Dispositionheader. My old multipart version quietly corrupted images.
My old WordPress notes started the REST API section with "install the JWT Authentication plugin first." That hasn't been true for a long time. Since WordPress 5.6, core ships application passwords, and they're all you need to read and write content from a script on another machine.
Everything here is PowerShell because that's where my scripts live, but it's plain HTTPS, so curl or anything else works the same way. Checked against the REST API handbook in September 2026.
Make an application password
In wp-admin, open your user profile and scroll to Application Passwords. Give it a name that says what it's for, like "Backup script" or "Laptop", and WordPress shows you the password once. If you have shell access, WP-CLIThe command-line tool for WordPress. Anything you'd click through in wp-admin, from updates to new users, you can type or script instead.More: WP-CLI: The Commands I Keep Coming Back To does the same thing:
wp user application-password create sam "Backup script" --porcelain
A few things worth knowing:
- It only works over HTTPS. WordPress won't accept it on a plain
http://site. - It acts as that user, with that user's role. Make it on an account that has only the access the script needs.
- Each one can be revoked on its own, so one per script or device is the way to go.
Then build the header once. The password comes from a prompt, never from the script:
$site = 'https://example.com'
$cred = Get-Credential -Message 'WordPress user name and application password'
$pair = '{0}:{1}' -f $cred.UserName, $cred.GetNetworkCredential().Password
$headers = @{ Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair)) }
Reading content
Published posts are public, so a plain GET works without the header. Drafts need the header and context=edit, which also gets you the raw title and content instead of the rendered HTML:
Invoke-RestMethod -Uri "$site/wp-json/wp/v2/posts?status=draft&context=edit&per_page=20" -Headers $headers | Select-Object id, @{ n = 'title'; e = { $_.title.raw } }, modified
Lists come in pages, capped at 100 per page. The response headers X-WP-Total and X-WP-TotalPages say how much there is. My old export function kept fetching until a page came back with fewer than 100 items, which works, but reading the header is cleaner:
$first = Invoke-WebRequest -Uri "$site/wp-json/wp/v2/posts?per_page=100&page=1" -UseBasicParsing
$pages = [int]$first.Headers['X-WP-TotalPages']
Creating and updating
| Job | Method and endpoint |
|---|---|
| List posts | GET /wp-json/wp/v2/posts |
| Create a post | POST /wp-json/wp/v2/posts |
| Update a post | POST /wp-json/wp/v2/posts/<id> |
| Delete (to trash) | DELETE /wp-json/wp/v2/posts/<id> |
| Delete for good | DELETE /wp-json/wp/v2/posts/<id>?force=true |
| Upload media | POST /wp-json/wp/v2/media |
| Site settings | GET or POST /wp-json/wp/v2/settings |
Updates are a POST to the item, with only the fields you want to change:
$body = @{ title = 'Holiday hours'; content = '<p>Closed on the 25th and the 1st.</p>'; status = 'draft' } | ConvertTo-Json
$post = Invoke-RestMethod -Uri "$site/wp-json/wp/v2/posts" -Method Post -Headers $headers -ContentType 'application/json' -Body $body
Invoke-RestMethod -Uri "$site/wp-json/wp/v2/posts/$($post.id)" -Method Post -Headers $headers -ContentType 'application/json' -Body (@{ status = 'publish' } | ConvertTo-Json)
Categories and tags go in as IDs, not names. My old helper looked each name up with ?search= and then picked the exact match, since search is fuzzy and "News" also finds "Newsletter."
Heads up
Status can be
publish,future,draft,pendingorprivate. To schedule a post, sendstatus = 'future'along with adatein the site's timezone.
Uploading media
This is where my old notes were wrong in a way that took a while to spot. They built a multipart form by turning the image bytes into a UTF-8 string, which mangles any byte that isn't valid text. The upload "worked" and the picture came out broken.
The simpler way is to send the file itself as the body, with a Content-Disposition header naming it:
$file = Get-Item .\storefront.jpg
$mediaHeaders = $headers + @{ 'Content-Disposition' = "attachment; filename=$($file.Name)" }
$media = Invoke-RestMethod -Uri "$site/wp-json/wp/v2/media" -Method Post -Headers $mediaHeaders -ContentType 'image/jpeg' -InFile $file.FullName
Invoke-RestMethod -Uri "$site/wp-json/wp/v2/media/$($media.id)" -Method Post -Headers $headers -ContentType 'application/json' -Body (@{ alt_text = 'The shop front at opening time' } | ConvertTo-Json)
The second call sets the alt text, since the upload itself only carries the file.
Tip
If you can SSHSecure Shell. An encrypted way to get a command line on another machine over the network. in, WP-CLI is usually faster for bulk work. The REST API wins when the script runs somewhere else, like a scheduled task on a Windows box that pulls a backup or posts weekly hours.
Where it stops
Plugins add their own routes under /wp-json/, and that's where things vary. WooCommerce products live under /wp-json/wc/v3/, and custom fields only show up in responses if the plugin or theme registers them for the REST API. Hit $site/wp-json/ in a browser to see every route a site actually exposes.
And an application password is a real key to the site. Treat it like one, and read locking down WordPress admin accounts before you hand one to anything automated.