Media
Uploading images into a site's media library: sizes and WebP copies, safe repeats, lookups by source, and why content should use path rather than url.
POST /media puts an image into the site’s media library the way WordPress itself would: the file lands in the site’s uploads under the current month, every registered size is generated — the core sizes plus onpress_sm, onpress_md, onpress_xl — the image handler writes a WebP copy next to every JPEG and PNG, the alt text is stored, and the attachment gets a Polylang language when media are translatable on the site.
The contract lives in the Node service: it checks the request shape, the source address, the size and the real type of sent bytes. The work is done by a PHP executor on the target site’s own domain, because sizes and WebP copies are hooked by the theme and do not happen under switch_to_blog(). One upload is one request; there is no bulk form, so loop and mind the rate limit.
POST /media
| Field | Type | Description |
|---|---|---|
url | string | source to download: http or https only, no credentials, no local or private hosts |
data | string | the file as base64 or a data: URI; mutually exclusive with url |
filename | string | required with data; the extension is corrected to the real type of the bytes |
alt | string | alt text |
title | string | attachment title; the file name by default |
lang | string | Polylang language; the site default when omitted |
Accepted types are JPEG, PNG, WebP, GIF and SVG, decided by the content, not by the extension or the source’s Content-Type. The limit is 15 MB.
SVG is accepted by this call only and is refused with unsafe_svg if it carries a script element, on*= handlers, javascript: links, <!ENTITY, foreignObject or embedded frames and objects. An SVG has no sizes and no WebP copy; its width and height come from the root element or its viewBox.
The service takes request bodies up to BODY_LIMIT, 10 MB by default, and base64 inflates a file by a third, so anything above about 7 MB has to go by url.
curl -s -X POST "$BASE/media" \
-H "Authorization: Bearer $OP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"blog_id": 235,
"url": "https://example.onpress.pro/wp-content/uploads/sites/56/2026/07/hub.png",
"alt": "Pomelli hub",
"lang": "en"
}'
The answer describes the attachment — the same shape as GET /media/{id} below — with deduped: false for a new upload.
Idempotency
An upload is safe to repeat. Every attachment remembers where it came from (_onpress_source_url, one row per source) and what is in it (_onpress_sha256). A repeated url is found without downloading anything. The same bytes under another URL, or sent as data, are found by hash, and the new URL is added to the attachment so the next lookup by it is direct. Either way the answer is the existing attachment with deduped: true and deduped_by, source_url or sha256.
A found attachment is not modified: alt, title and lang apply only when the attachment is created. Concurrent uploads of the same source are serialised by a database lock, so two racing requests produce one attachment.
path, not url, in content
Every address in the answer has two forms. path is relative to the site root and works on whatever domain serves the site. url is what WordPress builds for the domain that ran the executor. The staging copy of the network shares the database with production but writes files to its own disk: an absolute URL stored in content points at one host or the other, and on production the file only appears after uploads are synced. Put path into markdown and data:

webp is null where there is no WebP copy: GIF, WebP and SVG sources, or WebP switched off in the image handler. An image wider than the handler’s maximum, 1920 px by default, is stored scaled with -scaled in the name, and the original stays next to it. A -scaled suffix already in the source file name is dropped, so a file taken from another WordPress site keeps its base name.
GET /media by source
?source_url= finds the attachment uploaded from that address, ?sha256= (64 hex characters) finds it by content. The answer has the shape of POST /media with deduped: false; nothing found is 404 media_not_found. Look up before uploading when you only need the id or the path.
curl -s -G "$BASE/media" \
-H "Authorization: Bearer $OP_TOKEN" \
-H "X-OnPress-Blog-Id: 235" \
--data-urlencode "source_url=https://example.onpress.pro/wp-content/uploads/sites/56/2026/02/cropped-993838e9_1771708768.png"
GET /media/{id}
The same description for an attachment by id. It works for any attachment of the site, not only those uploaded through v2; for those source_urls is empty and sha256 is null. The real answer for the site icon of site 235:
curl -s "$BASE/media/6425" -H "Authorization: Bearer $OP_TOKEN" -H "X-OnPress-Blog-Id: 235"
{
"success": true,
"blog_id": 235,
"id": 6425,
"url": "https://example.onpress.pro/wp-content/uploads/sites/235/2026/09/cropped-993838e9_1771708768.png",
"path": "/wp-content/uploads/sites/235/2026/09/cropped-993838e9_1771708768.png",
"mime": "image/png",
"width": 512,
"height": 512,
"filesize": 4043,
"webp": "/wp-content/uploads/sites/235/2026/09/cropped-993838e9_1771708768.webp",
"sizes": {
"medium": {
"path": "/wp-content/uploads/sites/235/2026/09/cropped-993838e9_1771708768-300x300.png",
"width": 300,
"height": 300,
"mime": "image/png",
"webp": "/wp-content/uploads/sites/235/2026/09/cropped-993838e9_1771708768-300x300.webp"
},
"thumbnail": {
"path": "/wp-content/uploads/sites/235/2026/09/cropped-993838e9_1771708768-150x150.png",
"width": 150,
"height": 150,
"mime": "image/png",
"webp": "/wp-content/uploads/sites/235/2026/09/cropped-993838e9_1771708768-150x150.webp"
}
},
"alt": "Pomelli",
"title": "site-icon",
"lang": "en",
"source_urls": ["https://example.onpress.pro/wp-content/uploads/sites/56/2026/02/cropped-993838e9_1771708768.png"],
"sha256": "07273749f6d7cfa42c4ae5113dafb344af5f883254cd1afb775883e5ccdfe6b5",
"deduped": false
}
sizes lists only the sizes that were generated: a small image has no large sizes.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
source_required | 400 | neither url nor data |
source_ambiguous | 400 | both url and data |
filename_required | 400 | data without filename |
bad_url | 400 | not http or https, has credentials, or points at a local or private host |
bad_data | 400 | data is not base64 |
bad_field | 400 | alt, title or lang is not a string |
empty_file | 400, 422 | zero bytes |
unknown_language | 400 | no such language on the site; the known ones are in languages |
source_url_required | 400 | GET /media without source_url or sha256 |
bad_source_url, bad_sha256, bad_id | 400 | malformed lookup parameter |
media_not_found | 404 | no attachment for that source, hash or id |
file_too_large | 413 | over 15 MB |
unsupported_type | 415 | not JPEG, PNG, WebP, GIF or SVG, by extension or by content |
unsafe_svg | 422 | the SVG failed the safety check; the reason is in message |
source_not_found | 422 | the source URL answered 404 |
download_failed | 422 | the source could not be downloaded |
sideload_failed | 422 | WordPress refused the file |
media_failed | 502 | the executor answered something unexpected |
media_unavailable | 502, 503 | the executor is not deployed on the site or did not answer |
Uploading a file and putting it on a page step by step is in the recipe Upload an image.