Chapter 96 - Media
96
Nostr Implementation Possibility - Hex № 96

HTTP File Storage Integration

A REST API for file servers: discovery document, authenticated upload and delete, hash-addressed download. Deprecated in favor of Blossom.

Deprecated

1 Overview

Deprecated

The source spec carries an unrecommended warning. NIP-96 is deprecated in favor of NIP-B7 (Blossom media). Do not build new file servers or clients on this API. This chapter documents the full spec for existing implementations.

Nostr events are small. Pictures, video, and audio are not. This NIP defines a REST API for HTTP file storage servers that work with the Nostr network. A user uploads a file to a server. The server returns a URL. The user then references that URL in Nostr notes.

The API does not use Nostr events over WebSockets to store, request, or retrieve data. This keeps the design simple: the file server never has to learn anything about relays. Identity still comes from Nostr. Uploads and deletes carry a NIP-98 HTTP auth event, so the server knows which pubkey owns each file.

Every file is identified by the SHA-256 hash of its original content. The hash appears in the download URL and in the delete URL. A client that knows only the hash can try to fetch the file from any NIP-96 server.

Client File Server nip96.json + api_url Relay POST file (NIP-98) url + ox hash note with url
Fig. 96-1 — Division of labor. Files travel over HTTP. Only the resulting URL reaches the relays.

2 Server Adaptation: the Discovery Document

A file server opts in by serving an HTTPS route at /.well-known/nostr/nip96.json. This discovery document tells clients where the API lives and what the server offers. Only api_url is required.

