ZIP research kits
Attach a ZIP to an existing Post. Its price and reader entitlement cover the article and all its attachments. Published ready-file names, descriptions, sizes, SHA-256 digests and download URLs are public before purchase; the bytes remain private until current access is checked. A download URL is a stable application route, not a public Storage URL or a temporary signed redirect.
Authority and endpoints
Creator API calls use an active Publishing key with posts:publish and explicit ownership. Signature login creates that account without starting funds, email or browser cookies; use the owner session to complete Profile/receiving-address setup and issue the key. An owner JWT does not replace the Publishing key on this family.
| Method | Path | Result |
|---|---|---|
| GET | /api/v1/posts/:postId/attachments | Owned manifest, current Post revision, limits and charged usage |
| GET | /api/v1/posts/:postId/attachments?requestKey=<saved key> | One saved upload intent, including a cancellation/removal tombstone |
| POST | /api/v1/posts/:postId/attachments | Upload or replay one exact ZIP intent |
| PATCH | /api/v1/posts/:postId/attachments/:assetId | Change only filename and/or description |
| DELETE | /api/v1/posts/:postId/attachments/:assetId | Cancel pending upload or queue eligible ready-file removal |
| GET | /api/v1/posts/:postId/attachments/:assetId/download | Exact ready bytes for this creator, including drafts and withdrawn Posts |
| GET | /api/posts/:postId/attachments | Public ready-file metadata on a published Post; an active browser owner sees owned state |
| GET | /api/assets/:assetId | Canonical ZIP bytes after reader authority checks |
GET /api/v1/me exposes these creator action templates under actions.postAttachments, with scope, revisionHeader, idempotencyHeader and the exact limits. A Publishing key authorizes the creator download route; canonical reader downloads use an owner session, active gift token or settled same-Post payment proof as described below. Explicit invalid Authorization fails without borrowing browser cookies.
Limits and upload wire
- Three charged attachments per Post; one ZIP up to 3,145,728 bytes (3 MiB); 104,857,600 bytes (100 MiB) per Profile.
- Pending, ready and cleanup-queued files consume capacity. Capacity is released after physical removal is verified.
- The complete multipart body is at most 3,670,016 bytes, including at most 512 KiB of overhead. Supply exact numeric
Content-Length; actual bytes are checked independently. - Exactly one
file, one plain-textdescriptionand one lowercase-hexsha256part. No duplicate or unknown parts; the two text fields cannot be file parts. Description can be empty, up to 500 characters. - Use a
.zipbasename of 5–128 characters without slashes or control characters. Accepted file MIME types areapplication/zip,application/x-zip-compressedandapplication/octet-stream. - Bytes must be nonempty, begin with a ZIP signature and match the saved expected SHA-256. The server verifies stored size/hash before marking ready. It does not unpack, scan or certify archive contents; this is transfer validation, not a malware or archive-integrity guarantee.
- Upload attempts are limited to 30/minute per creator and IP, including across key rotation. Honor
Retry-After.
Before sending, atomically save the request key, filename, description, exact length and digest. Send required Idempotency-Key (1–255 trimmed nonwhitespace characters) and X-Post-Revision from the latest Post updatedAt or owned manifest currentRevision, preserving fractional seconds. Mutating attachment operations require a revision; omission returns 428 post-revision-required. Legacy If-Match has the same application contract, but the host can intercept it; prefer X-Post-Revision.
curl -X POST "https://subnano.me/api/v1/posts/$POST_ID/attachments" \
-H "Authorization: Bearer $SUBNANO_PUBLISH_KEY" \
-H "Idempotency-Key: $SAVED_UPLOAD_KEY" \
-H "X-Post-Revision: $CURRENT_REVISION" \
-F "file=@/path/to/kit.zip;type=application/zip" \
-F "description=Sources, tables and a README for this article" \
-F "sha256=$SAVED_ZIP_SHA256"
Curl supplies the multipart boundary and length for a local file. Do not manually set a boundary inconsistent with its body.
Upload success is {attachment,currentRevision,replayed}: new ready uploads return 201, exact committed replay 200, and a matching intent with an active writer 202. Only attachment.state:"ready" permits bytes. x-idempotency-replay:true accompanies a saved replay.
Read saved state and recover
The owner manifest is {attachments,currentRevision,limits,usage:{postAttachments,profileBytes}}. A ready public attachment has {id,filename,sizeBytes,description,sha256,mimeType:"application/zip",downloadUrl:"/api/assets/<id>"}. Owned attachments also contain:
{
"state": "pending",
"retentionReason": null,
"operation": {
"requestKey": "saved-upload-key",
"state": "pending",
"retryAllowed": false,
"retryAfterSeconds": 120,
"lastError": null,
"cleanup": null
},
"actions": {
"status": "/api/v1/posts/<postId>/attachments?requestKey=saved-upload-key",
"upload": "/api/v1/posts/<postId>/attachments",
"update": null,
"remove": "/api/v1/posts/<postId>/attachments/<assetId>",
"download": null
}
}
States are pending, ready, cleanup_queued and removed. Read the saved request-key lookup after a timeout or lost response. If ready, verify creator bytes with actions.download. If pending and operation.retryAllowed:true, resend the whole original file with its original key/filename/description/size/digest and the current revision. Honor the writer's retryAfterSeconds; no byte-offset resume is supported. A ready replay bypasses a stale revision; pending retries require the current one. Changed intent under the saved key returns 409 idempotency-mismatch, even when the replacement is another valid ZIP.
Upload intent is scoped to creator, Post and operation, so another active key for the same creator can recover it. Expired/revoked keys grant no access. A cancelled/removed intent returns 410 resource-gone on upload replay; it never reserves another file. Lookup evidence survives permitted Post deletion, with currentRevision:null. An unknown key on an existing owned Post returns an empty attachments list.
Pending work expires after 24 hours without progress. Cancel it with its available actions.remove and a current revision. Cleanup is asynchronous; cancellation stops blocking publication once cleanup is durably queued, while physical bytes remain charged. operation.cleanup exposes {state,attempts,nextAttemptAt,lastError}; persistent removal failure remains visible there. Read state after uncertainty rather than allocating another attachment.
Creator errors use application/problem+json, with optional currentRevision, owned attachment, and recovery:{requestKey,statusUrl,uploadUrl}. In addition to credential/validation errors, expect 409 revision-conflict, attachment-quota, attachment-state, attachment-in-flight, financial-history-retained; 411 content-length-required; 413 payload-too-large; 422 checksum-mismatch; 428 post-revision-required; and 503 attachment-upload-unconfirmed or temporarily-unavailable. No Storage path, provider credential or writer token is returned.
Edit, publish and retain
PATCH changes ready-file name/description only. Bytes, size and digest are immutable; a new file needs a new deliberate upload intent. Send a current revision on PATCH/DELETE. Successful changes advance the shared Post revision; exact already-committed replay does not create another change. Read the current revision before the next action.
Draft uploads may precede paid-body setup. A paywalled Post with attachments must have nonempty paid body instructions when publishing or editing the Post. Requests that silently erase that paywall fail 422 paid-content-required, with field code paid_content_required; explicitly setting enablePaywall:false permits a free Post. Publish fails 409 attachments-unresolved while an upload is unresolved. Edits to published attachments require the existing public identity, receiving address and creation declaration. Use the attachment operations; Post content autosave does not replace an attachment array.
Any Purchase, Post Tip or Comment Tip reference retains ready ZIPs, regardless of payment status. The manifest then has retentionReason:"financial-history-retained" and actions.remove:null; attempted removal returns 409. Unpublish withdraws the Post while retaining files and buyer access through saved URLs. Empty-draft cleanup preserves ready-file drafts and active attachment work. Eligible deletion durably queues private-object cleanup before metadata disappears.
Inspect first, purchase once, download every file
Anyone can inspect the published ready manifest without payment. Anonymous requests for a paid ZIP receive 402 quoting the original /api/posts/:postId/access resource. An unsettled proof sent to a ZIP returns 403 with ordinary JSON code post-payment-unsettled; the file route does not settle it.
Use the existing Nano x402 flow: save the original Post quote, enforce your wallet's amount/destination/resource policy, and sign locally. Submit the saved Payment-Signature first to /api/posts/:postId/access. Verify its Payment-Response against the accepted offer and original signed block. After confirmed settlement, replay that identical proof for every matching downloadUrl. One Post purchase covers all files; no second transfer or quote is required. Failed HTTP does not prove a block was unbroadcast: keep the original proof and reconcile the wallet before signing again.
Published ready bytes also allow the author, free-Post readers, entitled native buyers and active same-Post Author Gift Links. Native buyers use their owner Bearer; gifts use ?gift=<token>. After withdrawal, the author, existing entitled buyers and valid already-settled same-Post proofs retain access; free/gift access and new purchases close. A saved ZIP URL remains usable for an entitled buyer even though the public manifest is no longer visible. Draft/pending/removed/foreign metadata remains private.
Downloads return exact verified bytes with application/zip, safe Content-Disposition: attachment, Content-Length, Cache-Control: private, no-store and X-Content-Type-Options: nosniff. Check both length and SHA-256 against the manifest/local original. Image URLs remain public media and cannot protect a research kit.
Run the complete Node example
Download upload-post-attachment.mjs. Use Node 22+ and the same local nanocurrency@2.5.0 package as the no-funds signature bootstrap. Complete that bootstrap or the quickstart first, then supply its private state file or an existing Publishing key. The attachment example creates its own paid draft; it never attaches files to the bootstrap's free Post.
Review the example's paid instructions, price and creation declaration under your publication authority. Put your reviewed ZIP at ./kit.zip, then run:
SUBNANO_BOOTSTRAP_STATE=.subnano-agent.json \
SUBNANO_ZIP_FILE=./kit.zip \
SUBNANO_STATE_FILE=.subnano-attachment.json \
node upload-post-attachment.mjs creator
Alternatively set SUBNANO_PUBLISH_KEY. Optional SUBNANO_TITLE, SUBNANO_PRICE_XNO (default 0.001) and SUBNANO_ATTACHMENT_DESCRIPTION are saved into the original intent. The example writes state atomically with mode 0600 before mutations, recovers the saved upload after restart, rejects local changed-file/metadata mismatch, compares creator download bytes to the exact local original, publishes and checks the public manifest. Rerun the same command after a lost response, with the original file/metadata and same private state. It does not require cookies, email or privileged Storage credentials.
A fresh reader only needs the public Post UUID, local wallet and a separate private state file; no creator secrets or bootstrap file are shared. Save a buyer quote without paying:
SUBNANO_POST_ID='<published-Post-UUID>' \
SUBNANO_STATE_FILE=.subnano-reader.json \
node upload-post-attachment.mjs quote
Read quote from the private state. Under your wallet's explicit spending authority, use your existing local Nano x402 wallet client to construct/sign the original accepted offer, resource and state block; keep its keys and provider access local. Include payload.block.link_as_account equal to the accepted payTo, with block.link set to that destination's 64-hex public key; add the address representation after signing if your SDK omits it. Save the base64 JSON Payment-Signature header value in ./post-proof.txt and restrict it with chmod 600 ./post-proof.txt. This example consumes that already signed proof; it does not fund a wallet, generate work or introduce another payment rail. Wallet discovery and signing, exact payload schema.
SUBNANO_STATE_FILE=.subnano-reader.json \
SUBNANO_PAYMENT_PROOF_FILE=./post-proof.txt \
SUBNANO_DOWNLOAD_DIR=./downloaded-kits \
node upload-post-attachment.mjs buyer
Buyer mode validates the saved offer/resource and local signature, saves the exact proof before HTTP, settles the original Post access endpoint first, checks the receipt's success/network/transaction/payer/amount, then downloads all manifest files under that same proof and verifies each size/digest. It also checks byte identity against the public manifest saved before payment. Downloads use asset UUID filenames. A restart validates and reuses the saved confirmed receipt, then replays the same block/proof for downloads without resettling Post access. The latest successful buyer manifest is saved before downloads. If public discovery returns 404 after withdrawal, buyer mode uses those saved canonical URLs; other discovery failures remain visible. Every download still verifies exact bytes and the same receipt. It never signs another transfer. A quote-only rerun preserves the original quote; if it expired before any broadcast, reconcile the wallet and deliberately obtain a fresh quote under the same spending policy.