⚡ TL;DR
- 🔌 We have a public API. Grab a key on your profile, pull your server's data as JSON. api.nyx.cool
- 🎟️ Tickets get rated. Member closes a ticket, gets five stars in their DMs. You write the questions.
- 🍯 Honeypot exists. A trap channel that deletes spam bots on contact. It has been live for ages and we never told you.
- 📬 Update DMs split in two. Changelogs and product updates are separate. Take one, both, or neither.
- 📱 The mobile dashboard has navigation. It did not before. At all.
Everything below is the detail. Part two is the technical version.
Part one: what changed
🍯 Honeypot: the trap channel
Make a channel. Point nyx at it. Spam bots that post there get removed automatically.
That is the whole plugin. It has been live for a while and no changelog ever mentioned it, which is on me.
nyx pins a warning in the channel so real people know to walk away. Anyone who posts anyway is gone.
What you control:
- 🔨 Ban, softban, kick, or timeout up to 28 days
- 📉 Up to ten trap channels
- 🛡️ Admins exempt, on by default
- 💬 A DM to the offender, in your words
- 🪵 A log channel, with
{{user}},{{action}},{{channel}}and{{count}}filled in for you - ⚠️ Your own warning text, or none at all for a silent trap
The clever part. A spam bot triggers some other bot's slash command, and the reply lands in your trap. The message looks like it came from that other bot. nyx reads the interaction data and removes the human who ran it.
It also refuses to act on nyx's own replies. Otherwise this would be a very funny way to ban your own moderators.
Banned someone by mistake? Ban and softban log entries carry an Unban button. One click.
🔌 Public API
Your server's data, as JSON, on your own website.
Three days ago we published the contracts so you could read our API. Now you can call it.
Generate a key on your profile page. Then hit api.nyx.cool for:
- Your servers, and daily activity stats
- The progression leaderboard, or one member's level and XP
- Ticket analytics, and the live open ticket queue
- Honeypot catches
- A diagnostics report listing what you have configured wrong
The limits: five keys at once, 60 requests a minute, 10,000 a day. Works on servers you own with nyx+ active.
The key shows once. We store a hash, not the key. Lose it, revoke it, make a new one.
Every endpoint gets its own page with a copy-paste curl and colour-coded chips telling you what has to be switched on for it to answer.
🎟️ Tickets now ask questions
Ticket closes. Member gets a DM. Five stars. Done.
Turn it on under Tickets → Logging and Limits → Close-time ratings.
Want more than a number? Attach up to five questions per category. What could have gone better, is it actually resolved, anything you like. Answers get stored with the score.
Same for opening a ticket. The intake form has an editor now, so you can ask for an order number before a channel even exists.
Then read the results. Average rating, the full one to five spread, and volume, on the dashboard and through the API.
📬 Update DMs, split in two
Changelog is what shipped. Product update is an announcement. Two different things, so they are two different subscriptions now.
Pick either, both, or neither on your profile. Already turned update DMs off? You stay off for both. Nobody got re-enabled.
The updates page filters by type, and every post shows which kind it is.
📱 The dashboard on a phone
It had no navigation. None. You could open the dashboard and then go nowhere.
There is a menu button now, the same one the main site uses. The docs site got one too.
I am not thrilled this shipped in the first place.
🏷️ Rename a server, see it change
Renamed servers kept their old name in your list for up to ten minutes. Fixed.
Last update brought caching to the dashboard, which is why it got fast. This one teaches the cache when to let go: name, icon, banner, description, language and owner changes all clear it instantly.
🎨 Embed colours on more plugins
Colour picker and live preview on most plugin pages. Progression, automod, honeypot, starboard, logging and the rest can match your server instead of all looking like nyx.
Custom footer text needs nyx+, on every plugin, no exceptions. Colour does not.
🔧 Also fixed
- Duplicate ticket channels. One press, two channels, occasionally. Cause and fix in part two.
- Discord outages used to look like being kicked. A server going unreachable was treated as a departure, and its configuration got deleted. It now waits for the server to come back.
- The progression page was one dense column mixing XP rules with two separate embed editors. Appearance is its own section now.
- Around thirty pieces of dashboard copy got rewritten. Several described the opposite of what the setting did.
🔭 Next
- Ticket panel settings get the same tidy-up progression just had
- Plugin pages are still denser than they should be
- Per-key API usage on your profile
Something broken? Support server is one click from the footer. 🖤
Part two: how it was built
Everything above, from the other side. Skip freely.
The public API
Keys. A key is nyx_live_ plus 32 random bytes, base64url. The database stores a SHA-256 hash and the first few characters for display. Nothing else. Lookup is a hash comparison against a unique index, which matters because a slow password hash would be paid on every request rather than once at login. Losing a key means revoking it, because we genuinely cannot show it to you again.
Five live keys per owner. 60 requests a minute, 10,000 a day, counted per key.
Authorisation. Every request answers two questions before it touches data: does this key's owner own the server, and is nyx+ active on it. Ownership is Discord's, not ours, so transferring a server transfers API access with it.
One bug here is worth describing. The endpoint originally asked the billing provider whether the key's owner was a subscriber. That is a different question. Our developer override marks specific servers premium without a subscription, so the profile page cheerfully said "active via developer override" while the server refused to mint a key. Entitlement now comes from the same guild list the dashboard reads, so both surfaces agree by construction.
The docs site. New repo, Vite and TanStack Start, deployed separately from the dashboard. No highlighter library ships to the browser. The samples are literals authored in this repo, so a short regex pass tints them: keys in brand green, strings sky, numbers amber, with the input escaped first and the injected markup never re-scanned. Font ligatures are off, because !== rendering as a single glyph in an API example helps nobody. The status dot in the header is a real request to /health, not decoration.
The requirement chips exist because the first version stated requirements in grey body text, which is a roundabout way of not stating them at all. If a call is going to fail without a plugin enabled, that belongs in a colour you cannot scroll past.
Cache invalidation
Last update added caching. This one is about knowing when to throw it away.
The dashboard server list is built once, shared, then cached per actor in Redis. Fresh for 60 seconds, stale but usable for 10 minutes and served immediately while a rebuild happens behind it. Only a genuinely cold cache blocks. Guild access checks cache for an hour on top of that.
Time to live is the fallback, not the strategy. The list clears on events:
guildCreateandguildDeleteclear affected lists, because joining or leaving changes who sees what.guildUpdateclears them when anything displayed changes: name, icon, banner, description, locale, owner.- Member count deliberately does not invalidate. It changes constantly and appears nowhere important enough to justify throwing the cache away every few seconds.
That middle one came from a question I did not have an answer to, which is the best kind. A ten minute stale window is fine for a list that rarely changes and obviously wrong for a name someone just edited.
Discord's own cache keeps discord.js defaults for everything except messages, capped at 200 per channel with a sweeper every 15 minutes. Worth saying plainly: that is the only manager that differs from stock. I spent an unreasonable amount of time later proving it one manager at a time, and I will come back to why.
Honeypot internals
The interesting function is offender resolution. A message in a trap channel is not always from the person who should be punished:
// A plain human message: punish the author.
// A foreign bot's slash-command reply: punish interactionMetadata.user.
// nyx's own command output: punish nobody, ever.
// Ordinary bot chatter, system messages, webhooks: ignore.
That last case is the one that would have hurt. Without it, an admin running a nyx command in a trap channel gets banned by nyx for nyx's own reply.
Softban is a ban followed immediately by an unban with deleteMessageSeconds, which is the only way Discord lets you purge someone's history without keeping them banned. Warning messages are tracked by channel id in the plugin store, so editing the warning text edits the existing pin rather than stacking a new one on every save.
Tickets
Ratings storage. One rating per closed ticket, keyed by the closed channel id. A second press on the same DM upserts rather than double counting, which is what you want when someone misclicks three stars and immediately corrects to five.
Modals. Close feedback fields reuse the intake form builder, same shape and same five field cap, so there is one modal construction path rather than two that drift. That also meant migrating off the deprecated addComponents to LabelBuilder and addLabelComponents, which is Discord's Components V2 shape.
The prompt. It only fires on the close path. Manually deleting a ticket channel skips it, bots never get one, and a closed DM inbox swallows it silently. Off by default.
The duplicate ticket channel bug
One button press, two channels. Decoding the channel snowflakes put the pairs 11, 32 and 77 milliseconds apart, so this was one press producing two creations, not somebody double clicking.
Four theories got chased and guarded against before the right one: a second instance on Railway, a REST retry on a request that had already succeeded, our discord.js cache configuration, and Bun's fetch. None of them fixed it.
The answer came from an audit log entry missing the [nyx <id>] instance stamp our builds always write. Something running an older build, on the same bot token, outside Railway entirely. Two gateway connections, two interaction deliveries, two channels. Rotating the token fixed it immediately.
The guards came back out afterwards, one commit at a time. One stayed: ticket creation takes a short-lived Redis lock on tickets:create:{guild}:{user}:{category}, and a held lock means nyx points you at your existing ticket instead of opening a second. It was never the cause, it costs nothing, and the existing-ticket lookup has to run after the lock attempt rather than before, or the loser of the race reports a duplicate for a channel that by then exists.
Update DM delivery
Publishing a post used to block on DMing every owner, so the publish button hung for as long as delivery took.
- Delivery runs in the background now. Publish returns as soon as the post is live.
- Sends are paced at one every 500ms, well inside Discord's DM limits.
- An in-flight set stops the same update being announced twice concurrently.
- Delivery state records the update itself and a completion timestamp, so a restart mid-run resumes where it stopped instead of starting over and double DMing the first half of you.
Preference migration. The old preference was a single boolean. The new one has a flag per category. A stored record carrying only the old flag reads as that value for both categories, so someone who had opted out stays opted out rather than being silently re-enabled by a schema change. That is the kind of bug nobody reports, they just leave.
Two fixes with real consequences
guildDelete data loss. discord.js fires guildDelete both when the bot is removed and when a guild goes unavailable during a Discord outage. We treated both as departures and deleted plugin configuration. A large enough outage would have wiped settings for every affected server. The handler now checks guild.available === false and keeps everything.
Footer enforcement. Custom embed footers are a nyx+ feature. Gating the dashboard input is not enforcement, because the stored value survives a subscription lapsing. resolvePluginFooter checks entitlement at render time, so an expired subscription reverts to the default footer on the next message rather than whenever someone next opens the dashboard.
Repo housekeeping
@nyx/contracts, the package we made public three days ago, is consumed by the bot, the dashboard and now the API site. It was pinned by exact commit SHA in all three, which made every contract change a four commit dance. It tracks #master now, with lockfiles still pinning the resolved commit so builds stay reproducible. It is our own repository. The ceremony bought nothing.
One more, for anyone who has hit it. TanStack Router merges activeProps class lists into the base className rather than replacing them. A colour in the base class wins over the active colour by source order, so active nav links rendered dimmer than inactive ones. All colours moved into activeProps and inactiveProps. It caught us twice, in two different navs.
If you build something on the API, I want to see it. 🖤