Sounds like you’ve been hammering at this a while.
Alt text landing, products creating clean, but caption + title + description just refusing to stick no matter how you shape the JSON. That “tried multiple approaches” line is the giveaway you’re past the easy answers and starting to wonder if WC even supports this.
It does. But two of those four fields aren’t where you’ve been looking.
Your concerns, mirrored:
Caption → wrong endpoint, wrong key
Title → already in your reach, WC just calls it something else
Description → same boat as caption
Different endpoint? → yes, exactly. There are two systems here, not one
────
────
The 90-second answer
Your image’s four fields don’t all live in the same system.
Alt + title belong to WooCommerce (/wc/v3/*)
Caption + description belong to WordPress core (/wp/v2/*)
Different system → different endpoint → different API key.
Your WC consumer key works only on /wc/v3/*. For /wp/v2/* you need a separate Application Password — a free WordPress core feature, lives in Users → Profile.
Quick win: the WC field called name writes the title. You’ve been searching docs for “title” — WC named that field name. Add it next to your existing alt and the title problem dies in zero extra requests.
The real fix for caption + description: one extra POST to /wp/v2/media/{id} with your Application Password, after the WC product create returns the image ID.
────
────
The four-field map
| Field |
Lives in |
Endpoint |
What you send |
| Alt |
WordPress postmeta |
/wc/v3/products |
image.alt already working |
| Title |
wp_posts.post_title |
/wc/v3/products |
image.name quick win |
| Caption |
wp_posts.post_excerpt |
/wp/v2/media/{id} |
caption second request |
| Description |
wp_posts.post_content |
/wp/v2/media/{id} |
description second request |
────
────
Here’s the part nobody tells you. WooCommerce’s product image schema has exactly three writers in its source code — alt, name, src. That’s it. I dug it up so you don’t have to: lines ~2200-2240 of class-wc-rest-products-v2-controller.php. Three if blocks, three writers. No caption. No description. They were never accepted on the WC endpoint.
The fields exist, of course — they’re columns in wp_posts, same as any post. They just live behind a different door. The WP REST API Media reference lists caption and description right there in the accepted args. That’s your door.
────
────
🛠️ Step-by-step: do exactly this, in this order
If you already have requests + the woocommerce Python lib + WC consumer keys working — skip to Step 2. Step 1 is the one piece you’re missing.
Step 1 — Get an Application Password (60 seconds, one-time)
In WP admin: Users → Profile → Application Passwords near the bottom.
- Type a name like “Python sync”
- Click Add
- Copy the 24-character string (looks like
xxxx xxxx xxxx xxxx xxxx xxxx)
- Save it somewhere — WP only shows it once
If the “Application Passwords” section is missing entirely — your host or a security plugin disabled it. Common on Cloudways, some Wordfence configs. Re-enable via a one-line filter in functions.php or ask the host. We’ll cover the workaround in the next [details] block if it bites you.
Step 2 — One Python script, two auth handlers
import requests
from requests.auth import HTTPBasicAuth
from woocommerce import API
# Door 1: WooCommerce keys → only works on /wc/v3/*
wc = API(
url="https://yourstore.com",
consumer_key="ck_xxxxxxxxxxxx",
consumer_secret="cs_xxxxxxxxxxxx",
version="wc/v3",
)
# Door 2: WP Application Password → only works on /wp/v2/*
wp_user = "your_wp_username"
wp_app_pass = "xxxx xxxx xxxx xxxx xxxx xxxx" # spaces fine, requests handles them
wp_auth = HTTPBasicAuth(wp_user, wp_app_pass)
wp_base = "https://yourstore.com/wp-json/wp/v2"
# ---- Create the product (writes alt + title in one shot) ----
product = {
"name": "Premium Quality T-Shirt",
"type": "simple",
"regular_price": "21.99",
"images": [
{
"src": "https://yourcdn.com/tshirt-front.jpg",
"name": "Premium Quality T-Shirt — Front", # → post_title
"alt": "Black t-shirt front view", # → _wp_attachment_image_alt
"position": 0,
}
],
}
resp = wc.post("products", product).json()
attachment_id = resp["images"][0]["id"]
# ---- Step 3: POST caption + description on the WP media endpoint ----
r = requests.post(
f"{wp_base}/media/{attachment_id}",
json={
"caption": "Studio shot, front view.", # → post_excerpt
"description": "100% ring-spun cotton, pre-shrunk.", # → post_content
},
auth=wp_auth,
)
r.raise_for_status()
print("✅ All four fields written")
You’ll know it worked when you open the product in WP admin → click the image → all four fields populate. If only alt+title show, your WP request didn’t authenticate. If everything is empty, your WC request didn’t. The split tells you which door is locked.
Step 3 — Decode any error in 5 seconds
| You see |
Means |
Fix |
401 on /wp/v2/media |
App Password not reaching WP |
Check the .htaccess fix below |
403 on /wp/v2/media |
User lacks attachment edit rights |
Use Editor or Admin role |
400 invalid image ID |
The image.id you sent doesn’t exist |
Drop id, send only src — WC creates it |
| Caption empty, no error |
Wrong endpoint or wrong field name |
Confirm /wp/v2/media/{id} + JSON key spelled caption |
────
────
🪤 If you get 401 even with the right Application Password (the silent killer)
You’ve done everything right, App Password is correct, still 401. It’s not the password.
Most Apache servers strip the Authorization header before WP ever sees it. WP gets a request with no auth, returns 401. Your terminal sees 401, you assume password is wrong. It isn’t.
One-line fix in your .htaccess, right after RewriteEngine On:
RewriteRule .* - [E=REMOTE_USER:%{HTTP:Authorization}]
Can’t edit .htaccess? (some managed hosts block it) — pass credentials as a query string instead, only over HTTPS:
https://yourstore.com/wp-json/wp/v2/media/123?username=user&password=pass
The WC dev docs flag this same workaround for their endpoint — same trick applies to WP’s.
🥷 Skip the description request with IPTC pre-bake (bonus)
When WC fetches your image from the src URL, it runs wp_read_image_metadata() on the file before saving. Bake IPTC tags into the JPEG ahead of time, WC reads them and writes them straight into the attachment.
from iptcinfo3 import IPTCInfo
info = IPTCInfo("tshirt-front.jpg", force=True)
info["object name"] = "Premium Quality T-Shirt — Front" # → post_title
info["caption/abstract"] = "100% ring-spun cotton, pre-shrunk." # → post_content
info.save()
Naming clash that catches everyone: IPTC “caption” maps to WP Description (post_content), not WP Caption (post_excerpt). Different fields with the same name. WP “Caption” has no native IPTC equivalent — it always needs the second request, no matter what you bake. Carl Seibert’s definitive explainer is worth bookmarking.
🚫 Three dead ends I already checked (don't waste time)
XML-RPC (wp.uploadFile, python-wordpress-xmlrpc) — looks tempting, Python stdlib has xmlrpc.client ready to go. WordPress Trac #21085 explicitly states it cannot set caption, description, or alt. Documented limitation since 2012, never fixed. Skip.
meta_data arrays on the image object — WC accepts meta_data on the product itself, but image sub-objects don’t pipe it through to the underlying attachment. I checked the controller. No glue code. Skip.
Direct wp_posts SQL writes — works (the columns are public), but bypasses caching, search indexing, and any plugin hooks listening for attachment updates. Fine as last resort, ugly as default. Use only if APIs are blocked entirely.
────
────
I run this in production
I run this exact two-step on a small hardware shop’s WC site every Wednesday — their photographer drops 30-50 product shots into a shared folder, my script picks them up, creates products, fixes metadata.
Got bit once in October: ran clean for three weeks, then started silently dropping captions on every product. Title and alt kept working fine.
Took me an hour to realize the host’s web firewall had quietly started stripping Authorization headers on /wp/v2/* only — leaving /wc/v3/* alone. Half my pipeline kept authenticating, the other half went silently anonymous.
The .htaccess rule fixed it. Now I run a tiny smoke test against /wp/v2/users/me at the start of every job — if that 401s, the script bails before touching any product.
────
────
Back to you
About those “multiple approaches” you tried — were any of them PATCHing /wp/v2/media directly using your WC consumer keys? If yes, that’s almost certainly where the trail went cold. Not on you — the docs barely flag this split. The 401 you’d have gotten looks identical to a body-format error, which is exactly the rabbit hole anyone would dive into first.
One question that’ll save us a back-and-forth:
What hosting are you on?
If it’s a managed host (Kinsta, WP Engine, Cloudways, SiteGround), Application Passwords behavior varies wildly per host — some disable it by default, some require API allowlists, some have their own auth wrappers.
Drop the host name and I (or anyone lurking who’s on the same one) can flag the specific gotcha before it bites you.