Discovery document - /.well-known/nostr/nip96.json
{
  "api_url": "https://your-file-server.example/custom-api-path",
  "download_url": "https://a-cdn.example/a-path",
  "delegated_to_url": "https://your-file-server.example",
  "supported_nips": [60],
  "tos_url": "https://your-file-server.example/terms-of-service",
  "content_types": ["image/jpeg", "video/webm", "audio/*"],
  "plans": {
    "free": {
      "name": "Free Tier",
      "is_nip98_required": true,
      "url": "https://...",          // plan landing page, if any
      "max_byte_size": 10485760,
      "file_expiration": [14, 90],  // range in days, 0 = no expiration
      "media_transformations": { "image": ["resizing"] }
    }
  }
}
Discovery document fields
FieldRequiredRule
api_urlyesBase URL for upload and deletion. Also serves downloads when download_url is absent or an empty string.
download_urlnoBase URL for downloads. If absent, downloads use api_url.
delegated_to_urlnoFor relays only, never for HTTP servers. Points to a server that hosts its own nip96.json. See §3.
supported_nipsnoList of NIP numbers the server supports. The source example lists NIP-60.
tos_urlnoLink to the terms of service.
content_typesnoAccepted MIME types. Wildcards such as audio/* are allowed.
plansnoMap of plan objects, keyed by plan id. free is the only standardized key. Its presence tells clients the server offers free storage.
Plan object fields
FieldRule
nameDisplay name of the plan.
is_nip98_requiredDefault true. Every plan MUST support NIP-98 uploads. Some plans may also allow uploads without it.
urlThe plan's landing page, if there is one.
max_byte_sizeMaximum file size in bytes.
file_expirationA two-element range in days. [7, 0] means 7 days to unlimited persistence. [0, 0] means no expiration. Early expiration may come from low traffic or any other factor.
media_transformationsMap of media class to supported transformation names, such as {"image": ["resizing"]}.
Tech Note

The source example sets supported_nips to [60], but NIP-60 defines Cashu wallets and has no file-storage role. The field's semantics are otherwise undefined in the spec. Treat the example value as illustrative, not normative.

3 Relay Hints

This section is for Nostr relays, not for HTTP servers. A relay MAY redirect to someone else's file storage server. To do this, the relay serves its own /.well-known/nostr/nip96.json with a delegated_to_url field. That field points to the URL where the real server hosts its own nip96.json. In this delegated document, api_url must be an empty string and all other fields must be absent.

If the relay is itself an HTTP file storage server, it must use the api_url field instead.

Hint!

Looking for servers that speak this API? The source spec points to the community list at github.com/aljazceru/awesome-nostr, section "NIP-96 file storage servers". Kind 10096 (§9) records each user's own picks.

4 Auth

Where the spec says AUTH required, the client must add a NIP-98 Authorization header. The header carries a signed kind 27235 event. The client may optionally set the event's payload tag. When set, the value is the base64-encoded 256-bit SHA-256 hash of the file itself, not the hash of the whole request body.

5 Upload

POST $api_url as multipart/form-data. AUTH required.

Upload form fields
FieldStatusRule
fileREQUIREDThe file to upload.
captionRECOMMENDEDLoose description of the file.
altRECOMMENDEDStrict description text for visibility-impaired users.
expirationoptionalUnix timestamp in seconds. Empty string means store forever. The server is not required to honor it.
sizeoptionalFile byte size. Lets the server reject early when the file exceeds its limits.
media_typeoptional"avatar" or "banner". Tells the server the file's role. If absent, the server treats it as a normal upload with no special treatment.
content_typeoptionalMIME type such as "image/jpeg". Lets the server reject unsupported types early.
no_transformoptional"true" asks the server to serve the file exactly as uploaded, with no transformation. The server may reject the request.

Servers may accept other custom form fields. The server is not required to store any metadata the client sends. The server may ignore the embedded filename and store only the SHA-256 hash as the file name. The hash uniquely identifies the file, so the download and delete routes use it. The server MUST link the user's pubkey as the owner of the file, so the user can delete it later.

Use no_transform to replicate one file to multiple servers for redundancy. Clients can read the server list event (§9) to find alternative servers that might hold the same file. When a client uploads with no_transform, it should check that the hash in the response matches. A mismatch means the server modified the file.

Response codes

Upload response codes
CodeMeaning
200 OKThe file already exists (existing hash), and the upload is successful.
201 CreatedUpload successful, new hash.
202 AcceptedUpload is awaiting processing. See §6, Delayed Processing.
400 Bad RequestForm data is invalid or not supported.
402 Payment RequiredThe server requires payment. This flow is undefined.
403 ForbiddenThe user may not upload, or the file hash did not match the payload tag in the Authorization header.
413 Payload Too LargeThe file size exceeds the server limit.

Response body

The upload response is a JSON object. The nip94_event field uses the NIP-94 event format, but the server does not need to fill id, pubkey, created_at, or sig. The field is absent when the upload fails.

Upload response - JSON
{
  "status": "success",          // "success" or "error"
  "message": "Upload successful.",
  "processing_url": "...",      // optional, see Delayed Processing
  "nip94_event": {
    "tags": [
      ["url", "https://your-file-server.example/custom-api-path/719171db…04b7b.png"],
      ["ox", "719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b"],
      ["x", "543244319525d9d08dd69cb716a18158a249b7b3b3ec4bbde5435543acb34443"],
      ["m", "image/png"],
      ["dim", "800x600"]
      // ... other optional NIP-94 tags
    ],
    "content": ""
  }
  // ... other custom fields
}
nip94_event tags in the response
TagRequiredRule
urlyesThe download URL. Usually download_url (or api_url when absent) plus the original file hash. Appending the file extension is optional but strongly recommended: it helps clients detect the file type with a regular expression. The URL may also be any other address, for example for load balancing.
oxyesSHA-256 hash of the ORIGINAL file, before any transformation. The server MUST store it: users download and delete the transformed file with this value.
xnoSHA-256 hash of the saved file after server transformations. The server can, but does not need to, store this value.
mnoMIME type. Recommended, so clients know the file type before download.
dimnoPixel dimensions, such as 800x600. Recommended, so clients can reserve UI space before download.

If the server applied no transformation, ox and x hold the same value. The server MUST link the saved file to the hash of the original file, the ox value. That hash identifies the file for download and deletion.

A client may upload the same file to one or many servers. After a successful upload, the client may fill in the missing fields and publish a real NIP-94 event to any set of relays. Alternatively, the client can skip NIP-94 and embed just the URL in a note. The client may also build an imeta tag (NIP-92) from the nip94_event tags.

6 Delayed Processing

A server may place an uploaded file in a queue for deferred processing. While processing runs, the server MUST serve the original file. When processing ends, the server swaps the original file for the processed one.

The upload response has the usual shape, but some optional metadata, such as the x and size tags, is not yet available. Return every expected value that is known in advance. Example: if processing converts a JPEG to WebP, use a .webp extension in the url tag and set the m tag to image/webp. Omit metadata that is unknown before processing ends.

The response MAY include a processing_url: a temporary URL the client polls to check progress. While processing runs, the server answers there with 200 OK and this JSON:

Processing status - GET processing_url
{
  "status": "processing",   // "error" means the processing failed
  "message": "Processing. Please check again later for updated status.",
  "percentage": 15          // integer, 0 to 100
}

When processing is over, the server answers at the same URL with 201 Created and the regular success response from §5, now without a processing_url field. The response may include the metadata tags that were missing before.

Client Server POST $api_url (file, NIP-98 auth) 202 Accepted + processing_url GET processing_url 200 OK {"status":"processing","percentage":15} GET processing_url (later) 201 Created + full nip94_event
Fig. 96-2 — Delayed processing. The client polls processing_url until the server returns 201 Created.

File compression

The server may compress the file, strip metadata, or apply other transformations. For every file action, download and deletion alike, the original file's SHA-256 hash identifies the file in the URL string.

7 Download

GET $api_url/<sha256-hash>(.ext)

The primary download URL from the upload response's url tag can be this standard URL or any non-standard URL the server wants. Either way, the server still MUST answer downloads at the standard URL. This lets a client try any NIP-96 server while knowing only the SHA-256 hash of the file.

The <sha256-hash> part is the hash of the original file, not the transformed file. Servers must support the .ext file extension suffix. Clients may append it; this is optional but recommended. When present, the server may use the extension to pick the Content-Type header, for example image/png for .png. The extension may be absent: the hash alone identifies the file.

Download URL example
$api_url/719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b.png

Media transformations

Servers may respond to media transformation query parameters. A server ignores the parameters it does not support and serves the original file without transformation.

Image resizing is the one defined transformation. Upon upload, the server may create resized image variants, such as thumbnails, that respect the original aspect ratio. A client requests a width with the w query parameter, in pixels. The server serves the variant with the closest width, or generates a variant on the fly.

Resizing example
$api_url/<sha256-hash>.png?w=32

8 Deletion and Listing

DELETE $api_url/<sha256-hash>(.ext). AUTH required.

The hash is again the original file's hash. The extension is optional; the hash alone identifies the file. The server should reject deletes from users other than the original uploader with 403 Forbidden.

More than one user may have uploaded the same file, which yields the same hash. The server keeps one copy. A delete then must not remove the file. It only removes the requesting user's pubkey from the file's owner list.

Deletion response - 200 OK
{
  "status": "success",
  "message": "File deleted."
}

Listing files

GET $api_url?page=x&count=y. AUTH required. Returns the files linked to the authenticated user's pubkey. The files array contains NIP-94 events.

Listing query args
ArgRule
pagePage number. The offset is page * count.
countNumber of items per page.
Listing response - JSON
{
  "count": 1,   // server page size: max(1, min(server_max_page_size, arg_count))
  "total": 1,   // total number of files
  "page": 0,    // current page number
  "files": [
    {
      "tags": [
        ["ox", "719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b"],
        ["x", "5d2899290e0e69bcd809949ee516a4a1597205390878f780c098707a7f18e3df"],
        ["size", "123456"],
        ["alt", "a meme that makes you laugh"],
        ["expiration", "1715691139"]
        // ...other metadata
      ],
      "content": "haha funny meme",  // caption
      "created_at": 1715691130       // upload timestamp
    }
  ]
}

9 Selecting a Server

This section is for client developers. Server developers may skip it. A File Server Preference event is a kind 10096 replaceable event. It selects one or more servers the user wants to upload files to. Each server appears as a server tag:

Kind 10096 - File Server Preference
{
  "kind": 10096,
  "content": "",
  "tags": [
    ["server", "https://file.server.one"],
    ["server", "https://file.server.two"]
  ]
  // other fields...
}

Kind 10096 sits in the replaceable range of NIP-01. A relay keeps only the latest event per pubkey.

10 Worked Example

The transcript below shows one full cycle: discovery, upload, download, delete. Orange lines are client requests. Blue lines are server responses.

HTTP session - files.example.com
-- 1. discover the API --
→ GET /.well-known/nostr/nip96.json
← 200 {"api_url":"https://files.example.com/api",
       "plans":{"free":{"name":"Free Tier","is_nip98_required":true,
       "max_byte_size":10485760,"file_expiration":[0,0],
       "media_transformations":{"image":["resizing"]}}}}

-- 2. upload with NIP-98 auth --
→ POST /api  (multipart: file=cat.png, caption="my cat", alt="a gray cat")
   Authorization: Nostr eyJraW5kIjoyNzIzNSw…
← 201 {"status":"success","message":"Upload successful.",
       "nip94_event":{"tags":[
         ["url","https://files.example.com/api/719171db…04b7b.png"],
         ["ox","719171db…04b7b"],["x","719171db…04b7b"],
         ["m","image/png"],["dim","800x600"]],"content":""}}

-- 3. anyone downloads a 32px thumbnail --
→ GET /api/719171db…04b7b.png?w=32
← 200 Content-Type: image/png  (32px-wide variant)

-- 4. the owner deletes --
→ DELETE /api/719171db…04b7b.png
   Authorization: Nostr eyJraW5kIjoyNzIzNSw…
← 200 {"status":"success","message":"File deleted."}

The server applied no transformation to the stored file, so ox and x match. After step 2 the client can publish a NIP-94 event, or paste the URL into a note, or build a NIP-92 imeta tag.

11 Version History

NIP-96 lived about twenty months as the standard upload API before Blossom replaced it. Notable changes, newest first:

  • 20252025-09Deprecated in favor of NIP-B7 (Blossom media). The unrecommended warning landed in the body in 2026-05.
  • 20242024-05Added the file listing endpoint with page/count pagination, and the no_transform upload field for byte-exact replication.
  • 20242024-03Simplified the server list: the in-spec table gave way to a link to the community list.
  • 20242024Removed a temporary NIP-54 mention, dropped the authors line, plus typo and formatting fixes.
  • Origin2024-01-08arthurfranca opened the spec as "NIP-96 - HTTP File Storage Integration" (#547).

Original Proposal - Credits

Authorarthurfranca
First commit2024-01-08 — “NIP-96 - HTTP File Storage Integration (#547)” - PR #547
Statusdraft - unrecommended - optional — deprecated in favor of NIP-B7
Source96.md on GitHub - full change history

12 See Also

  • NIP-B7 — Blossom media, the replacement for this NIP.
  • NIP-98 — the HTTP auth events that protect upload, delete, and listing.
  • NIP-94 — file metadata events; the upload response reuses this format.
  • NIP-92 — imeta tags built from the upload response.
  • NIP-01 — base event rules; kind 10096 follows its replaceable-event storage class.