Blog ·
Five Pterodactyl API traps, and what Pelican changed
Creating a Pterodactyl server fails on a field your customer never sees, and egg variables have no required flag. Five measured traps, and what Pelican changed.
By Provibr
Pterodactyl's API is decent. It has a real envelope, it uses status codes honestly, and validation errors carry structured metadata. Compared to most hosting control panel APIs that is a high bar cleared.
It also has five behaviours that are not in the documentation and that you will discover in this order: while writing the create path, while writing the create path again, in production, in your graphs, and never.
Everything below was measured against Pterodactyl 1.11 in August 2026. The section at the end covers what changed in Pelican 1.0.0-beta38, the fork, measured 18 August 2026.
1. There is no GET /api/application/eggs/{id}
You have an egg ID, and you want its variables, its docker image and its startup command. The obvious call does not exist:
GET /api/application/eggs/1
404 Not FoundEggs in 1.x are only addressable through their nest:
GET /api/application/nests
GET /api/application/nests/{nest}/eggs/{egg}?include=variablesWhich is a problem if all you have is an egg ID, because nothing in your data model has a reason to know the nest. It is a Pterodactyl-internal grouping, not something a customer picks.
So you walk them: fetch the nest list, then try GET /nests/{n}/eggs/{egg} for each until one returns 200. A stock panel has four nests, so the common case is one to four requests, and the only case that costs the full walk is an egg ID that does not exist, which is exactly the case that deserves to be slow, because it ends in an error.
2. Egg variables have no required field
Here is an egg variable as the API returns it:
{"object":"egg_variable","attributes":{
"id":1,"egg_id":1,
"name":"Server Jar File",
"env_variable":"SERVER_JARFILE",
"default_value":"server.jar",
"user_viewable":true,"user_editable":true,
"rules":"required|regex:/^([\\w\\d._-]+)(\\.jar)$/"
}}There is no required: true. The requirement is inside rules, which is a Laravel validation string, pipe-separated. required|regex:/…/ is mandatory; nullable|string|max:20 is not.
Two ways to get this wrong:
Substring matching. Searching rules for "required" also matches required_without: and required_if:. Those are conditional rules that do not make the field unconditionally mandatory. Split on | and compare whole tokens.
Assuming "editable" is one flag. It is two: user_editable and user_viewable. A variable can be neither (the Paper egg's DL_PATH is invisible and uneditable), so checking only one renders a field the panel wants hidden, or hides one the customer needs.
And the sting: you must send every variable on create, including the invisible ones. Omit one and you get a 422 per missing field. Take the egg's defaults, let the customer's values overwrite them (including deliberately empty ones), send the merged set.
3. limits.io: 0 is not "unlimited"
Most numeric limits in Pterodactyl treat 0 as unlimited. Memory, disk, CPU, databases, backups: 0 means no cap, and that is the mental model you build.
io is not one of them. It is the block IO weight, validated as between:10,1000. Send 0 and you get a 422, on a field your customer has never heard of, in response to a form where they only touched memory and disk.
We fill in Pterodactyl's own default of 500 when a plan asks for 0. Our live test sends io: 0 deliberately and reads 500 back, so the workaround stays proven rather than assumed.
4. /resources is cached for twenty seconds
This is the one that corrupts data rather than failing loudly.
GET /api/client/servers/{identifier}/resourcesreturns CPU, memory, disk, network counters and uptime. That answer is cached for about twenty seconds, and byte-identical while cached, including uptime, on a container that is demonstrably running.
Measured by polling every two seconds against a live container. Wall clock left, reported uptime right:
167 ms uptime 18158
2399 ms uptime 18158
4653 ms uptime 18158
6911 ms uptime 39186 ← step
…
24819 ms uptime 39186
27045 ms uptime 59214 ← step, 20.1 s later
…
44913 ms uptime 59214
47163 ms uptime 79236 ← step, 20.1 s laterTwelve consecutive reads returned identical values. The value advances in steps of 20.1 seconds.
Now: what is a sensible polling interval for a metrics collector? Twenty seconds. So a meaningful fraction of your rounds read the same cached response as the previous round.
CPU, memory and disk survive that. They are gauges, and a stale gauge is merely stale. Network rates do not. They are derived from counters, and identical counters across two rounds produce a delta of exactly zero. Your graph shows a period of no traffic. There was traffic. You invented a dip.
The fix that works: make the cache detectable. Include uptime in the fingerprint you use to decide whether a reading is new. If the fingerprint matches the previous reading and the container is running, that is a cache hit, not a measurement. Omit the network rates rather than reporting zero, and do not advance your measurement timestamp. Advancing it is the subtle half: if you do, the next real reading spreads two intervals of traffic across one interval of time, and you turn the invented dip into an invented spike right after it.
A gap in a graph is honest. A zero is a claim.
5. Things that are only annoying
There is no version endpoint. Nothing in 1.11 tells you what version you are talking to.
filter[email] is a partial match. Looking up a user by email returns anything containing the string. Compare exactly on your side, or you will eventually attach one customer's server to another customer's account, an error with no technical symptom at all.
Mutations return an empty 204. Suspend, unsuspend, reinstall and every power action confirm with no body, so every mutation needs a fresh read after it if you want to know it worked. (Delete is the exception: there is nothing left to read.)
Validation errors name the label, not the field. A missing required variable comes back as "The Server Jar File variable field is required." The machine name SERVER_JARFILE appears nowhere in it. Take it from meta.source_field instead.
409 covers two distinct states, with different detail text: "This server has not yet completed its installation process" and "This server is currently suspended…". Worth distinguishing, because the first resolves by waiting and the second does not.
What Pelican changed
Pelican is the community fork. We measured 1.0.0-beta38 on 18 August 2026, and around 85 to 90% of a Pterodactyl client carries over unchanged. These are the differences that need real code. Note the beta: this is a snapshot of a moving target.
Nests are gone, and eggs are directly addressable. GET /api/application/nests returns 404; GET /api/application/eggs/{id}?include=variables returns 200. Trap 1 disappears: the nest walk becomes one request. nest is also gone from the egg and server attributes, which matters if you have a defaulted integer there quietly resolving to 0.
The egg list does not paginate. GET /api/application/eggs returned all 52 eggs with no meta block, and per_page was ignored. Safe if your pagination helper stops when meta is absent, but on a panel with hundreds of eggs the whole list arrives in one response, and your request timeout is the only limit.
rules is now an array. This is the expensive one:
"rules": ["required", "regex:/^([\\w\\d._-]+)(\\.jar)$/"]Pterodactyl gives you one string: "required|regex:/…/". A deserializer typed as String does not see an empty value here, it sees a type error, and because variables are nested inside the egg response that failure takes down the entire egg list rather than one field. Type it as a list of strings and split on the token, not the pipe.
limits.io reversed, in both directions at once:
| Pterodactyl 1.11 | Pelican 1.0.0-beta38 | |
|---|---|---|
io: 0 | 422 (between:10,1000) | 201, stored as 0 |
io omitted | allowed, panel fills a default | 422, required |
So the workaround from trap 3 is not merely unnecessary on Pelican, it is wrong. It overwrites a value the panel would have accepted. And a field you were allowed to omit is now mandatory.
Egg variables are not validated on create. Posting an empty SERVER_JARFILE (a variable whose rules say ["required", "regex:…"]) returned 201, and the server stored the empty value. Pterodactyl returns 422 here.
That sounds permissive until you follow it through. The server installs successfully, then starts, then dies:
Error: Unable to access jarfile server.jar
Detected server process in a crashed state! Exit code: 1A 422 at create time is a good error. A crash loop ten minutes later is not. The requirement has not disappeared. It has moved to you. Validate required variables before you post, because nothing downstream will.
/api/client/account has no id. Only uuid, and first_name/last_name are gone too. If your owner resolution falls back to "the account behind this key", reading a missing id as 0 silently assigns servers to user 0. Look the account up by email instead.
The twenty-second /resources cache exists here too, with the same 20.1-second step, measured the same way. Same fix, same reasoning.
Small stuff: key prefixes are papp_ and pacc_ rather than ptla_ and ptlc_. Node cpu is in hundredths. A node reporting 350 has 3.5 cores, and reading it raw tells your placement logic there are 350. The daemon is still Wings. And the panel ships an OpenAPI document, which is the nicest thing about integrating with it.
What we did not test
Pterodactyl measurements are from 1.11 only; we have not checked 1.6.x or earlier, and several of these behaviours are the kind that change in point releases. Pelican measurements are from 1.0.0-beta38, which is a beta and says so; re-measure before you ship anything against it.
We did not measure rate limiting on either panel. That requires hundreds of requests against a live panel, and the value did not justify it.
This came out of building game panel integrations for Provibr, where game servers are a billable resource type with their own capacity accounting, which means a metrics dip that never happened is a number somebody eventually pays for.