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.
payloadis the exact UTF-8 JSON bytes the signer produced. The signature is Ed25519 (RFC 8032, pure Ed25519) over the bytesnull-pass-v1\nfollowed 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
freeorpremium. 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(fromGET /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.
| Limit | Free | Premium |
|---|---|---|
Envelope size (max_envelope_bytes) | 1 MiB + 16 KiB | 100 MiB + 16 KiB |
| TTL presets | 10 min, 1 h, 24 h | adds 7 d and 30 d |
Views per drop (max_views) | 10 | 100 |
Files per drop (max_files) | 1 | 20 |
| Uploads per minute | 10 per IP | 60 per account |
| Reads per minute | 60 per IP | 300 per account |
| Reports per minute | 5 per IP | 20 per account |
Live storage (quota.max_live_bytes) | none | 2 GiB |
Live drops (quota.max_live_drops) | none | 1000 |
| Vault size | none | 256 KiB |
| Features | none | dashboard, 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, withoutRetry-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_filesentries 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 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..."
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 is412 vault_conflict.- Send the version from your last
GETso two tabs or devices never overwrite each other silently. On412, fetch again, merge, and retry. - An
If-Matchthat is not exactly one quoted number (for example*orW/"1") is400 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 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)
subis 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:
| Offset | Length | Field |
|---|---|---|
| 0 | 1 | version, 0x01 |
| 1 | 1 | kdf, 0x01 (Argon2id v1.3) |
| 2 | 4 | m_cost_kib, u32 big-endian |
| 6 | 4 | t_cost, u32 big-endian |
| 10 | 1 | lanes |
| 11 | 12 | IV, random per write |
| 23 | n + 16 | AES-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 is400 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.
Open alerts can be turned off on the service. When they are, open_alerts
is missing from features in GET /api/limits even with a premium pass,
and the header is refused with 403 tier_required. Check features before
offering the option.
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