# Nostr Media HTTP API reference

Base URL: `https://nostrmedia.com`
Blob host: `https://file.nostrmedia.com`

Every endpoint below returns JSON, sends `Access-Control-Allow-Origin: *`, and answers
`OPTIONS` with `204` for CORS preflight. Every endpoint except `GET /zapwall` requires
an `Authorization` header carrying a signed Nostr event.

An active paid subscription (Purple, Onyx or Gold) is required for all write operations.
Requests from a pubkey with no subscription are rejected with `403`.

## Authentication

Authentication is [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md)-style:
sign a Nostr event, base64-encode its JSON, and send it as a bearer-like credential.

```
Authorization: Nostr <base64(JSON.stringify(signedEvent))>
```

The event kind is `24242` (the Blossom authorization kind). The `t` tag names the
operation, and its value must match the endpoint being called:

| Operation | Event | Used by |
| --- | --- | --- |
| Upload | kind `24242`, tags `[["t","upload"],["x","<sha256>"]]` | `/upload`, `/media`, `/mirror` |
| Manage | kind `24242`, tags `[["t","manage"]]` | `/edit`, `/usage`, `/presigned`, `/check-subscription` |
| Delete | kind `24242`, tags `[["t","delete"],["x","<sha256>"]]` | `/delete` |

Additional rules the server enforces:

- `created_at` may be at most 5 seconds in the future.
- An optional `["expiration", "<unix-timestamp>"]` tag is honoured; an expired event is
  rejected.
- If the `t` tag is absent, a `["method", "POST"]` tag is accepted as a fallback.
- On `/upload`, the file's SHA-256 may come from the event's `x` tag **or** from an
  `X-SHA-256` request header.

### Building the header in JavaScript

```js
const event = {
  kind: 24242,
  created_at: Math.floor(Date.now() / 1000),
  tags: [["t", "upload"], ["x", sha256Hex]],
  content: "Uploading blob with SHA-256 hash",
  pubkey: await window.nostr.getPublicKey(),
};
const signed = await window.nostr.signEvent(event);
const auth = "Nostr " + btoa(JSON.stringify(signed));
```

---

## POST /upload

