---
title: ZIP research kits
description: Upload, recover and verify private ZIPs included in a Post purchase, then settle once and download every file.
navigationTitle: ZIP attachments
---

# ZIP research kits

Attach a ZIP to an existing **paid Post** (`enablePaywall:true`). Its price and reader entitlement cover the article and all its attachments. Published ready and currently safety-eligible file names, descriptions, sizes, SHA-256 digests, included file paths and download URLs are public before purchase; the bytes remain private until safety and current reader access are checked. A download URL is a stable application route, not a public Storage URL or a temporary signed redirect.

In the web editor, choosing or dropping a ZIP starts its upload automatically. Progress distinguishes **Transferred**, **Received**, **Checking** and **Ready**; an association conflict preserves received bytes while the Post settings are corrected. After a reload, reselect the original file so its exact length and SHA-256 can be checked. **Continue** saves details and waits for transfer before leaving. Current screening status remains visible after receipt.

## Authority and endpoints

Creator API calls use an active Publishing key with `posts:publish` and explicit ownership. [Signature login](https://docs.subnano.ai/v1/api/wallet-signatures) 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, revision, limits and usage                     |
| GET    | `/api/v1/posts/:postId/attachments?requestKey=<saved key>`         | Original intent, including cancellation/removal tombstones     |
| POST   | `/api/v1/posts/:postId/attachments/uploads`                        | Reserve or replay exact JSON intent                            |
| GET    | `/api/v1/posts/:postId/attachments/uploads?requestKey=<saved key>` | Recover one original attachment mutation                       |
| POST   | `/api/v1/posts/:postId/attachments/uploads/:operationId/transport` | Reconcile or authorize one private fixed part                  |
| POST   | `/api/v1/posts/:postId/attachments/uploads/:operationId/complete`  | Complete/recover original bytes and current association        |
| PATCH  | `/api/v1/posts/:postId/attachments/:assetId`                       | Edit filename/description                                      |
| DELETE | `/api/v1/posts/:postId/attachments/:assetId`                       | Cancel pending upload or queue permitted removal               |
| GET    | `/api/v1/posts/:postId/attachments/:assetId/download`              | Eligible creator bytes, including drafts/withdrawn Posts       |
| GET    | `/api/posts/:postId/attachments`                                   | Public eligible ready metadata; browser owners see owned state |
| GET    | `/api/assets/:assetId`                                             | Canonical bytes after current reader/safety checks             |

`GET /api/v1/me` supplies these action templates and limits under `actions.postAttachments`. Existing `upload` and `status` actions retain the legacy multipart/manifest contract; `reserve`, `lookup`, `transport` and `complete` advertise the modern flow. Publishing keys authorize creator controls/downloads. Canonical reader downloads use an owner session, active gift token or settled same-Post Nano proof; USDC readers use the owner or SIWX flow below. Invalid explicit Authorization never borrows browser cookies.

## Limits and upload wire

- Each ZIP and the active pending-plus-ready total per Post are at most **10,485,760 bytes (10 MiB)**, with up to **three unequal files**. For example, 7+2+1 MiB fits; 7+4 MiB or a fourth file fails atomically.
- Profile allowance remains **104,857,600 bytes (100 MiB)**. Cancellation frees active Post bytes and a slot immediately; retained originals, unresolved effects and cleanup-queued bytes remain Profile-charged until physical removal is proved.
- Controls are strict UTF-8 JSON of at most **16,384 bytes**, with exact numeric Content-Length. File bodies go directly to the attachment Edge relay. At or below **5,242,880 bytes (5 MiB)** there is one part; above it, send 5 MiB plus the exact remainder, serially. Transfer files serially too.
- Keep a `.zip` basename of 5–128 characters without slashes/control characters, a description up to 500 characters, exact nonempty integer `sizeBytes` and lowercase 64-hex `sha256`.
- ZIP/image transfers retain the shared 10/minute, 100/day and 250 MiB/day Profile admission budgets across key rotation; modern attempted parts consume upload request/byte admission. Honor current retry timing. [Rate limits](https://docs.subnano.ai/v1/api/rate-limits).

The modern contract is implemented and locally qualified for the candidate environment. This guide does not certify its availability on the deployed service; unavailable transfer controls require original-operation recovery, not a different upload intent.

### Reserve, transfer and complete

1. Atomically save creator/Post IDs, request key, filename, description, exact size and SHA-256. Reserve with `Idempotency-Key` and current `X-Post-Revision` from the Post's `updatedAt` or owned manifest, preserving fractional seconds. Missing revision returns `428`; legacy `If-Match` remains supported, but hosting can intercept it.
2. POST the exact intent to `/attachments/uploads`. The response is `{attachment,currentRevision,replayed}`. Save `attachment.id` as the original `operationId` before transferring bytes. An exact replay preserves the original pricing/reservation; changed intent returns `409 idempotency-mismatch`.
3. POST `{}` to `.../uploads/:operationId/transport` to reconcile, then `{ "partNumber": 1 }` (and later 2 if needed). A response adds nullable `transfer`. Only a private owner mutation returns that one-part descriptor; ordinary status/manifests never grant transfer authority.
4. Validate `transfer.url` against the independently trusted exact Edge origin/path `/functions/v1/upload-post-attachment-part`. PUT exactly the described original slice as `application/octet-stream`, with only `x-part-capability` authority. Refuse redirects, cookies, Bearer credentials, apikey and payment proofs. No Storage mutation URL or credential is needed. If Content-Length is supplied, it must equal the part; browser fetch supplies its own length. Do not use Content-Range or encoded bodies.
5. Reconcile after each PUT. Skip confirmed original parts. Once all parts are confirmed, POST `{}` to `.../complete`; the server verifies actual whole-file length/SHA-256, records receipt and separately reconciles current Post association. Screening follows.

```bash
curl -X POST "https://subnano.ai/api/v1/posts/$POST_ID/attachments/uploads" \
  -H "Authorization: Bearer $SUBNANO_PUBLISH_KEY" \
  -H "Idempotency-Key: $SAVED_UPLOAD_KEY" \
  -H "X-Post-Revision: $CURRENT_REVISION" \
  -H "Content-Type: application/json" \
  --data-binary @upload-intent.json
```

`upload-intent.json` contains only `{filename,description,sizeBytes,sha256}` from the saved source. Curl supplies its exact JSON length. Never print a private transfer response/capability in public logs, tool results or shared reader state.

**Modern HTTP `202` is not proof of receipt.** Reservation, transfer authorization and lookup can all return `202` before bytes arrive. Receipt is `safety.received:true` or `operation.transport.receipt.received:true`. A successful part PUT proves that part, not the whole file. Ready plus current safety eligibility and a nonnull download action gates creator bytes. Receipt does not clear screening.

### Legacy compatibility

`POST /api/v1/posts/:postId/attachments` remains the old adapter: one ZIP up to **3,145,728 bytes**, whole multipart body up to **3,670,016 bytes**, exact Content-Length and exactly one `file`, plain `description` and lowercase `sha256` part. MIME may be `application/zip`, `application/x-zip-compressed` or `application/octet-stream`; duplicate/unknown parts fail. Its receiving/received `202` checkpoint and explicit `operation.retryAllowed` whole-original-file retry remain supported. Use the original adapter for an existing legacy operation; never silently replace it with a modern reservation.

## Read saved state and recover

The owned manifest is `{attachments,currentRevision,limits,usage}`; usage contains active `postAttachments`, `postBytes`, `remainingPostBytes` and separately charged `profileBytes`. Normal lists show active pending/ready ZIPs; saved request-key lookup retains cancellation/removal state. Modern lookup returns one `{attachment,currentRevision,replayed}`, rather than the manifest's `attachments` array.

Owned attachments keep `state:pending|ready|cleanup_queued|removed`, independent `safety` and the saved `operation.requestKey`. Modern `operation.transport` adds stage, `{received,sizeBytes,sha256}` receipt, `{revision,conflict}` association and fixed-part number/length/generation/state. No physical path/version/provider identity is exposed. The public ready attachment remains `{id,filename,sizeBytes,description,sha256,mimeType,downloadUrl,contents}`. `contents` comes from the bounded scanner's verified original catalogue, omitting directories and macOS system files. Null/absent remains possible for unavailable historical lists; an empty array means no visible files. The UI shows five paths with **Show all files**/**Show fewer files**.

After interruption, timeout or a lost response, recover the original key/operation first. Confirmed parts are skipped; uncertain receiving/provider effects wait for authoritative reconciliation. An interrupted uncommitted part of up to 5 MiB may need retransmission; arbitrary byte-offset resume is unsupported. No elapsed lease or generic pending response authorizes resend or replacement. Save current status and honor `retryAfterSeconds` with bounded polling, then explicitly resume the same operation.

A `receiving` slot may request its existing `transport` control again. Send bytes only if that control returns a new transfer; the server fences expired or revoked receiving generations. `provider_started` and `unknown` still require reconciliation. If reservation recovery returns `404` before any operation ID was saved, keep the original key, source and revision and retry that reservation. Only a definite preallocation revision conflict plus an authoritative empty lookup permits an explicit retry under a newly inspected revision.

**After receipt, never resend bytes**, including scanner/provider failures or key rotation. Another active key for the same owner can recover; revoked/foreign keys cannot. Unrelated autosave or another reservation preserves receipt and frozen price evidence. A current association conflict needs compatible Post settings followed by `POST /api/v1/upload-operations/:operationId/associate`, or deliberate cancellation, without reupload/repricing. Native operation GET/DELETE also observes/cancels ZIPs; the image operation list excludes them.

Rejected, failed, held or cancelled results stop access; inspect sanitized `safety.lastError`. Corrected content needs a deliberate new intent. Cancellation removes inclusion immediately; unresolved provider effects remain charged and a late trusted unsafe finding can retain the exact original under hold. Cancel through `actions.remove` with current revision. Cancelled/removed replay returns `410 resource-gone`, never another reservation. Lookup tombstones survive permitted Post deletion with `currentRevision:null`. Pending work expires under the existing 24-hour progress policy; cleanup remains asynchronous and charged until proved complete.

Creator errors use problem details. Expect existing revision/quota/state/retention errors, `411 content-length-required`, `413 payload-too-large`, `422 validation`/`paid-post-required`, `428 post-revision-required` and `503 attachment-upload-unconfirmed` or paused/unavailable admission. Read original status after uncertainty. Error/public DTOs contain no private transfer authority.

## Admitted contents and coverage

Classic stored/deflate ZIPs may contain any file type, including PDF, Office documents, SVG, audio, video, executables and nested archives. The original ZIP and every extracted file still require completed ClamAV scanning. Encrypted, ZIP64, unsupported-compression/flag, malformed/overlapping, unsafe-path, duplicate-normalized-name, symlink or special-member outer archives are refused; an antivirus limit or encrypted-content alert also blocks approval.

Limits are 128 non-directory files, 16 MiB expanded per member, 64 MiB expanded total, and 100:1 expansion per member and aggregate (aggregate denominator is the entire original ZIP). Image extraction remains bounded at 32,000,000 canvas pixels per frame, 128,000,000 decoded canvas pixels and 128 distinct source/normalized representations per upload. Duplicate frames still consume pixel/scratch budgets. There is no separate fixed animation-frame cap or explicit directory-depth limit. Unsupported formats, failed ZIP-member image decoding or exhausted image pixel/representation budgets can yield `safety.csamCoverage:"partial"` after completed antivirus and successful available supported-image lookups. Global time, memory, scratch and antivirus failures remain blocking.

Shield checks recognized PNG/APNG, JPEG, GIF and WebP sources and available fully decoded/composited, metadata-stripped PNG frames. Positive malware/known-material findings block the whole ZIP; a failed required Shield lookup cannot become partial clearance. `safety.csamCoverage` is `complete` or `partial` only for the current eligible result, otherwise `null`. Complete describes the supported matching policy, not a virus-free or CSAM-free guarantee; matching can miss previously unseen material. PDF/Office, video and other opaque contents have no comprehensive CSAM extraction in this version. Standalone inline/OG/avatar/header uploads still require supported complete image inspection. Previously ready private ZIPs must be screened before current eligibility; historical public images remain legacy/unscanned under a separate backfill decision. Current content/publication policy is unchanged.

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](https://docs.subnano.ai/v1/api/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` or `paid-post-required`; `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.

ZIP uploads require `enablePaywall:true`; a saved price must provide an eligible ZIP offer (positive Nano with at most six decimals, or at least 0.25 USDC, including derived USDC offers); paid drafts may upload before completing body text. A 0.15 USDC-only Post must save an eligible price before a new ZIP upload; the same price with a ready accepted Nano rail can upload. Pending and ready ZIPs use the 0.25 USDC minimum; removing all such ZIPs restores the 0.10 USDC text minimum. Existing quotes, purchases and admitted ZIP price reservations keep their original terms. Free Posts reject upload with `422 paid-post-required` (`paid_post_required`) without consuming quota. Publication and edits that keep a ZIP Post public require a positive price and nonempty paid body instructions. Draft metadata and payment settings can be saved while the paid body is still incomplete; explicitly clearing paid body text with active ZIPs fails `422 paid-content-required` (`paid_content_required`). Setting `enablePaywall:false` while pending or ready ZIPs remain fails `422 paid-post-required`. Remove eligible files before making the Post free; cancelled files awaiting physical cleanup do not prevent a free Post. Existing free-file downloads are preserved, but new uploads and republication require a paid Post. Publish fails `409 attachments-unresolved` before an upload is durably received or while a desired upload has failed/rejected. Received ZIPs in queued/checking states allow immediate publication; the public manifest and downloads become available only after scan clearance. Background clearance on a published Post does not change the author's Post revision or require another publish action. 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. Safety restrictions still override delivery, public manifest inclusion and new payment quotes; financial retention never clears a hold or restores unsafe bytes. 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.

Deleting the creator account also removes purchased files. Saved buyer and settled-proof URLs lose access after account deletion commits. The purchase receipt and original financial facts remain available. Cleanup can remain pending while private safety evidence or active writers prevent removal. Ordinary Post withdrawal continues to retain eligible buyer access.

## Inspect first, purchase once, download every file

Anyone can inspect the published ready and currently eligible manifest without payment. Ineligible ZIPs return `409 attachment-safety-unavailable` before any payment quote; saved buyer/gift/creator/proof URLs do not bypass screening. Eligible 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](https://docs.subnano.ai/v1/api/payments#x402-protected-reads-and-assets): 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, legacy 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](https://docs.subnano.ai/v1/api/images) remain public media and cannot protect a research kit.

## Run the complete Node example

Download [upload-post-attachment.mjs](https://docs.subnano.ai/examples/upload-post-attachment.mjs). Use Node 22+ and the same local `nanocurrency@2.5.0` package as the [no-funds signature bootstrap](https://docs.subnano.ai/examples/nano-signature-publish.mjs). Complete that bootstrap or the [quickstart](https://docs.subnano.ai/v1/api/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:

```bash
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.10`) and `SUBNANO_ATTACHMENT_DESCRIPTION` are saved into the original intent. The example writes atomic mode-`0600` original intent and operation checkpoints before bytes. For modern transfer, set `SUBNANO_ATTACHMENT_PART_RELAY_URL` to the independently trusted exact Edge endpoint; at most two parts run serially. Without it, new files use the legacy adapter up to 3 MiB; an existing modern operation requires its same configured endpoint. A missing original operation after uncertain dispatch pauses recovery; it is not authority to replace it. The example distinguishes pending `202` from actual receipt, skips confirmed parts and polls the same operation at most 24 times per invocation, and resumes after restart or same-owner key rotation without repeating confirmed parts or received files. It honors current retry timing (bounded at 30 seconds), waits for ready plus eligible plus a download action, stops on sanitized terminal results without retrieval, rejects local changed-file/metadata mismatch, verifies exact creator bytes, publishes once 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:

```bash
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. Set `payload.block.link` to the accepted `payTo` destination's 64-hex public key. Canonical hex-only blocks are supported; `block.link_as_account` is optional and, if supplied, must encode the same destination. Keep `link` hexadecimal on the wire. 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](https://docs.subnano.ai/v1/api/payments#x402-protected-reads-and-assets), [exact payload schema](https://docs.subnano.ai/openapi/agent-platform-v1.json).

```bash
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.

## USDC and retained SIWX downloads

An EVM owner can buy the Post once through [Base USDC x402](https://docs.subnano.ai/v1/api/payments#base-usdc-purchases); a first buyer can use [guest checkout](https://docs.subnano.ai/v1/api/payments#guest-usdc-checkout) without prior login. Read all eligible ZIPs with the resulting owner Bearer or a fresh [resource-read SIWX proof](https://docs.subnano.ai/v1/api/payments#returning-owner-and-siwx-reads). A USDC payment authorization or transaction hash alone grants no private bytes. File routes do not settle payments. A failed download or blocked creator payout never requires another purchase. Saved buyer URLs retain access after withdrawal; verify each file's exact digest and length. Legitimate repeats remain free, subject to the documented burst limits.
