Premium

A NULL Premium account on mail.nullnet.sh also raises your limits on paste. The mail service signs a short-lived pass, and your paste client sends it with every request in the X-Null-Pass header. The paste service checks the pass offline and never learns who you are, only a stable pseudonym.

The pass

A pass is a token of the form:

np1.<base64url(payload)>.<base64url(signature)>
  • Both parts are base64url without padding. A token is at most 2048 bytes.
  • payload is the exact UTF-8 JSON bytes the signer produced. The signature is Ed25519 (RFC 8032, pure Ed25519) over the bytes null-pass-v1\n followed by the payload.
  • A pass is valid for one hour. It cannot be revoked; its lifetime is the revocation bound.

You do not need to parse or verify the pass to use it: send it as-is. The payload is plain JSON if you want to read it:

Pass payload

{
  "v": 1,
  "kid": "3f9a1c2b7d4e6f80",
  "tier": "premium",
  "sub": "<base64url of 32 bytes>",
  "limits": {
    "max_size_bytes": 104857600,
    "max_ttl_secs": 2592000,
    "max_files": 20,
    "live_storage_bytes": 2147483648,
    "uploads_per_min": 60,
    "features": ["dashboard", "open_alerts", "file_requests"]
  },
  "iat": 1761300000,
  "exp": 1761303600
}
  • Name
    v
    Type
    integer
    Description

    Payload version, 1.

  • Name
    kid
    Type
    string
    Description

    16 hex characters: the first 8 bytes of SHA-256 of the signing public key.

  • Name
    tier
    Type
    string
    Description

    free or premium. A free pass gives the same limits as no pass.

  • Name
    sub
    Type
    string
    Description

    The account pseudonym: 32 bytes, base64url. Stable per account and not linkable to the account without a secret held by the mail service. The vault key derivation uses it.

  • Name
    limits
    Type
    object
    Description

    The account's paste limits. Unknown members and unknown feature names are ignored.

  • Name
    iat
    Type
    integer
    Description

    Issued at, Unix seconds.

  • Name
    exp
    Type
    integer
    Description

    Expires at, Unix seconds.

Getting a pass

Mint a pass with POST https://mail.nullnet.sh/v1/passes/paste and an empty body, authenticated as a logged-in account with Authorization: Bearer <session token> or an API key in X-API-Key: grk_… (the key needs the write scope). The response is { "pass": "np1.…", "expires_at": <exp> }. An anonymous session gets 401. The full endpoint reference, including errors, is on API keys.

Mint a pass

curl -X POST https://mail.nullnet.sh/v1/passes/paste \
  -H "X-API-Key: grk_..."

Response

{ "pass": "np1.eyJ2IjoxLCJraWQiOi...", "expires_at": 1761303600 }

The signing public key is published at GET https://mail.nullnet.sh/v1/passes/public-key, if you want to verify passes yourself:

Response

{
  "v": 1,
  "alg": "Ed25519",
  "kid": "7a5fbb3aef3c07fb",
  "public_key": "q3q7Bv1_-GqCx8BX5dqJspYFpEVAzcR_3_2lBA3W68Y"
}