Uploads a file of up to **125 MB**. Larger files must use [`/presigned`](#post-presigned);
`/upload` answers `413` above 125 MB because the request would outlive the worker.

`PUT` is accepted as an alias for `POST`. `GET` and `HEAD` return `401` with an
`X-Reason` header (Blossom BUD-06 handling).

The endpoint serves two protocols and picks between them by looking at the
`Authorization` event: a kind `24242` event is treated as a Blossom upload, anything else
falls through to the NIP-96 handler. The two differ in how the body is sent and in the
shape of the response.

### Blossom style — raw body

```http
PUT /upload HTTP/1.1
Host: nostrmedia.com
Authorization: Nostr <base64-signed-event>
Content-Type: image/png
Content-Length: 184320

<raw file bytes>
```

`Content-Type` must be a supported MIME type and `Content-Length` must be present — the
server reads the size from that header. Response `200`:

```json
{
  "url": "https://file.nostrmedia.com/p/<pubkey>/<sha256>.png",
  "sha256": "<sha256>",
  "size": 184320,
  "type": "image/png",
  "uploaded": 1756339200
}
```

### NIP-96 style — multipart form

```http
POST /upload HTTP/1.1
Host: nostrmedia.com
Authorization: Nostr <base64-signed-event>
Content-Type: multipart/form-data; boundary=...

file: <the file>
caption: <optional string>
```

Response `200`, carrying a NIP-94 event template:

```json
{
  "status": "success",
  "message": "Upload successful.",
  "nip94_event": {
    "tags": [
      ["url", "https://file.nostrmedia.com/p/<pubkey>/<sha256>.png"],
      ["m", "image/png"],
      ["x", "<sha256>"],
      ["ox", "<sha256>"],
      ["size", "184320"]
    ],
    "content": ""
  }
}
```

### Errors

| Status | Meaning |
| --- | --- |
| `400` | Missing or unparseable authorization event, or no SHA-256 in either the `x` tag or the `X-SHA-256` header |
| `401` | Called with `GET`/`HEAD`, or blocked referer |
| `403` | No authorization header, no paid subscription, storage limit reached, or a file type restricted to Onyx/Gold |
| `405` | Method other than `GET`, `HEAD`, `POST`, `PUT`, `OPTIONS` |
| `413` | File larger than 125 MB — use `/presigned` |
| `415` | Unsupported content type |
| `500` | Server error; the message is echoed in the body |

---

## POST /presigned

The route for files between 125 MB and 5 GB. The client asks for presigned R2 URLs,
uploads the bytes straight to storage, then tells Nostr Media the upload is finished so
the file is recorded against the account.

Authorization: a `manage` event (kind `24242`, `["t","manage"]`).
Content type: `application/json`. The `action` field selects the sub-operation.

### Multipart flow (large files)

**1. `initiate`** — splits the file into 100 MB parts and returns one presigned URL per part.

```json
{
  "action": "initiate",
  "fileHash": "<sha256>",
  "fileSize": 524288000,
  "fileType": "video/mp4",
  "fileName": "clip.mp4"
}
```

```json
{
  "status": "success",
  "uploadId": "<r2-multipart-upload-id>",
  "key": "p/<pubkey>/<sha256>.mp4",
  "presignedUrls": [
    { "partNumber": 1, "url": "https://..." },
    { "partNumber": 2, "url": "https://..." }
  ]
}
```

**2. Upload each part** with a plain `PUT` to its presigned URL. Keep the `ETag` each
response returns.

**3. `complete`** — assembles the parts and records the file.

```json
{
  "action": "complete",
  "fileHash": "<sha256>",
  "fileSize": 524288000,
  "fileType": "video/mp4",
  "fileName": "clip.mp4",
  "uploadId": "<r2-multipart-upload-id>",
  "parts": [
    { "partNumber": 1, "etag": "\"...\"" },
    { "partNumber": 2, "etag": "\"...\"" }
  ]
}
```

```json
{
  "status": "success",
  "message": "Upload successful.",
  "url": "https://file.nostrmedia.com/p/<pubkey>/<sha256>.mp4",
  "sha256": "<sha256>",
  "size": 524288000,
  "type": "video/mp4",
  "uploaded": 1756339200
}
```

**`abort`** — cancels an initiated multipart upload. Same fields as `complete` minus
`parts`; returns `{"status":"success","message":"Upload aborted."}`.

### Single-PUT flow (medium files)

**`initiate-single`** takes the same fields as `initiate` and returns one URL:

```json
{ "status": "success", "presignedUrl": "https://...", "key": "p/<pubkey>/<sha256>.mp4" }
```

`PUT` the whole file to `presignedUrl` with the matching `Content-Type`, then call
**`complete-single`** with `{ "action": "complete-single", "key": "<key from above>",
"fileHash": ..., "fileSize": ..., "fileType": ..., "fileName": ... }`. The server
verifies the object landed in storage before recording it, and returns the same body as
`complete`.

### Errors

`400` file over 5 GB or invalid action · `403` no auth header, or no paid subscription,
or storage limit reached · `405` non-POST · `500` storage credentials unavailable or the
object missing after a single PUT.

---

## PUT /mirror

Blossom BUD-04. Copies a blob that already exists at some other URL into the caller's
Nostr Media storage, so the client never has to re-upload the bytes itself.

```http
PUT /mirror HTTP/1.1
Authorization: Nostr <base64-signed-event>
Content-Type: application/json

{ "url": "https://other-blossom-server.example/<sha256>.png" }
```

Returns the Blossom blob descriptor (`url`, `sha256`, `size`, `type`, `uploaded`).
`GET` and `HEAD` return `401`.

## PUT /media

Blossom BUD-05. Same request and response shape as `/mirror`, for blobs a client wants
processed as media. `GET` and `HEAD` return `200`.

---

## POST /edit

The management endpoint. Authorization must be a `manage` event. The request body
selects the operation.

### List every file

```json
{ "fetchAll": true }
```

```json
{
  "files": [
    {
      "name": "sunset.jpg",
      "hash": "<sha256>",
      "url": "https://file.nostrmedia.com/p/<pubkey>/<sha256>.jpg",
      "type": "image/jpeg",
      "size": 184320,
      "uploaded": 1756339200,
      "zapwall": false
    }
  ]
}
```

This is the endpoint to use instead of Blossom `GET /list/<pubkey>`, which is not
implemented yet.

### Fetch one file

```json
{ "sha256": "<sha256>" }
```

Returns `{ "file": { ...same shape as above... } }`.

### Rename a file

```json
{ "action": "rename-file", "fileHash": "<sha256>", "newFileName": "sunset-2026.jpg" }
```

Filenames are sanitised server-side to `[a-zA-Z0-9-_.]`; anything else is stripped. The
stored blob and its URL do not change — only the display name in the file manager.

### Add a Zapwall

Puts a file behind a Lightning paywall. The file moves from `p/<pubkey>/...` to
`zapwall/<pubkey>/...`, so its public URL changes accordingly.

```json
{
  "action": "add-zapwall",
  "fileUrl": "https://file.nostrmedia.com/p/<pubkey>/<sha256>.jpg",
  "satsAmount": 21,
  "ownerPubKey": "<pubkey>",
  "noteId": "<id of a signed kind 9734 zap request>"
}
```

`satsAmount` must be an integer of 1 or more. `noteId` is the id of a kind `9734` zap
request event the client signs beforehand. `ownerPubKey` must be a valid 64-character
hex Nostr pubkey.

### Remove a Zapwall

```json
{ "action": "remove-zapwall", "fileUrl": "https://file.nostrmedia.com/zapwall/<pubkey>/<sha256>.jpg", "ownerPubKey": "<pubkey>" }
```

Moves the blob back to `p/<pubkey>/...` and clears the Zapwall flag.

### Errors

`400` invalid parameters · `403` missing/invalid auth or no paid subscription ·
`404` file not found · `405` non-POST.

---

## GET /zapwall

The one unauthenticated endpoint. Asks whether a file is behind a Zapwall and, if so,
for how many satoshis.

```
GET /zapwall?fileUrl=https%3A%2F%2Ffile.nostrmedia.com%2Fzapwall%2F<pubkey>%2F<sha256>.jpg
```

```json
{ "monetized": true, "satsAmount": 21 }
```

An unmonetised file returns `{ "monetized": false }`. Payments settle directly to the
file owner's Lightning address; Nostr Media takes no cut.

`400` if `fileUrl` is missing · `405` for non-`GET` · `500` on a lookup failure.

---

## POST /usage

Returns how much storage the authenticated pubkey has consumed and which tier it is on.
Authorization must be a `manage` event. No request body.

```json
{ "usedStorage": 4294967296, "tier": "onyx" }
```

`usedStorage` is in bytes. `tier` is `"purple"`, `"onyx"`, `"gold"`, or `null` when the
pubkey has no active subscription.

---

## POST /check-subscription

Checks subscription status without touching storage. Authorization must be a signed
Nostr event; no request body.

```json
{ "status": "success", "message": "User is subscribed", "subscribed": true, "tier": "purple" }
```

A pubkey with no subscription gets `403` and
`{"status":"error","message":"A paid subscription (Purple, Onyx, or Gold) is required to upload files.","subscribed":false,"tier":null}`.

`401` if the `Authorization` header is absent, `400` if the event cannot be parsed.

---

## POST /delete

Deletes one file, or every file belonging to the pubkey. Authorization must be a
`delete` event (kind `24242`, tags `[["t","delete"],["x","<sha256>"]]`).

Delete one file:

```json
{ "fileHash": "<sha256>" }
```

Delete everything: send an empty JSON body. Both the blobs and the account's file record
are removed, and the CDN cache for each deleted URL is purged.

`403` missing auth · `404` nothing to delete · `405` non-POST.

---

## Storage limits and file types

| Tier | Storage | File types |
| --- | --- | --- |
| Purple | 100 GB | Images and video |
| Onyx | 210 GB | All supported types |
| Gold | 2.1 TB | All supported types |

Per-file ceiling on every tier is 5 GB.

Supported MIME types and extensions:

- **Images** — `.jpeg` `.jpg` `.png` `.gif` `.webp` `.bmp` `.tiff` `.heic` `.ico` `.svg`
- **Video** — `.mp4` `.webm` `.ogg` `.mov` `.avi` `.wmv` `.mkv` `.flv` `.mpeg` `.mpg` `.3gp` `.m4v`
- **Audio** — `.mp3` `.wav` `.flac` `.aac` `.m4a` `.wma`
- **Documents** — `.pdf` `.docx` `.xlsx` `.pptx` `.txt` `.rtf` `.odt` `.ods` `.csv`
- **Archives and other** — `.zip` `.rar` `.7z` `.tar.gz` `.psd` `.stl`

Uploading a type outside the Purple set from a Purple account returns `403` with
`"Only Onyx or Gold subscribers can upload this file type."`.

A file whose bytes do not match any known signature is stored with a `.bin` extension.

## URL layout

| Path | Meaning |
| --- | --- |
| `https://file.nostrmedia.com/p/<pubkey>/<sha256>.<ext>` | A normal blob |
| `https://file.nostrmedia.com/zapwall/<pubkey>/<sha256>.<ext>` | A Zapwalled blob |

Both are content-addressed: the path segment before the extension is the SHA-256 of the
file, so any client can verify the bytes it receives.

## Rate and size ceilings at a glance

| Limit | Value |
| --- | --- |
| Single `POST /upload` | 125 MB |
| Presigned multipart part size | 100 MB |
| Per-file maximum | 5 GB |
| Clock skew allowed on `created_at` | 5 seconds into the future |
