Skip to guide
Subnano Docs First API request

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.

MethodPathResult
GET/api/v1/posts/:postId/attachmentsOwned 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/attachmentsUpload or replay one exact ZIP intent
PATCH/api/v1/posts/:postId/attachments/:assetIdChange only filename and/or description
DELETE/api/v1/posts/:postId/attachments/:assetIdCancel pending upload or queue eligible ready-file removal
GET/api/v1/posts/:postId/attachments/:assetId/downloadExact ready bytes for this creator, including drafts and withdrawn Posts
GET/api/posts/:postId/attachmentsPublic ready-file metadata on a published Post; an active browser owner sees owned state
GET/api/assets/:assetIdCanonical 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-text description and one lowercase-hex sha256 part. No duplicate or unknown parts; the two text fields cannot be file parts. Description can be empty, up to 500 characters.
  • Use a .zip basename of 5–128 characters without slashes or control characters. Accepted file MIME types are application/zip, application/x-zip-compressed and application/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.