Image Uploads
POST /api/v1/posts/:postId/images receives one inline (intent=content) or OG (intent=og) image privately and promotes verified received bytes for immediate use while screening runs in the background. Profile avatar and header uploads use the same recoverable operation and become available while checks run in the background.
Returned image URLs are public media, including images placed in paid Markdown. They cannot enforce a purchase entitlement. Use private ZIP attachments for research kits included in a Post purchase.
Migration from immediate image URLs
Contract document 2.0.0 introduces a breaking change to image uploads on the existing /api/v1 routes. The previous 1.0.0 contract returned immediate avatarUrl/headerImageUrl or Post image URLs. These fields and immediate availability are replaced by the shared operation described below. Other route prefixes and credentials remain the same.
Save an Idempotency-Key, accept 202 as an operation receipt, and checkpoint the operation. safety.received:true is the durable byte checkpoint; receiving can still report false. All image uploads can return publicUrl while safety.state is queued or checking and eligible:false; use that current URL without waiting for clearance. Poll the existing status action if promotion has not yet produced a URL. A replay may return 200 with the same operation shape; it does not imply completed inspection. Profile uploads require no Post revision; Post uploads retain the revision and target-key fences. Do not retry confirmed bytes because a URL is absent.
All images, including avatars and headers, become public after durable receipt and byte verification. Scanning continues in the background. See Profile images and registration.
Save identity before transfer
Use an active Publishing key with posts:publish. Save the owner UUID, target kind/UUID, request key and inline node key before sending bytes. The same owner's rotated active key can recover that operation; expired or revoked credentials cannot.
Authorization: Bearer snpk_<keyId>_<secret>- Saved
Idempotency-Key: 1–255 trimmed nonwhitespace characters. One immutable request identity per deliberate upload. - Post images:
X-Post-Revisionwith the latest exact PostupdatedAt, preserving fractional seconds. LegacyIf-Matchis accepted; both headers must agree if supplied. Profile images require no Post revision. - Inline images: saved
X-Upload-Target-Keyfor this specific current editor node. OG/avatar/header use emptytargetKey. - Client-generated multipart boundary and numeric
Content-Length.
Query ?intent=content|og takes precedence over the optional multipart intent field; omission selects content. Invalid intent returns 422. Use exactly one multipart file field named file, plus only the optional Post intent field. No URLs, other owner IDs, duplicate files or extra parts.
PNG, JPEG, WebP and GIF require matching MIME and file signature. Inline content allows 5 MiB; OG/avatar/header allow 2 MiB. Actual request bytes allow the selected file limit plus 512 KiB overhead. Images must decode completely: at most 32,000,000 canvas pixels per frame and 128,000,000 decoded canvas pixels per upload, with at most 128 distinct source/normalized representations. Animations are inspected frame by frame; there is no independent fixed frame-count allowance. SVG, PDF, Office documents and video are unsupported. Unsupported, malformed or over-budget material never receives partial clearance.
There are ten unfinished image operations and 25 MiB of staged images per Profile; pending physical cleanup counts. ZIP and image transfers share 10/minute, 100/day and 250 MiB/day per Profile, including multipart bytes. New hosted operations also share ten unfinished slots. Inspect saved status before resending bytes and honor Retry-After. Shared rate limits.
curl -X POST "https://subnano.me/api/v1/posts/$POST_ID/images?intent=og" \
-H "Authorization: Bearer $SUBNANO_PUBLISH_KEY" \
-H "Idempotency-Key: $SAVED_OG_UPLOAD_KEY" \
-H "X-Post-Revision: $CURRENT_REVISION" \
-F "file=@/absolute/path/to/og-image.jpg;type=image/jpeg"
Receipt, availability and association
Image POST and individual recovery return {safety,publicUrl,currentRevision,replayed,actions}. Receiving/queued/checking returns 202; terminal or cleared reconciliation returns 200. Save the receipt before continuing. x-idempotency-replay:true marks replay. Owner responses are private, no-store.
{
"safety": {
"operationId": "11111111-1111-4111-8111-111111111111",
"targetKind": "og",
"targetId": "22222222-2222-4222-8222-222222222222",
"targetKey": "",
"generation": 1,
"currentGeneration": 1,
"associated": false,
"state": "queued",
"desired": true,
"held": false,
"received": true,
"eligible": false,
"policyVersion": "upload-safety-v1",
"lastError": null,
"retryAfterSeconds": 5,
"reuploadAllowed": false
},
"publicUrl": null,
"currentRevision": "2026-10-06T12:00:00.123456Z",
"replayed": false,
"actions": {
"status": "/api/v1/upload-operations/11111111-1111-4111-8111-111111111111",
"cancel": "/api/v1/upload-operations/11111111-1111-4111-8111-111111111111",
"associate": "/api/v1/upload-operations/11111111-1111-4111-8111-111111111111/associate"
}
}
received:true is a durable byte checkpoint. Recover status without sending those bytes again, including after scanner/provider failure or key rotation. eligible:true means received, cleared, desired, not held and the current generation; it does not by itself mean public promotion/association finished. A cleared operation with publicUrl:null remains progress. Poll its existing status action; never allocate a replacement to obtain a URL.
Inline completion requires a current publicUrl. Insert it only into the still-current matching node/Markdown, then save the current Post body through the existing revision contract. OG/avatar/header additionally require safety.associated:true; completed screening is not required to use their current public URL. Reconciliation changes only the current image field. Keep the previously confirmed image until its replacement has a usable URL and association. A late superseded/removed selection cannot restore itself. Inline admission cannot be borrowed for OG; using the same source image requires a separate keyed OG upload.
All images are publicly visible before inspection finishes. Background rejection or exhausted retries remove the exact saved Post references or Profile image field and queue public-copy deletion, even after the caller leaves. Transient scanner/provider failures keep retrying. Downloads and external caches cannot be recalled. ZIP downloads retain their pre-delivery clearance requirement.
Recover, resume or cancel
| Method | Path | Effect |
|---|---|---|
| GET | /api/v1/upload-operations?postId=<Post UUID> | Owned Post image list, excluding ZIPs; projects status without promotion |
| GET | /api/v1/upload-operations | Owned Profile image list |
| GET | /api/v1/upload-operations?requestKey=<saved key>&targetKind=<kind>&targetId=<UUID>&targetKey=<saved node key> | Exact pre-ID recovery and reconciliation; {operations:[]} if not yet found |
| GET | Returned actions.status | Reconcile receipt, exact promotion and current association |
| POST | Returned actions.associate | Same current reconciliation without new bytes or body |
| DELETE | Returned actions.cancel | Revoke desired inclusion immediately; does not clear an incident hold |
For exact recovery use targetKind inline|og|avatar|header, the original Post UUID or owner Profile UUID, and the saved inline node key. Omit or send an empty targetKey for OG/avatar/header. The exact query returns {operations:[result]} rather than the individual-result shape. A reservation-uncertain problem may expose recovery:{requestKey,statusUrl} before an operation ID is known; follow that safe URL. Once confirmed it may include operationId. ZIPs keep their attachment actions.
Use the current active key on each recovery call. Honor safety.retryAfterSeconds with bounded polling (for example 24 calls with at most 30 seconds between calls), then pause honestly and explicitly resume the saved operation. Empty/failed lookup does not prove bytes were never received. Only explicit reuploadAllowed:true before durable receipt permits retransferring the same saved intent; elapsed time alone does not.
States are receiving, queued, checking, cleared, rejected, failed, cancelled. Stop retrieval/association on rejected, failed, held, cancelled, undesired or obsolete results. lastError is a sanitized reason: unsupported/incomplete inspection, unavailable scanner/provider, stale definitions or exhausted attempts. Holds project screening-blocked; a match is not an ordinary caller's legal determination. Errors and recovery covers conflicts and safe problem codes. No private path, evidence or provider credential is returned.
Clearance means completed malware and known-material checks under the recorded policy, with coverage limits. It is never a promise of virus-free or CSAM-free content. Shield matches known material; previously unseen abuse can be missed. Remote embeds and old public images are outside the new hosted-upload guarantee: old images remain legacy/unscanned, while new bytes cannot overwrite or borrow a legacy identity.
For current-body/revision coordination see Posts API.