Wirepass developer notes

Everything is plain HTTP and JSON. Replace alex with your username. K is a pass key, F a feed key, SECRET the owner secret shown once at registration.

Send a message

curl -X POST "https://nilo.cc/alex/in?key=K" -d "Disk is 91% full"
curl -X POST "https://nilo.cc/alex/in?key=K&subject=Nightly%20backup" -H "Idempotency-Key: backup-2026-09-14" -d "done in 4m12s"

The whole body is one message (up to 10,000 characters). subject is optional. Repeating an Idempotency-Key within two days stores nothing new. Responses: 200 stored, 403 unknown or deleted pass, 413 too big, 429 slow down.

The sender shown is the From: header (decoded, so =?UTF-8?B?…?= reads as a name), falling back to the envelope sender when there is no header. Bulk senders put a bounce-tracking address in the envelope, which is what you would otherwise see. The header is stored whole and the export keeps it; feeds show the friendly part, so Monocle <newsletters@monocle.com> reads as Monocle, and an address with no name behind it shows as its domain rather than forty characters of bounce token. Rules match on the stored value, so from contains still sees both.

The plain-text part is used when there is one. If a sender only supplies HTML — or dumps a whole HTML document into the plain part, which many do — it is converted to text: tags, <script> and <style> removed, entities decoded, <li> as bullets, <img alt> as [alt], and <a> as words ( url ). Markup that arrived escaped (&lt;div&gt;) is unescaped first, and runs of invisible preheader padding are dropped. Nothing is ever rendered as HTML.

Conversion also runs when a message is read, so anything stored before this existed comes out readable too; storage itself is never rewritten and the export is always the original.

An email pass can instead have pictures turned on, in which case the HTML is kept as HTML (60,000 characters) after being rebuilt from an allow-list of tags: no scripts, styles, forms or frames, https images only, 1×1 tracking pixels dropped, and every link stripped of its handlers. The row carries html=1 and skips the conversion above, though a light tidy still runs when it is read: leftover declarations and the empty boxes a table layout leaves behind are dropped, so rows stored before a fix come out clean without storage being rewritten. Rules still match on the text version, so a rule written for a sender keeps working either way.

Email: send to K@yourdomain.com, where an email pass's K is its name followed by 16 hex (bank-alerts-4f8a2c9e1b3d7f02) and a webhook pass's is 32 hex. Both shapes are accepted forever, so keys made before this change still work. The plain-text part is kept (5,000 characters), attachments are dropped.

Read a feed

curl "https://nilo.cc/alex/inbox/rss?key=F"              # RSS 2.0, newest first, 100 per page
curl "https://nilo.cc/alex/inbox/rss?key=F&read=1"       # HTML
curl "https://nilo.cc/alex/inbox/rss?key=F&before=2026-09-01T00:00:00.000Z"   # older page
curl "https://nilo.cc/alex/news/rss"                     # public feed (no key)

Responses carry an ETag; send If-None-Match to get 304. Private feeds may be up to 45 seconds stale, public ones 5 minutes. Add &fresh=1 to bypass the cache.

Manage

POST JSON with your username and password. No cookies.

POST /register        {"u":"alex","p":"…"}                       → config + secret (once)
                      403 once this instance has an account, unless OPEN_SIGNUP is set
POST /login           {"u":"alex","p":"…"}                       → config
POST /reset-password  {"u":"alex","secret":"SECRET","newp":"…"}
POST /passes          {"u","p","action":"create","label":"bank","kind":"webhook|email","feed":"inbox"}
                      {"u","p","action":"delete"|"reset","pass":"K"}
                      {"u","p","action":"forward","pass":"K","to":"https://… | you@example.com | """}
                      {"u","p","action":"images","pass":"K","images":true}       email passes only
POST /feeds           {"u","p","action":"create"|"delete"|"reset","name":"news"}
                      {"u","p","action":"publish","name":"news","public":true}
                      {"u","p","action":"daily","name":"news","daily":true}
                      {"u","p","action":"configure","name":"news","keep":30}
POST /rules           {"u","p","pass":"K","rules":[…]}
POST /settings        {"u","p","type":"serif|sans"}          → how Read here sets message text
POST /reset-all       {"u","p","secret":"SECRET"}                → config + new secret

Rules

A rule is one condition and one action, and every rule belongs to a pass:

{"field":"from",    "contains":"bank.com", "action":"route", "value":"money"}
{"field":"subject", "contains":"urgent",   "action":"flag"}
{"field":"word",    "contains":"invoice",  "action":"tag",   "value":"bills"}
{"field":"any",                            "action":"drop"}

Fields: any, from, subject, word (subject or body). Matching is "contains", ignoring case. Actions: route, tag, flag, drop. Exactly one rule ever applies — the list is read in order and the first match wins. Sending an empty list clears the rules.

Feeds have no rules. Whether a feed arrives message by message or as one summary a day is daily on the feed, so "put these in the digest" is "send them to a feed that arrives daily". A daily feed queues arrivals in digest_items; the 08:00 UTC cron turns each feed's queue into a single message and empties it. A feed can't be both daily and public.

Links and typography

Bodies are stored exactly as received and exported that way. Rendering is where links are shortened: a bare URL becomes <a href="the whole thing">host/first-bit…</a>, in the HTML page and in the RSS <description>, which carries HTML so a reader can render it. A reader that shows descriptions as plain text will show the markup instead; add &read=1 and read it here in that case.

type on the account (serif or sans) sets the face for message text on the HTML page, as <body data-type>. Metadata is always monospace. The cache key includes the choice, so switching takes effect at once.

Forwarding

A pass has forwardUrl, forwardEmail and forwardWhen (all or important). Either destination can be set, or both. The url is sent a POST:

POST https://your-endpoint
content-type: application/json

{"user":"alex","pass":"bank alerts","feed":"money","time":"2026-09-14T08:20:40.374Z",
 "important":true,"sender":"alerts@bank.com","subject":"Statement ready","body":"…"}

forwardEmail only works on an email pass — a webhook arrival has no mail to relay — and Cloudflare will only deliver to a destination address verified in Email Routing; the original message goes on unchanged. important means a rule on that pass applied flag.

Forwarding happens after the rules, so anything drop ignored is never copied, and it never blocks storing the message: a dead endpoint or an unverified address loses the copy, not the message. Send "" for a destination to stop it. Records that used the older single forward field are still read correctly and are rewritten the next time the pass is saved.

Export

curl -H "Authorization: Bearer SECRET" "https://nilo.cc/alex/all" > export.json
curl -H "Authorization: Bearer SECRET" "https://nilo.cc/alex/all?before=…"   # next 5,000 messages, using "next" from the previous page

The export has your configuration (passes, feeds, rules), messages, and anything queued for a daily summary. Passwords and secrets are never included.

Discovery

curl "https://nilo.cc/.well-known/webfinger?resource=acct:alex@nilo.cc"

Lists public feeds only. Accounts with no public feed look the same as accounts that don't exist.

Operations