Using a pass

  • Send X-Null-Pass: np1.… on every /api/* request that should count as premium: uploads, extend, limits and the vault. Opening a drop needs no pass.
  • A missing, malformed, forged, expired or free-tier pass is never an error. The request simply gets the free limits.
  • Mint a fresh pass before pass_expires_at (from GET /api/limits) and keep a small margin for clock skew.
  • When a subscription lapses, the mail service mints free passes. Drops created while premium keep their expiry and stay owned by the account.

Premium limits

curl https://paste.nullnet.sh/api/limits \
  -H "X-Null-Pass: np1.eyJ2IjoxLCJraWQiOi..."

Premium limits

The paste service clamps each limit from the pass to its own ceilings and never goes below the free limits. Read the effective values from GET /api/limits with your pass; these are the current ceilings.

LimitFreePremium
Envelope size (max_envelope_bytes)1 MiB + 16 KiB100 MiB + 16 KiB
TTL presets10 min, 1 h, 24 hadds 7 d and 30 d
Views per drop (max_views)10100
Files per drop (max_files)120
Uploads per minute10 per IP60 per account
Reads per minute60 per IP300 per account
Reports per minute5 per IP20 per account
Live storage (quota.max_live_bytes)none2 GiB
Live drops (quota.max_live_drops)none1000
Vault sizenone256 KiB
Featuresnonedashboard, open_alerts

A premium response from GET /api/limits looks like this:

Response

{
  "tier": "premium",
  "max_envelope_bytes": 104873984,
  "ttl_presets_secs": [600, 3600, 86400, 604800, 2592000],
  "max_views": 100,
  "max_files": 20,
  "uploads_per_min": 60,
  "reads_per_min": 300,
  "features": ["dashboard", "open_alerts"],
  "quota": {
    "live_bytes": 1048576,
    "max_live_bytes": 2147483648,
    "live_drops": 3,
    "max_live_drops": 1000
  },
  "pass_expires_at": 1761303600
}
  • Rate limits with a pass are counted per account, not per IP, so they follow you across networks.
  • Quota. Every drop uploaded with a premium pass is owned by the account and counts against its live drops and live bytes until it expires, is consumed or is deleted. An upload over either cap gets 429 quota_exceeded, without Retry-After.
  • Large uploads. Envelopes over 1 MiB + 16 KiB are stored on disk and may take up to 10 minutes to upload instead of 15 seconds.
  • Bundles. Up to max_files entries in one envelope, opened with one view. See bundles.

Extend

POST /api/drops/{id}/extend with the manage token and your pass uses your premium presets when your account owns the drop, so an owned drop can be kept for up to 30 days after creation. For a drop created without a pass, or by another account, only the free presets apply and the limit stays 24 hours after creation.

Vault: My pastes

The vault holds the encrypted list behind My pastes in the web app: the ids, link keys, manage tokens and labels of your drops. The server stores one opaque blob per account and cannot read it. It needs a premium pass with the dashboard feature; otherwise every vault call returns 403 tier_required.

A vault expires 30 days after its latest write, and like drops it is lost if the service restarts its store.

GET/api/vault

Get the vault

Returns 200 with the blob (application/octet-stream) and ETag: "<version>", or 404 not_found when there is none. Counts against the read rate limit.

Request

curl -D - -o vault.bin https://paste.nullnet.sh/api/vault \
  -H "X-Null-Pass: np1.eyJ2IjoxLCJraWQiOi..."
PUT/api/vault

Write the vault

The body is the blob, at most 256 KiB (413 too_large beyond). Counts against the upload rate limit.

  • If-Match: "<version>" writes only if the stored vault is at that version. If-Match: "0" writes only if no vault exists yet. Otherwise the answer is 412 vault_conflict.
  • Send the version from your last GET so two tabs or devices never overwrite each other silently. On 412, fetch again, merge, and retry.
  • An If-Match that is not exactly one quoted number (for example * or W/"1") is 400 invalid_header.

Success is 200 { "version": 3 } with the new ETag.

Request

curl -X PUT https://paste.nullnet.sh/api/vault \
  -H "X-Null-Pass: np1.eyJ2IjoxLCJraWQiOi..." \
  -H 'If-Match: "2"' \
  -H "Content-Type: application/octet-stream" \
  --data-binary @vault.bin

Response

{ "version": 3 }
DELETE/api/vault

Delete the vault

Returns 204, or 404 not_found when there was none.

Vault encryption

The web app encrypts the vault in the browser under a key derived from the NULL account password. This is how it does it; follow it if your client should share the same vault.

sub   = the pass's "sub", base64url-decoded: 32 bytes
salt  = HKDF-SHA-256(IKM = sub, salt = empty, info = "zkbin-vault v1 salt", L = 16)
key   = Argon2id(UTF-8(NFC(account password)), salt,
                 m_cost_kib = 65536, t_cost = 3, lanes = 1, version 0x13, 32 bytes)
  • sub is the same on every device, so the same password gives the same key everywhere, and two accounts never share a salt.
  • The key is derived at sign-in, while the password is in memory for the login anyway, and kept only in memory.
  • The mail service sees the account password at login, so the vault is only as private as that password is from the mail service.

Blob layout:

OffsetLengthField
01version, 0x01
11kdf, 0x01 (Argon2id v1.3)
24m_cost_kib, u32 big-endian
64t_cost, u32 big-endian
101lanes
1112IV, random per write
23n + 16AES-256-GCM ciphertext and tag, AAD = bytes 0–10

The plaintext is a u32 big-endian JSON length, the UTF-8 JSON document, and 0x00 padding to a multiple of 4096 bytes. The Argon2id parameters must be within the envelope bounds. The document lists items keyed by drop id:

Vault document

{
  "v": 1,
  "items": [
    {
      "id": "0T4iUVyd4X1YUjpQK-PZVA",
      "key": "<43-character link key, or null>",
      "password": false,
      "token": "<43-character manage token>",
      "label": "",
      "created_at": 1792597224,
      "expires_at": 1792684224,
      "burn": false,
      "views": null,
      "files": 1,
      "updated_at": 1792597224123
    }
  ],
  "removed": [{ "id": "…", "expires_at": 1792684224 }]
}

updated_at is in Unix milliseconds. When merging two copies after a 412, the copy of an item with the later updated_at wins, and removed holds tombstones so a merge never brings a deleted item back.

Open alerts

With the open_alerts feature, add X-Zkbin-Alert-To to POST /api/drops to get an email when the drop is opened.

  • The value is a plain mailbox on mail.nullnet.sh, for example your NULL mail address. Anything else is 400 invalid_header.
  • At most one alert per drop every 10 minutes. The email contains no link and no drop id.
  • The address is stored with the drop and deleted with it.
  • Without the feature, the header is refused with 403 tier_required.

Request

curl -X POST "https://paste.nullnet.sh/api/drops?ttl=604800" \
  -H "X-Null-Pass: np1.eyJ2IjoxLCJraWQiOi..." \
  -H "X-Zkbin-Alert-To: you@mail.nullnet.sh" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @envelope.bin